{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ai-cost-card",
  "title": "AI cost card",
  "description": "A spend card for estimated AI cost: the window's total with its change on the prior stretch, a 7D–ALL window switch, the split by kind of work as horizontal sticks with a figure legend, and one stacked-stick column per date that fills up bottom to top.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://bash-ui-registry.vercel.app/r/stick-progress-bar.json"
  ],
  "files": [
    {
      "path": "registry/items/ai-cost-card/ai-cost-card.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState, type ReactNode } from \"react\";\nimport { motion, useReducedMotionConfig } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { StickProgressBar } from \"@/components/stick-progress-bar\";\n\n/** One kind of work the money went on. Order is the stacking order, bottom up. */\nexport type AiCostCategory = {\n  id: string;\n  label: string;\n  /** Any CSS colour; keep it a token (`var(--lead)`). */\n  color: string;\n};\n\n/** One day's priced spend, split by category id. Days with nothing may be left out. */\nexport type AiCostDay = {\n  /** ISO day, YYYY-MM-DD (UTC). */\n  date: string;\n  byCategory: Record<string, number>;\n};\n\n/** Everything the card shows for one window. Switching windows only re-reads these. */\nexport type AiCostReading = {\n  id: string;\n  /** The pill: \"7D\", \"30D\", \"ALL\". */\n  pill: string;\n  /** Plain words for the window, read by screen readers: \"Past 30 days\". */\n  label: string;\n  /** What the change is measured against, after \"on\": \"the 30 days before\". */\n  comparison: string;\n  /** First and last ISO day of the window, inclusive. */\n  from: string;\n  to: string;\n  /** Priced cost in the window. */\n  costUsd: number;\n  /** Priced cost in the equal-length stretch before; 0 means nothing to compare with. */\n  priorCostUsd: number;\n  /** AI requests in the window, priced or not. */\n  requests: number;\n  /** Of those, how many carried no cost, so the headline is a floor. */\n  unpriced?: number;\n  /** Requests the provider refused because a usage limit was reached. */\n  refused?: number;\n  totalTokens?: number;\n  /** Distinct models used in the window. */\n  models?: string[];\n  /** Per category: priced cost and request count. A category that ran unpriced keeps its row. */\n  byCategory: Record<string, { costUsd: number; requests: number }>;\n  spendByDay: AiCostDay[];\n  /** A caveat that belongs to this window (\"searches are only counted from 3 Oct\"). */\n  note?: ReactNode;\n};\n\nexport interface AiCostCardProps {\n  readings: AiCostReading[];\n  categories: AiCostCategory[];\n  /** Window selected at first render. Default the first reading. */\n  defaultReadingId?: string;\n  onReadingChange?: (id: string) => void;\n  title?: ReactNode;\n  /** Header action. Rendered as a plain link when given. */\n  href?: string;\n  linkLabel?: ReactNode;\n  /** Under the headline: why this is an estimate and not the bill. */\n  estimateNote?: ReactNode;\n  unpricedNote?: (count: number) => ReactNode;\n  refusedNote?: (count: number) => ReactNode;\n  /** Money formatting. Default USD to 2dp, \"<$0.01\" for a sub-cent cost. */\n  formatCost?: (value: number) => string;\n  className?: string;\n}\n\nconst EASE = [0.2, 0.7, 0.2, 1] as const;\nconst SHORT_MONTHS = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"];\n\nconst PLOT_HEIGHT = 252;\nconst TOP = 8;\nconst BOTTOM = 26;\nconst GUTTER = 48;\nconst RIGHT = 8;\nconst TICKS = 5;\n/** Sticks in the busiest column: enough that a 3px stick plus gap fills the plot. */\nconst MAX_STICKS = 36;\nconst STICK_HEIGHT = 3;\nconst STICK_PITCH = 6;\n/** Past this many columns the cascade is hundreds of animated nodes, so draw plain sticks. */\nconst ANIMATED_COLUMN_LIMIT = 31;\n/** Above this many days a window groups into weeks; past the next, months. */\nconst WEEKLY_ABOVE_DAYS = 31;\nconst MONTHLY_ABOVE_DAYS = 120;\n\nconst USD = new Intl.NumberFormat(\"en-US\", { minimumFractionDigits: 2, maximumFractionDigits: 2 });\n\nexport function formatUsd(value: number): string {\n  if (value > 0 && value < 0.01) return \"<$0.01\";\n  return `$${USD.format(value)}`;\n}\n\n/** \"58%\", and \"<1%\" rather than a round zero for a category that cost something. */\nfunction formatShare(share: number): string {\n  if (share <= 0) return \"0%\";\n  if (share < 0.005) return \"<1%\";\n  return `${Math.round(share * 100 + 1e-9)}%`;\n}\n\nfunction utcDay(isoDay: string): Date {\n  return new Date(`${isoDay}T00:00:00Z`);\n}\n\nfunction isoDay(date: Date): string {\n  return date.toISOString().slice(0, 10);\n}\n\n/** \"12 Sep\", or \"12 Sep 2026\" where a window crosses a year. */\nfunction formatDay(day: string, withYear = false): string {\n  const date = utcDay(day);\n  const label = `${date.getUTCDate()} ${SHORT_MONTHS[date.getUTCMonth()]}`;\n  return withYear ? `${label} ${date.getUTCFullYear()}` : label;\n}\n\ntype Column = {\n  key: string;\n  label: string;\n  tooltipLabel?: string;\n  total: number;\n  byCategory: Record<string, number>;\n};\n\n/** Every day in the window, including the ones with nothing on them. */\nfunction dailyColumns(reading: AiCostReading, withYear: boolean): Column[] {\n  const byDay = new Map(reading.spendByDay.map((day) => [day.date, day.byCategory]));\n  const columns: Column[] = [];\n  const end = utcDay(reading.to).getTime();\n  for (let day = utcDay(reading.from); day.getTime() <= end; day.setUTCDate(day.getUTCDate() + 1)) {\n    const key = isoDay(day);\n    const byCategory = { ...(byDay.get(key) ?? {}) };\n    const total = Object.values(byCategory).reduce((sum, v) => sum + v, 0);\n    columns.push({ key, label: formatDay(key, withYear), total, byCategory });\n  }\n  return columns;\n}\n\n/** Monday of the ISO week containing `date`. */\nfunction weekStart(date: Date): Date {\n  const start = new Date(date);\n  const weekday = start.getUTCDay();\n  start.setUTCDate(start.getUTCDate() + (weekday === 0 ? -6 : 1 - weekday));\n  return start;\n}\n\n/** Groups daily columns into Monday-start weeks or calendar months. */\nfunction groupedColumns(days: Column[], granularity: \"week\" | \"month\", withYear: boolean): Column[] {\n  const order: string[] = [];\n  const buckets = new Map<string, { start: Date; end: Date; total: number; byCategory: Record<string, number> }>();\n  for (const day of days) {\n    const date = utcDay(day.key);\n    const key =\n      granularity === \"week\"\n        ? isoDay(weekStart(date))\n        : `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, \"0\")}`;\n    let bucket = buckets.get(key);\n    if (!bucket) {\n      bucket = { start: date, end: date, total: 0, byCategory: {} };\n      buckets.set(key, bucket);\n      order.push(key);\n    }\n    bucket.end = date;\n    bucket.total += day.total;\n    for (const [id, value] of Object.entries(day.byCategory)) {\n      bucket.byCategory[id] = (bucket.byCategory[id] ?? 0) + value;\n    }\n  }\n  return order.map((key) => {\n    const bucket = buckets.get(key)!;\n    if (granularity === \"week\") {\n      const startLabel = formatDay(isoDay(bucket.start), withYear);\n      const endLabel = formatDay(isoDay(bucket.end), withYear);\n      return {\n        key,\n        label: startLabel,\n        tooltipLabel: startLabel === endLabel ? startLabel : `${startLabel} – ${endLabel}`,\n        total: bucket.total,\n        byCategory: bucket.byCategory,\n      };\n    }\n    const month = (style: \"short\" | \"long\", year: boolean) =>\n      bucket.start.toLocaleDateString(\"en-GB\", {\n        month: style,\n        year: year ? \"numeric\" : undefined,\n        timeZone: \"UTC\",\n      });\n    return {\n      key,\n      label: month(\"short\", withYear),\n      tooltipLabel: month(\"long\", true),\n      total: bucket.total,\n      byCategory: bucket.byCategory,\n    };\n  });\n}\n\nfunction niceCeiling(value: number): number {\n  if (value <= 0) return 1;\n  const magnitude = 10 ** Math.floor(Math.log10(value));\n  const normalised = value / magnitude;\n  const step = [1, 2, 2.5, 5, 10].find((candidate) => normalised <= candidate) ?? 10;\n  return step * magnitude;\n}\n\n/**\n * A column's total dealt into whole sticks across the categories that spent,\n * largest remainder first, stacked in category order so each kind of work sits\n * in the same place on every date.\n */\nfunction dealColumnSticks(\n  categoryIds: string[],\n  byCategory: Record<string, number>,\n  total: number,\n  count: number,\n): string[] {\n  const entries = categoryIds\n    .map((id) => ({ id, value: byCategory[id] ?? 0 }))\n    .filter((entry) => entry.value > 0);\n  if (entries.length === 0 || count <= 0 || total <= 0) return [];\n\n  const exact = entries.map((entry) => (entry.value / total) * count);\n  const base = exact.map(Math.floor);\n  let rest = count - base.reduce((sum, sticks) => sum + sticks, 0);\n  const order = entries\n    .map((_, index) => index)\n    .sort((a, b) => (exact[b] % 1) - (exact[a] % 1) || entries[b].value - entries[a].value);\n  const extra = new Array<number>(entries.length).fill(0);\n  for (let round = 0; rest > 0; round += 1) {\n    extra[order[round % order.length]] += 1;\n    rest -= 1;\n  }\n\n  const sticks: string[] = [];\n  entries.forEach((entry, index) => {\n    for (let stick = 0; stick < base[index] + extra[index]; stick += 1) sticks.push(entry.id);\n  });\n  return sticks;\n}\n\n/** First and last always, and as many evenly spaced dates between as the width holds. */\nfunction xLabelIndices(count: number, width: number): number[] {\n  if (count <= 0) return [];\n  const capacity = width > 520 ? 6 : width > 380 ? 4 : 3;\n  if (count <= capacity) return Array.from({ length: count }, (_, index) => index);\n  const indices = new Set<number>();\n  for (let slot = 0; slot < capacity; slot += 1) {\n    indices.add(Math.round((slot * (count - 1)) / (capacity - 1)));\n  }\n  return [...indices].sort((a, b) => a - b);\n}\n\n/** Lucide's loader-pinwheel, spun while the card arrives and on hover. */\nfunction PinwheelMark({ spinning }: { spinning: boolean }) {\n  return (\n    <motion.svg\n      width={18}\n      height={18}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={2}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className=\"text-faint\"\n      aria-hidden=\"true\"\n      animate={spinning ? { rotate: 360 } : { rotate: 0 }}\n      transition={\n        spinning ? { duration: 1.5, ease: \"linear\", repeat: Infinity, repeatType: \"loop\" } : { duration: 0 }\n      }\n    >\n      <circle cx={12} cy={12} r={10} />\n      <path d=\"M22 12a1 1 0 0 1-10 0 1 1 0 0 0-10 0\" />\n      <path d=\"M7 20.7a1 1 0 1 1 5-8.7 1 1 0 1 0 5-8.6\" />\n      <path d=\"M7 3.3a1 1 0 1 1 5 8.6 1 1 0 1 0 5 8.6\" />\n    </motion.svg>\n  );\n}\n\nconst defaultEstimateNote = (\n  <>\n    What this use would cost at the provider&rsquo;s standard paid rates. On a free tier, the actual bill can be $0.\n  </>\n);\n\nconst defaultUnpricedNote = (count: number) => (\n  <>\n    {count.toLocaleString()} {count === 1 ? \"request has\" : \"requests have\"} no cost we could work out: the provider\n    didn&rsquo;t report its size, or the model has no price on file. The figure above is a minimum.\n  </>\n);\n\nconst defaultRefusedNote = (count: number) => (\n  <>\n    The provider turned away {count.toLocaleString()} AI {count === 1 ? \"request\" : \"requests\"} in this period because\n    the project reached its usage limit.\n  </>\n);\n\nexport function AiCostCard({\n  readings,\n  categories,\n  defaultReadingId,\n  onReadingChange,\n  title = \"Estimated AI cost\",\n  href,\n  linkLabel = \"View all generations\",\n  estimateNote = defaultEstimateNote,\n  unpricedNote = defaultUnpricedNote,\n  refusedNote = defaultRefusedNote,\n  formatCost = formatUsd,\n  className,\n}: AiCostCardProps) {\n  const headingId = useId();\n  const reduceMotion = useReducedMotionConfig() ?? false;\n  const [readingId, setReadingId] = useState(defaultReadingId ?? readings[0]?.id);\n  const [hovered, setHovered] = useState<number | null>(null);\n  const containerRef = useRef<HTMLDivElement>(null);\n  const [width, setWidth] = useState(0);\n  const [iconIntro, setIconIntro] = useState(true);\n  const [iconHover, setIconHover] = useState(false);\n\n  useEffect(() => {\n    const timer = setTimeout(() => setIconIntro(false), 1500);\n    return () => clearTimeout(timer);\n  }, []);\n\n  useEffect(() => {\n    const node = containerRef.current;\n    if (!node) return;\n    const measure = (measured: number) => {\n      if (measured > 0) setWidth(measured);\n    };\n    measure(node.getBoundingClientRect().width);\n    const observer = new ResizeObserver(([entry]) => {\n      if (entry) measure(entry.contentRect.width);\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n\n  const reading = readings.find((candidate) => candidate.id === readingId) ?? readings[0];\n  if (!reading) return null;\n\n  const categoryIds = categories.map((category) => category.id);\n  const categoryById = new Map(categories.map((category) => [category.id, category]));\n  const colorOf = (id: string) => categoryById.get(id)?.color ?? \"var(--faint)\";\n  const labelOf = (id: string) => categoryById.get(id)?.label ?? id;\n\n  const hasPriced = reading.costUsd > 0;\n\n  // Categories that ran, largest cost first; those that ran unpriced stay, at the end.\n  const rows = categories\n    .map((category) => {\n      const totals = reading.byCategory[category.id] ?? { costUsd: 0, requests: 0 };\n      return {\n        ...category,\n        costUsd: totals.costUsd,\n        requests: totals.requests,\n        share: reading.costUsd > 0 ? totals.costUsd / reading.costUsd : 0,\n      };\n    })\n    .filter((row) => row.requests > 0 || row.costUsd > 0)\n    .sort((a, b) => b.costUsd - a.costUsd || categoryIds.indexOf(a.id) - categoryIds.indexOf(b.id));\n\n  const gaugeSegments = rows\n    .filter((row) => row.costUsd > 0)\n    .map((row) => ({ id: row.id, label: row.label, value: row.costUsd, color: row.color }));\n\n  const withYear = reading.from.slice(0, 4) !== reading.to.slice(0, 4);\n  const range = `${formatDay(reading.from, withYear)} – ${formatDay(reading.to, withYear)}`;\n  const rawDays = dailyColumns(reading, withYear);\n  const granularity =\n    rawDays.length <= WEEKLY_ABOVE_DAYS ? \"day\" : rawDays.length <= MONTHLY_ABOVE_DAYS ? \"week\" : \"month\";\n  const columns = granularity === \"day\" ? rawDays : groupedColumns(rawDays, granularity, withYear);\n  const totals = columns.map((column) => column.total);\n  const max = niceCeiling(Math.max(...totals, 0) / (TICKS - 1)) * (TICKS - 1);\n  const hasSpend = totals.some((total) => total > 0);\n  const busiest = columns.reduce<Column | null>(\n    (result, column) => (result && result.total >= column.total ? result : column),\n    null,\n  );\n\n  const plotWidth = Math.max(1, width - GUTTER - RIGHT);\n  const plotHeight = PLOT_HEIGHT - TOP - BOTTOM;\n  const baseline = TOP + plotHeight;\n  const slot = plotWidth / Math.max(1, columns.length);\n  const stickWidth = Math.max(1, Math.min(18, slot - 1));\n  const centre = (index: number) => GUTTER + slot * (index + 0.5);\n  const animateCascade = !reduceMotion && columns.length <= ANIMATED_COLUMN_LIMIT;\n  const labelled = xLabelIndices(columns.length, width);\n\n  const hoveredColumn = hovered !== null ? columns[hovered] : null;\n  const hoveredLeftPct = hovered !== null ? (centre(hovered) / Math.max(1, width)) * 100 : 0;\n\n  const splitOf = (column: Column) =>\n    categoryIds\n      .map((id) => ({ id, value: column.byCategory[id] ?? 0 }))\n      .filter((entry) => entry.value > 0)\n      .sort((a, b) => b.value - a.value);\n\n  return (\n    <section\n      aria-labelledby={headingId}\n      className={cn(\"@container overflow-hidden rounded-panel border border-rule bg-card font-body shadow-sm\", className)}\n    >\n      <div className=\"flex items-center justify-between gap-4 border-b border-rule-soft px-5 py-3.5 sm:px-6\">\n        <h2 id={headingId} className=\"flex items-center gap-2 text-[15px] font-semibold tracking-[-0.01em] text-ink\">\n          <span\n            className=\"inline-flex\"\n            onPointerEnter={() => setIconHover(true)}\n            onPointerLeave={() => setIconHover(false)}\n          >\n            <PinwheelMark spinning={!reduceMotion && (iconIntro || iconHover)} />\n          </span>\n          {title}\n        </h2>\n        {href && (\n          <a\n            href={href}\n            className=\"shrink-0 rounded-inset bg-lead px-3 py-1.5 text-[13px] font-semibold text-on-solid transition-colors hover:bg-lead/90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\"\n          >\n            {linkLabel}\n          </a>\n        )}\n      </div>\n\n      <div className=\"grid items-start gap-x-6 gap-y-4 px-5 pt-4 sm:px-6 @lg:grid-cols-[minmax(0,1fr)_auto]\">\n        <div className=\"min-w-0\">\n          <p className=\"flex flex-wrap items-baseline gap-x-3 gap-y-1\">\n            <span className=\"text-[30px] leading-none font-bold tracking-[-0.02em] tabular-nums text-ink\">\n              {formatCost(reading.costUsd)}\n            </span>\n            <span className=\"text-sm leading-[1.55] text-dim\">\n              {reading.requests === 0\n                ? \"no AI use in this stretch\"\n                : `across ${reading.requests.toLocaleString()} AI ${reading.requests === 1 ? \"request\" : \"requests\"}`}\n            </span>\n          </p>\n          <Stacked readings={readings} activeId={reading.id} className=\"mt-1.5\">\n            {(candidate) => <ChangeLine reading={candidate} />}\n          </Stacked>\n        </div>\n\n        {readings.length > 1 && (\n          <div className=\"flex min-w-0 flex-col items-start gap-2.5 @lg:items-end\">\n            <div\n              role=\"group\"\n              aria-label=\"How far back these figures look\"\n              className=\"flex max-w-full items-center gap-0.5 overflow-x-auto rounded-[10px] bg-paper p-1\"\n            >\n              {readings.map((candidate) => {\n                const selected = candidate.id === reading.id;\n                return (\n                  <button\n                    key={candidate.id}\n                    type=\"button\"\n                    aria-pressed={selected}\n                    aria-label={candidate.label}\n                    onClick={() => {\n                      setReadingId(candidate.id);\n                      setHovered(null);\n                      onReadingChange?.(candidate.id);\n                    }}\n                    className={cn(\n                      \"shrink-0 cursor-pointer rounded-[7px] px-3.5 py-1.5 text-[13px] font-semibold outline-none transition-all focus-visible:ring-2 focus-visible:ring-lead\",\n                      selected ? \"bg-card text-ink shadow-sm\" : \"text-dim hover:text-ink\",\n                    )}\n                  >\n                    {candidate.pill}\n                  </button>\n                );\n              })}\n            </div>\n          </div>\n        )}\n      </div>\n\n      {/* The window's split as horizontal sticks, with the figures in the legend. */}\n      {estimateNote && (\n        <p className=\"px-5 pt-3 text-[12.5px] leading-[1.55] text-dim sm:px-6\">{estimateNote}</p>\n      )}\n\n      <div className=\"px-5 pt-8 pb-6 sm:px-6 sm:pb-8\">\n        <h3 className=\"text-[14px] font-semibold text-ink\">What it went on</h3>\n        {hasPriced ? (\n          <>\n            <div className=\"mt-2.5\">\n              <StickProgressBar\n                key={reading.id}\n                segments={gaugeSegments}\n                ariaLabel={`What this window's estimated AI cost went on, ${reading.label.toLowerCase()}`}\n                valueFormatter={formatCost}\n              />\n            </div>\n            <ul className=\"mt-3 flex flex-wrap gap-x-4 gap-y-1.5\">\n              {rows.map((row) => (\n                <li key={row.id} className=\"flex items-center gap-1.5 text-[12.5px] leading-none\">\n                  <span aria-hidden=\"true\" className=\"size-1.5 shrink-0 rounded-full\" style={{ background: row.color }} />\n                  <span className=\"text-dim\">{row.label}</span>\n                  {row.costUsd > 0 ? (\n                    <>\n                      <span className=\"font-semibold tabular-nums text-ink\">{formatCost(row.costUsd)}</span>\n                      <span className=\"tabular-nums text-dim\">{formatShare(row.share)}</span>\n                    </>\n                  ) : (\n                    <span className=\"text-dim\">no cost recorded</span>\n                  )}\n                </li>\n              ))}\n            </ul>\n          </>\n        ) : (\n          <p className=\"mt-2 text-[13px] leading-[1.55] text-dim\">\n            {reading.requests === 0\n              ? \"No generation ran in this stretch, so there is nothing to split.\"\n              : \"Nothing in this stretch has been priced yet, so there is no split to show.\"}\n          </p>\n        )}\n      </div>\n\n      {/* One stacked-stick column per date, height against the dollar axis. */}\n      <div ref={containerRef} className=\"relative mx-2 mt-2 mb-4 px-3 sm:mx-4\" style={{ height: PLOT_HEIGHT }}>\n        {width > 0 && (\n          <svg\n            role=\"img\"\n            aria-label={\n              hasSpend && busiest\n                ? `Estimated AI cost by date, ${range}. Busiest ${busiest.tooltipLabel ?? busiest.label}, ${formatCost(busiest.total)}.`\n                : `Estimated AI cost by date, ${range}`\n            }\n            className=\"block w-full\"\n            height={PLOT_HEIGHT}\n          >\n            {Array.from({ length: TICKS }, (_, index) => {\n              const value = (max / (TICKS - 1)) * index;\n              const y = baseline - (plotHeight / (TICKS - 1)) * index;\n              return (\n                <g key={index}>\n                  <line x1={GUTTER} x2={GUTTER + plotWidth} y1={y} y2={y} stroke=\"var(--rule-soft)\" strokeDasharray=\"4 4\" />\n                  <text x={GUTTER - 8} y={y + 3.5} textAnchor=\"end\" fill=\"var(--faint)\" className=\"font-body text-[10.5px]\">\n                    {formatCost(value)}\n                  </text>\n                </g>\n              );\n            })}\n\n            {columns.map((column, index) => {\n              const total = totals[index] ?? 0;\n              const count = total > 0 && max > 0 ? Math.max(1, Math.round((total / max) * MAX_STICKS)) : 0;\n              const sticks = dealColumnSticks(categoryIds, column.byCategory, total, count);\n              const split = splitOf(column);\n              return (\n                <g key={`${reading.id}-${column.key}`}>\n                  {hovered === index && (\n                    <rect\n                      x={GUTTER + slot * index + 2}\n                      y={TOP - 4}\n                      width={Math.max(1, slot - 4)}\n                      height={plotHeight + 8}\n                      rx={8}\n                      fill=\"var(--paper)\"\n                      opacity={0.7}\n                    />\n                  )}\n                  {sticks.map((id, stickIndex) => {\n                    const geometry = {\n                      x: centre(index) - stickWidth / 2,\n                      y: baseline - (stickIndex + 1) * STICK_PITCH + (STICK_PITCH - STICK_HEIGHT),\n                      width: stickWidth,\n                      height: STICK_HEIGHT,\n                      rx: 1.5,\n                      fill: colorOf(id),\n                    };\n                    return animateCascade ? (\n                      <motion.rect\n                        key={stickIndex}\n                        {...geometry}\n                        // Fill-up cascade: delaying by stick index lays each\n                        // column bottom to top, one column after the previous.\n                        initial={{ opacity: 0, y: 5 }}\n                        animate={{ opacity: 1, y: 0 }}\n                        transition={{\n                          duration: 0.28,\n                          ease: EASE,\n                          delay: Math.min(index * 0.05, 0.45) + Math.min(stickIndex * 0.022, 0.7),\n                        }}\n                      />\n                    ) : (\n                      <rect key={stickIndex} {...geometry} />\n                    );\n                  })}\n                  {labelled.includes(index) && (\n                    <text\n                      x={index === 0 ? GUTTER : index === columns.length - 1 ? GUTTER + plotWidth : centre(index)}\n                      y={baseline + 17}\n                      textAnchor={index === 0 ? \"start\" : index === columns.length - 1 ? \"end\" : \"middle\"}\n                      fill=\"var(--faint)\"\n                      className=\"font-body text-[10.5px]\"\n                    >\n                      {column.label}\n                    </text>\n                  )}\n                  <rect\n                    x={GUTTER + slot * index}\n                    y={TOP}\n                    width={slot}\n                    height={plotHeight}\n                    fill=\"transparent\"\n                    pointerEvents=\"all\"\n                    onPointerEnter={() => setHovered(index)}\n                    onPointerLeave={() => setHovered((current) => (current === index ? null : current))}\n                  >\n                    <title>\n                      {`${column.tooltipLabel ?? column.label}: ${formatCost(total)}${\n                        split.length > 1\n                          ? ` — ${split.map((entry) => `${labelOf(entry.id)} ${formatCost(entry.value)}`).join(\", \")}`\n                          : \"\"\n                      }`}\n                    </title>\n                  </rect>\n                </g>\n              );\n            })}\n\n            {!hasSpend && (\n              <text\n                x={GUTTER + plotWidth / 2}\n                y={baseline - plotHeight / 2}\n                textAnchor=\"middle\"\n                fill=\"var(--dim)\"\n                className=\"font-body text-[12px]\"\n              >\n                No estimated AI cost in this period\n              </text>\n            )}\n          </svg>\n        )}\n\n        {/* Floating tooltip over the hovered column, clamped inside the card. */}\n        {hoveredColumn && (\n          <div\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute top-6 z-10 w-60 -translate-x-1/2 rounded-xl border border-rule-soft bg-card px-4 py-3 shadow-lg\"\n            style={{ left: `max(7.5rem, min(calc(100% - 7.5rem), ${hoveredLeftPct}%))` }}\n          >\n            <p className=\"text-[13px] font-bold text-ink\">{hoveredColumn.tooltipLabel ?? hoveredColumn.label}</p>\n            {splitOf(hoveredColumn).length > 0 ? (\n              <ul className=\"mt-2 space-y-1.5 text-[12.5px]\">\n                {splitOf(hoveredColumn).map((entry) => (\n                  <li key={entry.id} className=\"flex items-center justify-between gap-3\">\n                    <span className=\"flex items-center gap-1.5 text-dim\">\n                      <span className=\"size-1.5 rounded-full\" style={{ background: colorOf(entry.id) }} />\n                      {labelOf(entry.id)}:\n                    </span>\n                    <span className=\"font-bold tabular-nums text-ink\">{formatCost(entry.value)}</span>\n                  </li>\n                ))}\n              </ul>\n            ) : (\n              <p className=\"mt-1 text-[12.5px] text-dim\">No cost recorded</p>\n            )}\n          </div>\n        )}\n      </div>\n\n      {readings.some(hasFooter) && (\n        <Stacked readings={readings} activeId={reading.id} className=\"px-5 pt-4 pb-5 sm:px-6\">\n          {(candidate) => (\n            <ReadingFooter reading={candidate} unpricedNote={unpricedNote} refusedNote={refusedNote} />\n          )}\n        </Stacked>\n      )}\n    </section>\n  );\n}\n\n/**\n * Every window's version of a block, laid in the same grid cell with only the\n * open one visible. The cell is as tall as the tallest version, so switching\n * windows never changes the card's height.\n */\nfunction Stacked({\n  readings,\n  activeId,\n  className,\n  children,\n}: {\n  readings: AiCostReading[];\n  activeId: string;\n  className?: string;\n  children: (reading: AiCostReading) => ReactNode;\n}) {\n  return (\n    <div className={cn(\"grid\", className)}>\n      {readings.map((candidate) => {\n        const active = candidate.id === activeId;\n        return (\n          <div\n            key={candidate.id}\n            aria-hidden={active ? undefined : true}\n            className={cn(\"col-start-1 row-start-1 min-w-0\", !active && \"invisible\")}\n          >\n            {children(candidate)}\n          </div>\n        );\n      })}\n    </div>\n  );\n}\n\nfunction ChangeLine({ reading }: { reading: AiCostReading }) {\n  const change =\n    reading.priorCostUsd > 0 ? ((reading.costUsd - reading.priorCostUsd) / reading.priorCostUsd) * 100 : null;\n  const rising = change !== null && change > 0;\n  return (\n    <p className=\"text-[13px] leading-[1.55]\">\n      {change === null ? (\n        <span className=\"text-dim\">No AI cost in {reading.comparison}, so there is nothing to compare this with yet.</span>\n      ) : (\n        <span className={cn(\"font-semibold\", rising ? \"text-stop\" : \"text-go\")}>\n          {rising ? \"Up\" : \"Down\"} {Math.abs(change).toFixed(0)}% on {reading.comparison}\n        </span>\n      )}\n    </p>\n  );\n}\n\nfunction hasFooter(reading: AiCostReading) {\n  return reading.requests > 0 || (reading.unpriced ?? 0) > 0 || (reading.refused ?? 0) > 0 || Boolean(reading.note);\n}\n\nfunction ReadingFooter({\n  reading,\n  unpricedNote,\n  refusedNote,\n}: {\n  reading: AiCostReading;\n  unpricedNote: (count: number) => ReactNode;\n  refusedNote: (count: number) => ReactNode;\n}) {\n  const unpriced = reading.unpriced ?? 0;\n  const refused = reading.refused ?? 0;\n  if (!hasFooter(reading)) return null;\n  return (\n    <div className=\"flex flex-wrap items-center justify-between gap-x-6 gap-y-2\">\n      {reading.requests > 0 && (\n        <p className=\"text-[13.5px] leading-[1.55] text-dim\">\n          {(reading.totalTokens ?? 0).toLocaleString()} tokens\n          {reading.models?.length ? ` · ${reading.models.join(\", \")}` : \"\"}\n        </p>\n      )}\n      {unpriced > 0 && <p className=\"text-[12.5px] leading-[1.55] text-dim\">{unpricedNote(unpriced)}</p>}\n      {refused > 0 && <p className=\"text-[12.5px] leading-[1.55] font-semibold text-hold\">{refusedNote(refused)}</p>}\n      {reading.note && <p className=\"text-[12.5px] leading-[1.55] text-dim\">{reading.note}</p>}\n    </div>\n  );\n}\n\nexport default AiCostCard;\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "kind": "widget",
    "whenToUse": "Use to watch a metered cost (AI tokens, API calls, SMS) over several windows where the reader asks both how much and on what. Not for a single figure with no breakdown (use a metric card), and not as an invoice: it is built to say its numbers are estimates and floors.",
    "origin": {
      "project": "180Connect",
      "path": "src/components/dashboard/ai-spend-overview-preview.tsx"
    },
    "credits": [
      {
        "name": "Lucide loader-pinwheel icon (paths), animated as in Animate UI's icon",
        "url": "https://lucide.dev/icons/loader-pinwheel",
        "license": "ISC"
      },
      {
        "name": "Animate UI (spin-on-hover icon behaviour)",
        "url": "https://animate-ui.com",
        "license": "MIT"
      }
    ]
  },
  "type": "registry:component"
}
