Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,185 @@
# OpenSpec Change: enterprise-react-ui-pack-auth
- **Change ID**: enterprise-react-ui-pack-auth
- **Package**: `@suaveplan/react-ui-pack-auth`
- **Category**: react
- **Target path**: `packages/react/react-ui-pack-auth`
- **Status**: PROPOSED
- **Date**: 2026-05-05
- **Design source**: `openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md` §1.2 row 5, §5 (component-pack contract), §9 (`createDomainForm` factory), §11 (`TranslationKey`)
- **Naming note**: this package replaces what would historically have been a hand-rolled `@suaveplan/react-auth-forms`. Naming follows the `@suaveplan/react-ui-pack-<name>` convention introduced in the master design §5.8 so theme packs (`react-ui-theme-<name>`) and component packs share a discoverable prefix.
- **Prereqs (one SHIPPED, two in-flight)**:
- `enterprise-react-i18n` shipped 2026-05-05 — archived at `openspec/archive/2026-05-05-enterprise-react-i18n/`; capability spec at `openspec/specs/react-i18n/spec.md`. This pack consumes `TranslationKey` for every form-field label across LoginForm / RegisterForm / PasswordResetForm / TwoFactorForm / MagicLinkForm, contributes `react-ui-pack-auth.<form>.<key>` keys to `ContributedTranslationKeys` in all 5 locales, and uses `tDynamic` for any runtime-computed error-code-to-message lookups (e.g. server-side `auth.error.<code>`).
- `enterprise-react-ui-v2` (sibling, in-flight) — provides the `ComponentPack<TName>` contract, `PackComponents` global augmentation interface, `PackRegistryProvider`, `usePackComponent` typed lookup, and `peerRange` validation that this pack registers against.
- `enterprise-react-forms-v2` (sibling, in-flight) — provides the `createDomainForm` factory each of the five forms is built atop, and the validators (`required`, `email`, `passwordStrength`, `pattern`) the form schemas compose from.
## Why
This package exists for three reasons, each of which would justify it on its own:
1. **Prove the `ComponentPack` contract end-to-end as an external package.** Section 5 of the master design defines a TypeScript-augmented registry (`PackComponents`), a per-pack scoped React context, lifecycle hooks (`onRegister` / `onUnregister`), pack-shipped translation contributions (§5.5), and a `peerRange` compatibility check (§5.6). Until at least one external pack consumes this surface, the contract is unverified. `react-ui-pack-auth` is that external consumer — it lives in its own package, it has its own `package.json`, and it depends on `@suaveplan/react-ui` only as a peer. If the contract has gaps, this package is the smallest viable surface that surfaces them before downstream consumers (the ~40 `react-*` packages waiting on `react-ui`) start adopting it.
2. **Exercise the full `react-ui` + `react-i18n` + `react-forms` + `@suaveplan/api-schemas` stack in a single high-fidelity demo.** Every layer the React stack ships gets a real workout in one place — `createDomainForm` (forms factory, §9.4), `TranslationKey`-typed labels (§11.2), `<Trans>` for marketing copy in marketing slots, locale-bundle contribution via module augmentation (§5.5 + §11.2), pack-scoped lifecycle, runtime override via `usePackComponent`, and Zod validation sourced directly from `@suaveplan/api-schemas/auth`. If any of those boundaries are awkward, the auth pack will surface the awkwardness.
3. **Ship genuinely useful auth components every consumer app needs anyway.** Login, register, password reset, two-factor entry, and magic-link request are not bespoke flows — every Suaveplan-built product ships them. Packaging them as a contract-proving demo means consumers get a production-ready pack on day one rather than a contrived "Hello, Pack" example.
## Current-state audit (genesis)
`packages/react/react-ui-pack-auth/` does not exist. There is no `@suaveplan/react-ui-pack-auth` package in the workspace. The five forms this pack ships do not exist anywhere on genesis. `@suaveplan/api-schemas/auth` ships `LoginInput`, `RegisterInput`, `RefreshInput`, `LogoutInput` and the `LoginUser` / `LoginOutput` / `RegisterOutput` response types — the additional auth schemas this pack needs (`PasswordResetRequestInput`, `PasswordResetConfirmInput`, `TwoFactorVerifyInput`, `MagicLinkRequestInput`, `MagicLinkVerifyInput`) do not yet exist and are added in the same change set under §"What changes" below.
## Available workspace deps on genesis
| Package | Role | Version range |
|---|---|---|
| `@suaveplan/react-ui` | Provides `ComponentPack` contract, `usePackComponent`, `useThemedVariants`, every primitive (`Button`, `Field`, `Input`, `Label`, `Alert`, `Card`, `Sheet`) the forms render with | `peer` + `workspace:^` (dev) |
| `@suaveplan/react-i18n` | Provides `TranslationKey` brand, `useTranslation`, `<Trans>`, `ContributedTranslationKeys` augmentation point | `peer` + `workspace:^` (dev) |
| `@suaveplan/react-forms` | `createDomainForm` factory, `useDomainForm`, validation primitives, recipe surfaces (used by 2FA's countdown timer) | `workspace:^` |
| `@suaveplan/api-schemas` | Provides `LoginInput` / `RegisterInput` / `RefreshInput` / `LogoutInput` schemas; this change extends the `auth` subpath with five new schemas (see below) | `workspace:^` |
| `@suaveplan/error` | `defineCodes("REACT_UI_PACK_AUTH", [...])` registry, `SuaveplanError` base class | `workspace:^` |
| `@suaveplan/types` | Shared `Clock`, `MessageResolver`, `LoggerLike` contracts | `workspace:^` |
| `@suaveplan/testing` | `@suaveplan/testing/runner` — sole test-runner import surface (CLAUDE.md §11) | `workspace:^` (dev) |
| `@suaveplan/testing-dom` | `render`, `screen`, `userEvent`, jest-dom matchers, `composeStories` adapter | `workspace:^` (dev) |
| `@suaveplan/testing-e2e` | Playwright config factory, fixture extensions, axe-core helpers | `workspace:^` (dev) |
| React | Function-component peer | `peer` `^18.0.0` or `^19.0.0` |
| `tslib` | Compiled-helper runtime | `catalog:build` |
| `zod` | Runtime schema validation at every public boundary | `catalog:forms` |
| `@opentelemetry/api` | Submit-counter and submit-duration histogram | `catalog:observability` |
External: **no** Radix imports — Radix is a peer of `@suaveplan/react-ui`, not this package. **No** form-library imports — `react-hook-form` is a peer of `@suaveplan/react-forms`. **No** direct i18next / react-i18next imports — the `TranslationKey` brand and `useTranslation` come exclusively from `@suaveplan/react-i18n`.
## Public API surface
```ts
// Pack export — the canonical ComponentPack<'auth'> instance
export { authPack };
// Per-form components (direct-import path, tree-shake friendly)
export { LoginForm, type LoginFormProps } from "./components/login-form/login-form.js";
export { RegisterForm, type RegisterFormProps } from "./components/register-form/register-form.js";
export {
PasswordResetForm,
type PasswordResetFormProps,
} from "./components/password-reset-form/password-reset-form.js";
export { TwoFactorForm, type TwoFactorFormProps } from "./components/two-factor-form/two-factor-form.js";
export { MagicLinkForm, type MagicLinkFormProps } from "./components/magic-link-form/magic-link-form.js";
// Translation-key contribution (side-effect import on root `index.ts`)
import "./translations/augmentation.js";
// Error registry
export { ReactUiPackAuthError, REACT_UI_PACK_AUTH_CODES } from "./errors/errors.js";
```
Each form's `Props` is the union `{ onSuccess: (result: <SchemaOutput>) => void; onError?: (error: SuaveplanError) => void; defaultValues?: Partial<<SchemaInput>>; submitClient: AuthSubmitClient; redirectTo?: string }`. The `AuthSubmitClient` interface is published from `./types/auth-submit-client.types.ts` and is an injectable contract with one method per form (`login`, `register`, `requestPasswordReset`, `confirmPasswordReset`, `verifyTwoFactor`, `requestMagicLink`, `verifyMagicLink`). No global state assumptions — every form takes its submit client as a prop (or pulls it from a `<AuthSubmitClientProvider>` whose default is unset).
## Design decisions
1. **Implements `ComponentPack<'auth'>` from `@suaveplan/react-ui/pack`.** The exported `authPack` object satisfies the `ComponentPack<'auth'>` interface verbatim — `name: 'auth'`, `version: '0.0.0'`, `peerRange: '^0.1.0'`, `components: { LoginForm, RegisterForm, PasswordResetForm, TwoFactorForm, MagicLinkForm }`, plus the optional `translations`, `onRegister`, `onUnregister` slots. A `pack.contract.test.tsx` asserts the object literally satisfies the type via `expectTypeOf` and that registering it through `<PackRegistryProvider packs={[authPack]}>` causes `usePackComponent('auth', 'LoginForm')` to return the same component reference (`Object.is`).
2. **Each form is built via `createDomainForm` from `@suaveplan/react-forms` — no hand-rolled JSX.** The five forms are pure factory invocations. This is deliberate: it exercises `createDomainForm`'s expressiveness across four different submit-shape patterns (single-step credential, multi-field with cross-field validation, single-input with rate-limit feedback, OTP with countdown timer, magic-link with bifurcated request/verify states) and proves the factory handles them all without dropping into the render-prop escape hatch. Any form that *would* require the escape hatch is a signal that `createDomainForm` is incomplete and needs a follow-up change against `@suaveplan/react-forms` rather than a workaround here.
3. **Schemas come straight from `@suaveplan/api-schemas/auth` — no schema duplication.** `LoginForm` consumes `LoginInput`, `RegisterForm` consumes `RegisterInput`. The five new schemas (`PasswordResetRequestInput`, `PasswordResetConfirmInput`, `TwoFactorVerifyInput`, `MagicLinkRequestInput`, `MagicLinkVerifyInput`) are added to `@suaveplan/api-schemas/auth` in the same change set so the API server and the React client share a single source of truth. The pack never re-defines a Zod object inline; if the server changes the contract, the form fails at compile time the next time `bun install` runs.
4. **All field labels, placeholders, helper text, and error messages typed as `TranslationKey` (master design §11).** Every user-visible string the pack renders is a key, never a literal. Each form's field config uses keys under the namespace `react-ui-pack-auth.<form>.<field>.<role>` — e.g. `react-ui-pack-auth.login.email.label`, `react-ui-pack-auth.login.email.placeholder`, `react-ui-pack-auth.login.email.errors.required`. The pack's locale JSON files are the canonical source for the type-generation script that augments `ContributedTranslationKeys` so a typo in the field config fails `tsc` not at runtime.
5. **Per-locale translations under `react-ui-pack-auth.*` namespace, registered via module augmentation.** A generated `src/translations/augmentation.d.ts` module-augments `@suaveplan/react-i18n`'s `ContributedTranslationKeys` interface with every key in `src/translations/en-GB.json`, and `src/translations/augmentation.ts` imports each locale JSON for its runtime side effect (registering the bundle into the `authPack.translations` map). Importing the package's `index.ts` registers both the type-level union and the runtime bundles — consumers do not need a separate import.
6. **Five locales: `en-GB`, `zh-CN`, `ar-AE`, `cy-GB`, `ru-RU`.** Mirrors the master-design §1.1 set. `ar-AE` proves RTL flip on every form; `cy-GB` proves Welsh's six plural categories don't break the OTP "X attempts remaining" string (`{count, plural, zero {…} one {…} two {…} few {…} many {…} other {…}}`). The pack's locale completeness gate (`scripts/lint/check-locale-completeness.ts`, run via `bunx turbo lint`) blocks any commit that introduces a key in `en-GB.json` without translations in the other four locales.
7. **`onRegister(ctx)` lifecycle hook pre-warms i18n bundles for the auth namespace.** When `<PackRegistryProvider packs={[authPack]}>` mounts, it calls `authPack.onRegister(ctx)` with a `PackContext` exposing `i18n`, `theme`, `logger`, and a pack-scoped `AbortSignal`. The hook calls `ctx.i18n.addResourceBundle('react-ui-pack-auth', authPack.translations[ctx.i18n.locale])` and warms the message catalog so the first form-render does not Suspense. `onUnregister(ctx)` calls `removeResourceBundle` so the i18n core stays canonical and pack mount/unmount is symmetric.
8. **Each form takes typed `onSuccess` / `onError` callbacks — no global state assumptions.** The pack does not call `window.location.assign`, does not push to a router, does not write to `localStorage`. `LoginForm` returns a `LoginOutput`-shaped value to its `onSuccess` callback; the consumer wires it to whatever auth/session/router stack they ship. This keeps the pack composable across Next.js, Remix, vanilla Vite, and React Native Web consumers without per-host adapters.
9. **Single-direction import policy.** The pack imports from `@suaveplan/react-ui` (peer-dep) but never re-exports its types — consumers depend on `@suaveplan/react-ui` directly per CLAUDE.md rule 4 ("never re-export external deps"). The pack's `pack.ts` file is a thin object literal whose components reference internal-to-this-package implementations only.
10. **No hidden retries or backoff.** Submit calls are one-shot. If a consumer wants exponential backoff or rate-limit handling, they wrap their submit client with `@suaveplan/retry`. The pack stays at the rendering layer.
## What changes
### `@suaveplan/react-ui-pack-auth` (new package)
- ADDED: `authPack` — a `ComponentPack<'auth'>` instance exporting all five forms, all five locale bundles, the `onRegister` warmer, and the `onUnregister` unwinder.
- ADDED: `LoginForm` — built via `createDomainForm({ schema: LoginInput, fields: { email, password }, submit: { ... } })`. Field kinds: `email`, `password`. Optional `defaultValues.email` for the pre-filled-from-magic-link path.
- ADDED: `RegisterForm` — built via `createDomainForm({ schema: RegisterInput.extend({ confirmPassword: ..., terms: ... }), fields: { email, name, password, confirmPassword, terms }, submit: { ... } })`. Cross-field validator: `confirmPassword === password`. The `terms` checkbox is required and has its own translation key.
- ADDED: `PasswordResetForm` — two-mode factory (`mode: 'request' | 'confirm'`). Request mode uses `PasswordResetRequestInput` (single email field); confirm mode uses `PasswordResetConfirmInput` (token + new password + confirm). Mode is a prop, not internal state.
- ADDED: `TwoFactorForm` — built via `createDomainForm({ schema: TwoFactorVerifyInput, fields: { code }, ... })`. The `code` field is `kind: 'otp'` (six-digit InputOTP from `react-ui`). Includes a "send new code" button gated by a 60-second countdown surfaced via `@suaveplan/timing`'s countdown helper passed in as a prop (so tests can inject a fake clock).
- ADDED: `MagicLinkForm` — bifurcated factory. Request mode uses `MagicLinkRequestInput` (email only); verify mode uses `MagicLinkVerifyInput` (token from the URL — typically auto-submitted on mount, with a manual fallback button).
- ADDED: Translations namespace `react-ui-pack-auth.*` with full coverage in `en-GB`, `zh-CN`, `ar-AE`, `cy-GB`, `ru-RU` for every label, placeholder, helper text, success message, and error message every form renders.
- ADDED: Module augmentation `declare module "@suaveplan/react-i18n" { interface ContributedTranslationKeys { ... } }` registering every `react-ui-pack-auth.*` key into the brand union.
- ADDED: Module augmentation `declare module "@suaveplan/react-ui/pack" { interface PackComponents { auth: { LoginForm: ...; RegisterForm: ...; PasswordResetForm: ...; TwoFactorForm: ...; MagicLinkForm: ... } } }` registering the pack's component types into the `usePackComponent` lookup union.
- ADDED: `AuthSubmitClient` interface — the injectable contract every form takes as a prop. One method per form (`login`, `register`, `requestPasswordReset`, `confirmPasswordReset`, `verifyTwoFactor`, `requestMagicLink`, `verifyMagicLink`), each returning a typed `Promise<<SchemaOutput>>`.
- ADDED: Error codes via `defineCodes("REACT_UI_PACK_AUTH", [...])`:
- `REACT_UI_PACK_AUTH.SUBMIT_CLIENT_MISSING` — form rendered without a submit client (neither prop nor provider)
- `REACT_UI_PACK_AUTH.PACK_VERSION_MISMATCH` — `peerRange` check against running `@suaveplan/react-ui` version failed at registration
- `REACT_UI_PACK_AUTH.UNKNOWN_FIELD_KIND` — internal sanity check; thrown if a field config references a kind the pack-local registry has not whitelisted
- `REACT_UI_PACK_AUTH.MODE_REQUIRED` — `PasswordResetForm` or `MagicLinkForm` rendered without the mandatory `mode` prop
### `@suaveplan/api-schemas` (extension)
- ADDED to `./auth`: `PasswordResetRequestInput` (`{ email }`), `PasswordResetRequestOutput` (`{ requestedAt }`)
- ADDED to `./auth`: `PasswordResetConfirmInput` (`{ token, newPassword }`), `PasswordResetConfirmOutput` (`{ confirmedAt }`)
- ADDED to `./auth`: `TwoFactorVerifyInput` (`{ code }`), `TwoFactorVerifyOutput` (`{ token, refreshToken, user: LoginUser }`)
- ADDED to `./auth`: `MagicLinkRequestInput` (`{ email }`), `MagicLinkRequestOutput` (`{ requestedAt }`)
- ADDED to `./auth`: `MagicLinkVerifyInput` (`{ token }`), `MagicLinkVerifyOutput` (`{ token, refreshToken, user: LoginUser }`)
These five additions live in `packages/api/api-schemas/src/auth/` alongside `login.ts` / `register.ts`, follow the existing convention (input + output Zod schemas, inferred TypeScript types, sibling `.md`), and are exported from `packages/api/api-schemas/src/auth/index.ts`. They land in this change set rather than a separate change because `react-ui-pack-auth` is the first consumer that needs them — splitting them into a separate change would be a `@suaveplan/api-schemas` change with no consumer.
## Parity Checklist
**N/A — genesis-original; not a migration.**
This package does not exist in `../suaveplan/`. There is no upstream `react-auth-forms` (or equivalently named) library to port. The pack's existence is justified by the master design §1.2 row 5, which scopes it as one of the five new genesis packages. A parity checklist is included by `openspec/AGENTS.md` §0a only when a library is being migrated from upstream — this is a new-build, so the checklist is intentionally absent. The five form components ARE inspired in spirit by the legacy `react-auth` and `react-auth-whitelabel` packages on `main`, but the implementation, public API, and dependency layout are wholly new — there is no shape to preserve and no upstream feature set to enumerate.
## Telemetry
- ADDED: counter `pack.auth.submit_total` with attributes `{ form: "login" | "register" | "passwordResetRequest" | "passwordResetConfirm" | "twoFactor" | "magicLinkRequest" | "magicLinkVerify", outcome: "success" | "validation_error" | "submit_error" }` — incremented on every form-submit completion. Allowlist enforced at the histogram-write site so unbounded label cardinality cannot creep in.
- ADDED: histogram `pack.auth.submit.duration_ms` with attribute `{ form: "<same enum>" }` — wall-clock time from form submit through `onSuccess` / `onError` resolution.
- ADDED: counter `pack.auth.lifecycle_total` with attribute `{ phase: "register" | "unregister", outcome: "success" | "version_mismatch" | "failed" }` — fired by the `onRegister` / `onUnregister` hooks; the `version_mismatch` value carries the structured `pack.version_mismatch` warning out via `@suaveplan/logger` per master design §5.6.
All three instruments are no-ops when no exporter is registered (the `@opentelemetry/api` default behaviour). The pack does not register an SDK — that is the consumer's responsibility.
## Verification approach
Each spec requirement carries a `**Verified by:**` line listing the concrete tests that exercise it.
- `packages/react/react-ui-pack-auth/src/pack/pack.contract.test.tsx` — `authPack` type-conforms to `ComponentPack<'auth'>`; registering it makes `usePackComponent('auth', '<form>')` return the same component reference for all five forms.
- `packages/react/react-ui-pack-auth/src/pack/pack.lifecycle.test.tsx` — `onRegister` is called on mount with the active i18n / theme / logger / signal; `onUnregister` is called on unmount; calling order is FIFO across multiple packs; bundles are added on register and removed on unregister.
- `packages/react/react-ui-pack-auth/src/components/<form>/<form>.test.tsx` (five files) — each form renders without throwing under all five locales (parametric `it.each`); submitting calls the injected `AuthSubmitClient` method exactly once with the typed payload; `onSuccess` fires with the typed response; field-level validation errors render the correct translation key; field labels, placeholders, helper text, and error messages all resolve through `react-i18n`'s catalog (no string literal in the rendered DOM).
- `packages/react/react-ui-pack-auth/src/components/<form>/<form>.stories.tsx` (five files) — `composeStories` round-trips each story through `vitest`; one story per locale per form (× theme variant for the `WithTheme` story).
- `packages/react/react-ui-pack-auth/e2e/auth-submit-success.spec.ts` — Playwright; for each form, fills required fields, submits against a stubbed `AuthSubmitClient`, asserts `onSuccess` payload matches the API-schema shape.
- `packages/react/react-ui-pack-auth/e2e/auth-submit-error.spec.ts` — Playwright; submits against a stub that throws `SuaveplanError`; asserts the form maps the error code to the correct translation key and renders it next to the offending field (or as a form-level alert when no field maps).
- `packages/react/react-ui-pack-auth/e2e/auth-rtl.spec.ts` — Playwright; loads each form under `ar-AE`, asserts `<html dir="rtl">`, asserts the OTP cells flow right-to-left, asserts the password "show / hide" eye icon mirrors.
- `packages/react/react-ui-pack-auth/e2e/auth-themes.spec.ts` — Playwright; loads the `LoginForm` under `light`, `dark`, `highContrast`, and `dracula`; asserts each renders without console errors and axe-core finds zero serious or critical violations.
- `packages/react/react-ui-pack-auth/src/translations/translations.test.ts` — runs the locale-completeness gate; asserts every key in `en-GB.json` exists in the other four files; asserts the generated `augmentation.d.ts` mirrors `en-GB.json` exactly.
## Out of scope
These flows are deliberately deferred — the pack stays narrowly scoped to the five forms the master design names. Each is justified individually:
- **OAuth / social-login buttons** — defer until first consumer ask. `@suaveplan/react-ui-social` is a separate package on `STATUS.md`; combining it with the auth pack would entangle two independently versioned surfaces.
- **Passkey / WebAuthn** — credential management is a wholly different UX (browser-native prompts, no form fields), and the relevant browser APIs (`navigator.credentials.create` / `.get`) deserve their own pack so the auth pack does not pull `@suaveplan/crypto` into the React tree.
- **Password recovery via SMS** — telephony is not an `@suaveplan/api-schemas` capability today; adding it requires a new schema family and a transport contract that does not yet exist.
- **Multi-tenant org-picker** — pre-auth tenant selection is highly product-specific (subdomain routing, query-param routing, header-based routing); the master design does not name it as part of the v1 pack and a generic implementation would be a guess.
- **Session management UI** ("active sessions" list, "log out everywhere" action) — belongs in a follow-on `@suaveplan/react-ui-pack-account` once the auth pack contracts settle.
- **Email-verification confirmation page** — verification typically happens on a server-rendered route with a one-time token, not an interactive form; if a future consumer needs an interactive widget, add it as a sixth form in a follow-on change.
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/react-ui-pack-auth` to enumerate. Currently zero — this is a leaf pack that no genesis package depends on (verified by `grep -rl "@suaveplan/react-ui-pack-auth" /home/ubuntu/web/genesis/packages/ --include="package.json"` returning empty before this change lands).
- No dependents (leaf pack; verified by `bunx turbo ls --filter=...@suaveplan/react-ui-pack-auth` — empty result).
### Adjacent packages touched in the same change set
- `@suaveplan/api-schemas` — five new auth schemas added under `./auth` (see `## What changes`). Existing exports are not modified; no consumer of `LoginInput`, `RegisterInput`, `RefreshInput`, or `LogoutInput` sees a behaviour change. Tests run: `bunx turbo test --filter=@suaveplan/api-schemas`.
### Prerequisites
This change cannot ship until the four upstream packages it depends on are green on genesis:
- `@suaveplan/react-i18n` (change `enterprise-react-i18n`) — provides `TranslationKey`, `useTranslation`, `<Trans>`, `ContributedTranslationKeys`.
- `@suaveplan/react-ui` (change `enterprise-react-ui-v2` per master design §13) — provides `ComponentPack`, `PackComponents`, `usePackComponent`, every primitive the forms render with.
- `@suaveplan/react-forms` (change `enterprise-react-forms-v2` per master design §13) — provides `createDomainForm`, `useDomainForm`, validation primitives.
- `@suaveplan/api-schemas` — already on genesis; this change extends its `auth` subpath.
The order matches master design §14 Wave 6 ("`react-ui-pack-auth` and `react-ui-theme-dracula` ship together; end-to-end proof that contracts work").

View File

@@ -0,0 +1,224 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: react-ui-packs
**Package**: `@suaveplan/react-ui-pack-auth` (first concrete pack against the capability)
**Status**: PROPOSED
> Every requirement carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
This delta introduces the `react-ui-packs` capability — the contract every `@suaveplan/react-ui-pack-<name>` package satisfies — and lands the first concrete implementation, the `auth` pack. The capability spec defines what every pack MUST provide; the requirements below are scoped to the `auth` pack specifically since it is the only pack landing in this change.
## ADDED Requirements
### Requirement: authPack exposes name "auth"
`@suaveplan/react-ui-pack-auth` SHALL export an `authPack` object that satisfies the `ComponentPack<'auth'>` type from `@suaveplan/react-ui/pack`. The object SHALL expose `name: 'auth'` literal, `version: '0.0.0'` literal, `peerRange: string` (semver range against `@suaveplan/react-ui`), and the optional `translations`, `onRegister`, `onUnregister`, `themeTokens`, `variants` slots defined by the contract.
#### Scenario: authPack literally satisfies ComponentPack<"auth">
- **GIVEN** a TypeScript test importing `authPack` from `@suaveplan/react-ui-pack-auth`
- **WHEN** the test asserts `expectTypeOf(authPack).toMatchTypeOf<ComponentPack<'auth'>>()`
- **THEN** the assertion passes at compile time without `// @ts-expect-error`
#### Scenario: authPack name field is the literal "auth"
- **WHEN** consumer code reads `authPack.name`
- **THEN** the value equals `'auth'` AND its TypeScript type is the literal `'auth'` (not `string`)
**Verified by:** `packages/react/react-ui-pack-auth/src/pack/pack.contract.test.tsx::"authPack literally satisfies ComponentPack<'auth'>"` AND `packages/react/react-ui-pack-auth/src/pack/pack.contract.test.tsx::"name is the literal 'auth'"`
---
### Requirement: authPack exposes the five canonical auth forms
`authPack.components` SHALL expose `LoginForm`, `RegisterForm`, `PasswordResetForm`, `TwoFactorForm`, and `MagicLinkForm` — exactly those five components, no more and no fewer in v1. Each SHALL be a React function component whose props type is the matching `<Form>Props` exported from the same package. The pack SHALL also export each component as a top-level named export so consumers can choose between the registry-lookup form (`usePackComponent('auth', 'LoginForm')`) and the direct-import form (`import { LoginForm }`) per master design §5.4.
#### Scenario: registry lookup returns the same reference as the direct export
- **GIVEN** a tree wrapped in `<PackRegistryProvider packs={[authPack]}>`
- **WHEN** a child calls `const FromRegistry = usePackComponent('auth', 'LoginForm')` and the test imports `LoginForm` directly
- **THEN** `Object.is(FromRegistry, LoginForm) === true`
#### Scenario: every form is a function component
- **WHEN** `typeof authPack.components.<formName>` is checked for each of the five forms
- **THEN** every value is `'function'` AND every component renders without throwing when wrapped in the standard provider stack
**Verified by:** `packages/react/react-ui-pack-auth/src/pack/pack.contract.test.tsx::"registry lookup returns same reference as direct export"` AND `packages/react/react-ui-pack-auth/src/pack/pack.contract.test.tsx::"every form is a function component"`
---
### Requirement: every form is built via createDomainForm
Every component in `authPack.components` SHALL be the return value of a single `createDomainForm` call from `@suaveplan/react-forms`. No component SHALL drop into the render-prop escape hatch, the `<Form.Field>` static accessor, or the `useDomainForm()` standalone hook. This is a structural requirement: if any of the five forms cannot be expressed via a single `createDomainForm` invocation, that is a defect in `@suaveplan/react-forms` and MUST be addressed there before this pack ships.
#### Scenario: createDomainForm is the only form constructor used
- **GIVEN** the `auth-pack` source tree
- **WHEN** the test runs `grep -rn "createDomainForm\|useDomainForm\|<.*\.Field" packages/react/react-ui-pack-auth/src/components/` and counts occurrences
- **THEN** every `createDomainForm` count is exactly 1 per form-component file (5 in total) AND `useDomainForm` count is 0 AND `<.*\.Field` count is 0
#### Scenario: each factory invocation passes a Zod schema as the spine
- **WHEN** the test inspects each `createDomainForm` call site
- **THEN** the `schema` property is set AND its value is a Zod object schema imported from `@suaveplan/api-schemas/auth`
**Verified by:** `packages/react/react-ui-pack-auth/src/pack/pack.factory-conformance.test.ts::"createDomainForm is the sole form constructor"` AND `packages/react/react-ui-pack-auth/src/pack/pack.factory-conformance.test.ts::"each factory passes a Zod schema as the spine"`
---
### Requirement: schemas are sourced from @suaveplan/api-schemas/auth
The pack SHALL NOT define a Zod schema inline for any form. Every form's `schema` property SHALL reference an export from `@suaveplan/api-schemas/auth`: `LoginInput` for `LoginForm`, `RegisterInput` (extended with `confirmPassword` and `terms` cross-field refinements via `.extend(...)`) for `RegisterForm`, `PasswordResetRequestInput` / `PasswordResetConfirmInput` for `PasswordResetForm`, `TwoFactorVerifyInput` for `TwoFactorForm`, and `MagicLinkRequestInput` / `MagicLinkVerifyInput` for `MagicLinkForm`. This guarantees the API server and the React client share a single source of truth.
#### Scenario: pack imports auth schemas from api-schemas only
- **GIVEN** the pack's source tree
- **WHEN** the test runs `grep -rn "z\.object\|new ZodObject" packages/react/react-ui-pack-auth/src/components/`
- **THEN** the only matches are the inline `.extend({ confirmPassword, terms })` call inside `register-form.ts` (cross-field augmentation, not a redefinition)
#### Scenario: server-schema change breaks the client at compile time
- **GIVEN** a hypothetical change in `@suaveplan/api-schemas/auth` that renames `LoginInput.email` to `LoginInput.emailAddress`
- **WHEN** `bunx turbo typecheck --filter=@suaveplan/react-ui-pack-auth` runs
- **THEN** the build fails because the field config references the missing `email` property
**Verified by:** `packages/react/react-ui-pack-auth/src/pack/pack.factory-conformance.test.ts::"schemas come from api-schemas only"` AND a `*.expect-error.ts` fixture proving the rename example above
---
### Requirement: every user-visible string is a TranslationKey
Every `label`, `placeholder`, `helperText`, `errorMessage`, `aria-label`, `title`, success-toast body, error-alert body, and submit-button text rendered by any of the five forms SHALL be typed as `TranslationKey` (the brand from `@suaveplan/react-i18n`), never as a string literal. Field configs MUST use the `react-ui-pack-auth.<form>.<field>.<role>` namespace. The pack's translation-key-existence gate (`scripts/lint/check-translation-keys.ts`) and no-hardcoded-strings gate (`scripts/lint/check-no-hardcoded-strings.ts`) SHALL both pass with zero diagnostics.
#### Scenario: a string-literal label fails typecheck
- **GIVEN** a hypothetical edit replacing `label: 'react-ui-pack-auth.login.email.label'` with `label: 'Email'`
- **WHEN** `bunx turbo typecheck --filter=@suaveplan/react-ui-pack-auth` runs
- **THEN** the typecheck fails with a `Type 'string' is not assignable to type 'TranslationKey'` error
#### Scenario: every t-call references an existing key
- **WHEN** `bun scripts/lint/check-translation-keys.ts` runs against `packages/react/react-ui-pack-auth/`
- **THEN** every `t('...')` call resolves to a key present in `src/translations/en-GB.json`
#### Scenario: no hardcoded user-visible strings exist in the pack source
- **WHEN** `bun scripts/lint/check-no-hardcoded-strings.ts` runs against `packages/react/react-ui-pack-auth/src/`
- **THEN** the script exits 0 with zero diagnostics
**Verified by:** `packages/react/react-ui-pack-auth/src/pack/pack.translation-key-conformance.test.ts::"string-literal label fails typecheck"` (TypeScript expect-error fixture) AND the two lint gates above wired into `bunx turbo lint --filter=@suaveplan/react-ui-pack-auth`
---
### Requirement: onRegister and onUnregister fire on PackRegistryProvider mount and unmount
`authPack.onRegister(ctx)` SHALL be called by `<PackRegistryProvider>` exactly once when the pack enters the `packs` prop, with a `PackContext` exposing `i18n`, `theme`, `logger`, and a pack-scoped `AbortSignal`. The hook SHALL call `ctx.i18n.addResourceBundle('react-ui-pack-auth', authPack.translations[ctx.i18n.locale])` so the message catalog is warm before the first form-render. `authPack.onUnregister(ctx)` SHALL be called exactly once when the pack leaves the `packs` prop and SHALL call `ctx.i18n.removeResourceBundle('react-ui-pack-auth', ctx.i18n.locale)`.
#### Scenario: onRegister adds the locale bundle
- **GIVEN** an `<PackRegistryProvider packs={[authPack]}>` with a fresh `I18nInstance` whose locale is `en-GB`
- **WHEN** the provider mounts
- **THEN** `i18n.addResourceBundle` was called once with `('react-ui-pack-auth', authPack.translations['en-GB'])` AND `pack.auth.lifecycle_total{phase=register, outcome=success}` counter incremented by 1
#### Scenario: onUnregister removes the locale bundle
- **GIVEN** a mounted `<PackRegistryProvider packs={[authPack]}>`
- **WHEN** the provider unmounts
- **THEN** `i18n.removeResourceBundle` was called once with `('react-ui-pack-auth', 'en-GB')` AND `pack.auth.lifecycle_total{phase=unregister, outcome=success}` counter incremented by 1
#### Scenario: pack-scoped AbortSignal aborts on unmount
- **GIVEN** an `onRegister` that captures `ctx.signal`
- **WHEN** the provider unmounts before any in-flight async work resolves
- **THEN** `ctx.signal.aborted === true` AND any AbortController-aware operation observes the abort
**Verified by:** `packages/react/react-ui-pack-auth/src/pack/pack.lifecycle.test.tsx::"onRegister adds the bundle"` AND `packages/react/react-ui-pack-auth/src/pack/pack.lifecycle.test.tsx::"onUnregister removes the bundle"` AND `packages/react/react-ui-pack-auth/src/pack/pack.lifecycle.test.tsx::"pack-scoped AbortSignal aborts on unmount"`
---
### Requirement: translation contributions register via TypeScript module augmentation
The pack SHALL contribute its translation keys into `@suaveplan/react-i18n`'s `ContributedTranslationKeys` interface via `declare module "@suaveplan/react-i18n" { interface ContributedTranslationKeys { 'react-ui-pack-auth.login.title': true; ... } }`. The augmentation file SHALL be auto-generated from `src/translations/en-GB.json` (the canonical source) by `bun scripts/generate-translation-types.ts` and SHALL be byte-identical across runs. The augmentation file SHALL be imported as a side-effect-only entry from the package's root `index.ts` so `import "@suaveplan/react-ui-pack-auth"` registers the keys with the type system.
#### Scenario: every key in en-GB.json appears in the augmentation file
- **WHEN** the test compares the keys in `src/translations/en-GB.json` against the keys augmented into `ContributedTranslationKeys`
- **THEN** the two sets are equal (no missing keys, no extra keys)
#### Scenario: typing a literal key from en-GB.json typechecks
- **GIVEN** any key in `src/translations/en-GB.json` (e.g. `'react-ui-pack-auth.login.title'`)
- **WHEN** consumer code declares `const k: TranslationKey = '<that-literal>'`
- **THEN** the assignment typechecks without error
#### Scenario: typing a key absent from en-GB.json fails typecheck
- **WHEN** consumer code declares `const k: TranslationKey = 'react-ui-pack-auth.does-not-exist'`
- **THEN** the assignment fails typecheck with `Type '...' is not assignable to type 'TranslationKey'`
**Verified by:** `packages/react/react-ui-pack-auth/src/translations/translations.test.ts::"augmentation mirrors en-GB.json"` AND a `*.expect-error.ts` fixture proving both the positive and negative typecheck scenarios above
---
### Requirement: pack ships translations in five locales with full key coverage
`authPack.translations` SHALL provide complete key coverage for `en-GB`, `zh-CN`, `ar-AE`, `cy-GB`, and `ru-RU`. The locale-completeness gate (`scripts/lint/check-locale-completeness.ts`) SHALL pass with zero diagnostics — every key in `en-GB.json` SHALL exist in each of the four other locale files. Welsh six-form plurals (`zero | one | two | few | many | other`) SHALL be exercised by the OTP "X attempts remaining" string in `src/translations/cy-GB.json`.
#### Scenario: every locale has every key
- **WHEN** `bun scripts/lint/check-locale-completeness.ts` runs against the pack
- **THEN** the script exits 0 with zero diagnostics
#### Scenario: Welsh six-form plurals all resolve
- **GIVEN** the OTP "X attempts remaining" key parsed by `MessageParser`
- **WHEN** the test resolves the plural for `count = 0, 1, 2, 3, 6, 4`
- **THEN** the categories returned are `zero, one, two, few, many, other` respectively AND each maps to a distinct translated string in `cy-GB.json`
**Verified by:** `packages/react/react-ui-pack-auth/src/translations/translations.test.ts::"every locale has every key"` AND `packages/react/react-ui-pack-auth/src/translations/translations.test.ts::"Welsh six-form plurals resolve"`
---
### Requirement: pack does not depend on Radix or any DOM library
The pack's `package.json` SHALL list `@suaveplan/react-ui` and `@suaveplan/react-i18n` as `peerDependencies` only — never as runtime `dependencies`. The pack SHALL NOT import any `@radix-ui/*` package directly, SHALL NOT import any DOM-manipulation library (`@floating-ui/*`, `framer-motion`, `react-aria`, `vaul`, `cmdk`), and SHALL NOT import `react-hook-form` directly. Every Radix primitive and every DOM concern reaches the pack transitively through `@suaveplan/react-ui`'s peer surface; every form-engine concern reaches it through `@suaveplan/react-forms`.
#### Scenario: peer-dep policy is honoured
- **WHEN** the test reads `packages/react/react-ui-pack-auth/package.json`
- **THEN** `dependencies` does NOT contain any `@suaveplan/react-ui*` or `@suaveplan/react-i18n` entry AND `peerDependencies` contains both with `workspace:^`
#### Scenario: no Radix or form-library import
- **WHEN** the test runs `grep -rn "from ['\"]\(@radix-ui\|@floating-ui\|framer-motion\|react-aria\|vaul\|cmdk\|react-hook-form\)" packages/react/react-ui-pack-auth/src/`
- **THEN** the grep returns zero matches
**Verified by:** `packages/react/react-ui-pack-auth/src/pack/pack.dependency-conformance.test.ts::"peer-dep policy"` AND `packages/react/react-ui-pack-auth/src/pack/pack.dependency-conformance.test.ts::"no Radix or form-library import"`
---
### Requirement: each form takes a typed AuthSubmitClient and onSuccess / onError callbacks
Each `<Form>Props` interface SHALL include `submitClient: AuthSubmitClient` (or pull from `<AuthSubmitClientProvider>` via `useAuthSubmitClient()` when the prop is omitted), `onSuccess: (result: <FormOutput>) => void`, and `onError?: (error: SuaveplanError) => void`. The pack SHALL NOT call `window.location.assign`, SHALL NOT push to a router, SHALL NOT touch `localStorage` or `sessionStorage`, and SHALL NOT depend on any global session singleton. When neither the prop nor the provider supplies an `AuthSubmitClient`, the form SHALL throw `ReactUiPackAuthError(REACT_UI_PACK_AUTH.SUBMIT_CLIENT_MISSING)` at render time.
#### Scenario: typed onSuccess receives the schema-typed payload
- **GIVEN** a `<LoginForm submitClient={stub} onSuccess={spy} />` whose stub resolves with a `LoginOutput`-shaped value
- **WHEN** the form is submitted with valid credentials
- **THEN** the spy is called once with a value that satisfies `LoginOutput.parse(value)`
#### Scenario: missing AuthSubmitClient throws SUBMIT_CLIENT_MISSING
- **GIVEN** a `<LoginForm onSuccess={spy} />` rendered with no `submitClient` prop and no `<AuthSubmitClientProvider>` ancestor
- **WHEN** the test renders the component
- **THEN** the render throws `ReactUiPackAuthError(REACT_UI_PACK_AUTH.SUBMIT_CLIENT_MISSING)`
#### Scenario: onError receives a SuaveplanError when the submit client throws
- **GIVEN** a stub `AuthSubmitClient.login` that rejects with `new SuaveplanError({ code: 'AUTH.INVALID_CREDENTIALS' })`
- **WHEN** the form submits with valid credentials
- **THEN** `onError` is called once with the original error AND the form renders the `react-ui-pack-auth.login.error.AUTH.INVALID_CREDENTIALS.body` translation key as a form-level alert
**Verified by:** `packages/react/react-ui-pack-auth/src/components/login-form/login-form.test.tsx::"onSuccess receives typed payload"` AND `packages/react/react-ui-pack-auth/src/components/login-form/login-form.test.tsx::"missing AuthSubmitClient throws SUBMIT_CLIENT_MISSING"` AND `packages/react/react-ui-pack-auth/src/components/login-form/login-form.test.tsx::"onError receives SuaveplanError"`

View File

@@ -0,0 +1,174 @@
# Tasks: enterprise-react-ui-pack-auth
**Change ID**: enterprise-react-ui-pack-auth
**Package**: `@suaveplan/react-ui-pack-auth`
**Target path**: `packages/react/react-ui-pack-auth`
**Prerequisite changes (must be archived before this change ships)**: `enterprise-react-i18n`, `enterprise-react-ui-v2`, `enterprise-react-forms-v2`
**Prerequisite packages on Verdaccio**: `@suaveplan/react-i18n`, `@suaveplan/react-ui`, `@suaveplan/react-forms`, `@suaveplan/api-schemas`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/testing`, `@suaveplan/testing-dom`, `@suaveplan/testing-e2e`
## 0. Pre-flight (non-negotiable)
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
## 1. Spec authoring
- [ ] 1.1 Read `proposal.md` and `specs/react-ui-packs/spec.md` end-to-end
- [ ] 1.2 Read `openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md` §5 (component-pack contract), §9 (`createDomainForm`), §11 (`TranslationKey`)
- [ ] 1.3 Read the in-flight `enterprise-react-ui-v2` proposal (specifically the `ComponentPack` / `PackRegistryProvider` / `usePackComponent` surface) and confirm the augmentation pattern this pack uses matches that proposal verbatim
- [ ] 1.4 Read the in-flight `enterprise-react-forms-v2` proposal (specifically `createDomainForm`'s field-config types and the `TranslationKey`-typed label / placeholder / helperText / errorMessage constraint)
- [ ] 1.5 `openspec change validate enterprise-react-ui-pack-auth --strict` exits with no errors
## 2. Adjacent api-schemas extension
- [ ] 2.1 In `packages/api/api-schemas/src/auth/`, add `password-reset-request.ts` exporting `PasswordResetRequestInput` (`{ email }`) and `PasswordResetRequestOutput` (`{ requestedAt }`) plus inferred types and sibling `.md`
- [ ] 2.2 Add `password-reset-confirm.ts` exporting `PasswordResetConfirmInput` (`{ token, newPassword }`) and `PasswordResetConfirmOutput` (`{ confirmedAt }`) plus inferred types and sibling `.md`
- [ ] 2.3 Add `two-factor-verify.ts` exporting `TwoFactorVerifyInput` (`{ code }`, six-digit string with `regex(/^\d{6}$/)` refinement) and `TwoFactorVerifyOutput` (`{ token, refreshToken, user: LoginUser }`) plus inferred types and sibling `.md`
- [ ] 2.4 Add `magic-link-request.ts` exporting `MagicLinkRequestInput` (`{ email }`) and `MagicLinkRequestOutput` (`{ requestedAt }`) plus inferred types and sibling `.md`
- [ ] 2.5 Add `magic-link-verify.ts` exporting `MagicLinkVerifyInput` (`{ token }`) and `MagicLinkVerifyOutput` (`{ token, refreshToken, user: LoginUser }`) plus inferred types and sibling `.md`
- [ ] 2.6 Update `packages/api/api-schemas/src/auth/index.ts` with explicit named exports for all five new modules (no `export *`, per CLAUDE.md §10)
- [ ] 2.7 Add a sibling `.test.ts` per new schema asserting parse success on a happy-path payload, parse failure on every refinement violation (empty, malformed email, invalid token, wrong code length), and idempotent round-trip through `safeParse`
- [ ] 2.8 `bunx turbo lint typecheck test --filter=@suaveplan/api-schemas` green
- [ ] 2.9 `bunx turbo test:coverage --filter=@suaveplan/api-schemas` at 100/100/100/100 across the touched files
## 3. Scaffold
- [ ] 3.1 Create `packages/react/react-ui-pack-auth/` with `package.json` at version `0.1.0`, `tsconfig.lib.json` (extends `@suaveplan/typescript-config/react-library.json`), `vitest.config.ts` (uses `createTestConfig()` from `@suaveplan/testing/config`, `provider: 'istanbul'`, thresholds `100/100/100/100`), `vite.config.ts` (vite-plugin-dts), `playwright.config.ts` (uses `createPlaywrightConfig()` from `@suaveplan/testing-e2e`)
- [ ] 3.2 Dependencies: `@suaveplan/react-forms workspace:^`, `@suaveplan/api-schemas workspace:^`, `@suaveplan/error workspace:^`, `@suaveplan/types workspace:^`, `@opentelemetry/api catalog:observability`, `tslib catalog:build`, `zod catalog:forms`
- [ ] 3.3 Peer dependencies: `react ^18.0.0 || ^19.0.0`, `@suaveplan/react-ui workspace:^`, `@suaveplan/react-i18n workspace:^`
- [ ] 3.4 Dev dependencies: `@suaveplan/react-ui workspace:^`, `@suaveplan/react-i18n workspace:^`, `@suaveplan/testing workspace:^`, `@suaveplan/testing-dom workspace:^`, `@suaveplan/testing-e2e workspace:^`, `@vitest/coverage-istanbul catalog:testing`, `react`, `react-dom`, `@types/react`, `@types/react-dom`, `vitest`, `vite`, `vite-plugin-dts`, `fast-check catalog:testing`
- [ ] 3.5 NO direct imports of `vitest` / `bun:test` (CLAUDE.md §11) — all test-runner primitives via `@suaveplan/testing/runner`
- [ ] 3.6 NO direct imports of `react-hook-form`, `@radix-ui/*`, `i18next`, `react-i18next` — all consumed transitively through `@suaveplan/react-forms`, `@suaveplan/react-ui`, `@suaveplan/react-i18n`
- [ ] 3.7 `"sideEffects": ["./src/translations/augmentation.js"]` — only the augmentation file has a side effect (registering the bundle); everything else is pure
- [ ] 3.8 Subpath exports: `.`, `./pack`, `./components/login-form/login-form`, `./components/register-form/register-form`, `./components/password-reset-form/password-reset-form`, `./components/two-factor-form/two-factor-form`, `./components/magic-link-form/magic-link-form`, `./errors`, `./translations`, `./types/auth-submit-client`
## 4. Errors and shared types
- [ ] 4.1 Create `src/errors/errors.ts` with `defineCodes("REACT_UI_PACK_AUTH", ["SUBMIT_CLIENT_MISSING", "PACK_VERSION_MISMATCH", "UNKNOWN_FIELD_KIND", "MODE_REQUIRED"])`; export `REACT_UI_PACK_AUTH_CODES` and `ReactUiPackAuthError extends SuaveplanError<ReactUiPackAuthErrorCode>`
- [ ] 4.2 Sibling `errors.test.ts` — every code resolves through the registry, error chain preserves `cause`, JSON wire format round-trips
- [ ] 4.3 Sibling `errors.md`
- [ ] 4.4 Create `src/types/auth-submit-client.types.ts` defining the `AuthSubmitClient` interface — one method per form, each typed against the matching `<Form>Input` / `<Form>Output` from `@suaveplan/api-schemas/auth`
- [ ] 4.5 Sibling `auth-submit-client.md` (no `.test.ts` — pure type file)
- [ ] 4.6 Create `src/types/auth-submit-client-provider.tsx` exporting `<AuthSubmitClientProvider>` and `useAuthSubmitClient()` (returns the provider's value, throws `SUBMIT_CLIENT_MISSING` when no provider AND no prop is passed at the form level)
- [ ] 4.7 Sibling `auth-submit-client-provider.test.tsx` and `auth-submit-client-provider.md`
## 5. Translations
- [ ] 5.1 Create `src/translations/en-GB.json` — every key the five forms reference: `react-ui-pack-auth.<form>.title`, `.subtitle`, `.<field>.label`, `.<field>.placeholder`, `.<field>.helperText`, `.<field>.errors.<code>`, `.submit.label`, `.submit.busyLabel`, `.cancel.label`, `.success.title`, `.success.body`, `.error.<code>.title`, `.error.<code>.body`. Add the OTP plural string with full Welsh six-form coverage (`{remaining, plural, zero {…} one {…} two {…} few {…} many {…} other {…}}`).
- [ ] 5.2 Create `src/translations/zh-CN.json` mirroring every key
- [ ] 5.3 Create `src/translations/ar-AE.json` mirroring every key (RTL — translation values are LTR-marked correctly via Unicode bidi where required)
- [ ] 5.4 Create `src/translations/cy-GB.json` mirroring every key (Welsh six-form plural for the OTP attempts string)
- [ ] 5.5 Create `src/translations/ru-RU.json` mirroring every key (Russian three-form plural where applicable)
- [ ] 5.6 Create `src/translations/translations.schema.ts` — Zod schema constraining the shape every locale JSON must satisfy (used by the locale-completeness gate to fail fast on missing keys)
- [ ] 5.7 Create `src/translations/augmentation.d.ts` (generated) — `declare module "@suaveplan/react-i18n" { interface ContributedTranslationKeys { ... } }` registering every key from `en-GB.json` into the brand union
- [ ] 5.8 Create `src/translations/augmentation.ts` — runtime side-effect entry that imports each locale JSON statically and exports `bundles: Record<SupportedLocale, Record<string, string>>`
- [ ] 5.9 Create `src/translations/translations.test.ts` — locale-completeness gate (every key in en-GB.json exists in every other locale), parse-against-Zod test for every locale, augmentation-mirrors-en-GB test
- [ ] 5.10 Create `src/translations/translations.md` (the only `.md` for the directory; per-JSON sibling docs not required since JSON files have no exports)
- [ ] 5.11 Wire `bun scripts/generate-translation-types.ts` for this package so re-running it produces a byte-identical `augmentation.d.ts`
## 6. ComponentPack export
- [ ] 6.1 Create `src/pack/pack.ts` exporting `authPack: ComponentPack<'auth'>` with `name: 'auth'`, `version: '0.0.0'`, `peerRange: '^0.1.0'`, `components: { LoginForm, RegisterForm, PasswordResetForm, TwoFactorForm, MagicLinkForm }`, `translations: bundles`, `onRegister`, `onUnregister`
- [ ] 6.2 `onRegister(ctx)` — calls `ctx.i18n.addResourceBundle('react-ui-pack-auth', bundles[ctx.i18n.locale])`, fires `pack.auth.lifecycle_total{phase=register, outcome=success}` counter, logs structured `pack.version_mismatch` warning via `ctx.logger` when the runtime `@suaveplan/react-ui` version falls outside `peerRange`
- [ ] 6.3 `onUnregister(ctx)` — calls `ctx.i18n.removeResourceBundle('react-ui-pack-auth', ctx.i18n.locale)` and fires the matching counter
- [ ] 6.4 Create `src/pack/pack.augmentation.d.ts` — `declare module "@suaveplan/react-ui/pack" { interface PackComponents { auth: { LoginForm: ComponentType<LoginFormProps>; RegisterForm: ComponentType<RegisterFormProps>; PasswordResetForm: ComponentType<PasswordResetFormProps>; TwoFactorForm: ComponentType<TwoFactorFormProps>; MagicLinkForm: ComponentType<MagicLinkFormProps> } } }`
- [ ] 6.5 Create `src/pack/pack.contract.test.tsx` — `expectTypeOf(authPack).toMatchTypeOf<ComponentPack<'auth'>>()`; registering it through `<PackRegistryProvider packs={[authPack]}>` makes `usePackComponent('auth', '<form>')` return the same component reference (`Object.is`) for all five forms
- [ ] 6.6 Create `src/pack/pack.lifecycle.test.tsx` — `onRegister` is called with `{ i18n, theme, logger, signal }` on mount; `onUnregister` is called on unmount; `addResourceBundle` and `removeResourceBundle` were called with the matching arguments; `pack.version_mismatch` fires only when `peerRange` is unsatisfied
- [ ] 6.7 Create `src/pack/pack.md`
## 7. LoginForm
- [ ] 7.1 Create `src/components/login-form/login-form.ts` building the form via `createDomainForm({ schema: LoginInput, fields: { email: { kind: 'email', label: 'react-ui-pack-auth.login.email.label', placeholder: 'react-ui-pack-auth.login.email.placeholder', autoComplete: 'email' }, password: { kind: 'password', label: 'react-ui-pack-auth.login.password.label', autoComplete: 'current-password' } }, layout: 'vertical', submit: { label: 'react-ui-pack-auth.login.submit.label', busyLabel: 'react-ui-pack-auth.login.submit.busyLabel', onSubmit: async (values, ctx) => { const result = await ctx.submitClient.login(values); ctx.onSuccess(result); } } })`
- [ ] 7.2 Export `LoginForm` and `LoginFormProps`
- [ ] 7.3 Sibling `login-form.test.tsx` — renders under all 5 locales (`it.each`); submits the typed payload exactly once; calls `onSuccess` with the `LoginOutput`-typed value; renders the typed key from the catalog (no string literal); maps a thrown `SuaveplanError("AUTH.INVALID_CREDENTIALS")` to the correct `react-ui-pack-auth.login.error.AUTH.INVALID_CREDENTIALS.body` key
- [ ] 7.4 Sibling `login-form.stories.tsx` — Default, Playground, WithI18n (parametric over the 5 locales), WithTheme (parametric over light/dark/highContrast/dracula), WithError (server-error mapping), WithDefaultEmail (the magic-link-prefilled flow)
- [ ] 7.5 Sibling `login-form.md`
## 8. RegisterForm
- [ ] 8.1 Create `src/components/register-form/register-form.ts` building the form via `createDomainForm({ schema: RegisterInput.extend({ confirmPassword: z.string().min(8), terms: z.literal(true) }), fields: { email, name, password, confirmPassword: { kind: 'password', label: 'react-ui-pack-auth.register.confirmPassword.label', validate: { matches: 'password' } }, terms: { kind: 'checkbox', label: 'react-ui-pack-auth.register.terms.label', required: true } }, layout: 'vertical', submit: { ... onSuccess wires onto AuthSubmitClient.register ... } })`
- [ ] 8.2 Export `RegisterForm` and `RegisterFormProps`
- [ ] 8.3 Sibling `register-form.test.tsx` — under all 5 locales; cross-field validator fires when `confirmPassword !== password` and renders `react-ui-pack-auth.register.confirmPassword.errors.mismatch`; the `terms` checkbox is required and submit stays disabled until checked; submit returns a `RegisterOutput`-typed value
- [ ] 8.4 Sibling `register-form.stories.tsx` — Default, Playground, WithI18n, WithTheme, WithMismatchedPasswords, WithUncheckedTerms, WithExistingEmailError
- [ ] 8.5 Sibling `register-form.md`
## 9. PasswordResetForm
- [ ] 9.1 Create `src/components/password-reset-form/password-reset-form.ts` exporting a single component whose `mode: 'request' | 'confirm'` prop selects between two factory-built forms — `mode = 'request'` builds against `PasswordResetRequestInput`, `mode = 'confirm'` builds against `PasswordResetConfirmInput`
- [ ] 9.2 When `mode` is missing or invalid, throw `ReactUiPackAuthError(MODE_REQUIRED)` at render time
- [ ] 9.3 Export `PasswordResetForm` and `PasswordResetFormProps` (the props discriminated union over `mode`)
- [ ] 9.4 Sibling `password-reset-form.test.tsx` — both modes render under all 5 locales; request-mode submit calls `submitClient.requestPasswordReset` once; confirm-mode submit calls `submitClient.confirmPasswordReset` once with `{ token, newPassword }`; missing `mode` throws `MODE_REQUIRED`
- [ ] 9.5 Sibling `password-reset-form.stories.tsx` — RequestDefault, ConfirmDefault, Playground, WithI18n, WithTheme, RequestSuccess, ConfirmSuccess, RequestRateLimitError, ConfirmInvalidTokenError
- [ ] 9.6 Sibling `password-reset-form.md`
## 10. TwoFactorForm
- [ ] 10.1 Create `src/components/two-factor-form/two-factor-form.ts` building via `createDomainForm({ schema: TwoFactorVerifyInput, fields: { code: { kind: 'otp', length: 6, label: 'react-ui-pack-auth.twoFactor.code.label', helperText: 'react-ui-pack-auth.twoFactor.code.helperText' } }, layout: 'vertical', submit: { ... } })` — the `kind: 'otp'` field renders the `react-ui` `InputOTP` primitive
- [ ] 10.2 Add a "send new code" sub-component gated by a 60-second countdown; the countdown is driven by an injectable `clock?: Clock` prop (default `Date.now`-based) so tests can use a `FakeClock` from `@suaveplan/testing`
- [ ] 10.3 The remaining-attempts string uses the `react-ui-pack-auth.twoFactor.code.helperText` key with a `count` argument, exercising Welsh six-form plurals under `cy-GB`
- [ ] 10.4 Export `TwoFactorForm` and `TwoFactorFormProps`
- [ ] 10.5 Sibling `two-factor-form.test.tsx` — renders under all 5 locales; six-digit input pattern is enforced (submit disabled until `/^\d{6}$/`); submit calls `submitClient.verifyTwoFactor` with `{ code }`; "send new code" is disabled while the countdown is active and re-enables when the FakeClock advances 60 s; Welsh plural categories `zero / one / two / few / many / other` all resolve to the right string
- [ ] 10.6 Sibling `two-factor-form.stories.tsx` — Default, Playground, WithI18n, WithTheme, WithCountdownActive, WithCountdownExpired, WithInvalidCode, WithWelshPlurals (one story per Welsh plural category)
- [ ] 10.7 Sibling `two-factor-form.md`
## 11. MagicLinkForm
- [ ] 11.1 Create `src/components/magic-link-form/magic-link-form.ts` exporting a component whose `mode: 'request' | 'verify'` prop selects between two factory-built forms — `mode = 'request'` builds against `MagicLinkRequestInput`, `mode = 'verify'` builds against `MagicLinkVerifyInput`
- [ ] 11.2 In `verify` mode, accept an optional `token` prop — when present, auto-submit on mount; when absent, render the manual fallback form
- [ ] 11.3 Throw `ReactUiPackAuthError(MODE_REQUIRED)` if `mode` is missing
- [ ] 11.4 Export `MagicLinkForm` and `MagicLinkFormProps`
- [ ] 11.5 Sibling `magic-link-form.test.tsx` — both modes render under all 5 locales; request-mode submit calls `submitClient.requestMagicLink`; verify-mode auto-submit fires `submitClient.verifyMagicLink` exactly once on mount when `token` is provided; manual-fallback button submits the typed `{ token }` payload otherwise
- [ ] 11.6 Sibling `magic-link-form.stories.tsx` — RequestDefault, VerifyAutoSubmit, VerifyManualFallback, Playground, WithI18n, WithTheme, RequestRateLimitError, VerifyExpiredTokenError
- [ ] 11.7 Sibling `magic-link-form.md`
## 12. Telemetry
- [ ] 12.1 Create `src/telemetry/telemetry.ts` exporting the three instruments (`pack.auth.submit_total`, `pack.auth.submit.duration_ms`, `pack.auth.lifecycle_total`); enforce the `outcome` and `phase` allowlists at the recording site so unbounded label cardinality is impossible
- [ ] 12.2 Wire each form's submit pipeline through the counter + histogram (success path increments `outcome=success`, validation failures `outcome=validation_error`, thrown errors `outcome=submit_error`)
- [ ] 12.3 Sibling `telemetry.test.ts` — counters fire with the right attributes; histogram records duration; allowlist rejects unknown values
- [ ] 12.4 Sibling `telemetry.md`
## 13. Public root + README
- [ ] 13.1 Create `src/index.ts` with explicit named re-exports (no `export *`, per CLAUDE.md §10) for `authPack`, the five `<Form>` components, the matching `<Form>Props` types, `AuthSubmitClient`, `AuthSubmitClientProvider`, `useAuthSubmitClient`, `ReactUiPackAuthError`, `REACT_UI_PACK_AUTH_CODES`. Side-effect import `./translations/augmentation.js` and `./pack/pack.augmentation.js` so type augmentation and runtime bundle registration happen on root import.
- [ ] 13.2 Create `README.md` at the package root with: package name, one-paragraph description, install snippet (`bun add @suaveplan/react-ui-pack-auth`), quick-start (registering the pack via `<PackRegistryProvider packs={[authPack]}>` and rendering `<LoginForm submitClient={mySubmitClient} onSuccess={...} />`), Modules section linking to every co-located `.md`
- [ ] 13.3 Run the per-package doc-coverage gate: every implementation `.ts`/`.tsx` (excluding tests, types, stories, barrels) has a sibling `.md` ≥ 200 words with simple + advanced examples + API reference
## 14. End-to-end + a11y
- [ ] 14.1 Create `e2e/auth-submit-success.spec.ts` — for each of the five forms, fill required fields and submit against a stub `AuthSubmitClient`; assert `onSuccess` payload matches the API-schema shape
- [ ] 14.2 Create `e2e/auth-submit-error.spec.ts` — submit against a stub that throws a structured `SuaveplanError`; assert the form maps the error code to the correct translation key and renders next to the offending field (or as a form-level alert when no field maps)
- [ ] 14.3 Create `e2e/auth-rtl.spec.ts` — load each form under `ar-AE`; assert `<html dir="rtl">`; assert OTP cells flow right-to-left; assert the password show/hide eye icon mirrors
- [ ] 14.4 Create `e2e/auth-themes.spec.ts` — load `LoginForm` under each built-in theme + the dracula example theme; assert no console errors; assert axe-core finds zero serious or critical violations
- [ ] 14.5 Create `e2e/pack-lifecycle.spec.ts` — mount the `<PackRegistryProvider>` with `authPack`; assert bundles are added; unmount; assert bundles are removed and `usePackComponent('auth', 'LoginForm')` now returns `undefined`
## 15. Pre-publish gate
- [ ] 15.1 `bunx turbo lint typecheck test --filter=@suaveplan/react-ui-pack-auth` green
- [ ] 15.2 `bunx turbo test:coverage --filter=@suaveplan/react-ui-pack-auth` at 100/100/100/100
- [ ] 15.3 `bunx turbo build --filter=@suaveplan/react-ui-pack-auth` green
- [ ] 15.4 Banned-pattern scan zero matches: `grep -rn "@deprecated\|TODO\|FIXME\|ts-ignore\|ts-expect-error\|\.skip\|\.only\|\bany\b\|console\.log" packages/react/react-ui-pack-auth/src/ --include="*.ts" --include="*.tsx"`
- [ ] 15.5 Test-runner-import scan zero matches: `grep -rnE "from ['\"](vitest|bun:test)['\"]" packages/react/react-ui-pack-auth/src/ --include="*.ts" --include="*.tsx"`
- [ ] 15.6 Barrel-shape scan zero matches in feature barrels: `grep -rn "^\s*export\s\+\(type\s\+\)\?\*\s\+from" packages/react/react-ui-pack-auth/src/ --include="index.ts" | grep -v "/src/index\.ts:"`
- [ ] 15.7 Hardcoded-string scan against the §11 watchlist (label, placeholder, helperText, errorMessage, aria-label, title, alt) zero matches: every value resolves to a `TranslationKey`
- [ ] 15.8 Locale-completeness scan green — every key in `en-GB.json` exists in `zh-CN.json`, `ar-AE.json`, `cy-GB.json`, `ru-RU.json`
- [ ] 15.9 Translation-key-existence scan green — every `t('...')` call references a key that exists in `en-GB.json`
- [ ] 15.10 React single-version check: `find . -path "*/node_modules/react/package.json" | head -5` returns one path
- [ ] 15.11 Subpath-export count matches feature module count (per CLAUDE.md §6)
- [ ] 15.12 Dependent sweep `bunx turbo test --filter=...@suaveplan/react-ui-pack-auth` (expect zero dependents on first ship; future packs that consume this will be picked up automatically)
## 16. Manifest + STATUS refresh
- [ ] 16.1 Add `@suaveplan/react-ui-pack-auth` to the package listing in `CLAUDE.md` under the `react-ui` plugin's package list (immediately after `react-ui` and `react-ui-theme-dracula`)
- [ ] 16.2 Run `bun scripts/folder-reorg/generate-manifest.ts` to refresh `MANIFEST.md`
- [ ] 16.3 Run `bun scripts/generate-missing.ts` to refresh `MISSING.yml`
- [ ] 16.4 Update `STATUS.md` to move `@suaveplan/react-ui-pack-auth` (if it appears in Pending) to the Completed section, or add a new Completed row if it does not appear in Pending
- [ ] 16.5 Update `SUMMARY.md` with a per-package paragraph reflecting the shipped surface (five forms, pack contract proof, five locales, AuthSubmitClient injection)
- [ ] 16.6 `bunx markdownlint-cli2 --fix` — re-stage modified files; `bunx markdownlint-cli2` exits 0
## 17. OpenSpec archival
- [ ] 17.1 Fold `specs/react-ui-packs/spec.md` into `openspec/specs/react-ui-packs/spec.md` (creating the canonical spec file since this is the first change against the capability)
- [ ] 17.2 Move `openspec/changes/enterprise-react-ui-pack-auth/` → `openspec/archive/<yyyy-mm-dd>-enterprise-react-ui-pack-auth/`
- [ ] 17.3 Confirm `git status` shows only intentional changes; no scratch / debug artifacts remain