# Vendor admin — form / settings page pattern

## Before using this pattern

Read `node_modules/@spark-web/design-system/patterns/vendor-admin/CLAUDE.md`
fully before implementing this pattern. The layout-shell rule, the page-title
rule (`Heading level="2"`), the spacing/density rule, and the role/feature-flag
gating rule defined there all apply to this pattern and take precedence over
component-level rules.

## What this pattern is

A full page layout for a vendor-admin **form or settings screen** — one or more
sections of inputs the vendor edits and saves. Unlike the list page, a form page
is a centered, bounded, standalone flow: it is **not** opened in a side panel
(see the surface "Detail interaction rule" — settings is a full page, not a
panel).

## When to use this pattern

Use this pattern when the PRD describes any of the following:

- A settings, configuration, or preferences screen the vendor edits
- A "create" or "edit a record" flow with input fields and a save action
- The words "settings", "configure", "preferences", "form", "edit", or "manage
  your …"

If the screen instead displays a list of records, use the list-page pattern:

`node_modules/@spark-web/design-system/patterns/vendor-admin/list-page.md`

---

## Component docs to read

Read these before implementing — they own the component-level rules. Only read
the ones the screen actually uses:

- `node_modules/@spark-web/container/CLAUDE.md` — Container `size` keys (page
  centering)
- `node_modules/@spark-web/stack/CLAUDE.md` — vertical stacking, gap, align
- `node_modules/@spark-web/heading/CLAUDE.md` — page title (`level="2"`) and
  section titles (`level="3"`)
- `node_modules/@spark-web/divider/CLAUDE.md` — Divider, section separation
- `node_modules/@spark-web/field/CLAUDE.md` — Field API (label, message, tone),
  and the Field-context disabled mechanism that wraps every input
- `node_modules/@spark-web/text-input/CLAUDE.md` — TextInput API
- `node_modules/@spark-web/text-area/CLAUDE.md` — TextArea API
- `node_modules/@spark-web/select/CLAUDE.md` — Select API (single-select)
- `node_modules/@spark-web/multi-select/CLAUDE.md` — MultiSelect API (grouped
  options)
- `node_modules/@spark-web/checkbox/CLAUDE.md` — Checkbox API (tone, size)
- `node_modules/@spark-web/currency-input/CLAUDE.md` — CurrencyInput API (money
  fields)
- `node_modules/@spark-web/date-picker/CLAUDE.md` — DatePicker API (date fields)
- `node_modules/@spark-web/alert/CLAUDE.md` — inline success/error feedback
- `node_modules/@spark-web/button/CLAUDE.md` — submit Button, `loading` prop
- `node_modules/@spark-web/a11y/CLAUDE.md` — VisuallyHidden (accessible labels)

---

## Page structure

```
Page
  Container size="medium"                    — centers + bounds the page (940px)
    Outer Stack                              — gap="xxlarge" marginY="xxlarge" marginX="large"
      Heading level="2"                      — page title (surface rule — NOT PageHeader, NOT level 1)
      Branch / context guard (conditional)   — render the form only once a branch/context is selected
      Section (per form section)             — separated by Divider
        Divider                              — between sections
        Section Stack gap="xlarge"
          Heading level="3"                  — section title
          Alert (conditional)                — section-level success/error feedback
          Field-wrapped inputs               — checkbox / select / multi-select / text-input /
                                               text-area / currency-input / date-picker
          Stack align="right"                — submit row
            Button loading={saving}          — section-level save (when sections save independently)
      Stack align="right" (conditional)      — page-level save (when the whole form saves at once)
        Button loading={saving}
```

---

## Section 1 — Page shell

The page is centered and width-bounded by `Container`, then given vertical
rhythm by an outer `Stack`.

```tsx
import { Container } from '@spark-web/container';
import { Heading } from '@spark-web/heading';
import { Stack } from '@spark-web/stack';

<Container size="medium">
  <Stack gap="xxlarge" marginY="xxlarge" marginX="large">
    <Heading level="2">{pageTitle}</Heading>
    {/* sections */}
  </Stack>
</Container>;
```

