---
name: aircall-blocks/migrate-dashboard/card
description: >
  Migrate @dashboard/library Paper and PaperForm to @aircall/ds Card primitives
  (Card, CardHeader, CardTitle, CardDescription, CardAction, CardContent, CardFooter)
  and @aircall/blocks CardSaveBar for save/discard bars. Load when a file imports
  Paper or PaperForm from @dashboard/library.
type: sub-skill
library: aircall-blocks
requires:
  - aircall-blocks/setup
  - aircall-blocks/migrate-dashboard
sources:
  - "aircall/hydra:packages/ds/src/index.ts"
---

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

> **Migrating a settings-page `Paper` / `PaperForm`?** Prefer `SettingCard` — load
> `@aircall/blocks#aircall-blocks/migrate-dashboard/setting-card`. It is purpose-built for
> the title / description / toggle / save-bar layout and gets the level backgrounds and
> level-aware title for free. Use the raw `Card` primitives below only for **non-settings**
> card surfaces (tiles, generic containers with no title/toggle semantics).

## 1. Component mapping

| @dashboard/library | @aircall/ds / @aircall/blocks |
| --- | --- |
| `Paper` (root surface) | `Card` (`@aircall/ds`) |
| `Paper` `title` prop | `CardTitle` inside `CardHeader` (`@aircall/ds`) |
| `Paper` `subtitle` prop | `CardDescription` inside `CardHeader` (`@aircall/ds`) |
| `Paper` `titleSide` prop | `CardAction` inside `CardHeader` (`@aircall/ds`) |
| `Paper` `children` (body) | `CardContent` (`@aircall/ds`) |
| `Paper` `footer` prop (generic) | `CardFooter` (`@aircall/ds`) |
| `Paper` `footer` prop (save/discard bar) | `CardSaveBar` (`@aircall/blocks`) |
| `PaperForm` (form wrapper with save bar) | `Card` + `useForm` + `CardSaveBar` (`@aircall/blocks`) |
| `Paper` `banner` prop | Inline `Banner` before `CardHeader` inside `Card` (`@aircall/ds`) |
| `Paper` `disabled` / `disabledText` props | `className="opacity-50 cursor-not-allowed"` on `Card` + custom label |
| `Paper` `fluid` prop | `className="w-full"` on `CardContent` (default is already full-width) |

`PaperForm` is a compound: it wires a `react-final-form` form, renders `Paper`, and
appends a `SaveBar` footer. The replacement is `Card` with TanStack Form (`useForm` from
`@aircall/blocks`) and `CardSaveBar` as a direct child of `Card` (it is the footer itself).

## 2. Imports

```tsx
// DS primitives — structural card shell
import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
  Banner,
  BannerTitle,
  BannerDescription
} from '@aircall/ds';

// blocks — TanStack Form + animated save bar
import { useForm, CardSaveBar } from '@aircall/blocks';
```

## 3. Before / After

### 3a. Basic Paper — read-only surface

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

function SettingsSection() {
  return (
    <Paper
      title="General settings"
      subtitle="Manage your workspace preferences."
    >
      <p>Section content here.</p>
    </Paper>
  );
}
```

**After (`@aircall/ds`):**
```tsx
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@aircall/ds';

function SettingsSection() {
  return (
    <Card>
      <CardHeader>
        <CardTitle>General settings</CardTitle>
        <CardDescription>Manage your workspace preferences.</CardDescription>
      </CardHeader>
      <CardContent>
        <p>Section content here.</p>
      </CardContent>
    </Card>
  );
}
```

Key changes:
- `Paper` `title` → `CardTitle` inside `CardHeader`.
- `Paper` `subtitle` → `CardDescription` inside `CardHeader`.
- `Paper` `children` → wrapped in `CardContent`.
- Drop `BoxProps` spread (`maxWidth`, `borderColor`, etc.) — `Card` owns the surface.

### 3b. Paper with a header action (titleSide)

**Before (`@dashboard/library`):**
```tsx
import { Paper } from '@dashboard/library';
import { Button } from '@aircall/tractor';

