{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cells",
  "title": "Cells kit",
  "description": "Table cells that make rows look finished: avatar with name, status badge, number with a rolling value and coloured delta, a progress meter that fills on view, a self-updating relative time, and a mono ID with a copy button.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json",
    "https://eased.dev/r/eased-number-roll.json",
    "https://eased.dev/r/eased-table-core.json",
    "https://eased.dev/r/avatar.json",
    "https://eased.dev/r/badge.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/cells/cells.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion, useInView } from \"motion/react\";\nimport { useRef, useState, useSyncExternalStore, type ReactNode } from \"react\";\nimport { NumberRoll } from \"../_primitives/number-roll/number-roll\";\nimport { useTableDensity } from \"../_primitives/table-core/table-core\";\nimport { Avatar } from \"../avatar/avatar\";\nimport { Badge, type BadgeTone } from \"../badge/badge\";\nimport { instant, reducedFade, smooth, swap, swapIn, swapOut, swapShown, useSafeReducedMotion } from \"../lib/motion\";\nimport { pressIcon } from \"../lib/press\";\n\n/* ---------- Person ------------------------------------------------------------------------ */\n\nexport type PersonCellProps = {\n  name: string;\n  /** Avatar image URL; initials show without one. */\n  src?: string;\n  /** A second line (email, role). Shown in comfortable tables only: a 36px row has room for one line. */\n  detail?: ReactNode;\n};\n\n/** Avatar with name. 24px avatar in dense tables, 32px in comfortable ones. */\nexport function PersonCell({ name, src, detail }: PersonCellProps) {\n  const comfortable = useTableDensity() === \"comfortable\";\n  return (\n    <span className=\"flex min-w-0 items-center gap-2\">\n      <Avatar name={name} src={src} size={comfortable ? \"md\" : \"sm\"} />\n      <span className=\"flex min-w-0 flex-col\">\n        <span className=\"truncate\">{name}</span>\n        {comfortable && detail && <span className=\"truncate text-ui-xs font-normal text-text-muted\">{detail}</span>}\n      </span>\n    </span>\n  );\n}\n\n/* ---------- Status ------------------------------------------------------------------------ */\n\nexport type StatusCellProps = {\n  children: ReactNode;\n  tone?: BadgeTone;\n  /** The dot breathes: for states that are happening now (\"Running\", \"Live\"). */\n  live?: boolean;\n};\n\n/** A status badge with a dot. Left-aligned in its column so every badge starts on one edge. */\nexport function StatusCell({ children, tone = \"neutral\", live = false }: StatusCellProps) {\n  return (\n    <Badge tone={tone} dot={live ? \"live\" : true}>\n      {children}\n    </Badge>\n  );\n}\n\n/* ---------- Number with delta ------------------------------------------------------------- */\n\nexport type DeltaCellProps = {\n  value: number;\n  /** Change as a fraction: 0.124 is +12.4%. */\n  delta: number;\n  /** How `value` is formatted, e.g. `{ style: \"currency\", currency: \"USD\", maximumFractionDigits: 0 }`. */\n  format?: Intl.NumberFormatOptions;\n  locale?: Intl.LocalesArgument;\n  /** For metrics where down is good (churn, latency): a fall reads green. */\n  invert?: boolean;\n};\n\n/**\n * A number and its change. The value rolls when it updates; the delta sits in a fixed track\n * so values and deltas each keep one right edge down the column. A rise is success, a fall is\n * warning, never danger (red is for errors).\n */\nexport function DeltaCell({ value, delta, format, locale, invert = false }: DeltaCellProps) {\n  const sign = Math.abs(delta) < 0.0005 ? 0 : Math.sign(delta);\n  const good = invert ? sign < 0 : sign > 0;\n  const tone = sign === 0 ? \"text-text-muted\" : good ? \"text-success-text\" : \"text-warning-text\";\n  const pct = new Intl.NumberFormat(locale, { style: \"percent\", maximumFractionDigits: 1, minimumFractionDigits: 1 }).format(\n    Math.abs(delta),\n  );\n  const spoken = sign === 0 ? \"unchanged\" : `${sign > 0 ? \"up\" : \"down\"} ${pct}`;\n  return (\n    <span className=\"inline-grid grid-cols-[auto_3.75rem] items-center justify-end gap-2 tabular-nums\">\n      <NumberRoll value={value} format={format} locale={locale} className=\"justify-self-end\" />\n      <span className={`inline-flex items-center justify-end gap-0.5 text-ui-xs ${tone}`}>\n        <span className=\"sr-only\">{spoken}</span>\n        <svg\n          viewBox=\"0 0 12 12\"\n          aria-hidden\n          className={`size-3 shrink-0 fill-none stroke-current ${sign === 0 ? \"hidden\" : \"\"} ${sign < 0 ? \"rotate-180\" : \"\"}`}\n          strokeWidth={1.5}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        >\n          <path d=\"M6 9.5v-7M3 5.25 6 2.25l3 3\" />\n        </svg>\n        <span aria-hidden>{sign === 0 ? \"0%\" : pct}</span>\n      </span>\n    </span>\n  );\n}\n\n/* ---------- Progress ---------------------------------------------------------------------- */\n\nexport type ProgressCellProps = {\n  value: number;\n  max?: number;\n  /** The meter's accessible name, e.g. \"Onboarding\". */\n  label?: string;\n  locale?: Intl.LocalesArgument;\n};\n\n/**\n * A slim meter and its percentage. The bar fills from empty the first time it scrolls into\n * view and glides on updates (smooth spring, no overshoot); it turns green at 100%. The track\n * and label have fixed widths, so every row's bar starts and ends on the same lines.\n */\nexport function ProgressCell({ value, max = 100, label, locale }: ProgressCellProps) {\n  const reduced = useSafeReducedMotion();\n  const ref = useRef<HTMLSpanElement>(null);\n  const inView = useInView(ref, { once: true });\n  const fraction = max > 0 ? Math.min(1, Math.max(0, value / max)) : 0;\n  const done = fraction === 1;\n  return (\n    <span\n      ref={ref}\n      role=\"meter\"\n      aria-label={label}\n      aria-valuemin={0}\n      aria-valuemax={max}\n      aria-valuenow={value}\n      className=\"inline-grid grid-cols-[4rem_2rem] items-center gap-2 tabular-nums\"\n    >\n      <span className=\"relative h-1 overflow-clip rounded-pill bg-surface-sunken inset-shadow-well\">\n        <motion.span\n          className={`absolute inset-0 origin-left rounded-pill transition-colors duration-150 ease-standard ${done ? \"bg-success\" : \"bg-brand\"}`}\n          initial={{ scaleX: 0 }}\n          animate={{ scaleX: inView ? fraction : 0 }}\n          transition={reduced ? instant : smooth}\n        />\n      </span>\n      <span aria-hidden className=\"text-right text-ui-xs text-text-muted\">\n        {new Intl.NumberFormat(locale, { style: \"percent\", maximumFractionDigits: 0 }).format(fraction)}\n      </span>\n    </span>\n  );\n}\n\n/* ---------- Relative time ----------------------------------------------------------------- */\n\nconst subscribeMinute = (onTick: () => void) => {\n  const id = window.setInterval(onTick, 30_000);\n  return () => window.clearInterval(id);\n};\nconst minuteNow = () => Math.floor(Date.now() / 30_000) * 30_000;\n\nconst UNITS: [Intl.RelativeTimeFormatUnit, number][] = [\n  [\"year\", 31_536_000_000],\n  [\"month\", 2_592_000_000],\n  [\"week\", 604_800_000],\n  [\"day\", 86_400_000],\n  [\"hour\", 3_600_000],\n  [\"minute\", 60_000],\n];\n\n/** \"just now\", \"5 min. ago\", \"yesterday\", \"in 3 days\". */\nexport function formatRelative(date: Date, now: number, locale?: Intl.LocalesArgument) {\n  const diff = date.getTime() - now;\n  if (Math.abs(diff) < 45_000) return \"just now\";\n  const rtf = new Intl.RelativeTimeFormat(locale as string | undefined, { numeric: \"auto\", style: \"short\" });\n  for (const [unit, ms] of UNITS) if (Math.abs(diff) >= ms || unit === \"minute\") return rtf.format(Math.round(diff / ms), unit);\n  return \"\";\n}\n\nexport type RelativeTimeCellProps = {\n  date: Date | string | number;\n  /** Render relative to this instant instead of a live clock (tests, fixtures, server output). */\n  now?: Date | number;\n  locale?: Intl.LocalesArgument;\n};\n\n/**\n * A relative time (\"2h ago\") in a `<time>` element with the full date as its tooltip and\n * machine value. It keeps itself current on a shared 30-second clock; before hydration it\n * shows the short date, so server and client markup agree.\n */\nexport function RelativeTimeCell({ date, now, locale }: RelativeTimeCellProps) {\n  const d = new Date(date);\n  const clock = useSyncExternalStore(subscribeMinute, minuteNow, () => null);\n  const at = now !== undefined ? new Date(now).getTime() : clock;\n  const full = new Intl.DateTimeFormat(locale as string | undefined, { dateStyle: \"medium\", timeStyle: \"short\" }).format(d);\n  return (\n    <time dateTime={d.toISOString()} title={full} className=\"whitespace-nowrap tabular-nums\">\n      {at === null\n        ? new Intl.DateTimeFormat(locale as string | undefined, { month: \"short\", day: \"numeric\" }).format(d)\n        : formatRelative(d, at, locale)}\n    </time>\n  );\n}\n\n/* ---------- Mono ID ----------------------------------------------------------------------- */\n\nexport type IdCellProps = {\n  id: string;\n  /** Show a copy button on row hover or focus. Default false. */\n  copyable?: boolean;\n};\n\n/**\n * An identifier in mono with slashed zeros. With `copyable`, a copy button waits beside it,\n * invisible until the row is hovered or the button focused (it always holds its space, so\n * nothing shifts), and swaps to a tick when the ID is copied.\n */\nexport function IdCell({ id, copyable = false }: IdCellProps) {\n  const reduced = useSafeReducedMotion();\n  const [copied, setCopied] = useState(false);\n  const timer = useRef<number | undefined>(undefined);\n\n  const copy = async () => {\n    try {\n      await navigator.clipboard.writeText(id);\n    } catch {\n      return;\n    }\n    setCopied(true);\n    window.clearTimeout(timer.current);\n    timer.current = window.setTimeout(() => setCopied(false), 1400);\n  };\n\n  return (\n    <span className=\"inline-flex max-w-full min-w-0 items-center gap-1\">\n      <span className=\"truncate font-mono text-ui-xs text-text-muted tabular-nums slashed-zero\">{id}</span>\n      {copyable && (\n        <button\n          type=\"button\"\n          onClick={copy}\n          aria-label={copied ? \"Copied\" : `Copy ${id}`}\n          className={`relative inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-control-xs text-text-muted opacity-0 group-hover-fine:opacity-100 focus-visible:opacity-100 hover-fine:bg-surface-active hover-fine:text-text ${\n            copied ? \"opacity-100\" : \"\"\n          } ${pressIcon}`}\n        >\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.svg\n              key={copied ? \"done\" : \"copy\"}\n              viewBox=\"0 0 14 14\"\n              aria-hidden\n              className=\"size-3.5 fill-none stroke-current\"\n              strokeWidth={1.5}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              initial={reduced ? { opacity: 0 } : swapIn}\n              animate={reduced ? { opacity: 1 } : swapShown}\n              exit={reduced ? { opacity: 0 } : swapOut}\n              transition={reduced ? reducedFade : swap}\n            >\n              {copied ? (\n                <path d=\"M3 7.5 5.75 10 11 4\" />\n              ) : (\n                <>\n                  <rect x=\"4.75\" y=\"4.75\" width=\"7\" height=\"7\" rx=\"1.5\" />\n                  <path d=\"M9.25 2.75h-5a1.5 1.5 0 0 0-1.5 1.5v5\" />\n                </>\n              )}\n            </motion.svg>\n          </AnimatePresence>\n          <span aria-live=\"polite\" className=\"sr-only\">\n            {copied ? \"Copied\" : \"\"}\n          </span>\n        </button>\n      )}\n    </span>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/cells/cells.tsx"
    }
  ],
  "docs": "Import PersonCell, StatusCell, DeltaCell, ProgressCell, RelativeTimeCell and IdCell from components/eased/cells/cells. The Eased tokens were merged into your global CSS; nothing to import. Dark mode follows .dark or data-theme=\"dark\".",
  "type": "registry:ui"
}