# Question card

Package: @commenda/pathfinder@0.1.0
Page: /docs/components/question-card
Figma: https://www.figma.com/design/dn7BkGYEfIyP0o2zfesfVh/The-System?node-id=607-4462

```tsx
import { QuestionCard } from '@commenda/pathfinder/question-card';

<QuestionCard
  key={request.id}
  title="Which entity should I set up first?"
  options={entities.map((entity) => ({ id: entity.id, label: entity.name }))}
  writeInConfig={{}}
  onSubmit={async (answer) => {
    const receipt = await saveAnswer(request.id, answer);
    return receipt.ok
      ? { status: 'accepted', recap: receipt.recap }
      : { status: 'failed', message: receipt.message };
  }}
  onDismiss={() => temporarilyHide(request.id)}
/>;
```


## Usage [#usage]

```tsx
import { QuestionCard } from '@commenda/pathfinder/question-card';

<QuestionCard
  key={request.id}
  title="Which entity should I set up first?"
  options={entities.map((entity) => ({ id: entity.id, label: entity.name }))}
  writeInConfig={{}}
  onSubmit={async (answer) => {
    const receipt = await saveAnswer(request.id, answer);
    return receipt.ok
      ? { status: 'accepted', recap: receipt.recap }
      : { status: 'failed', message: receipt.message };
  }}
  onDismiss={() => temporarilyHide(request.id)}
/>;
```

## Multiple answers [#multiple-answers]


```tsx
import { QuestionCard } from '@commenda/pathfinder/question-card';

<QuestionCard
  mode="multiple"
  key={request.id}
  title="Which entity should I set up first?"
  options={entities.map((entity) => ({ id: entity.id, label: entity.name }))}
  writeInConfig={{}}
  onSubmit={async (answer) => {
    const receipt = await saveAnswer(request.id, answer);
    return receipt.ok
      ? { status: 'accepted', recap: receipt.recap }
      : { status: 'failed', message: receipt.message };
  }}
  onDismiss={() => temporarilyHide(request.id)}
/>;
```


## More options and search [#more-options-and-search]


```tsx
import { QuestionCard } from '@commenda/pathfinder/question-card';

<QuestionCard
  key={request.id}
  title="Which entity should I set up first?"
  options={allEntities.map((entity) => ({ id: entity.id, label: entity.name }))}
  writeInConfig={{}}
  onSubmit={async (answer) => {
    const receipt = await saveAnswer(request.id, answer);
    return receipt.ok
      ? { status: 'accepted', recap: receipt.recap }
      : { status: 'failed', message: receipt.message };
  }}
  onDismiss={() => temporarilyHide(request.id)}
/>;
```


## Inline placement [#inline-placement]


```tsx
import { QuestionCard } from '@commenda/pathfinder/question-card';

<QuestionCard
  placement="inline"
  key={request.id}
  title="Which entity should I set up first?"
  options={entities.map((entity) => ({ id: entity.id, label: entity.name }))}
  writeInConfig={{}}
  onSubmit={async (answer) => {
    const receipt = await saveAnswer(request.id, answer);
    return receipt.ok
      ? { status: 'accepted', recap: receipt.recap }
      : { status: 'failed', message: receipt.message };
  }}
  onDismiss={() => temporarilyHide(request.id)}
/>;
```


## Failed submission and retry [#failed-submission-and-retry]


```tsx
// Return { status: 'failed', message: 'Please try again.' } from the handler to retain this card.
import { QuestionCard } from '@commenda/pathfinder/question-card';

<QuestionCard
  key={request.id}
  title="Which entity should I set up first?"
  options={entities.map((entity) => ({ id: entity.id, label: entity.name }))}
  writeInConfig={{}}
  onSubmit={async (answer) => {
    const receipt = await saveAnswer(request.id, answer);
    return receipt.ok
      ? { status: 'accepted', recap: receipt.recap }
      : { status: 'failed', message: receipt.message };
  }}
  onDismiss={() => temporarilyHide(request.id)}
/>;
```


## Loading [#loading]


```tsx
import { QuestionCard } from '@commenda/pathfinder/question-card';

<QuestionCard
  state={{ status: 'loading' }}
  key={request.id}
  title="Which entity should I set up first?"
  options={entities.map((entity) => ({ id: entity.id, label: entity.name }))}
  writeInConfig={{}}
  onSubmit={async (answer) => {
    const receipt = await saveAnswer(request.id, answer);
    return receipt.ok
      ? { status: 'accepted', recap: receipt.recap }
      : { status: 'failed', message: receipt.message };
  }}
  onDismiss={() => temporarilyHide(request.id)}
/>;
```


Reset restores the example. Simulate failure retains the card for retry; no backend request is made.

## API reference [#api-reference]