function IntegrationCard() {
  return (
    <Paper
      title="Integrations"
      subtitle="Connect your tools."
      titleSide={<Button variant="primary" size="small">Add integration</Button>}
    >
      <p>Integration list here.</p>
    </Paper>
  );
}
```

**After (`@aircall/ds`):**
```tsx
import { Button, Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from '@aircall/ds';

function IntegrationCard() {
  return (
    <Card>
      <CardHeader>
        <CardTitle>Integrations</CardTitle>
        <CardDescription>Connect your tools.</CardDescription>
        <CardAction>
          <Button variant="default" size="sm">Add integration</Button>
        </CardAction>
      </CardHeader>
      <CardContent>
        <p>Integration list here.</p>
      </CardContent>
    </Card>
  );
}
```

`CardAction` must be inside `CardHeader` — the header grid (`grid-cols-[1fr_auto]`) positions it top-right automatically.

### 3c. PaperForm — form with save/discard bar

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

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

**After (`@aircall/blocks`):**
```tsx
import { useForm, CardSaveBar } from '@aircall/blocks';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@aircall/ds';

const form = useForm({
  defaultValues: { name: '' },
  onSubmit: async ({ value }) => { /* submit */ }
});

function ProfileForm() {
  return (
    <form.AppForm>
      <form onSubmit={(e) => { e.preventDefault(); form.handleSubmit(); }}>
        <Card>
          <CardHeader>
            <CardTitle>Profile</CardTitle>
            <CardDescription>Update your display name.</CardDescription>
          </CardHeader>
          <CardContent>
            <form.AppField name="name">
              {(field) => (
                <input
                  value={field.state.value}
                  onChange={(e) => field.handleChange(e.target.value)}
                />
              )}
            </form.AppField>
          </CardContent>
          <CardSaveBar submitLabel="Save" resetLabel="Discard" />
        </Card>
      </form>
    </form.AppForm>
  );
}
```

Key changes:
- `PaperForm` `formProps` + `react-final-form` render-prop → `useForm` from `@aircall/blocks` (TanStack Form).
- `PaperForm` `footer` (implicit `SaveBar`) → `CardSaveBar` as a **direct child of `Card`** (do NOT wrap it in a `CardFooter` — it already carries `data-slot="card-footer"` itself).
- **No `className` needed on `Card`**: it already has `overflow-hidden` built in, and `CardSaveBar`'s `data-slot="card-footer"` makes the card auto-collapse its bottom padding (`has-data-[slot=card-footer]:pb-0`) so the bar sits flush.
- `CardSaveBar` reads `isDirty` / `canSubmit` / `isSubmitting` from form context automatically — no extra props needed.

**Submit error banner (`getErrorMessage` / `submitError`).** `PaperForm` surfaced a submit failure via `getErrorMessage` + `submitError`. There is no built-in equivalent — catch and store it in `onSubmit`, then render an inline `Alert` above `CardContent`:

```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 CardContent:
{submitError && <Alert variant="error">{submitError}</Alert>}
```

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

### 3d. Paper with a generic footer

**Before (`@dashboard/library`):**
```tsx
import { Paper } from '@dashboard/library';
import { Button } from '@aircall/tractor';

function BillingCard() {
  return (
    <Paper
      title="Billing"
      footer={
        <div style={{ padding: 16 }}>
          <Button variant="primary">Upgrade plan</Button>
        </div>
      }
    >
      <p>Current plan: Free</p>
    </Paper>
  );
}
```

**After (`@aircall/ds`):**
```tsx
import { Button, Card, CardContent, CardFooter, CardHeader, CardTitle } from '@aircall/ds';

function BillingCard() {
  return (
    <Card>
      <CardHeader>
        <CardTitle>Billing</CardTitle>
      </CardHeader>
      <CardContent>
        <p>Current plan: Free</p>
      </CardContent>
      <CardFooter>
        <Button variant="default">Upgrade plan</Button>
      </CardFooter>
    </Card>
  );
}
```

`CardFooter` applies `border-t bg-muted/50 p-4` by default. For a transparent footer pass `className="bg-transparent border-none"`.

---

## 4. Common mistakes

### Mistake 1 — Spreading BoxProps onto Card

```tsx
// ❌ Wrong — tractor/dashboard BoxProps (maxWidth, borderRadius, backgroundColor…) on Card
<Card maxWidth={1372} borderRadius="sm" backgroundColor="white">
  <CardContent>Content</CardContent>
</Card>

// ✅ Correct — Card owns the surface; use className for true one-offs only
<Card className="max-w-[1372px]">
  <CardContent>Content</CardContent>
</Card>
```

`Paper` accepted arbitrary `BoxProps` from `@aircall/tractor`. `Card` extends `React.ComponentProps<'div'>` — it accepts `className`, not tractor spacing/color tokens. Spread them and React will warn on unknown HTML attributes.

Source: `packages/ds/src/components/card.tsx`

### Mistake 2 — Wrapping CardSaveBar in a CardFooter (or adding overflow-hidden/pb-0)

```tsx
// ❌ Wrong — CardSaveBar already IS the footer; a CardFooter around it doubles
//    the top border and padding, and the className hints are redundant
<Card className="overflow-hidden pb-0">
  <CardContent>…</CardContent>
  <CardFooter className="p-0">
    <CardSaveBar />
  </CardFooter>
</Card>

// ✅ Correct — drop CardSaveBar in as a direct child; no wrapper, no className
<Card>
  <CardContent>…</CardContent>
  <CardSaveBar />
</Card>
```

`CardSaveBar` renders its own `border-t` + padding and carries `data-slot="card-footer"`, so it already behaves as the footer. `Card` has `overflow-hidden` built in (clips the slide-in), and the `data-slot="card-footer"` triggers `has-data-[slot=card-footer]:pb-0`, so the bar sits flush with no manual `overflow-hidden`, `pb-0`, or `CardFooter` wrapper.

Source: `packages/blocks/src/components/card-save-bar.tsx`
