{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "feature-comparison",
  "title": "Feature Comparison",
  "description": "A dated us-vs-competitors capability table — yes / partial / no / not-disclosed told apart by shape and word, numeric rows with a per-row unit, a frozen capability column, and a verified-on date measured against an injected now.",
  "dependencies": [
    "zod",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/blocks/feature-comparison.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Check, CircleHelp, Contrast, ExternalLink, MoveHorizontal, Rows3, TriangleAlert, X } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport type {\n  FeatureComparisonCell,\n  FeatureComparisonProduct,\n  FeatureComparisonRow,\n  FeatureComparisonStatus,\n} from \"./feature-comparison.contract\"\n\nconst DAY_MS = 86_400_000\n\nexport interface FeatureComparisonProps extends Omit<React.HTMLAttributes<HTMLElement>, \"title\"> {\n  /** Columns, left to right. */\n  products: FeatureComparisonProduct[]\n  /** Capabilities, top to bottom. Cells align to `products` by index. */\n  rows: FeatureComparisonRow[]\n  /** Which branch to render. All four are first-class, not `&&` afterthoughts. */\n  status: FeatureComparisonStatus\n  /** `YYYY-MM-DD` — the day these capabilities were last verified. */\n  asOf: string\n  /**\n   * The instant \"how old is this table\" is measured from — ISO string, epoch ms\n   * or Date. Required on purpose: `new Date()` during render is impure and\n   * desyncs SSR from hydration, and this component's whole claim to honesty is\n   * that the age of its data is computed rather than asserted.\n   */\n  now: string | number | Date\n  /** Your own column. Buys a tint and an \"Us\" pill — never a different glyph. */\n  ownProductId?: string | null\n  /** BCP 47 tag. Explicit by default — `Intl.*(undefined)` desyncs SSR from the reader. */\n  locale?: string\n  /** Older than this many days → the \"re-verify\" flag. Clamped to ≥ 1. */\n  staleAfterDays?: number\n  /** Heading above the table; it also seeds the table's accessible name. */\n  title?: string\n  /** One line under the heading — scope, methodology, who maintains it. */\n  description?: string\n  /** Label on the own column's pill. */\n  ownBadgeLabel?: string\n  /** Rows drawn in the loading branch. Clamped to 1–24. */\n  skeletonRows?: number\n  /** Omit to drop the retry affordance in the error branch entirely. */\n  onRetry?: () => void\n  /** Replaces the whole zero-state body. */\n  emptyState?: React.ReactNode\n}\n\ntype SupportKind = Exclude<FeatureComparisonCell[\"kind\"], \"amount\"> | \"missing\"\n\ninterface SupportMeta {\n  Icon: typeof Check\n  label: string\n  tone: string\n  /** Extra classes for this glyph only — see `partial`. */\n  iconClass?: string\n}\n\n/**\n * Support states are told apart by **shape and word**, never by colour: a check,\n * a half-filled disc, a cross and a question mark, each with its own text label.\n * Greyscale printing and colour-blind readers get the same table everyone else\n * does, which is also why the palette here stays almost monochrome.\n */\nconst SUPPORT_META: Record<SupportKind, SupportMeta> = {\n  // A payload that skipped `schema.parse` can be ragged; a gap is \"not disclosed\",\n  // never a silent \"no\".\n  missing: { Icon: CircleHelp, label: \"Not disclosed\", tone: \"text-muted-foreground\" },\n  no: { Icon: X, label: \"No\", tone: \"text-muted-foreground\" },\n  // lucide ships every icon stroked, so the half-disc inside `Contrast` only\n  // reads as \"half\" once that one path is filled.\n  partial: { Icon: Contrast, iconClass: \"[&>path]:fill-current\", label: \"Partial\", tone: \"text-foreground\" },\n  unknown: { Icon: CircleHelp, label: \"Not disclosed\", tone: \"text-muted-foreground\" },\n  yes: { Icon: Check, label: \"Yes\", tone: \"text-foreground\" },\n}\n\ninterface CellView {\n  Icon: typeof Check | null\n  iconClass?: string\n  /** What the eye reads in the cell. */\n  label: string\n  /** What a screen reader hears — carries the unit the visible cell leaves to the row heading. */\n  spoken: string\n  tone: string\n  note?: string\n}\n\n/** Epoch ms, or null when the value can't be parsed (bad ISO, Invalid Date, NaN). */\nfunction toEpochMs(value: string | number | Date): number | null {\n  if (value instanceof Date) {\n    const ms = value.getTime()\n    return Number.isNaN(ms) ? null : ms\n  }\n  if (typeof value === \"number\") return Number.isFinite(value) ? value : null\n  const ms = Date.parse(value)\n  return Number.isNaN(ms) ? null : ms\n}\n\n/** A cell → what to draw. */\nfunction viewCell(\n  cell: FeatureComparisonCell | undefined,\n  unit: string | undefined,\n  format: Intl.NumberFormat,\n): CellView {\n  if (!cell) {\n    const meta = SUPPORT_META.missing\n    return { Icon: meta.Icon, label: meta.label, spoken: meta.label, tone: meta.tone }\n  }\n\n  if (cell.kind === \"amount\") {\n    const value = cell.value\n    // null and +Infinity both mean \"no ceiling\"; NaN means the figure never arrived.\n    if (value === null || value === Number.POSITIVE_INFINITY) {\n      return { Icon: null, label: \"Unlimited\", note: cell.note, spoken: \"Unlimited\", tone: \"text-foreground\" }\n    }\n    if (!Number.isFinite(value)) {\n      const meta = SUPPORT_META.missing\n      return { Icon: meta.Icon, label: meta.label, note: cell.note, spoken: meta.label, tone: meta.tone }\n    }\n    const label = format.format(value)\n    return {\n      Icon: null,\n      label,\n      note: cell.note,\n      // The unit is printed once in the row heading so the figures stay a clean\n      // column; the spoken name has to carry it, because a screen-reader user\n      // landing on one cell hears only this string.\n      spoken: unit ? `${label} ${unit}` : label,\n      tone: \"text-foreground\",\n    }\n  }\n\n  const meta = SUPPORT_META[cell.kind]\n  return {\n    Icon: meta.Icon,\n    iconClass: meta.iconClass,\n    label: meta.label,\n    note: cell.note,\n    spoken: meta.label,\n    tone: meta.tone,\n  }\n}\n\n/**\n * A malformed BCP 47 tag makes every `Intl.*` constructor raise a RangeError, so\n * the amount formatter falls back the same way the header's date formatters do —\n * a bad `locale` prop must not take the whole table down.\n */\nfunction amountFormat(locale: string, fractionDigits: number | undefined) {\n  const options: Intl.NumberFormatOptions = {\n    maximumFractionDigits: fractionDigits ?? 2,\n    minimumFractionDigits: fractionDigits ?? 0,\n  }\n  try {\n    return new Intl.NumberFormat(locale, options)\n  } catch {\n    return new Intl.NumberFormat(\"en-US\", options)\n  }\n}\n\nfunction SkeletonBar({ className, style }: { className?: string; style?: React.CSSProperties }) {\n  return (\n    <span\n      className={cn(\"block h-3 animate-pulse rounded bg-muted motion-reduce:animate-none\", className)}\n      style={style}\n    />\n  )\n}\n\nexport const FeatureComparison = React.forwardRef<HTMLElement, FeatureComparisonProps>(function FeatureComparison(\n  {\n    asOf,\n    className,\n    description,\n    emptyState,\n    locale = \"en-US\",\n    now,\n    onRetry,\n    ownBadgeLabel = \"Us\",\n    ownProductId = null,\n    products,\n    rows,\n    skeletonRows = 5,\n    staleAfterDays = 180,\n    status,\n    title = \"Capability comparison\",\n    ...props\n  },\n  ref,\n) {\n  /**\n   * Whether the table is genuinely wider than its viewport. Measured, not guessed\n   * from a breakpoint: the same three columns overflow at 375px and don't at\n   * 1440px, and a \"scroll sideways\" hint that lies is worse than no hint.\n   */\n  const [overflowing, setOverflowing] = React.useState(false)\n\n  /**\n   * A ref callback with a cleanup (React 19) rather than an effect: the observer\n   * is bound exactly when the scroll viewport mounts and dropped when the branch\n   * changes, so there is no dependency array to keep in sync with the state\n   * machine. `observe()` fires once on subscribe, which doubles as the first\n   * measurement, and a setState from an observer callback is async — it never\n   * trips the \"no setState in an effect body\" rule.\n   */\n  const bindViewport = React.useCallback((node: HTMLDivElement | null) => {\n    if (!node || typeof ResizeObserver === \"undefined\") return\n    const observer = new ResizeObserver(() => {\n      const next = node.scrollWidth - node.clientWidth > 1\n      setOverflowing(previous => (previous === next ? previous : next))\n    })\n    observer.observe(node)\n    // The viewport keeps its width while the *content* grows (a product is\n    // added), so the table is observed too.\n    const table = node.firstElementChild\n    if (table) observer.observe(table)\n    return () => observer.disconnect()\n  }, [])\n\n  const shell = (children: React.ReactNode) => (\n    <section\n      className={cn(\"@container w-full overflow-hidden rounded-lg border bg-card text-card-foreground\", className)}\n      ref={ref}\n      {...props}\n    >\n      {children}\n    </section>\n  )\n\n  const asOfMs = toEpochMs(asOf)\n  const nowMs = toEpochMs(now)\n  const ageDays = asOfMs === null || nowMs === null ? null : Math.floor((nowMs - asOfMs) / DAY_MS)\n  const staleThreshold = Math.max(1, Math.floor(Number.isFinite(staleAfterDays) ? staleAfterDays : 180))\n  const stale = ageDays !== null && ageDays > staleThreshold\n\n  let asOfText = asOf\n  let ageText: string | null = null\n  try {\n    if (asOfMs !== null) {\n      // UTC on purpose: `asOf` is a plain calendar date, so formatting it in the\n      // reader's zone would show \"17 Jun\" to everyone west of Greenwich.\n      asOfText = new Intl.DateTimeFormat(locale, { dateStyle: \"medium\", timeZone: \"UTC\" }).format(asOfMs)\n    }\n    // A negative age means the data is dated in the future — a data bug, not an\n    // age — so it is left unlabelled instead of rendered as \"in 3 days\".\n    if (ageDays !== null && ageDays >= 0) {\n      ageText = new Intl.RelativeTimeFormat(locale, { numeric: \"auto\" }).format(-ageDays, \"day\")\n    }\n  } catch {\n    // Unknown locale tag — RangeError. The ISO date is still correct, just unstyled.\n    asOfText = asOf\n    ageText = null\n  }\n\n  /**\n   * The provenance line only renders next to real data. A verification date over\n   * a skeleton or over an empty panel would be dating rows that aren't there.\n   */\n  const header = (withProvenance: boolean) => (\n    <div className=\"flex flex-col gap-1.5 border-b px-4 py-3\">\n      <div className=\"flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1\">\n        <h3 className=\"text-sm font-medium\">{title}</h3>\n        {withProvenance && (\n          <p className=\"flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground\">\n            <span>\n              Capabilities verified{\" \"}\n              <time className=\"font-medium text-foreground\" dateTime={asOf}>\n                {asOfText}\n              </time>\n              {ageText && <span> · {ageText}</span>}\n            </span>\n            {stale && (\n              <span className=\"inline-flex items-center gap-1 rounded-full border border-destructive/40 px-2 py-0.5 font-medium text-destructive\">\n                <TriangleAlert aria-hidden=\"true\" className=\"size-3 shrink-0\" />\n                Older than {staleThreshold} days — re-verify\n              </span>\n            )}\n          </p>\n        )}\n      </div>\n      {description && <p className=\"text-xs text-muted-foreground\">{description}</p>}\n    </div>\n  )\n\n  if (status === \"loading\") {\n    const count = Math.max(1, Math.min(24, Math.floor(Number.isFinite(skeletonRows) ? skeletonRows : 5)))\n    const columns = Math.max(2, products.length || 3)\n    return shell(\n      <>\n        <span className=\"sr-only\" role=\"status\">\n          Loading comparison\n        </span>\n        {header(false)}\n        {/* aria-hidden: a skeleton row heading has no name, so it must not reach\n            the accessibility tree at all. */}\n        <div aria-hidden=\"true\" className=\"w-full overflow-x-auto\">\n          <table className=\"w-full border-separate border-spacing-0 text-sm\">\n            <tbody>\n              {Array.from({ length: count + 1 }, (_, rowIndex) => (\n                <tr key={rowIndex}>\n                  <td className={cn(\"w-40 min-w-40 px-4 py-3 @md:w-56 @md:min-w-56\", rowIndex > 0 && \"border-t\")}>\n                    <SkeletonBar className=\"max-w-full\" style={{ width: `${58 + ((rowIndex * 13) % 32)}%` }} />\n                  </td>\n                  {Array.from({ length: columns }, (_, columnIndex) => (\n                    <td\n                      className={cn(\"min-w-32 px-3 py-3 @md:min-w-40\", rowIndex > 0 && \"border-t\")}\n                      key={columnIndex}\n                    >\n                      <SkeletonBar className=\"mx-auto w-12\" />\n                    </td>\n                  ))}\n                </tr>\n              ))}\n            </tbody>\n          </table>\n        </div>\n      </>,\n    )\n  }\n\n  if (status === \"error\") {\n    return shell(\n      <>\n        {header(false)}\n        <div className=\"flex flex-col items-center gap-3 px-6 py-14 text-center\">\n          <TriangleAlert aria-hidden=\"true\" className=\"size-8 text-destructive\" />\n          <div className=\"flex flex-col gap-1\">\n            <p className=\"text-sm font-medium\">Couldn&apos;t load the comparison</p>\n            <p className=\"text-sm text-muted-foreground\">\n              Nothing is shown rather than a half-filled table — a comparison missing a column reads as a claim about\n              that column.\n            </p>\n          </div>\n          {onRetry && (\n            <button\n              className=\"cursor-pointer rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\"\n              onClick={onRetry}\n              type=\"button\"\n            >\n              Try again\n            </button>\n          )}\n        </div>\n      </>,\n    )\n  }\n\n  // A \"ready\" payload with no products or no rows would render a bare table\n  // shell — headings with nothing under them. That is the empty state, so it is\n  // rendered as one instead of as a broken table.\n  if (status === \"empty\" || products.length === 0 || rows.length === 0) {\n    return shell(\n      <>\n        {header(false)}\n        {emptyState ?? (\n          <div className=\"flex flex-col items-center gap-2 px-6 py-14 text-center\">\n            <Rows3 aria-hidden=\"true\" className=\"size-8 text-muted-foreground\" />\n            <p className=\"text-sm font-medium\">Nothing to compare yet</p>\n            <p className=\"text-sm text-muted-foreground\">\n              Add at least one product and one capability to build the table.\n            </p>\n          </div>\n        )}\n      </>,\n    )\n  }\n\n  const ownIndex = ownProductId === null ? -1 : products.findIndex(product => product.id === ownProductId)\n  const sourced = products.filter(product => product.source)\n  const captionText = `${title} — ${products.map(product => product.name).join(\", \")}. Capabilities verified ${asOfText}.`\n\n  // The only thing the own column gets is a tint. Glyphs, labels, weights and\n  // sizes are identical in every column, so a competitor's \"Yes\" is drawn with\n  // exactly the same check as ours.\n  const columnClass = (index: number) => (index === ownIndex ? \"bg-primary/5\" : undefined)\n\n  return shell(\n    <>\n      {header(true)}\n\n      {overflowing && (\n        <p className=\"flex items-center gap-1.5 border-b px-4 py-2 text-xs text-muted-foreground\">\n          <MoveHorizontal aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n          Scroll sideways to see all {products.length} products — the capability column stays put.\n        </p>\n      )}\n\n      <div\n        className=\"w-full overflow-x-auto\"\n        ref={bindViewport}\n        // Only a region that actually scrolls becomes a keyboard stop and a named\n        // region: a tab stop on a table that fits is noise, and a landmark per\n        // comparison would pollute screen-reader navigation.\n        {...(overflowing ? { \"aria-label\": `${title}, scrollable`, role: \"region\", tabIndex: 0 } : {})}\n      >\n        {/* border-separate: `border-collapse` drops the borders of sticky cells,\n            and the frozen first column is made of sticky cells. */}\n        <table className=\"w-full border-separate border-spacing-0 text-sm\">\n          {/* On `<caption>`, `sr-only`'s absolute positioning blockifies the\n              table-caption box so it collapses to 1×1 — unlike `sr-only` on\n              `<table>` itself, where width:1px is only a lower bound and the\n              table stays full width. */}\n          <caption className=\"sr-only\">{captionText}</caption>\n          <thead>\n            <tr>\n              <th\n                className=\"sticky left-0 z-30 w-40 min-w-40 border-r border-b bg-card px-4 py-3 text-left align-bottom text-xs font-medium text-muted-foreground @md:w-56 @md:min-w-56\"\n                scope=\"col\"\n              >\n                Capability\n              </th>\n              {products.map((product, index) => (\n                <th\n                  className={cn(\n                    \"min-w-32 border-b px-3 py-3 text-center align-bottom @md:min-w-40\",\n                    columnClass(index),\n                  )}\n                  key={product.id}\n                  scope=\"col\"\n                >\n                  <span className=\"flex flex-col items-center gap-1\">\n                    <span className=\"flex flex-wrap items-center justify-center gap-1.5\">\n                      <span className=\"font-medium wrap-anywhere\">{product.name}</span>\n                      {index === ownIndex && (\n                        <span className=\"rounded-full border border-primary/40 bg-primary/10 px-1.5 py-px text-xs font-medium text-primary\">\n                          {ownBadgeLabel}\n                        </span>\n                      )}\n                    </span>\n                    {product.note && (\n                      <span className=\"text-xs font-normal wrap-anywhere text-muted-foreground\">{product.note}</span>\n                    )}\n                  </span>\n                </th>\n              ))}\n            </tr>\n          </thead>\n          <tbody>\n            {rows.map((row, rowIndex) => {\n              // One formatter per row, not per cell: the row owns the unit and the\n              // decimals, which is what stops two columns disagreeing about them.\n              const format = amountFormat(locale, row.fractionDigits)\n              return (\n                <tr key={row.id}>\n                  <th\n                    className={cn(\n                      // Opaque bg-card, not a tint: a translucent sticky cell lets\n                      // the scrolling columns show through it.\n                      \"sticky left-0 z-20 w-40 min-w-40 border-r bg-card px-4 py-3 text-left align-top font-medium @md:w-56 @md:min-w-56\",\n                      rowIndex > 0 && \"border-t\",\n                    )}\n                    scope=\"row\"\n                  >\n                    <span className=\"flex min-w-0 flex-col gap-0.5\">\n                      <span className=\"wrap-anywhere\">\n                        {row.feature}\n                        {row.unit && <span className=\"font-normal text-muted-foreground\"> ({row.unit})</span>}\n                      </span>\n                      {row.hint && (\n                        <span className=\"text-xs font-normal wrap-anywhere text-muted-foreground\">{row.hint}</span>\n                      )}\n                    </span>\n                  </th>\n                  {products.map((product, columnIndex) => {\n                    const view = viewCell(row.cells[columnIndex], row.unit, format)\n                    const Icon = view.Icon\n                    return (\n                      <td\n                        // The whole point of naming the cell: a screen-reader user\n                        // who lands here must not hear \"check\" with no idea which\n                        // product or which capability it belongs to.\n                        aria-label={`${product.name} — ${row.feature}: ${view.spoken}${view.note ? `. ${view.note}` : \"\"}`}\n                        className={cn(\n                          \"min-w-32 px-3 py-3 text-center align-top @md:min-w-40\",\n                          rowIndex > 0 && \"border-t\",\n                          columnClass(columnIndex),\n                        )}\n                        key={product.id}\n                      >\n                        <span className=\"flex flex-col items-center gap-1\">\n                          <span className={cn(\"inline-flex items-center justify-center gap-1.5\", view.tone)}>\n                            {Icon && <Icon aria-hidden=\"true\" className={cn(\"size-4 shrink-0\", view.iconClass)} />}\n                            <span className={cn(Icon ? undefined : \"tabular-nums\")}>{view.label}</span>\n                          </span>\n                          {view.note && (\n                            // No fixed height and no line clamp: a qualifier that\n                            // gets cut off turns \"Partial\" back into an\n                            // unexplained mark.\n                            <span className=\"text-xs wrap-anywhere text-muted-foreground\">{view.note}</span>\n                          )}\n                        </span>\n                      </td>\n                    )\n                  })}\n                </tr>\n              )\n            })}\n          </tbody>\n        </table>\n      </div>\n\n      {sourced.length > 0 && (\n        <div className=\"flex flex-wrap items-baseline gap-x-3 gap-y-1 border-t px-4 py-3 text-xs text-muted-foreground\">\n          <span className=\"font-medium text-foreground\">Sources</span>\n          <ul className=\"flex flex-wrap gap-x-4 gap-y-1\">\n            {sourced.map(product => (\n              <li key={product.id}>\n                <a\n                  className=\"inline-flex items-center gap-1 underline underline-offset-2 transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\"\n                  href={product.source?.href}\n                  rel=\"noreferrer\"\n                  target=\"_blank\"\n                >\n                  {product.name}: {product.source?.label}\n                  <ExternalLink aria-hidden=\"true\" className=\"size-3 shrink-0\" />\n                  <span className=\"sr-only\">(opens in a new tab)</span>\n                </a>\n              </li>\n            ))}\n          </ul>\n        </div>\n      )}\n    </>,\n  )\n})\n\nFeatureComparison.displayName = \"FeatureComparison\"\n\nexport default FeatureComparison\n",
      "type": "registry:block"
    },
    {
      "path": "src/registry/blocks/feature-comparison.contract.ts",
      "content": "import { z } from \"zod\"\n\n/**\n * A destination that actually exists. `\"\"`, `\"#\"` and `javascript:` URLs are the\n * three ways a \"source link\" turns into decoration — and a comparison table whose\n * citations go nowhere is worse than one with no citations at all, because it\n * looks sourced.\n */\nexport const featureComparisonHrefSchema = z.string().refine(\n  value => {\n    const href = value.trim()\n    return href.length > 0 && href !== \"#\" && !/^javascript:/i.test(href)\n  },\n  { message: 'href must be a real destination — not \"\", \"#\" or a javascript: URL' },\n)\n\n/** Where a claim was read. Rendered as a real external link in the footer. */\nexport const featureComparisonSourceSchema = z.object({\n  /** What the reader is clicking through to, e.g. \"pricing page\", \"docs · SSO\". */\n  label: z.string().min(1),\n  href: featureComparisonHrefSchema,\n})\n\n/**\n * One column. Every product — yours and every competitor — uses this exact shape:\n * there is no \"us\" variant with extra fields, which is what keeps the renderer\n * from being able to draw your column differently from theirs.\n */\nexport const featureComparisonProductSchema = z.object({\n  id: z.string(),\n  /** Column heading, rendered in a `<th scope=\"col\">`. */\n  name: z.string().min(1),\n  /** One short qualifier under the name — which plan/tier the column describes. */\n  note: z.string().optional(),\n  /**\n   * The page these claims were verified against. Optional in the type, mandatory\n   * in practice for competitor columns: an unsourced claim about someone else's\n   * product is the part of this component that gets you a legal letter.\n   */\n  source: featureComparisonSourceSchema.optional(),\n})\n\n/**\n * The cell union. `partial` is a first-class third state, not a softer `no`:\n * it carries a **required** qualifier, because \"partially supported\" with no\n * explanation tells the reader nothing they can act on.\n *\n * - `yes`     — supported as described by the row\n * - `partial` — supported with a limit; `note` is required (\"Enterprise plan only\")\n * - `no`      — verified absent\n * - `unknown` — not published / not verifiable. Never collapse this into `no`;\n *               \"we couldn't find out\" and \"they don't have it\" are different claims\n * - `amount`  — a figure for a measurable row (`value: null` means \"no ceiling\").\n *               The unit lives on the row, so two columns can't disagree about it\n */\nexport const featureComparisonCellSchema = z.discriminatedUnion(\"kind\", [\n  z.object({ kind: z.literal(\"yes\"), note: z.string().optional() }),\n  z.object({ kind: z.literal(\"partial\"), note: z.string().min(1) }),\n  z.object({ kind: z.literal(\"no\"), note: z.string().optional() }),\n  z.object({ kind: z.literal(\"unknown\"), note: z.string().optional() }),\n  z.object({\n    kind: z.literal(\"amount\"),\n    /** `null` = unlimited. zod rejects NaN/±Infinity, so no cell can render \"NaN\". */\n    value: z.number().nullable(),\n    note: z.string().optional(),\n  }),\n])\n\n/**\n * One capability. `unit` and `fractionDigits` are declared **once per row** rather\n * than per cell — that is the structural reason a row can't show \"500 GB\" next to\n * \"0.5 TB\", and it lets the boolean rows and the numeric rows share one table.\n */\nexport const featureComparisonRowSchema = z.object({\n  id: z.string(),\n  /** Row heading, rendered in a `<th scope=\"row\">`. Phrase it neutrally. */\n  feature: z.string().min(1),\n  /** One muted line under the heading: the definition, or what \"supported\" means here. */\n  hint: z.string().optional(),\n  /** Unit for every `amount` cell in this row, e.g. \"GB\", \"days\", \"req/min\". */\n  unit: z.string().optional(),\n  /** Pins decimals for this row's amounts so a column of figures keeps its shape. */\n  fractionDigits: z.number().int().min(0).max(4).optional(),\n  /** One cell per product, aligned to `products` by index. */\n  cells: z.array(featureComparisonCellSchema),\n})\n\nexport const featureComparisonStatusSchema = z.enum([\"loading\", \"empty\", \"error\", \"ready\"])\n\nexport const featureComparisonSchema = z\n  .object({\n    status: featureComparisonStatusSchema,\n    /** Columns, left to right. Index 0 carries no special meaning. */\n    products: z.array(featureComparisonProductSchema),\n    rows: z.array(featureComparisonRowSchema),\n    /**\n     * Which column is yours. It buys a background tint and an \"Us\" pill — nothing\n     * else. Cell glyphs, labels and type are identical in every column by\n     * construction, so a competitor's `yes` can never be drawn to read as a `no`.\n     * `null` renders a neutral, unattributed table.\n     */\n    ownProductId: z.string().nullable().optional(),\n    /**\n     * The day these capabilities were last verified, as a plain `YYYY-MM-DD` date.\n     * Required, and deliberately not derivable: competitor capabilities change\n     * weekly, so a comparison without a verification date is an undated claim.\n     * Plain-date form (no time, no offset) is enforced so the rendered date can't\n     * shift a day between a UTC server and a UTC+8 reader.\n     */\n    asOf: z\n      .string()\n      .refine(value => /^\\d{4}-\\d{2}-\\d{2}$/.test(value) && !Number.isNaN(Date.parse(value)), {\n        message: \"asOf must be a real calendar date in YYYY-MM-DD form\",\n      }),\n  })\n  // Every refine below guards its own inputs. zod runs *all* refinements even after\n  // an earlier one fails, so a ragged payload reaching an unguarded `data.rows[0]\n  // .cells[2].kind` would throw a TypeError out of `safeParse` instead of returning\n  // `{ success: false }` — the caller would never see an error object at all.\n  .superRefine((data, ctx) => {\n    const products = Array.isArray(data.products) ? data.products : []\n    const rows = Array.isArray(data.rows) ? data.rows : []\n\n    rows.forEach((row, index) => {\n      const cells = row && Array.isArray(row.cells) ? row.cells : null\n      if (!cells || cells.length === products.length) return\n      ctx.addIssue({\n        code: \"custom\",\n        message: `row \"${row?.id ?? index}\" has ${cells.length} cells but there are ${products.length} products — cells align to products by index`,\n        path: [\"rows\", index, \"cells\"],\n      })\n    })\n\n    const ownId = data.ownProductId\n    if (typeof ownId === \"string\" && !products.some(product => product?.id === ownId)) {\n      ctx.addIssue({\n        code: \"custom\",\n        message: `ownProductId \"${ownId}\" is not one of the products`,\n        path: [\"ownProductId\"],\n      })\n    }\n\n    const ids = products.map(product => product?.id)\n    const duplicate = ids.find((id, index) => ids.indexOf(id) !== index)\n    if (duplicate !== undefined) {\n      ctx.addIssue({\n        code: \"custom\",\n        message: `duplicate product id \"${duplicate}\"`,\n        path: [\"products\"],\n      })\n    }\n  })\n\nexport type FeatureComparisonSource = z.infer<typeof featureComparisonSourceSchema>\nexport type FeatureComparisonProduct = z.infer<typeof featureComparisonProductSchema>\nexport type FeatureComparisonCell = z.infer<typeof featureComparisonCellSchema>\nexport type FeatureComparisonRow = z.infer<typeof featureComparisonRowSchema>\nexport type FeatureComparisonStatus = z.infer<typeof featureComparisonStatusSchema>\nexport type FeatureComparisonData = z.infer<typeof featureComparisonSchema>\n",
      "type": "registry:block"
    }
  ],
  "type": "registry:block"
}