Rules:

- The outer wrapper is **`Container size="medium"`** (940px) — this is the
  form-page centering rule. Do not use `Stack height="full"` here (that is the
  list-page wrapper) and do not omit the Container.
- The page body is a single `Stack` with `gap="xxlarge"` (between major
  sections), `marginY="xxlarge"`, and `marginX="large"`. These spacing tokens
  come straight from the surface spacing/density rule — never raw pixel gaps.
- The page title is **`Heading level="2"`** per the surface page-title rule —
  never `PageHeader`, never `level="1"`. The fixed shell Header already carries
  product identity; the in-page title is a section heading within the shell.

`Container`'s `size` keys are `xsmall | small | medium | large | xlarge`
(`medium` = 940px). Use `size="medium"` for a standard settings/form page; use a
narrower size only when the PRD calls for a tight single-column form.

---

## Section 2 — Sectioned layout

A form page is a vertical sequence of titled sections separated by a `Divider`.
Each section groups a coherent set of fields plus (when each section saves
independently) its own save button.

```tsx
import { Divider } from '@spark-web/divider';
import { Heading } from '@spark-web/heading';
import { Stack } from '@spark-web/stack';

<>
  <Divider />
  <Stack gap="xlarge">
    <Heading level="3">{sectionTitle}</Heading>
    {/* fields */}
  </Stack>
</>;
```

Rules:

- Separate major sections with `<Divider />` from `@spark-web/divider`. A
  `Divider` precedes each section so sections read as distinct blocks within the
  `gap="xxlarge"` outer rhythm.
- `Divider`'s `color` defaults to `'standard'` — do not pass a color unless the
  PRD requires a specific divider tone; the default is correct for section
  separation.
- Each section is a `Stack gap="xlarge"` titled with **`Heading level="3"`** (a
  sub-section step-down from the page's `level="2"` title, per the surface
  page-title rule). Never use `level="2"` for a section title (reserved for the
  page) and never jump to `level="4"+`.
- Render `null` for a section that is conditionally hidden — never render an
  empty titled section or a dangling `Divider`.

---

## Section 3 — Form fields (every input wrapped in Field)

Every input on a vendor-admin form page is wrapped in `Field` from
`@spark-web/field`. `Field` owns the visible label, the help/error `message`,
and the `tone`, and it propagates `disabled` to its child input via React
context.

```tsx
import { Field } from '@spark-web/field';
import { TextInput } from '@spark-web/text-input';

<Field
  label="Trading name"
  message={error?.message}
  tone={error ? 'critical' : 'neutral'}
>
  <TextInput value={name} onChange={e => setName(e.target.value)} />
</Field>;
```

`Field` rules:

- `Field`'s `tone` union is **`critical | neutral | positive`** — there is no
  `info` or `caution` tone on a field. Use `critical` for validation errors,
  `positive` for confirmed-valid state, `neutral` (default) otherwise.
- A `message` only renders the error styling when paired with `tone="critical"`
  (`invalid = message && tone === 'critical'`). Always set `tone="critical"`
  alongside an error `message`.
- `Field` disables its child input by passing `disabled` down through context —
  set `disabled` on the `Field`, not on the input, so the input, label, and
  message all reflect the disabled state together.

### CRITICAL — DatePicker and CurrencyInput MUST be inside a Field

`DatePicker` (`@spark-web/date-picker`) and `CurrencyInput`
(`@spark-web/currency-input`) read their `disabled` state from the **Field
context** (`useFieldContext()`), not from a prop. Rendered outside a `Field`,
they have no context to read and the disabled/placeholder behaviour breaks.
**Always wrap them in a `Field`.**

