Files
suaveplan/openspec/changes/enterprise-react-ui-pack-auth/specs/react-ui-packs/spec.md
2026-05-27 23:29:53 +01:00

17 KiB

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"