Portfolio card
ProOne workspace in a portfolio (a client, a team, a project): a card that takes the person into it. The name is the card's one link, its hit area stretched over the whole card, so the card holds one control and reads as one.
It shows the workspace's colour as a bar along the top (hue, the same slot as environmentHue: identity only, never a state, and the name stays in the text colour), its status as a dot with a word, how many things need the person ("2 need you"; "All clear" only when the count is known and zero; "Not checked" when it isn't known), a mode chip, a summary, up to three figures, and an estimate that says it is one ("Estimate" beside the value, its basis in words under it).
Give needsYou the same count the workspace's own home page and navigation show for it, so the portfolio and the workspace agree. The summary may be an agent's words: it is drawn as plain text, isolated so it can't reorder the card. For a list view, show the same fields in a DataTable.
Version 5.2.0· other versions
This page is for 5.2.0, the current release. An earlier release’s page is the Markdown that release published, so the docs match the version you have installed (candor-lock.json records it).
Not in the docs of 1.3.0, 1.4.0, 1.5.0, 1.5.1, 1.6.0, 1.7.0, 1.8.0, 1.8.1, 1.9.0, 1.9.1, 2.0.0, 2.1.0, 2.2.0, 2.3.0, 2.4.0, 2.5.0, 2.5.1, 2.6.0, 2.6.1, 2.6.2, 2.7.0, 2.8.0, 2.9.0, 2.10.0, 3.0.0, 4.0.0, 4.0.1, 4.0.2, 4.1.0, 4.2.0 and 4.3.0: the page came later, or had another name then.
Docs pages weren’t kept for 1.0.0, 1.0.1, 1.1.0, 1.1.1, 1.2.0 and 1.2.1: they are kept from 1.3.0 on, and before that only llms.txt was published.
Preview
Loading the preview
Install
First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/portfolio-card
Brings in: bidi, heading-level, heading-scope, link, locale, tokens, utils · npm: lucide-react@^1.67.0
Usage
"use client"
import * as React from "react"
import { DashboardGrid } from "@/components/candor/dashboard-grid"
import { DataTable, type DataTableColumn } from "@/components/candor/data-table"
import { PortfolioCard, type PortfolioEstimate, type PortfolioStatus } from "@/components/candor/portfolio-card"
import { ToggleGroup, ToggleGroupItem } from "@/components/candor/ui/toggle-group"
type Client = {
id: string
name: string
hue: 1 | 2 | 3 | 4 | 5
status: PortfolioStatus
// The same count each workspace's own home page and navigation show.
needsYou: number | null
mode: string
summary: string
health: string
environments: string
open: string
estimate: PortfolioEstimate
}
const clients: Client[] = [
{ id: "harbour", name: "Harbour Foods", hue: 2, status: { word: "Healthy", tone: "success" }, needsYou: 2, mode: "Solo", summary: "Two workstreams running. The licence review found seats nobody has used in 90 days.", health: "84", environments: "4", open: "4", estimate: { value: "$22k a year", basis: "18 idle seats at list price" } },
{ id: "acme", name: "Acme Robotics", hue: 3, status: { word: "Needs work", tone: "warning" }, needsYou: 1, mode: "Team", summary: "Work arrives from their tracker and ships as pull requests into their repository.", health: "72", environments: "5", open: "6", estimate: { value: "$43k a year", basis: "unused add-ons at list price" } },
{ id: "meridian", name: "Meridian Clinics", hue: 4, status: { word: "Connection expired", tone: "destructive" }, needsYou: null, mode: "Solo", summary: "Read-only review delivered. No model key connected, so agents are off until one is.", health: "61", environments: "1", open: "0", estimate: { value: "$83k a year", basis: "duplicate licences at list price" } },
{ id: "coastal", name: "Coastal Credit", hue: 1, status: { word: "Healthy", tone: "success" }, needsYou: 0, mode: "Solo", summary: "Quiet. The next review is scheduled for the spring release.", health: "91", environments: "2", open: "0", estimate: { value: "$5k a year", basis: "4 idle seats at list price" } },
]
const columns: DataTableColumn<Client>[] = [
{ id: "name", label: "Client", value: (c) => c.name, render: (c) => <a href={`#${c.id}`} className="underline underline-offset-2">{c.name}</a> },
{ id: "status", label: "Status", value: (c) => c.status.word },
{ id: "needs", label: "Needs you", value: (c) => c.needsYou, numeric: true, render: (c) => (c.needsYou === null ? "Not checked" : c.needsYou === 0 ? "All clear" : String(c.needsYou)) },
{ id: "health", label: "Health", value: (c) => Number(c.health), numeric: true },
{ id: "estimate", label: "Reclaimable (estimate)", value: (c) => c.estimate.value, render: (c) => `${c.estimate.value}, estimate from ${c.estimate.basis}` },
]
export default function Example() {
const [view, setView] = React.useState("cards")
return (
<div className="flex flex-col gap-4">
<ToggleGroup type="single" variant="outline" size="sm" value={view} onValueChange={(v) => v && setView(v)} aria-label="Show clients as" className="self-end">
<ToggleGroupItem value="cards">Cards</ToggleGroupItem>
<ToggleGroupItem value="list">List</ToggleGroupItem>
</ToggleGroup>
{view === "cards" ? (
<DashboardGrid minTileWidth={280} className="w-full">
{clients.map((c) => (
<PortfolioCard
key={c.id}
name={c.name}
href={`#${c.id}`}
hue={c.hue}
status={c.status}
needsYou={c.needsYou}
mode={c.mode}
summary={c.summary}
figures={[
{ label: "Health", value: c.health },
{ label: "Environments", value: c.environments },
{ label: "Open", value: c.open },
]}
estimate={c.estimate}
/>
))}
</DashboardGrid>
) : (
<DataTable<Client> title="Clients" columns={columns} rows={clients} rowId={(c) => c.id} />
)}
</div>
)
}<PortfolioCard>
| Prop | Type | Default | Description |
|---|---|---|---|
| namerequired | string | — | The workspace's name: the card's link and its heading. |
| hrefrequired | string | — | Where the card goes: into that workspace. |
| hue | 1 | 2 | 3 | 4 | 5 | undefined | — | The workspace's colour slot (1–5), drawn as a bar along the top: identity only, never a state. |
| status | PortfolioStatus | undefined | — | The workspace's state, as a dot with its word. |
| needsYourequired | number | null | — | How many things in this workspace wait on the person; `null` when it couldn't be checked. |
| mode | string | undefined | — | A short word for how the workspace is run, shown as a chip. |
| summary | string | undefined | — | One or two sentences about the workspace, the app's or an agent's words: drawn as plain text. |
| figures | PortfolioFigure[] | undefined | — | Up to three figures; more are left out (and a warning is logged in development). |
| estimate | PortfolioEstimate | undefined | — | A figure that is estimated, drawn with the word "Estimate" and its basis. |
| headingLevel | HeadingLevel | undefined | — | The heading level of the name, so the cards fit the page's outline. Defaults to one below the nearest `HeadingScope`, else 2 (`<h2>`). |
| className | string | undefined | — | Extra classes for the outer element. |
Strings
The fixed words, in English by default. Change any of them under portfolioCard in <LocaleProvider strings={{ … }}>; the table is exported as portfolioCardStrings.
| Key | English |
|---|---|
| needsYou | { one: "{count} needs you", other: "{count} need you" } |
| allClear | All clear |
| notChecked | Not checked |
| estimate | Estimate |
Types
- PortfolioStatus
- A workspace's state as a word with a dot ("Healthy", "Connection expired").
{ /** The word printed beside the dot. */ word: string; /** The dot's colour. */ tone: "success" | "warning" | "destructive" | "neutral"; }- PortfolioFigure
- One figure on a card: its value as the app formats it ("84", "4") over its label ("Health", "Environments").
{ /** What the figure is. */ label: string; /** The figure, formatted by the app. */ value: string; }- PortfolioEstimate
- A figure that is estimated, not measured: its value, and what the estimate rests on in words.
{ /** The estimated value, formatted by the app ("$22k a year"). */ value: string; /** What it rests on, in words: "10 idle seats at list price". Shown under the value, never on hover only. */ basis: string; }
Types from other items
The props above take these. Each is defined by another item; import it from the path shown.
- HeadingLevel from @/lib/candor/heading-level
- A heading's level in the page's outline: `1` is `<h1>`, `6` is `<h6>`. Every Candor item that draws a heading takes its `headingLevel` as this type; the level is where the heading sits in the outline, never how large it looks.
1 | 2 | 3 | 4 | 5 | 6