```tsx
import { Field } from '@spark-web/field';
import { CurrencyInput } from '@spark-web/currency-input';
import { DatePicker } from '@spark-web/date-picker';

<Field label="Amount" disabled={isLocked}>
  <CurrencyInput value={amount} onChange={setAmount} />
</Field>

<Field label="Start date" disabled={isLocked}>
  <DatePicker value={startDate} onChange={setStartDate} />
</Field>;
```

`DatePicker.onChange` is `(day: Date | undefined) => void`; `CurrencyInput`
takes a controlled `value` / `onChange` pair.

### Input components

- **Text** — `TextInput` from `@spark-web/text-input` (single line), `TextArea`
  from `@spark-web/text-area` (multi line).
- **Single-select** — `Select` from `@spark-web/select`: pass `options`
  (`Array<Option | Group>`), `value`, `onChange={e => …e.target.value}`, and a
  `placeholder`. Always inside a `Field`.
- **Multi-select** — `MultiSelect` from `@spark-web/multi-select`: pass grouped
  `options` (`Array<{ label, options: Option[] }>`), `onChange` (receives a
  `SelectedOptions` object keyed by group label), `placeholder`, and optional
  `defaultOptions`. `MultiSelect` renders no visible label, so either wrap it in
  a `Field` or label it with `aria-labelledby` pointing at a `VisuallyHidden`
  element.
- **Checkbox** — `Checkbox` from `@spark-web/checkbox`: controlled via `checked`
  / `onChange`. Its `tone` union is **`critical | neutral | positive`** (no
  `info`/`caution`) and `size` is **`small | medium`** (`small` is the default).
  Provide an accessible label (its `children`, or a `VisuallyHidden` child when
  the visible label lives elsewhere, e.g. in a settings-row layout).
- **Currency** — `CurrencyInput` (see the CRITICAL Field-wrapping rule above).
- **Date** — `DatePicker` (see the CRITICAL Field-wrapping rule above).

### React-hook-form binding convention

Wire forms with `react-hook-form`, validating with a Zod schema via
`@hookform/resolvers` (`zodResolver`). Bind each input through `register` (or a
`Controller` for non-native inputs like `Select`/`MultiSelect`/`DatePicker`/
`CurrencyInput`) and drive each `Field`'s error presentation from the form's
`errors[name]`:

- when `errors[name]` is present → `tone="critical"` and
  `message={errors[name].message}`;
- otherwise → `tone="neutral"` (and any static help `message`).

In the **vendor-portal** repo this `errors[name] → tone/message` mapping is
already packaged in a local `FormField` wrapper (exported as `Field`) — use it
as the consumer substitution for `@spark-web/field`. See the overlay (below); do
not re-derive the mapping by hand on vendor-portal pages.

---

## Section 4 — Feedback

Surface save success/error with an `Alert` from `@spark-web/alert`, rendered
inside the section it relates to (above the submit row), or at page level for a
page-wide save.

```tsx
import { Alert } from '@spark-web/alert';

{
  updateState && (
    <Alert
      tone={updateState.success ? 'positive' : 'critical'}
      onClose={() => setUpdateState(null)}
      closeLabel="Dismiss"
    >
      {updateState.message}
    </Alert>
  );
}
```

Rules:

- `Alert`'s `tone` union is `caution | critical | info | positive`. For save
  feedback use `positive` (success) or `critical` (failure).
- `onClose` and `closeLabel` must be provided together — `Alert` only renders
  the close button when both are set. Reset the feedback state on close and when
  the form context changes.
- Working in vendor-portal? Its `FlashMessage` is the local success/error
  binding over `Alert` — apply the overlay substitution.

---

## Section 5 — Submit

The save action is a single `Button` from `@spark-web/button`, right-aligned in
a `Stack align="right"`, with its `loading` prop bound to the in-flight save
state.

```tsx
import { Button } from '@spark-web/button';
import { Stack } from '@spark-web/stack';

<Stack align="right">
  <Button type="submit" onClick={handleSave} loading={saving}>
    Save changes
  </Button>
</Stack>;
```

