---
name: aircall-blocks/migrate-dashboard/setting-card
description: >
  Migrate @dashboard/library Paper and PaperForm (settings-page surfaces) to the
  @aircall/blocks SettingCard family (SettingCard, SettingCardHeader, SettingCardTitle,
  SettingCardDescription, SettingCardAction, SettingCardContent) plus useForm + CardSaveBar
  for the save bar. Load when a file imports Paper or PaperForm from @dashboard/library and
  the surface is a settings section (title / description / optional toggle or action /
  optional save bar). The errorBoundary is handled OUTSIDE the card — it does not migrate.
type: sub-skill
library: aircall-blocks
requires:
  - aircall-blocks/setup
  - aircall-blocks/migrate-dashboard
sources:
  - "aircall/hydra:packages/blocks/src/components/setting-card.tsx"
  - "aircall/hydra:packages/blocks/src/components/card-save-bar.tsx"
---

This skill builds on aircall-blocks/migrate-dashboard.

`Paper` / `PaperForm` are the `@dashboard/library` settings-page surfaces (title, subtitle,
optional header action, content, optional save-bar footer). Their target is **`SettingCard`**
(`@aircall/blocks`) — a thin composition over the DS `Card` that adds the settings level
backgrounds and the level-aware title. For a **non-settings** card (a tile or generic
container with no title/toggle semantics) use the raw `Card` primitives instead — load
`@aircall/blocks#aircall-blocks/migrate-dashboard/card`.

## 1. Component mapping

| @dashboard/library | @aircall/blocks / @aircall/ds |
| --- | --- |
| `Paper` (root surface) | `SettingCard` |
| `Paper` `title` prop | `SettingCardTitle` inside `SettingCardHeader` |
| `Paper` `subtitle` prop | `SettingCardDescription` inside `SettingCardHeader` |
| `Paper` `titleSide` prop | `SettingCardAction` inside `SettingCardHeader` |
| `Paper` `children` (body) | `SettingCardContent` |
| `Paper` `footer` (save/discard bar) | `useForm` + `CardSaveBar` (form on the outer level) |
| `Paper` `footer` (generic) | plain children after `SettingCardContent`, or a DS `CardFooter` |
| `Paper` `banner` prop | inline DS `Banner` before `SettingCardHeader` |
| `Paper` `disabled` | `SettingCard` `disabled` — dims the card and makes the subtree `inert`, so nested controls need no `disabled` of their own |
| `Paper` `disabledText` | app-level — no Figma spec for a lock label; compose one into `SettingCardHeader` if the screen needs it |
| `Paper` `fluid` prop | content is already full-width — drop it |
| `Paper` `errorBoundary` prop | **stays in the app, OUTSIDE `SettingCard`** — see §3. Does NOT migrate. |
| `Paper` `BoxProps` spread | drop — `className` for genuine one-offs only |
| `PaperForm` (form + save bar) | `<form>` (outer) + `useForm` + `SettingCard` + `CardSaveBar` |
| `PaperForm` `formProps` (`react-final-form`) | `useForm` (TanStack Form) — `defaultValues`, `onSubmit: async ({ value })` |
| `PaperForm` `getErrorMessage` / `submitError` | catch in `onSubmit`, store in state, render an `Alert` above the content |
| `PaperForm` `shouldBlockNavigation` | app-level (navigation blocker) — not part of blocks |
| `PaperForm` `submitButtonText` / `undoButtonText` | `CardSaveBar` `submitLabel` / `resetLabel` |

## 2. Imports

```tsx
// blocks — SettingCard family + TanStack Form + save bar
import {
  SettingCard,
  SettingCardHeader,
  SettingCardTitle,
  SettingCardDescription,
  SettingCardAction,
  SettingCardContent,
  useForm,
  CardSaveBar
} from '@aircall/blocks';

// DS primitives used inside (Button, Switch, Alert, Banner, fields …)
import { Button, Switch, Alert } from '@aircall/ds';
```

## 3. The errorBoundary stays OUTSIDE SettingCard

`Paper` wrapped **its children** in `ScopedErrorBoundary` (plus an optional
`ErrorContextProvider`) via the `errorBoundary` prop. Those are **app-level**
`@dashboard/library` components — `ScopedErrorBoundary` is react-router-aware and reads
`ErrorContext`; neither migrates to blocks/ds. `SettingCard` is a presentational layout
primitive and deliberately has **no** `errorBoundary` prop.

So keep the boundary in the app and wrap the **content** (not the whole card), matching
`Paper`'s scope — a content crash still leaves the card title/description visible:

```tsx
import { ScopedErrorBoundary, ErrorContextProvider } from '@dashboard/library';

<SettingCard>
  <SettingCardHeader>
    <SettingCardTitle>Redaction</SettingCardTitle>
    <SettingCardDescription>Mask sensitive data in transcripts.</SettingCardDescription>
  </SettingCardHeader>
  <SettingCardContent>
    {/* errorBoundary lives here, around the fallible content — NOT on SettingCard */}
    <ScopedErrorBoundary message="Couldn't load redaction settings" showRetryButton>
      <RedactionCategories />
    </ScopedErrorBoundary>
  </SettingCardContent>
</SettingCard>
```

If `Paper`'s `errorBoundary` config carried a `context`, keep the
`ErrorContextProvider` wrapper too (same nesting as `Paper` did — provider outside the
boundary):

```tsx
<ErrorContextProvider value={ctx}>
  <ScopedErrorBoundary {...cfg}>{content}</ScopedErrorBoundary>
</ErrorContextProvider>
```

> If the whole section (header included) should be replaced on error, wrap the entire
> `<SettingCard>` instead. Default to wrapping only the content — that is what `Paper` did.

## 4. Before / After

### 4a. Basic Paper — settings surface

