SetupCard
What every card shares: white with a hairline edge (CARD_FRAME), its topic as a heading on the white, and what it holds (fields, rows, its buttons at the foot) in a grey section inset 8px. A card of several topics passes sectioned and gives each a CardSection. Collapsed, it's one line, the topic and a summary: what stays where a card appeared earlier in the conversation. SetupField is its labelled text field that saves on blur or Enter.
Playground
Studio
import { SetupCard, SetupField } from "@/components/shared/setup-card";
<SetupCard
title="Studio"
summary="Atelier Mila · Nail studio"
>
<SetupField label="What clients call you" saved={nickname} onCommit={save} />
</SetupCard>Saving on blur or Enter
SetupField shows the saved value until the owner types. Leaving the field or pressing Enter hands the text to onCommit: return null once it's saved, or the reason it can't be — the text then stays, marked, with the reason under it. Escape brings the saved value back. While nobody is typing, the field follows saved, so a change made elsewhere shows at once. Try a number in this one.
import { SetupCard, SetupField } from "@/components/shared/setup-card";
<SetupField
label="What clients call you"
saved={nickname}
onCommit={(raw) => {
if (/\d/.test(raw)) return "Leave out the numbers.";
saveNickname(raw);
return null;
}}
/>Optional and refused
optional takes the word for it and puts it, bracketed, in the label. error shows a reason from outside, such as the server's, tied to the input.
import { SetupCard, SetupField } from "@/components/shared/setup-card";
<SetupField label="Address" optional="optional" saved="" onCommit={save} />
<SetupField label="Website" saved={typed} error="That isn't a website address." onCommit={save} />Adding a row
A new service or person has no Add to tap: its row saves itself as the owner leaves it or presses Enter, like every other field on the card. Left blank, it goes quietly; started but incomplete, it stays with what's missing. Its trash button lets it go unsaved. See it on ServicesCard and TeamCard.
API reference
SetupCard
| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | — | The topic, as a heading. |
| summary | string | — | One line of what the card holds, shown when collapsed. |
| collapsed | boolean | false | Just the title and summary. |
| action | ReactNode | — | Beside the title: one button at most. |
| level | 2 | 3 | 4 | 2 | The title's heading level. |
| tone | "page" | "thread" | "page" | The type it reads in; the surface is the same. page: setup's fields' own. thread: a card in a chat (ChangeCard, DayCard, TodayCard), in the messages' type: text-sm, text-base on touch. |
| sectioned | boolean | false | Its children are CardSections, a topic each (DayCard, TodayCard): held as they are, not in the one grey section. |
| hideTitle | boolean | false | The heading is read, not shown — in the conversation, where Okami's line introduces the card. |
| className | string | — | Layout only. |
SetupField
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | — | The field's visible name. |
| saved | string | — | What's saved now. |
| onCommit | (raw: string) => string | null | — | Save the text and return null, or return why it can't be saved. |
| optional | string | — | The word for optional, in the label. |
| error | string | null | — | A reason from outside to show. |
| hideLabel | boolean | false | Read the label, don't show it (rows under one heading). |
| leadingIcon | Icon | — | Drawn in the field, before the text. |
| size | "sm" | "md" | "lg" | "md" | As Input. |
Guidelines
- Build each topic on it — Studio, Services, Team, Hours, Summary — so they read as one set in the conversation.
- Save on blur or Enter, never per keystroke: a half-typed name shouldn't reach the draft, or Okami.
- A collapsed card is a trace, not a control. The live one is lower in the thread.