Rules:

- Right-align the submit with `Stack align="right"` — do not center or
  left-align the primary save.
- Bind `loading={saving}` so the button shows a spinner and is non-interactive
  during the mutation — never render a separate external spinner.
- One primary save per save-scope (see conditional rules) — do not stack
  multiple primary buttons in one row.

---

## Conditional rules

### Branch / context-selection guard

A vendor-admin form often depends on a selected branch/vendor context. Guard the
form: when no context is selected, render a short selection prompt instead of
the form, and render the sections only once the context exists.

```tsx
if (!selectedContextId) {
  return (
    <Container size="small">
      <Stack padding="large" margin="large" align="center">
        <Text size="large">Please select a branch first.</Text>
      </Stack>
    </Container>
  );
}
```

- Use a narrower `Container size="small"` for the guard prompt; the form itself
  uses `size="medium"`.
- Also guard each section's data: when the underlying record is absent, fall
  back to the same prompt rather than rendering empty sections. Adding a `key`
  per section tied to the selected context forces section state to reset when
  the context changes.

### Section-level save vs page-level save

- **Section-level save** — when each section persists independently (e.g. a
  settings page where "Product settings" and "Notification settings" each have
  their own Save). Put a `Stack align="right"` + `Button loading` **inside each
  section** and give each section its own in-flight + feedback state.
- **Page-level save** — when the whole form submits as one unit (e.g. a create
  flow). Put a single `Stack align="right"` + `Button loading` **after the last
  section**, driven by one form-submit handler and one in-flight state.

Choose one per section/page; do not mix a page-level save with per-section saves
for the same fields.

---

## Structural skeleton

Use this skeleton as the starting point for new builds and uplifts. Do not use
existing page implementations as a structural reference. (This example shows
section-level saves; collapse to a single trailing save row for a page-level
save.)

```tsx
import { Container } from '@spark-web/container';
import { Divider } from '@spark-web/divider';
import { Heading } from '@spark-web/heading';
import { Stack } from '@spark-web/stack';
import { Text } from '@spark-web/text';

// Branch / context guard — see Conditional rules. When a section's underlying
// record is absent, fall back to this same prompt instead of rendering empty
// sections.
if (!selectedContextId) {
  return (
    <Container size="small">
      <Stack padding="large" margin="large" align="center">
        <Text size="large">Please select a branch first.</Text>
      </Stack>
    </Container>
  );
}

<Container size="medium">
  <Stack gap="xxlarge" marginY="xxlarge" marginX="large">
    <Heading level="2">{pageTitle}</Heading>

    {/* One block per form section — Section 2 (see above) */}
    <Divider />
    <Stack gap="xlarge" key={`${record.id}-details`}>
      <Heading level="3">Details</Heading>

      {/* Section 4 — feedback Alert (conditional) — see above */}

      {/* Section 3 — Field-wrapped inputs; DatePicker / CurrencyInput per the
          CRITICAL Field-wrapping rule — see above */}

      {/* Section 5 — submit row (Stack align="right" + Button loading) — see
          above; section-level save shown here, or collapse to a single
          trailing save row for a page-level save */}
    </Stack>
  </Stack>
</Container>;
```

---

## Working in vendor-portal?

Apply the substitutions in
`node_modules/@spark-web/design-system/patterns/vendor-admin/vendor-portal.md`.
The relevant ones for a form page: the `FormField` wrapper (exported as `Field`)
in place of hand-wiring `@spark-web/field` error props; `FlashMessage` in place
of a hand-built `Alert` success/error toggle; `MultiselectCheckbox` is being
superseded by `@spark-web/multi-select` (the shapes match — prefer `MultiSelect`
in new code); and the `SettingsPanel` label-beside-control settings row.

---

## Documented exceptions summary

No raw CSS is required by this pattern's canonical snippets; if you add any,
list it here with a justification and use the `css` prop.

