{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "kpi",
  "type": "registry:component",
  "title": "KPI",
  "description": "One number and what it is doing: its change, its shape over time, its progress to a target.",
  "dependencies": [
    "@base-ui/react",
    "tailwind-variants"
  ],
  "registryDependencies": [],
  "cssVars": {
    "light": {
      "success": "oklch(0.696 0.17 162.48)",
      "warning": "oklch(0.769 0.188 70.08)",
      "info": "oklch(0.623 0.214 259.815)"
    },
    "dark": {
      "success": "oklch(0.696 0.17 162.48)",
      "warning": "oklch(0.769 0.188 70.08)",
      "info": "oklch(0.623 0.214 259.815)"
    }
  },
  "files": [
    {
      "path": "components/panelui/kpi.tsx",
      "type": "registry:component",
      "target": "@components/panelui/kpi.tsx",
      "content": "'use client';\n\n/**\n * Kpi — one number, and what it is doing.\n *\n * ```tsx\n * <Kpi>\n *   <Kpi.Header>\n *     <Kpi.Title>Subscribers</Kpi.Title>\n *   </Kpi.Header>\n *   <Kpi.Content>\n *     <Kpi.Value>18,240</Kpi.Value>\n *     <Kpi.Trend value={6.4} />\n *   </Kpi.Content>\n *   <Kpi.Chart data={month} />\n * </Kpi>\n * ```\n *\n * ## The number is the message\n *\n * A metric card is not a chart with a caption. The parts are sized and ordered\n * round the number: a title that stays quiet, a value that does not, a trend\n * that says which way and by how much, and a sparkline that is allowed to be\n * small because nobody reads values off it.\n *\n * ## The trend is given a number\n *\n * `Kpi.Trend` takes the change as a number and decides the sign and the colour\n * itself, so a minus sign and a red cannot fall out of step at the call site.\n * Where a rise is the bad news, such as churn, latency or cost, say so with\n * `goodDirection=\"down\"` and the colour follows the meaning and not the sign.\n *\n * ## The same parts as the app\n *\n * The parts and their names are the app library's Kpi, so a metric written for\n * one reads the same in the other. Two things differ because the platform\n * does: the chart takes a plain list of numbers, and colours are named by the\n * theme's roles as everywhere in this collection.\n *\n * ## Not a client module\n *\n * It holds no state and handles no events, so there is no `'use client'` at\n * the top. A Server Component can render it, dotted parts and all, and it\n * adds nothing to the JavaScript a page sends.\n */\nimport type { ComponentProps, ReactNode } from 'react';\nimport { tv } from 'tailwind-variants';\nimport { mergeProps } from '@base-ui/react/merge-props';\nimport { useRender } from '@base-ui/react/use-render';\nimport type { ElementType as RenderElementType } from 'react';\n\n/** Draws a part as another element, or composes it with a component: `render={<a href=\"/\" />}`. */\ntype RenderProp<Tag extends RenderElementType> = { render?: useRender.ComponentProps<Tag>['render'] };\n\n/** Which of the theme's roles a chart or a bar is drawn in. */\nexport type KpiColor = 'primary' | 'info' | 'success' | 'warning' | 'destructive';\n\n/** Which direction of movement is the good news for this metric. */\nexport type KpiGoodDirection = 'up' | 'down' | 'none';\n\n/** How a part is coloured once the metric's polarity has been applied. */\nexport type KpiTone = 'good' | 'bad' | 'flat' | 'neutral';\n\nconst kpiVariants = tv({\n  slots: {\n    root: 'flex w-full flex-col gap-2 text-foreground',\n    // A title in a header takes the room between the icon and the actions.\n    header: 'flex items-center gap-2 [&>[data-kpi-title]]:flex-1',\n    icon: 'flex size-8 shrink-0 items-center justify-center rounded-xl [&_svg]:size-4',\n    title: 'min-w-0 truncate text-sm font-medium text-muted-foreground',\n    actions: 'ms-auto flex shrink-0 items-center gap-2',\n    content: 'flex items-end justify-between gap-3',\n    // The stacked title, value and trend, tight enough to read as one thing.\n    stat: 'flex min-w-0 flex-1 flex-col items-start gap-1',\n    value: 'text-2xl font-bold tabular-nums tracking-tight text-foreground',\n    trend: 'inline-flex shrink-0 items-center gap-1 font-medium',\n    caption: 'font-normal text-muted-foreground',\n    chart: 'block w-full overflow-visible',\n    progress: 'flex w-full flex-col gap-2',\n    progressHeader: 'flex items-baseline justify-between gap-3 text-sm',\n    progressTrack: 'h-2 w-full overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--pu-color)_16%,transparent)]',\n    progressFill: 'h-full rounded-full bg-[var(--pu-color)]',\n    footer: 'flex items-center gap-2 text-sm text-muted-foreground',\n    separator: 'h-px w-full shrink-0 bg-border',\n    group: 'flex w-full divide-border',\n  },\n  variants: {\n    surface: {\n      true: {\n        root: 'rounded-3xl border bg-card p-5 text-card-foreground',\n        group: 'rounded-3xl border bg-card p-5 text-card-foreground',\n      },\n    },\n    color: {\n      primary: { chart: '[--pu-color:var(--primary)]', progress: '[--pu-color:var(--primary)]' },\n      info: { chart: '[--pu-color:var(--info)]', progress: '[--pu-color:var(--info)]' },\n      success: { chart: '[--pu-color:var(--success)]', progress: '[--pu-color:var(--success)]' },\n      warning: { chart: '[--pu-color:var(--warning)]', progress: '[--pu-color:var(--warning)]' },\n      destructive: {\n        chart: '[--pu-color:var(--destructive)]',\n        progress: '[--pu-color:var(--destructive)]',\n      },\n    },\n    /** Which way the number moved, after the metric's own polarity. */\n    tone: {\n      good: {\n        trend: 'text-[var(--success)]',\n        icon: 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-[var(--success)]',\n      },\n      bad: {\n        trend: 'text-destructive',\n        icon: 'bg-[color-mix(in_oklab,var(--destructive)_14%,transparent)] text-destructive',\n      },\n      flat: { trend: 'text-muted-foreground', icon: 'bg-muted text-muted-foreground' },\n      neutral: { trend: 'text-muted-foreground', icon: 'bg-secondary text-muted-foreground' },\n    },\n    /*\n     * `text` is the quieter of the two and the default: a line of colour under\n     * the number, which is where the eye already is. `badge` puts a pill round\n     * it for a card dense enough that a bare line of colour gets lost.\n     */\n    trendVariant: {\n      text: { trend: 'text-sm' },\n      badge: { trend: 'rounded-full px-2 py-0.5 text-xs' },\n    },\n    /** Where the chart sits relative to the number. */\n    layout: {\n      below: {},\n      // Beside the number: the text takes the width and the chart a fixed\n      // box on the end, so charts line up down a column of cards.\n      inline: { content: 'items-center gap-4' },\n    },\n    orientation: {\n      // The padding is on the children and not the rule, so the rule reaches\n      // the full height or width of the panel.\n      horizontal: {\n        group: 'flex-row items-stretch divide-x *:min-w-0 *:flex-1 *:px-4 *:first:ps-0 *:last:pe-0',\n      },\n      vertical: { group: 'flex-col divide-y *:py-4 *:first:pt-0 *:last:pb-0' },\n    },\n  },\n  compoundVariants: [\n    {\n      trendVariant: 'badge',\n      tone: 'good',\n      class: { trend: 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)]' },\n    },\n    {\n      trendVariant: 'badge',\n      tone: 'bad',\n      class: { trend: 'bg-[color-mix(in_oklab,var(--destructive)_14%,transparent)]' },\n    },\n    { trendVariant: 'badge', tone: 'flat', class: { trend: 'bg-muted' } },\n    { trendVariant: 'badge', tone: 'neutral', class: { trend: 'bg-muted' } },\n  ],\n  defaultVariants: { tone: 'neutral', trendVariant: 'text', layout: 'below', color: 'primary' },\n});\n\nexport interface KpiProps extends ComponentProps<'div'> {\n  /** Draws the metric as a card of its own. Leave it off inside a frame or a group. */\n  surface?: boolean;\n}\n\nfunction KpiRoot({ surface = false, className, render, ...props }: KpiProps & RenderProp<'div'>) {\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps({ className: kpiVariants({ surface }).root({ className }) }, props),\n  });\n}\nKpiRoot.displayName = 'Kpi';\n\nexport interface KpiHeaderProps extends ComponentProps<'div'> {}\n\n/** The row over the number: an icon, the title, and anything at the end. */\nfunction KpiHeader({ className, render, ...props }: KpiHeaderProps & RenderProp<'div'>) {\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps({ className: kpiVariants().header({ className }) }, props),\n  });\n}\nKpiHeader.displayName = 'Kpi.Header';\n\nexport interface KpiIconProps extends ComponentProps<'span'> {\n  /** How the tile is coloured: by good or bad news, or left neutral. */\n  tone?: KpiTone;\n}\n\n/** A small tile for an icon, at the start of the header. */\nfunction KpiIcon({ tone = 'neutral', className, render, ...props }: KpiIconProps & RenderProp<'span'>) {\n  return useRender({\n    defaultTagName: 'span',\n    render,\n    props: mergeProps({ 'aria-hidden': 'true', className: kpiVariants({ tone }).icon({ className }) }, props),\n  });\n}\nKpiIcon.displayName = 'Kpi.Icon';\n\nexport interface KpiTitleProps extends ComponentProps<'div'> {}\n\n/** What the number measures. */\nfunction KpiTitle({ className, render, ...props }: KpiTitleProps & RenderProp<'div'>) {\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps({ 'data-kpi-title': '', className: kpiVariants().title({ className }) }, props),\n  });\n}\nKpiTitle.displayName = 'Kpi.Title';\n\nexport interface KpiActionsProps extends ComponentProps<'div'> {}\n\n/** Whatever belongs at the end of the header: a period, a badge, a button. */\nfunction KpiActions({ className, render, ...props }: KpiActionsProps & RenderProp<'div'>) {\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps({ className: kpiVariants().actions({ className }) }, props),\n  });\n}\nKpiActions.displayName = 'Kpi.Actions';\n\nexport interface KpiContentProps extends ComponentProps<'div'> {\n  /** `inline` puts a chart beside the number in a fixed box; `below` leaves it under everything. */\n  layout?: 'below' | 'inline';\n}\n\n/** The row the number is in, with a trend or a chart at its other end. */\nfunction KpiContent({ layout = 'below', className, render, ...props }: KpiContentProps & RenderProp<'div'>) {\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps({ className: kpiVariants({ layout }).content({ className }) }, props),\n  });\n}\nKpiContent.displayName = 'Kpi.Content';\n\nexport interface KpiStatProps extends ComponentProps<'div'> {}\n\n/** Title, value and trend stacked as one block, for a row with a chart beside it. */\nfunction KpiStat({ className, render, ...props }: KpiStatProps & RenderProp<'div'>) {\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps({ className: kpiVariants().stat({ className }) }, props),\n  });\n}\nKpiStat.displayName = 'Kpi.Stat';\n\nexport interface KpiValueProps extends ComponentProps<'div'> {}\n\n/** The number. */\nfunction KpiValue({ className, render, ...props }: KpiValueProps & RenderProp<'div'>) {\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps({ className: kpiVariants().value({ className }) }, props),\n  });\n}\nKpiValue.displayName = 'Kpi.Value';\n\nexport interface KpiTrendProps extends Omit<ComponentProps<'span'>, 'children'> {\n  /** The change. Its sign decides the direction. */\n  value: number;\n  /** Writes the change. Defaults to a signed percentage with one decimal: \"+7.8%\". */\n  format?: (value: number) => string;\n  /** Which direction is the good news. `down` for churn, latency, cost; `none` for no colour. */\n  goodDirection?: KpiGoodDirection;\n  /** `text` is a line of colour; `badge` puts a pill and an arrow round it. */\n  variant?: 'text' | 'badge';\n  /** Quiet words after the change: \"this month\". */\n  caption?: string;\n  /** A change this small or smaller is drawn as no change. */\n  threshold?: number;\n}\n\n/**\n * The change. Colour comes from what the movement means, not from its sign: a\n * fall in churn is good news and is drawn as good news.\n */\nfunction KpiTrend({\n  value,\n  format,\n  goodDirection = 'up',\n  variant = 'text',\n  caption,\n  threshold = 0,\n  className,\n  render,\n  ...props\n}: KpiTrendProps & RenderProp<'span'>) {\n  const flat = Math.abs(value) <= threshold;\n  const rising = value > 0;\n  const tone: KpiTone = flat\n    ? 'flat'\n    : goodDirection === 'none'\n      ? 'neutral'\n      : (rising && goodDirection === 'up') || (!rising && goodDirection === 'down')\n        ? 'good'\n        : 'bad';\n  const slots = kpiVariants({ tone, trendVariant: variant });\n\n  const label = format\n    ? format(value)\n    : // A true minus sign and not a hyphen: at this size a hyphen reads as a\n      // dash between two words, and the sign is half the meaning.\n      `${rising ? '+' : value < 0 ? '−' : ''}${Math.abs(value).toFixed(1)}%`;\n  const spoken = `${flat ? 'No change' : rising ? 'Up' : 'Down'} ${Math.abs(value).toFixed(1)} percent${caption ? `, ${caption}` : ''}`;\n\n  return useRender({\n    defaultTagName: 'span',\n    render,\n    props: mergeProps(\n      {\n        role: 'img',\n        'aria-label': spoken,\n        className: slots.trend({ className }),\n        children: (\n          <>\n            {/* No arrow in `text`: the sign is already in front of the number. */}\n            {variant === 'badge' ? (\n              <svg\n                aria-hidden=\"true\"\n                viewBox=\"0 0 24 24\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth=\"2.5\"\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                className=\"size-3\"\n              >\n                {flat ? (\n                  <path d=\"M5 12h14\" />\n                ) : rising ? (\n                  <path d=\"M12 19V5M5 12l7-7 7 7\" />\n                ) : (\n                  <path d=\"M12 5v14M19 12l-7 7-7-7\" />\n                )}\n              </svg>\n            ) : null}\n            <span aria-hidden=\"true\">\n              {label}\n              {caption ? <span className={slots.caption()}> {caption}</span> : null}\n            </span>\n          </>\n        ),\n      },\n      props\n    ),\n  });\n}\nKpiTrend.displayName = 'Kpi.Trend';\n\nexport interface KpiChartProps extends Omit<ComponentProps<'svg'>, 'children' | 'color'> {\n  /** The values, oldest first. */\n  data: number[];\n  /** Which of the theme's roles the line is drawn in. */\n  color?: KpiColor;\n  /** Fills under the line with a wash of the same colour. */\n  filled?: boolean;\n  /** How tall the chart is, in pixels. */\n  height?: number;\n  /**\n   * A fixed box for the end of a `layout=\"inline\"` row. Charts beside numbers\n   * of different lengths are then all one width, and line up down a column.\n   */\n  inline?: boolean;\n  /** How thick the line is, in pixels. */\n  strokeWidth?: number;\n}\n\n/** The box an inline chart is given, in pixels. */\nconst INLINE_WIDTH = 128;\nconst INLINE_HEIGHT = 56;\n/** The grid the line is drawn on. It is stretched to whatever size the chart is. */\nconst GRID_WIDTH = 100;\nconst GRID_HEIGHT = 40;\n\nconst round = (value: number) => Number(value.toFixed(2));\n\n/** A smooth line through the points, as a path. */\nfunction linePath(points: [number, number][]): string {\n  if (points.length === 0) return '';\n  const [first, ...rest] = points;\n  if (!first) return '';\n  let path = `M${round(first[0])} ${round(first[1])}`;\n  rest.forEach((point, index) => {\n    const before = points[index] ?? point;\n    // Control points a third of the way along, level with each end, which\n    // rounds the corners without the line ever overshooting a value.\n    const reach = (point[0] - before[0]) / 3;\n    path += ` C${round(before[0] + reach)} ${round(before[1])} ${round(point[0] - reach)} ${round(point[1])} ${round(point[0])} ${round(point[1])}`;\n  });\n  return path;\n}\n\n/** The sparkline: the shape of the number over time, with no axis and no values. */\nfunction KpiChart({\n  data,\n  color = 'primary',\n  filled = false,\n  height,\n  inline = false,\n  strokeWidth = 2,\n  className,\n  style,\n  render,\n  ...props\n}: KpiChartProps & RenderProp<'svg'>) {\n  const slots = kpiVariants({ color });\n  const low = Math.min(...data);\n  const high = Math.max(...data);\n  const span = high - low || 1;\n  // A little room top and bottom, so the line's own thickness is not cut off\n  // at its highest and lowest values.\n  const pad = 3;\n  const points: [number, number][] = data.map((value, index) => [\n    data.length === 1 ? GRID_WIDTH / 2 : (index / (data.length - 1)) * GRID_WIDTH,\n    pad + (1 - (value - low) / span) * (GRID_HEIGHT - pad * 2),\n  ]);\n  const line = linePath(points);\n\n  return useRender({\n    defaultTagName: 'svg',\n    render,\n    props: mergeProps(\n      {\n        role: 'img',\n        'aria-label': 'Trend',\n        viewBox: `0 0 ${GRID_WIDTH} ${GRID_HEIGHT}`,\n        preserveAspectRatio: 'none',\n        className: slots.chart({ className }),\n        style: {\n          height: height ?? (inline ? INLINE_HEIGHT : 56),\n          ...(inline ? { width: INLINE_WIDTH, flex: 'none' } : null),\n          ...style,\n        },\n        children: (\n          <>\n            {filled && line ? (\n              <path\n                d={`${line} L${GRID_WIDTH} ${GRID_HEIGHT} L0 ${GRID_HEIGHT} Z`}\n                fill=\"var(--pu-color)\"\n                opacity=\"0.12\"\n              />\n            ) : null}\n            <path\n              d={line}\n              fill=\"none\"\n              stroke=\"var(--pu-color)\"\n              strokeWidth={strokeWidth}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              // The grid is stretched to the chart's size. Without this the line\n              // would be stretched with it, thick across and thin up and down.\n              vectorEffect=\"non-scaling-stroke\"\n            />\n          </>\n        ),\n      },\n      props\n    ),\n  });\n}\nKpiChart.displayName = 'Kpi.Chart';\n\nexport interface KpiProgressProps extends Omit<ComponentProps<'div'>, 'children' | 'color'> {\n  /** How far along. */\n  value: number;\n  /** What `value` is out of. */\n  maxValue?: number;\n  /** Words at the start of the row over the bar: \"of 5,000\". */\n  label?: ReactNode;\n  /** Writes the percentage at the end of the row over the bar. */\n  showValueLabel?: boolean;\n  /** Which of the theme's roles the bar is drawn in. */\n  color?: KpiColor;\n}\n\n/** Progress towards a target, under the number. */\nfunction KpiProgress({\n  value,\n  maxValue = 100,\n  label,\n  showValueLabel = false,\n  color = 'primary',\n  className,\n  render,\n  ...props\n}: KpiProgressProps & RenderProp<'div'>) {\n  const slots = kpiVariants({ color });\n  const share = Math.min(1, Math.max(0, maxValue === 0 ? 0 : value / maxValue));\n  const percent = Math.round(share * 100);\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        className: slots.progress({ className }),\n        children: (\n          <>\n            {label !== undefined || showValueLabel ? (\n              <div className={slots.progressHeader()}>\n                <span className=\"font-medium\">{label}</span>\n                {showValueLabel ? <span className=\"text-muted-foreground\">{percent}%</span> : null}\n              </div>\n            ) : null}\n            <div\n              role=\"progressbar\"\n              aria-valuemin={0}\n              aria-valuemax={maxValue}\n              aria-valuenow={value}\n              aria-label={typeof label === 'string' ? label : 'Progress'}\n              className={slots.progressTrack()}\n            >\n              <div className={slots.progressFill()} style={{ width: `${percent}%` }} />\n            </div>\n          </>\n        ),\n      },\n      props\n    ),\n  });\n}\nKpiProgress.displayName = 'Kpi.Progress';\n\nexport interface KpiFooterProps extends ComponentProps<'div'> {}\n\n/** A quiet line under everything: when it was measured, how long is left. */\nfunction KpiFooter({ className, render, ...props }: KpiFooterProps & RenderProp<'div'>) {\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps({ className: kpiVariants().footer({ className }) }, props),\n  });\n}\nKpiFooter.displayName = 'Kpi.Footer';\n\nexport interface KpiSeparatorProps extends ComponentProps<'div'> {}\n\n/** A rule between two parts of one card. */\nfunction KpiSeparator({ className, render, ...props }: KpiSeparatorProps & RenderProp<'div'>) {\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps({ role: 'separator', className: kpiVariants().separator({ className }) }, props),\n  });\n}\nKpiSeparator.displayName = 'Kpi.Separator';\n\nexport interface KpiGroupProps extends ComponentProps<'div'> {\n  /** Side by side with upright rules, or stacked with level ones. */\n  orientation?: 'horizontal' | 'vertical';\n  /** Draws the group as a card of its own, for a group not already inside one. */\n  surface?: boolean;\n}\n\n/** Several metrics as one panel, with a rule between each and the next. */\nfunction KpiGroup({\n  orientation = 'horizontal',\n  surface = false,\n  className,\n  render,\n  ...props\n}: KpiGroupProps & RenderProp<'div'>) {\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps({ className: kpiVariants({ orientation, surface }).group({ className }) }, props),\n  });\n}\nKpiGroup.displayName = 'Kpi.Group';\n\nexport const Kpi = Object.assign(KpiRoot, {\n  Header: KpiHeader,\n  Icon: KpiIcon,\n  Title: KpiTitle,\n  Actions: KpiActions,\n  Content: KpiContent,\n  Stat: KpiStat,\n  Value: KpiValue,\n  Trend: KpiTrend,\n  Chart: KpiChart,\n  Progress: KpiProgress,\n  Footer: KpiFooter,\n  Separator: KpiSeparator,\n  Group: KpiGroup,\n});\n\nexport {\n  KpiHeader,\n  KpiIcon,\n  KpiTitle,\n  KpiActions,\n  KpiContent,\n  KpiStat,\n  KpiValue,\n  KpiTrend,\n  KpiChart,\n  KpiProgress,\n  KpiFooter,\n  KpiSeparator,\n  KpiGroup,\n};\n"
    }
  ]
}
