# Portfolio card

One 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`.

- Registry name: `portfolio-card` (`@candor/portfolio-card`)
- Tier: Pro. Installing it needs a Pro, Premium or Team licence key: the person runs `npx candor-ui key <key>` themselves; never ask for the key in chat or write it to a file.
- Docs: https://candor.themethodic.com/docs/portfolio-card

## Install

```bash
npx candor-ui add portfolio-card
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/portfolio-card`.
It brings in: bidi, heading-level, heading-scope, link, locale, tokens, utils.
npm packages: lucide-react@^1.67.0.

## Example

```tsx
"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>

Props:

- `name: string`: The workspace's name: the card's link and its heading.
- `href: 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.
- `needsYou: 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`

## Strings (`portfolioCard`)

The fixed words, English by default. Override any of them under `portfolioCard` in `<LocaleProvider strings={{ portfolioCard: { … } }}>`; the table is exported as `portfolioCardStrings`.

- `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").

```ts
{
  /** 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").

```ts
{
  /** 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.

```ts
{
  /** 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

Import: `import type { HeadingLevel } from "@/lib/candor/heading-level"` (item `heading-level`, https://candor.themethodic.com/docs/heading-level.md)

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.

```ts
1 | 2 | 3 | 4 | 5 | 6
```

## Rules for using Candor UI

- Components take plain typed data. Never pass model output as HTML or render it as a link; messages are typed blocks.
- A control that can't be used says why: wrap it in `<Locked reason="…">`, don't just disable it.
- Don't show a value you don't have: leave it out (it renders as —) rather than guess. An estimate is labelled as one.
- Nothing is pre-selected in a decision a person makes, and the agent is named from your app's data, never from model output.
- Your app's words go in `LocaleProvider`: its fixed strings in `strings`, its nouns in `nouns`; entity kinds and agent roles in `EntityKindsProvider` and `AgentRolesProvider`.
