Typed object card
What Flo shows in a chat besides words: a table, a pick list, what it changed, a draft. One card per object type, through loading, ready and acted on. A card never moves the screen.
Playground
Change the controls to try each prop. The code under the preview updates as you go and lists only the props you changed; copy it into your page.
Shortlist for Senior Data Engineer
- Maya Chen
- Priya Nair
- Owen Hart
import { TypedObjectCard } from '@/ui/typed-object-card';<TypedObjectCard title="Shortlist for Senior Data Engineer" />Ready
The card's title names what it holds, with a count at the header's end. The body is the object; the footer holds its one primary action, named for the result, and the secondary ones. Press Add: the card becomes its receipt, with Undo.
Shortlist for Senior Data Engineer
3 of 142- Maya ChenGreatRan Halcyon's lakehouse
- Priya NairGoodSpark, Northwind
- Owen HartOkayNo banking
import { useState } from 'react';
import { Button } from '@/ui/button';
import { Lozenge } from '@/ui/lozenge';
import { RecordChip } from '@/ui/record-chip';
import { TypedObjectCard } from '@/ui/typed-object-card';
const people = [
{ name: 'Maya Chen', fit: 'Great', reason: "Ran Halcyon's lakehouse" },
{ name: 'Priya Nair', fit: 'Good', reason: 'Spark, Northwind' },
{ name: 'Owen Hart', fit: 'Okay', reason: 'No banking' },
];
export default function TypedObjectCardPickList() {
const [added, setAdded] = useState(false);
return (
<TypedObjectCard
className="w-full max-w-[25rem]"
title="Shortlist for Senior Data Engineer"
meta="3 of 142"
status={added ? 'acted' : 'ready'}
receipt="Added 3 to Wave 2"
onUndo={() => setAdded(false)}
footer={
<>
<Button variant="primary" onClick={() => setAdded(true)}>
Add 3 to Wave 2
</Button>
<Button>Export</Button>
</>
}
>
<ul>
{people.map((person) => (
<li key={person.name} className="flex h-11 items-center gap-100 border-b border-border px-150 last:border-b-0">
<RecordChip variant="inline" type="candidate" name={person.name} />
<Lozenge appearance={person.fit === 'Great' ? 'success' : person.fit === 'Good' ? 'information' : 'warning'}>{person.fit}</Lozenge>
<span className="min-w-0 truncate text-text-subtle">{person.reason}</span>
</li>
))}
</ul>
</TypedObjectCard>
);
}Loading
While the tool runs, the card holds its final height with skeleton rows (44px each, loadingRows) and says what Flo is doing in the header, so nothing below it jumps when the result arrives. The result fades in over 150ms.
Finding people…
import { TypedObjectCard } from '@/ui/typed-object-card';
export default function TypedObjectCardLoading() {
return <TypedObjectCard className="w-full max-w-[25rem]" status="loading" title="Shortlist for Senior Data Engineer" loadingLabel="Finding people…" />;
}A table
Records of one type, as a slice of the table: the first column is the record's name, and Open as a View carries the filters to the full table.
Candidates at CV sent
3 of 12| Name | Job | Waiting |
|---|---|---|
| Maya Chen | Senior Data Engineer | 6 days |
| Tomás Reyes | Head of Risk | 4 days |
| Priya Nair | Senior Data Engineer | 3 days |
import { Link } from '@/ui/link';
import { RecordChip } from '@/ui/record-chip';
import { TypedObjectCard } from '@/ui/typed-object-card';
const rows = [
{ name: 'Maya Chen', job: 'Senior Data Engineer', waiting: '6 days' },
{ name: 'Tomás Reyes', job: 'Head of Risk', waiting: '4 days' },
{ name: 'Priya Nair', job: 'Senior Data Engineer', waiting: '3 days' },
];
export default function TypedObjectCardTable() {
return (
<TypedObjectCard
className="w-full max-w-120"
title="Candidates at CV sent"
meta="3 of 12"
footer={
<Link href="#candidates" variant="subtle">
Open as a View
</Link>
}
>
<table className="w-full border-collapse text-start">
<thead>
<tr className="h-8 border-b border-border text-body-small leading-body-small text-text-subtle">
<th className="px-150 text-start font-medium">Name</th>
<th className="px-150 text-start font-medium">Job</th>
<th className="px-150 text-start font-medium">Waiting</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.name} className="h-9 border-b border-border last:border-b-0">
<td className="px-150">
<RecordChip variant="plain" type="candidate" name={row.name} href={`#${row.name}`} />
</td>
<td className="px-150 text-text-subtle">{row.job}</td>
<td className="px-150 text-text-subtle tabular-nums">{row.waiting}</td>
</tr>
))}
</tbody>
</table>
</TypedObjectCard>
);
}Acted on
Once acted on, the card folds to one quiet line: a check, what happened in the past tense with a link to where it went, and Undo.
Added 2 to Wave 2 · Open Job
import { Link } from '@/ui/link';
import { TypedObjectCard } from '@/ui/typed-object-card';
const later = () => new Promise<void>((resolve) => setTimeout(resolve, 800));
export default function TypedObjectCardActed() {
return (
<TypedObjectCard
className="w-full max-w-[25rem]"
status="acted"
title="Shortlist for Senior Data Engineer"
receipt={
<>
Added 2 to Wave 2 ·{' '}
<Link href="#job" variant="subtle">
Open Job
</Link>
</>
}
onUndo={later}
/>
);
}When the tool failed
If the tool behind the card failed, the card keeps its title and says what happened and what to do next, in danger, with Try again.
Shortlist for Senior Data Engineer
Flo couldn't finish the search: it timed out. Try again, or narrow it to one company.
import { TypedObjectCard } from '@/ui/typed-object-card';
export default function TypedObjectCardError() {
return (
<TypedObjectCard
className="w-full max-w-[25rem]"
status="error"
title="Shortlist for Senior Data Engineer"
error="Flo couldn't finish the search: it timed out. Try again, or narrow it to one company."
onRetry={() => {}}
/>
);
}Receipts
Every change Flo made also lands as a receipt line under its reply, so the chat reads as a log. Undo is an underlined word; once it worked the line is struck through and reads Undone. A change that can't go back has no Undo.
Moved Maya Chen to CV sent
Added Priya Nair to Wave 2Undone
Sent Maya Chen's CV to Halcyon Bank
import { FloReceipt } from '@/ui/typed-object-card';
const later = () => new Promise<void>((resolve) => setTimeout(resolve, 800));
export default function TypedObjectCardReceipts() {
return (
<div className="flex flex-col gap-050">
<FloReceipt onUndo={later}>Moved Maya Chen to CV sent</FloReceipt>
<FloReceipt status="undone" onUndo={later}>
Added Priya Nair to Wave 2
</FloReceipt>
<FloReceipt>Sent Maya Chen's CV to Halcyon Bank</FloReceipt>
</div>
);
}