{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "portfolio-1",
  "title": "Portfolio 1: investments overview",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "An investment portfolio: total value and its change over a week, month or year, a line you scrub by pointer or arrow keys, an allocation ring, and sortable holdings with price, share, day change and a small trend line.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/segmented-control.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/portfolio-1/portfolio-1.tsx",
      "content": "// Ballmac UI: Portfolio 1. https://ui.ballmac.com/blocks/portfolio-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowDown, ArrowUp, ArrowUpDown, Briefcase, TrendingDown, TrendingUp } from \"lucide-react\"\n\nimport { SegmentedControl, SegmentedControlItem } from \"@/components/ballmac/segmented-control\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Portfolio1Holding = {\n  id: string\n  symbol: string\n  name: string\n  /** The group it belongs to, such as \"Stocks\" or \"Bonds\". */\n  kind: string\n  shares: number\n  price: number\n  /** Change today, as a percent. */\n  dayPct: number\n  /** Recent prices, oldest first, for the small line. */\n  spark: number[]\n}\n\ntype Portfolio1Range = \"1W\" | \"1M\" | \"1Y\"\n\ntype Portfolio1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    holdings?: Portfolio1Holding[]\n    /** The value of the whole portfolio over time for each range, oldest first. */\n    series?: Partial<Record<Portfolio1Range, number[]>>\n    /** \"Past week\", \"Past month\", \"Past year\" labels are fixed; this is the first date label per range. */\n    title?: string\n    currency?: string\n    locale?: string\n    defaultRange?: Portfolio1Range\n  }\n\nconst wave = (n: number, start: number, end: number, amp: number, phase: number) => Array.from({ length: n }, (_, i) => Math.round((start + ((end - start) * i) / (n - 1) + Math.sin(i * 0.9 + phase) * amp * (1 - i / (n * 1.6))) * 100) / 100)\n\nconst sample: Portfolio1Holding[] = [\n  { id: \"h1\", symbol: \"VTI\", name: \"Total US Market ETF\", kind: \"Stocks\", shares: 210, price: 284.12, dayPct: 0.82, spark: wave(24, 262, 284, 3, 0) },\n  { id: \"h2\", symbol: \"VXUS\", name: \"Total International ETF\", kind: \"Stocks\", shares: 340, price: 64.9, dayPct: -0.34, spark: wave(24, 62, 64.9, 1, 1) },\n  { id: \"h3\", symbol: \"BND\", name: \"Total Bond Market ETF\", kind: \"Bonds\", shares: 520, price: 72.45, dayPct: 0.11, spark: wave(24, 71.5, 72.45, 0.3, 2) },\n  { id: \"h4\", symbol: \"AAPL\", name: \"Apple Inc.\", kind: \"Stocks\", shares: 60, price: 231.7, dayPct: 1.46, spark: wave(24, 205, 231.7, 5, 0.5) },\n  { id: \"h5\", symbol: \"VNQ\", name: \"Real Estate ETF\", kind: \"Real estate\", shares: 150, price: 91.38, dayPct: -0.62, spark: wave(24, 94, 91.38, 1.5, 2.5) },\n  { id: \"h6\", symbol: \"GLD\", name: \"Gold Trust\", kind: \"Commodities\", shares: 45, price: 248.2, dayPct: 0.27, spark: wave(24, 236, 248.2, 2, 3) },\n  { id: \"h7\", symbol: \"CASH\", name: \"Money market\", kind: \"Cash\", shares: 18400, price: 1, dayPct: 0, spark: wave(24, 1, 1, 0, 0) },\n]\n\nconst sampleSeries: Record<Portfolio1Range, number[]> = {\n  \"1W\": wave(7, 171900, 176583, 900, 0),\n  \"1M\": wave(30, 164000, 176583, 2000, 1),\n  \"1Y\": wave(52, 131500, 176583, 5200, 2),\n}\n\nconst tones = [\"var(--chart-1)\", \"var(--chart-2)\", \"var(--chart-3)\", \"var(--chart-4)\", \"var(--chart-5)\", \"var(--muted-foreground)\"]\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-6\">\n      <Skeleton className=\"h-72 rounded-3xl\" />\n      <Skeleton className=\"h-80 rounded-2xl\" />\n    </div>\n  )\n}\n\nfunction Spark({ data, up }: { data: number[]; up: boolean }) {\n  if (data.length < 2 || Math.max(...data) === Math.min(...data)) return <span aria-hidden=\"true\" className=\"bg-border block h-px w-20\" />\n  const lo = Math.min(...data)\n  const hi = Math.max(...data)\n  const pts = data.map((v, i) => `${(i / (data.length - 1)) * 80},${22 - ((v - lo) / (hi - lo)) * 20}`).join(\" \")\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 80 24\" className=\"h-6 w-20\">\n      <polyline points={pts} fill=\"none\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={up ? \"stroke-chart-2\" : \"stroke-destructive\"} />\n    </svg>\n  )\n}\n\n/**\n * An investment portfolio: the total value with how much it moved over a week, month or year, a line you can scrub with the pointer or arrow keys to read any\n * day's value, how the money is split as a ring, and every holding with its price, share of the portfolio, day change and a small line, sortable by column.\n * Everything is worked out from the holdings and the series you pass; gains and losses are written with a sign and an arrow, not only colored.\n */\nfunction Portfolio1({ holdings = sample, series = sampleSeries, title = \"Portfolio\", currency = \"USD\", locale = \"en\", defaultRange = \"1M\", state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Portfolio1Props) {\n  const id = React.useId()\n  const list = state === \"empty\" ? [] : holdings\n  const [range, setRange] = React.useState<Portfolio1Range>(defaultRange)\n  const [at, setAt] = React.useState<number | null>(null)\n  const [kind, setKind] = React.useState<string | null>(null)\n  const [sort, setSort] = React.useState<{ key: \"symbol\" | \"value\" | \"day\"; dir: 1 | -1 }>({ key: \"value\", dir: -1 })\n  const money = (n: number, d = 0) => new Intl.NumberFormat(locale, { style: \"currency\", currency, maximumFractionDigits: d, minimumFractionDigits: d }).format(n)\n  const signed = (n: number, d = 2) => `${n >= 0 ? \"+\" : \"−\"}${Math.abs(n).toFixed(d)}%`\n\n  const data = series[range] ?? series[\"1M\"] ?? []\n  const total = list.reduce((n, h) => n + h.shares * h.price, 0)\n  const first = data[0] ?? total\n  const last = data.at(-1) ?? total\n  const gain = last - first\n  const gainPct = first ? (gain / first) * 100 : 0\n  const shown = at === null ? last : (data[at] ?? last)\n  const shownGain = shown - first\n  const dayGain = list.reduce((n, h) => n + h.shares * h.price * (h.dayPct / (100 + h.dayPct)), 0)\n  const kinds = [...new Set(list.map((h) => h.kind))]\n  const byKind = kinds.map((k, i) => ({ k, v: list.filter((h) => h.kind === k).reduce((n, h) => n + h.shares * h.price, 0), c: tones[i % tones.length]! })).sort((a, b) => b.v - a.v)\n  const rows = list\n    .filter((h) => !kind || h.kind === kind)\n    .map((h) => ({ h, value: h.shares * h.price }))\n    .sort((a, b) => (sort.key === \"symbol\" ? a.h.symbol.localeCompare(b.h.symbol) : sort.key === \"day\" ? a.h.dayPct - b.h.dayPct : a.value - b.value) * sort.dir)\n\n  // The area chart: a smooth line scaled to its own range, with a marker for the day being read.\n  const W = 600\n  const H = 180\n  const lo = Math.min(...data)\n  const hi = Math.max(...data)\n  const x = (i: number) => (data.length > 1 ? (i / (data.length - 1)) * W : 0)\n  const y = (v: number) => 12 + (hi === lo ? 0.5 : 1 - (v - lo) / (hi - lo)) * (H - 24)\n  const line = data.map((v, i) => `${i ? \"L\" : \"M\"}${x(i).toFixed(1)} ${y(v).toFixed(1)}`).join(\" \")\n  const area = `${line} L${W} ${H} L0 ${H} Z`\n  const up = gain >= 0\n\n  // The ring: each group is an arc whose length is its share.\n  const R = 52\n  const C = 2 * Math.PI * R\n  let acc = 0\n\n  const Th = ({ k, children, end }: { k: \"symbol\" | \"value\" | \"day\"; children: React.ReactNode; end?: boolean }) => (\n    <th scope=\"col\" aria-sort={sort.key === k ? (sort.dir === 1 ? \"ascending\" : \"descending\") : \"none\"} className={cn(\"px-4 py-3 font-medium\", end ? \"text-end\" : \"text-start\")}>\n      <button type=\"button\" onClick={() => setSort((s) => (s.key === k ? { key: k, dir: (s.dir * -1) as 1 | -1 } : { key: k, dir: k === \"symbol\" ? 1 : -1 }))} className=\"focus-visible:ring-ring/50 inline-flex items-center gap-1 rounded-sm outline-none focus-visible:ring-[3px]\">\n        {children}\n        {sort.key === k ? sort.dir === 1 ? <ArrowUp aria-hidden=\"true\" className=\"size-3.5\" /> : <ArrowDown aria-hidden=\"true\" className=\"size-3.5\" /> : <ArrowUpDown aria-hidden=\"true\" className=\"size-3.5 opacity-40\" />}\n      </button>\n    </th>\n  )\n\n  const content = !list.length ? (\n    <div className=\"bg-card rounded-2xl border px-6 py-16 text-center\">\n      <span aria-hidden=\"true\" className=\"bg-muted mx-auto flex size-12 items-center justify-center rounded-full\">\n        <Briefcase className=\"size-6\" />\n      </span>\n      <h2 className=\"mt-5 text-lg font-semibold tracking-tight\">{emptyState?.title ?? \"No holdings yet\"}</h2>\n      <p className=\"text-muted-foreground mx-auto mt-1.5 max-w-xs text-sm text-pretty\">{emptyState?.description ?? \"Once you add or connect an account, your holdings and how they have done appear here.\"}</p>\n      {emptyState?.action}\n    </div>\n  ) : (\n    <div className=\"grid grid-cols-1 gap-6\">\n      <div className=\"grid gap-6 lg:grid-cols-[minmax(0,1fr)_17rem]\">\n        <div className=\"bg-card min-w-0 rounded-3xl border p-5 sm:p-7\">\n          <div className=\"flex flex-wrap items-start justify-between gap-4\">\n            <div>\n              <p className=\"text-muted-foreground text-sm\">{at === null ? \"Portfolio value\" : `Value at point ${at + 1} of ${data.length}`}</p>\n              <p className=\"text-4xl font-semibold tracking-[-0.045em] tabular-nums sm:text-5xl\">{money(shown, 0)}</p>\n              <p className=\"mt-2 flex items-center gap-2 text-sm font-medium tabular-nums\">\n                {(at === null ? up : shownGain >= 0) ? <TrendingUp aria-hidden=\"true\" className=\"text-chart-2 size-4\" /> : <TrendingDown aria-hidden=\"true\" className=\"text-destructive size-4\" />}\n                {at === null ? `${gain >= 0 ? \"+\" : \"−\"}${money(Math.abs(gain))} (${signed(gainPct)})` : `${shownGain >= 0 ? \"+\" : \"−\"}${money(Math.abs(shownGain))} since the start`}\n                <span className=\"text-muted-foreground font-normal\">{range === \"1W\" ? \"this week\" : range === \"1M\" ? \"this month\" : \"this year\"}</span>\n              </p>\n              <p className=\"text-muted-foreground mt-1 text-sm tabular-nums\">\n                Today {dayGain >= 0 ? \"+\" : \"−\"}\n                {money(Math.abs(dayGain))}\n              </p>\n            </div>\n            <SegmentedControl value={range} onValueChange={(v) => { setRange(v as Portfolio1Range); setAt(null) }} aria-label=\"Time range\" size=\"sm\">\n              {([\"1W\", \"1M\", \"1Y\"] as const).map((r) => (\n                <SegmentedControlItem key={r} value={r}>\n                  {r}\n                </SegmentedControlItem>\n              ))}\n            </SegmentedControl>\n          </div>\n          <div\n            dir=\"ltr\"\n            role=\"slider\"\n            tabIndex={0}\n            aria-label=\"Portfolio value over time\"\n            aria-valuemin={0}\n            aria-valuemax={data.length - 1}\n            aria-valuenow={at ?? data.length - 1}\n            aria-valuetext={`${money(shown, 0)}, point ${(at ?? data.length - 1) + 1} of ${data.length}`}\n            onKeyDown={(e) => {\n              const cur = at ?? data.length - 1\n              const n = e.key === \"ArrowLeft\" ? cur - 1 : e.key === \"ArrowRight\" ? cur + 1 : e.key === \"Home\" ? 0 : e.key === \"End\" ? data.length - 1 : null\n              if (n === null) return\n              e.preventDefault()\n              setAt(Math.max(0, Math.min(data.length - 1, n)))\n            }}\n            onBlur={() => setAt(null)}\n            onPointerLeave={() => setAt(null)}\n            onPointerMove={(e) => { const r = e.currentTarget.getBoundingClientRect(); setAt(Math.max(0, Math.min(data.length - 1, Math.round(((e.clientX - r.left) / r.width) * (data.length - 1))))) }}\n            className=\"focus-visible:ring-ring/50 relative mt-6 cursor-crosshair rounded-xl outline-none focus-visible:ring-[3px]\"\n          >\n            <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio=\"none\" className=\"block h-44 w-full\" aria-hidden=\"true\">\n              <defs>\n                <linearGradient id={`${id}-g`} x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">\n                  <stop offset=\"0\" stopColor={up ? \"var(--chart-2)\" : \"var(--destructive)\"} stopOpacity=\"0.28\" />\n                  <stop offset=\"1\" stopColor={up ? \"var(--chart-2)\" : \"var(--destructive)\"} stopOpacity=\"0\" />\n                </linearGradient>\n              </defs>\n              <path d={area} fill={`url(#${id}-g)`} />\n              <path d={line} fill=\"none\" strokeWidth=\"2.4\" strokeLinejoin=\"round\" strokeLinecap=\"round\" vectorEffect=\"non-scaling-stroke\" stroke={up ? \"var(--chart-2)\" : \"var(--destructive)\"} />\n            </svg>\n            {at !== null ? (\n              <>\n                <span aria-hidden=\"true\" className=\"bg-foreground/30 pointer-events-none absolute inset-y-0 w-px\" style={{ left: `${(x(at) / W) * 100}%` }} />\n                <span aria-hidden=\"true\" className=\"border-background bg-foreground pointer-events-none absolute size-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2\" style={{ left: `${(x(at) / W) * 100}%`, top: `${(y(data[at]!) / H) * 100}%` }} />\n              </>\n            ) : null}\n          </div>\n        </div>\n\n        <div className=\"bg-card rounded-3xl border p-5 sm:p-6\">\n          <h2 className=\"text-sm font-semibold\">Allocation</h2>\n          <div className=\"relative mx-auto mt-4 size-40\">\n            <svg role=\"img\" aria-label={`Allocation: ${byKind.map((b) => `${b.k} ${((b.v / total) * 100).toFixed(0)}%`).join(\", \")}`} viewBox=\"0 0 120 120\" className=\"size-full -rotate-90 rtl:rotate-90\">\n              <circle cx=\"60\" cy=\"60\" r={R} fill=\"none\" strokeWidth=\"14\" className=\"stroke-muted\" />\n              {byKind.map((b) => {\n                const len = (b.v / total) * C\n                const el = <circle key={b.k} cx=\"60\" cy=\"60\" r={R} fill=\"none\" strokeWidth=\"14\" strokeDasharray={`${Math.max(0, len - 2)} ${C - Math.max(0, len - 2)}`} strokeDashoffset={-acc} style={{ stroke: b.c }} />\n                acc += len\n                return el\n              })}\n            </svg>\n            <span className=\"absolute inset-0 flex flex-col items-center justify-center text-center\">\n              <span className=\"text-muted-foreground text-xs\">{list.length} holdings</span>\n              <span className=\"text-lg font-semibold tabular-nums\">{money(total)}</span>\n            </span>\n          </div>\n          <ul className=\"mt-5 grid gap-2 text-sm\">\n            {byKind.map((b) => (\n              <li key={b.k} className=\"flex items-center justify-between gap-3\">\n                <span className=\"flex items-center gap-2\">\n                  <span aria-hidden=\"true\" className=\"size-2.5 rounded-full\" style={{ background: b.c }} />\n                  {b.k}\n                </span>\n                <span className=\"tabular-nums\">{((b.v / total) * 100).toFixed(1)}%</span>\n              </li>\n            ))}\n          </ul>\n        </div>\n      </div>\n\n      <div className=\"bg-card min-w-0 rounded-2xl border\">\n        <div className=\"flex flex-wrap items-center justify-between gap-3 border-b px-5 py-3.5\">\n          <h2 className=\"font-semibold tracking-tight\">Holdings</h2>\n          <div role=\"group\" aria-label=\"Filter by type\" className=\"flex flex-wrap gap-2\">\n            {[null, ...kinds].map((k) => (\n              <button key={k ?? \"all\"} type=\"button\" aria-pressed={kind === k} onClick={() => setKind(k)} className={cn(\"focus-visible:ring-ring/50 rounded-full border px-3 py-1 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none\", kind === k ? \"bg-foreground text-background border-foreground\" : \"hover:bg-accent\")}>\n                {k ?? \"All\"}\n              </button>\n            ))}\n          </div>\n        </div>\n        <div role=\"region\" tabIndex={0} aria-label=\"Holdings table\" className=\"focus-visible:ring-ring/50 overflow-x-auto outline-none focus-visible:ring-[3px] focus-visible:ring-inset\">\n          <table className=\"w-full min-w-[40rem] text-sm\">\n            <caption className=\"sr-only\">Holdings with value, share of the portfolio and change today</caption>\n            <thead className=\"text-muted-foreground text-xs\">\n              <tr>\n                <Th k=\"symbol\">Holding</Th>\n                <th scope=\"col\" className=\"px-4 py-3 text-end font-medium\">Price</th>\n                <Th k=\"value\" end>\n                  Value\n                </Th>\n                <th scope=\"col\" className=\"px-4 py-3 text-end font-medium\">Share</th>\n                <Th k=\"day\" end>\n                  Today\n                </Th>\n                <th scope=\"col\" className=\"px-4 py-3 text-end font-medium\">Trend</th>\n              </tr>\n            </thead>\n            <tbody className=\"divide-y border-t\">\n              {rows.map(({ h, value }) => (\n                <tr key={h.id}>\n                  <th scope=\"row\" className=\"px-4 py-3 text-start font-normal\">\n                    <span className=\"block font-semibold\">{h.symbol}</span>\n                    <span className=\"text-muted-foreground block text-xs\">\n                      {h.name} · {new Intl.NumberFormat(locale).format(h.shares)} {h.shares === 1 ? \"share\" : \"shares\"}\n                    </span>\n                  </th>\n                  <td className=\"px-4 py-3 text-end tabular-nums\">{money(h.price, 2)}</td>\n                  <td className=\"px-4 py-3 text-end font-medium tabular-nums\">{money(value)}</td>\n                  <td className=\"px-4 py-3 text-end tabular-nums\">{((value / total) * 100).toFixed(1)}%</td>\n                  <td className=\"px-4 py-3 text-end tabular-nums\">\n                    <span className=\"inline-flex items-center gap-1 font-medium\">\n                      {h.dayPct > 0 ? <ArrowUp aria-hidden=\"true\" className=\"text-chart-2 size-3.5\" /> : h.dayPct < 0 ? <ArrowDown aria-hidden=\"true\" className=\"text-destructive size-3.5\" /> : null}\n                      {signed(h.dayPct)}\n                    </span>\n                  </td>\n                  <td className=\"px-4 py-3\">\n                    <span className=\"ms-auto block w-fit\">\n                      <Spark data={h.spark} up={h.spark.at(-1)! >= h.spark[0]!} />\n                    </span>\n                  </td>\n                </tr>\n              ))}\n            </tbody>\n          </table>\n        </div>\n      </div>\n    </div>\n  )\n\n  return (\n    <section data-slot=\"portfolio-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-6xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <h1 id={`${id}-title`} className=\"mb-6 text-3xl font-semibold tracking-[-0.035em] text-balance\">\n        {title}\n      </h1>\n      <StateView state={state === \"loading\" || state === \"error\" ? state : \"ready\"} onRetry={onRetry} label=\"Loading the portfolio\" loading={<Skel />} error={{ title: errorState?.title ?? \"The portfolio could not be loaded\", description: errorState?.description ?? \"Your holdings are safe. Check your connection and try again.\", ...errorState }}>\n        {content}\n      </StateView>\n    </section>\n  )\n}\n\nexport { Portfolio1, type Portfolio1Props, type Portfolio1Holding, type Portfolio1Range }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/portfolio-1/portfolio-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "portfolio",
      "investments",
      "holdings",
      "stocks",
      "finance",
      "allocation",
      "wealth",
      "returns",
      "dashboard",
      "assets"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "An investment portfolio. <Portfolio1 holdings={[{ id, symbol, name, kind: 'Stocks', shares, price, dayPct, spark: number[] }]} series={{ '1W': number[], '1M': number[], '1Y': number[] }} currency='USD' locale defaultRange='1M' />. Total value, allocation by kind, each holding's share and today's gain are worked out from holdings; the change over the range comes from the first and last point of the series. The line is a slider (Left, Right, Home, End) that reads out any point. Gains and losses carry a sign and an arrow as well as color.",
      "whenToUse": [
        "An investing or wealth app's overview",
        "A finance dashboard showing what is held and how it is doing"
      ],
      "whenNotToUse": [
        "A trading screen with order entry",
        "A single stock price chart with candles"
      ],
      "composesWith": [
        "dashboard-1",
        "invoice-1",
        "funnel-1",
        "app-shell-1"
      ],
      "a11y": [
        {
          "keys": "Tab, then Left / Right / Home / End",
          "action": "The value line is a slider; its value text reads the amount and 'point 12 of 30', and a pointer shows the same on hover"
        },
        {
          "keys": "Sort and filter",
          "action": "Column headers are buttons with aria-sort; type filters say whether they are on; the table is a captioned, focusable region that scrolls on phones"
        },
        {
          "keys": "Gains and losses",
          "action": "Written as '+0.82%' or '−0.34%' with an up or down arrow, never by color alone; the ring has a text equivalent listing each share"
        }
      ],
      "customization": [
        "series can hold any of the three ranges; a missing one falls back to the month",
        "currency and locale format every amount",
        "The chart and ring keep their direction in right-to-left, like any chart"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "portfolio-1-loading",
        "trigger": "state=\"loading\"",
        "note": "A skeleton chart and table under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No holdings",
        "example": "portfolio-1-empty",
        "trigger": "state=\"empty\" (or holdings={[]})",
        "note": "Says holdings appear once an account is added or connected; emptyState sets the copy and can add a button."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "portfolio-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying your holdings are safe, with Try again (onRetry)."
      }
    ],
    "examples": [
      "portfolio-1-demo",
      "portfolio-1-down",
      "portfolio-1-loading",
      "portfolio-1-empty",
      "portfolio-1-error"
    ],
    "url": "https://ui.ballmac.com/components/portfolio-1"
  },
  "categories": [
    "blocks",
    "portfolio"
  ],
  "type": "registry:block"
}