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. (Seeopenspec/AGENTS.mdSection 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
authPackfrom@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'(notstring)
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 importsLoginFormdirectly - 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-packsource tree - WHEN the test runs
grep -rn "createDomainForm\|useDomainForm\|<.*\.Field" packages/react/react-ui-pack-auth/src/components/and counts occurrences - THEN every
createDomainFormcount is exactly 1 per form-component file (5 in total) ANDuseDomainFormcount is 0 AND<.*\.Fieldcount is 0
Scenario: each factory invocation passes a Zod schema as the spine
- WHEN the test inspects each
createDomainFormcall site - THEN the
schemaproperty 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 insideregister-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/auththat renamesLoginInput.emailtoLoginInput.emailAddress - WHEN
bunx turbo typecheck --filter=@suaveplan/react-ui-pack-authruns - THEN the build fails because the field config references the missing
emailproperty
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'withlabel: 'Email' - WHEN
bunx turbo typecheck --filter=@suaveplan/react-ui-pack-authruns - 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.tsruns againstpackages/react/react-ui-pack-auth/ - THEN every
t('...')call resolves to a key present insrc/translations/en-GB.json
Scenario: no hardcoded user-visible strings exist in the pack source
- WHEN
bun scripts/lint/check-no-hardcoded-strings.tsruns againstpackages/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 freshI18nInstancewhose locale isen-GB - WHEN the provider mounts
- THEN
i18n.addResourceBundlewas called once with('react-ui-pack-auth', authPack.translations['en-GB'])ANDpack.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.removeResourceBundlewas called once with('react-ui-pack-auth', 'en-GB')ANDpack.auth.lifecycle_total{phase=unregister, outcome=success}counter incremented by 1
Scenario: pack-scoped AbortSignal aborts on unmount
- GIVEN an
onRegisterthat capturesctx.signal - WHEN the provider unmounts before any in-flight async work resolves
- THEN
ctx.signal.aborted === trueAND 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.jsonagainst the keys augmented intoContributedTranslationKeys - 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.tsruns 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, otherrespectively AND each maps to a distinct translated string incy-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
dependenciesdoes NOT contain any@suaveplan/react-ui*or@suaveplan/react-i18nentry ANDpeerDependenciescontains both withworkspace:^
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 aLoginOutput-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 nosubmitClientprop 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.loginthat rejects withnew SuaveplanError({ code: 'AUTH.INVALID_CREDENTIALS' }) - WHEN the form submits with valid credentials
- THEN
onErroris called once with the original error AND the form renders thereact-ui-pack-auth.login.error.AUTH.INVALID_CREDENTIALS.bodytranslation 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"