# 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-` 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>()` - **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 `
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 `` - **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.` 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 `` 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...` 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 `` 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 `` 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 `` - **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 = ''` - **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 `Props` interface SHALL include `submitClient: AuthSubmitClient` (or pull from `` via `useAuthSubmitClient()` when the prop is omitted), `onSuccess: (result: ) => 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 `` 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 `` rendered with no `submitClient` prop and no `` 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"`