**Before:**
```tsx
import { Paper } from '@dashboard/library';

<Paper title="General settings" subtitle="Manage your workspace preferences.">
  <WorkspacePreferences />
</Paper>
```

**After:**
```tsx
import { SettingCard, SettingCardHeader, SettingCardTitle, SettingCardDescription, SettingCardContent } from '@aircall/blocks';

<SettingCard>
  <SettingCardHeader>
    <SettingCardTitle>General settings</SettingCardTitle>
    <SettingCardDescription>Manage your workspace preferences.</SettingCardDescription>
  </SettingCardHeader>
  <SettingCardContent>
    <WorkspacePreferences />
  </SettingCardContent>
</SettingCard>
```

- `title` → `SettingCardTitle`, `subtitle` → `SettingCardDescription`, both inside `SettingCardHeader`.
- Body → `SettingCardContent`. Drop the `BoxProps` spread — `SettingCard` owns the surface.

### 4b. Paper with a header action / toggle (titleSide)

**Before:**
```tsx
<Paper
  title="Call recording"
  subtitle="Record inbound and outbound calls."
  titleSide={<Switch checked={enabled} onChange={setEnabled} />}
>
  {enabled && <RecordingOptions />}
</Paper>
```

**After:**
```tsx
<SettingCard>
  <SettingCardHeader>
    <SettingCardTitle>Call recording</SettingCardTitle>
    <SettingCardDescription>Record inbound and outbound calls.</SettingCardDescription>
    <SettingCardAction>
      <Switch checked={enabled} onCheckedChange={setEnabled} />
    </SettingCardAction>
  </SettingCardHeader>
  {enabled && (
    <SettingCardContent>
      <RecordingOptions />
    </SettingCardContent>
  )}
</SettingCard>
```

- `titleSide` → `SettingCardAction` (inside `SettingCardHeader`; the header grid pins it top-right).
- DS `Switch` uses `onCheckedChange`, not tractor's `onChange`.
- **Progressive disclosure is consumer-controlled**: render `SettingCardContent` only when the toggle is on (`{enabled && …}`). `SettingCard` has no internal toggle→content coupling, so gated data/fields never mount while off.

### 4c. Nested settings (levels are automatic)

A `SettingCard` rendered inside another card's `SettingCardContent` auto-detects **level 2**
(white background + border, 16px medium title); the top card is **level 1** (muted,
borderless, 18px bold). No prop needed. For a rare 3rd level, force `level={1}` on the
deepest card to reuse the level-1 background (its title stays 16px medium automatically).

### 4d. PaperForm — form with save/discard bar

**Before:**
```tsx
import { PaperForm } from '@dashboard/library';

<PaperForm
  title="Profile"
  subtitle="Update your display name."
  formProps={{ initialValues: { name: '' }, onSubmit: async (values) => save(values) }}
  submitButtonText="Save"
  undoButtonText="Discard"
>
  {({ values }) => <input name="name" defaultValue={values.name} />}
</PaperForm>
```

**After:**
```tsx
import { SettingCard, SettingCardHeader, SettingCardTitle, SettingCardDescription, SettingCardContent, useForm, FormInputField } from '@aircall/blocks';
import { FieldGroup, Input } from '@aircall/ds';

function ProfileForm() {
  const form = useForm({
    defaultValues: { name: '' },
    onSubmit: async ({ value }) => { await save(value); }
  });

  return (
    // Form on the OUTER level — SettingCard stays presentational.
    <form onSubmit={e => { e.preventDefault(); void form.handleSubmit(); }}>
      <SettingCard>
        <SettingCardHeader>
          <SettingCardTitle>Profile</SettingCardTitle>
          <SettingCardDescription>Update your display name.</SettingCardDescription>
        </SettingCardHeader>
        <SettingCardContent>
          <FieldGroup>
            <FormInputField form={form} name="name" label="Display name">
              {(_field, { inputProps }) => <Input {...inputProps} />}
            </FormInputField>
          </FieldGroup>
        </SettingCardContent>
        <form.AppForm>
          <form.CardSaveBar submitLabel="Save" resetLabel="Discard" />
        </form.AppForm>
      </SettingCard>
    </form>
  );
}
```

Key changes:
- `react-final-form` `formProps` + render-prop → `useForm` from `@aircall/blocks` (TanStack Form): `defaultValues` (not `initialValues`), `onSubmit` receives `{ value }`.
- Hand-wired inputs → `Form*Field` wrappers driven by the form (never per-field `useState`).
- Put the `<form>` on the **outer level** (wrapping `SettingCard`), and drop **`form.CardSaveBar`** in as the last child of `SettingCard`. It reads `isDirty`/`canSubmit`/`isSubmitting` from context, slides in when dirty, and carries `data-slot="card-footer"` so the card auto-collapses its bottom padding and the bar sits flush. **No `className` is needed on any part** - the card's own layout (row gap + auto `pb-0`) handles both the clean and dirty states.
- `submitButtonText`/`undoButtonText` → `CardSaveBar` `submitLabel`/`resetLabel`.

**Submit error (`getErrorMessage` / `submitError`).** No built-in equivalent — catch in
`onSubmit`, store it, and render an `Alert` above the content:

```tsx
const [submitError, setSubmitError] = useState<string | null>(null);
const form = useForm({
  defaultValues: { name: '' },
  onSubmit: async ({ value }) => {
    try { await save(value); setSubmitError(null); }
    catch (e) { setSubmitError(getErrorMessage(e)); }
  }
});
// In JSX, above the fields inside SettingCardContent:
{submitError && <Alert variant="error">{submitError}</Alert>}
```

> DS `Alert`'s error variant is `error` (not tractor's `critical`). Full set: `default` / `info` / `success` / `warning` / `error`.
