How any flow page should use this library: the shell, the main CTA, inputs
and errors. This is the library's side of the contract. App logic —
validation, state, navigation, API calls — belongs to the consuming app
(st-appflow keeps its own docs/step-authoring.md for that).
Each rule says how it's enforced today. "Not yet" means it's written down but nothing checks it; those are candidates for the design-system rules ticket. Linked from AGENTS.md.
1. Compose pages from library components
- Build every visible part of a page from
spring-design-library-reactcomponents, withAppFlowPageTitleas the page title. - Don't hand-build inputs, buttons, selectors or modals in a page.
- If a page needs something the library doesn't have, that's a library change (a new component or a new prop), made here — not something built inside the page.
- Radio choices are always
SpringRadioGroup(built from theSpringRadioatom). Don't mapSpringRadios into your own group or draw radios in a page;SpringRadioon its own is for library components such asPlanCard.
Enforced by: not yet.
2. The shell is fixed; only the page body changes
AppFlowShell (via AppFlowShellWithParams) renders the same frame on every
page: header, optional progress bar, the page body, the main CTA (when the
page has one), and the legal footer (desktop). A page renders only its body.
- Don't render a header, progress bar or footer inside a page.
- Progress, caption,
logoHrefandlegalLinksare shell props, not page content.progress={null}hides the bar.
Enforced by: AppFlowShell.test.tsx (the shell always renders these regions).
3. A page's main CTA is registered with the shell
Not every page has one. Pages where the customer moves on (most steps) register one main CTA; end pages with nothing to continue to — e.g. application complete, sorry — register none.
useAppFlowCta({
label: 'Continue',
onClick: onContinue,
loading: submitting,
disabled: !canContinue,
error: submitError, // shown above the button on phones
})
- When a page has a main CTA, register it with
useAppFlowCta; the shell draws it (inline on desktop, floating bar on phones). Don't render your own Continue button. - A page without one registers nothing (or passes
null); the shell then draws no CTA.
Known gap: the library's own reference screens mix two patterns — about
27 screen files use the in-body AppFlowInlineCta, 13 use useAppFlowCta.
Which one reference screens should use is an open decision for the UX
engineer.
Enforced by: AppFlowShell.test.tsx checks that a registered CTA renders
and fires, and that no CTA renders when a page registers none.
4. Inputs: give them their value; don't write to the input directly
Inputs such as TextField keep their own copy of the value and render from
it. Use either:
- controlled:
value+onChange(the page owns the value), or - uncontrolled:
defaultValue(the field owns it).
Don't set the input element's value directly (through a ref, or a form
library's register, which does this): the field's next render overwrites it
with its own value, so stored answers and programmatic fills disappear. With
react-hook-form, bind fields through Controller (value + onChange).
Enforced by: not yet.
5. Errors: field errors on the field, form errors on the CTA
- Field error (this input is wrong): the input's own
status="error"anderrorMessage. Supported byTextField,TileSelector,SpringRadioGroup,DropdownField,PhoneNumberField,OtpField,SplitPillInput,LoanAmountSliderandFileUploader. - Form error (the submit failed, or a rule across fields): the CTA's
error, which the shell shows above the button on phones. On desktop the page shows it in its body (st-appflow uses its ownStepFormErrorfor that). - Don't hand-build error text or icons; use the components' error props or
the library
Message.
Enforced by: not yet.
6. Plan cards: pick by what the customer does with them
| The customer… | Use | Phones |
|---|---|---|
| chooses a Foundation plan with a radio | PlanCard (mode="select", the default) | PlanCarousel, cards with isMobile |
| chooses it with the card's own button ("Get Gold") | PlanCard mode="action" + action | PlanCarousel, cards with isMobile |
| only compares plans, nothing to choose here | PlanCard mode="readOnly" | Carousel + CarouselTrack + CarouselIndicator, cards with isMobile |
| sees the plan they already chose | PlanSummaryCard | — |
| sees a personal-loan offer or picks an SPL plan | SpringLoanCard (layout) | — |
PlanCarousel is for choosing (its pager is the radio group); Carousel is
for showing. Rows: answer makes a row expand; highlight marks a plan's
selling point (accent when included). Figma's plan-card / foundation and
plan-card / feature-row use the same property names (mode, isMobile,
highlight, expandable).
When the request doesn't say (agents: ask, don't guess):
- Can the customer choose a plan here — with a radio, with a button, or not
at all? →
mode. - Phone layout? → the carousel in the table.
- Should any row open with details (
answer) or stand out (highlight)?
If the request describes the elements instead ("two cards, a Get Gold button, the Bloom row stands out"), map them to props and confirm the result in one line rather than asking again. If the design already makes it obvious, use it and say which mode you picked.
Enforced by: types — mode="action" without action doesn't compile.
Which card a page uses: not yet.
7. Layout (rule candidate, not built yet)
Every page body should share one column width and spacing. Today each page sets its own classes, and the library has no shared page-body component or layout tokens, so pages drift (different widths and mobile gaps). Planned: a shared page-body component with fixed width options and layout tokens.
Enforced by: not yet.