| Prop | Type | Requirement | Behavior |
| --- | --- | --- | --- |
| `options` | `readonly QuestionOption[]` | required | Stable unique `id`, `label`, optional `description`, decorative `icon`, `disabled` |
| `mode` | `"single" \| "multiple"` | optional | Single by default; activating a single option submits immediately |
| `defaultAnswer` | `QuestionAnswer` | optional | Initial draft; use a new React key for a new request |
| `onAnswerChange` | `((answer: QuestionAnswer) => void)` | optional | Observe draft changes for host persistence |
| `onSubmit` | `RequestHandler<QuestionAnswer>` | required | Accept or reject explicitly; do not clear the draft before acceptance |
| `onSkip` | `RequestHandler` | optional | Optional explicit skip, distinct from temporary dismissal |
| `writeInConfig` | `{ placeholder?: string; label?: string; maxLength?: number; }` | optional | Enables a written answer; single choice clears it when selecting an option |
| `paginationConfig` | `{ index: number; count: number; onChange: (index: number) => void; }` | optional | Zero-based host-owned question navigation; pending disables navigation |
| `title` | `string` | required |  |
| `description` | `string` | optional | Supporting guidance associated with the field. |
| `placement` | `"docked" \| "inline"` | optional |  |
| `state` | `RequestState` | optional |  |
| `onDismiss` | `(() => void)` | optional |  |
| `className` | `string` | optional | Additional classes for the component root. |

### defaultAnswer fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `optionIds` | `readonly string[]` | Yes |  |
| `text` | `string` | Yes |  |

### writeInConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `placeholder` | `string` | No |  |
| `label` | `string` | No |  |
| `maxLength` | `number` | No |  |

### paginationConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `index` | `number` | Yes |  |
| `count` | `number` | Yes |  |
| `onChange` | `(index: number) => void` | Yes |  |

### state fields

Choose one shape; excluded fields cannot be combined.

#### Option 1

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | `"ready" \| "pending" \| "loading"` | Yes |  |

#### Option 2

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | `"resolved"` | Yes |  |
| `recap` | `ResponseRecapProps` | Yes |  |

### Shared request props [#shared-request-props]

| Prop          | Type                   | Behavior                                                                 |
| ------------- | ---------------------- | ------------------------------------------------------------------------ |
| `title`       | `string`               | Required card title.                                                     |
| `description` | `string`               | Optional supporting text.                                                |
| `placement`   | `'docked' \| 'inline'` | Docked by default; parent controls width and placement                   |
| `state`       | `RequestState`         | Ready, loading skeleton, externally pending, or resolved with a recap    |
| `onDismiss`   | `() => void`           | Temporary dismissal via Not now or scoped Escape; not a submitted answer |
| `className`   | `string`               | Layout override                                                          |

`RequestHandler<T>` returns `{ status: 'accepted', recap: ResponseRecapProps }` or `{ status: 'failed', message: string }`, directly or as a Promise. A thrown error shows a retry message and preserves the draft. The host persists accepted responses; the component displays the returned receipt. `state={{ status: 'resolved', recap }}` restores history. Use a stable request ID as the React key, and change the key when the request changes. External pending is a rendering state; authorization and idempotency remain server responsibilities.

**REQUEST-01:** Docked tray uses pearl100, a raised shadow, radius16 and a white radius10 inset with 16px padding. Header40, footer at least44; controls wrap on narrow screens.

**REQUEST-02:** The parent places the request 12px above Composer at the same width. Inline requests are flat and fill their message column. Escape only temporarily dismisses; no global listener is installed.

**REQUEST-03:** Reading content does not animate. Existing Button and Popover motion respects reduced motion. Pending retains the draft and exposes a polite status. Acceptance never precedes the host result.

## Design rules [#design-rules]

- **QUESTION-01:** Single pointer/Space/Enter or number activation submits; arrow keys only move selection. Multiple choices and written answers require Continue (Enter also submits written text outside IME composition).
- **QUESTION-02:** Up to four options remain inline. Five or more show the first three and More options; eight or more enable search. Selected overflow labels remain visible outside the popup. Only the selected number lead turns blue.
- **QUESTION-03:** Number shortcuts operate only inside this card, never inside text fields or the overflow dialog. They cannot approve an action. Escape dismisses the popup first, then the request on a subsequent Escape.
- **QUESTION-04:** Failures retain the answer, pending blocks duplicate submission, and acceptance renders the supplied ResponseRecap.

Use unique IDs rather than labels as values. Keep the option set stable for a request. RadioGroup primitives provide single-choice arrow navigation; Checkbox provides multiple-choice semantics. The overflow uses a Popover instead of an action-only Dropdown so choices retain radio/checkbox semantics.

## Integration and accessibility [#integration-and-accessibility]

Buttons have explicit names and keyboard focus. No network transport or AI SDK provider is required. Requests, tool execution, permission checks, durable outcomes and composer coordination stay in the application. See [AI content integration](/docs/guides/ai-content) for migration mappings; application migration is outside this build.