| Value | Property | Reason        |
| ----- | -------- | ------------- |
| —     | —        | none required |

---

## Do NOTs

- NEVER place `DatePicker` or `CurrencyInput` outside a `Field` — both read
  `disabled` from `Field` context (`useFieldContext()`); without a `Field`
  wrapper their disabled/placeholder behaviour breaks.
- NEVER use a `tone` outside `critical | neutral | positive` on `Checkbox` or
  `Field` — there is no `info` or `caution` tone there (those exist on `Alert`,
  not on fields/checkboxes).
- NEVER pass a `Checkbox` `size` other than `small | medium`.
- NEVER set an error `message` on a `Field` without also setting
  `tone="critical"` — the error styling only renders when both are present.
- NEVER use `Stack height="full"` as the form-page wrapper — the form page is
  centered/bounded with `Container size="medium"` (that full-height wrapper
  belongs to the list page).
- NEVER replace the page title with `PageHeader` or promote it to `level="1"` —
  vendor-admin page titles are `Heading level="2"`, section titles are
  `level="3"`.
- NEVER open a settings/form screen in a side panel — settings is a full page
  (see the surface "Detail interaction rule").
- NEVER render an empty titled section or a dangling `Divider` for a hidden
  section — return `null` instead.
- NEVER left-align or center the primary save — it is right-aligned via
  `Stack align="right"`.
- NEVER render an external loading spinner for the save — use the Button
  `loading` prop.
- NEVER pass a `color` to `Divider` for plain section separation — the default
  `'standard'` is correct.
- NEVER import `Divider` from a non-Spark package (e.g. `rsuite`) — always
  `@spark-web/divider`.
- NEVER hand-wire `@spark-web/field` error props on a vendor-portal page when
  the `FormField` (`Field`) wrapper already maps `errors[name]` — use the
  overlay substitution.

---

## Validation checklist

Run this checklist before marking any form-page task complete. Fix every
violation first. The uplift protocol in
`node_modules/@spark-web/design-system/CLAUDE.md` also runs this checklist
against existing pages and reports PASS/FAIL per item.

1. Page wrapper is `Container size="medium"` (not `Stack height="full"`, not a
   missing Container), with an inner
   `Stack gap="xxlarge" marginY="xxlarge" marginX="large"`.
2. Page title is `Heading level="2"`; every section title is `Heading level="3"`
   — no `PageHeader`, no `level="1"`, no `level="2"` section titles.
3. Sections are separated by `<Divider />` from `@spark-web/divider` with the
   default `color="standard"`; hidden sections return `null` (no empty section,
   no dangling Divider).
4. Every input is wrapped in a `Field` from `@spark-web/field` (or the
   consumer-overlay `Field`/`FormField`), with the label on the `Field`.
5. `DatePicker` and `CurrencyInput` are each inside a `Field` — verified by
   inspection (they read `disabled` from Field context).
6. `Field`/`Checkbox` tones are only `critical | neutral | positive`; an error
   `message` is always paired with `tone="critical"`; `Checkbox` `size` is
   `small` or `medium` only.
7. Feedback uses `Alert` from `@spark-web/alert` (or the overlay `FlashMessage`)
   with `tone` `positive`/`critical` and paired `onClose` + `closeLabel`.
8. The save is a single right-aligned `Button` per save-scope inside
   `Stack align="right"`, with `loading` bound to the in-flight state — no
   external spinner.
9. Save scope is consistent — section-level saves live inside each section; a
   page-level save is a single trailing save row; the two are not mixed for the
   same fields.
10. If the PRD describes a vendor/branch-scoped form, a context-selection guard
    renders a `Container size="small"` prompt when no context is selected, and
    the form renders only once the context exists; if the form is account-wide,
    no guard is required.
11. No raw CSS values are used (the Documented exceptions table is empty); every
    component is `@spark-web/*` or an explicit consumer-overlay substitute, and
    anything missing is flagged with a `// COMPONENT GAP:` comment.
