{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "date-separator",
  "title": "Date separator",
  "description": "Date headers: the iOS two-line header with its service line, the mid-list one-liner, and the macOS variant.",
  "registryDependencies": [
    "https://imessage.swerdlow.dev/r/platform.json",
    "https://imessage.swerdlow.dev/r/tokens.json"
  ],
  "files": [
    {
      "path": "registry/imessage/date-separator.tsx",
      "content": "\"use client\";\n\nimport type { ComponentProps, ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePlatform, type Platform } from \"@/components/message-ui/platform\";\nimport { fontStack } from \"@/components/message-ui/tokens\";\n\n/**\n * The centered timestamp that separates conversation history.\n *\n * Measured (references/ios/captures/conv3-light.png @3x, references/macos/captures/conversation-pane-light-partial.png @2x):\n * - iOS: two lines (\"iMessage\" then \"Today 1:25 AM\"), 11pt on a 14pt pitch, #8a8a8e. The service line and\n *   \"Today\" render at weight 500; the time is regular. The first bubble sits 7.6pt below the second line box.\n * - macOS: one line, 9pt on an 11pt pitch (ink 63 x 9pt for \"Today 1:47 AM\"; SPEC.md's 11pt is 20% too wide),\n *   \"Today\" at weight **500**, #808080 light / #9a9a9a dark. The first bubble's body top sits 6.0 below the\n *   header's ink bottom. Weight 500 is measured, not assumed: rendering the string at 9pt/2x and comparing\n *   the ten ink runs of `conversation-pane-light-partial.png` (normalised to the \"T\", pt from its left edge:\n *   0.00-10.50, 11.00-16.00, 16.50-21.00, 22.00-26.50, 30.00-33.00, 34.50-36.00, 37.00-42.50, 43.00-47.50,\n *   49.50-55.50, 56.50-63.00) puts weight 500 within one device px on all ten and on the 63.00 total, where\n *   600 runs 0.5 wide on seven of them and 63.50 overall, and 400 is 0.5 narrow on eight. Same weight as iOS.\n *   `gapBelow` is 6 even though the box wants 5.7: at 9pt/2x Blink paints the ink at `round(box top) + 2`,\n *   so 5.7 and 6 put the ink on the same device row, and 6 is the number the capture states.\n *\n * Only the header that opens a conversation carries the service name; a header inserted mid-list after an\n * hour-long gap is one line and sits in a gap of its own. Measured in `dateheader-mid-light.png`: the\n * previous body bottom is at 605.0, the header ink runs 621.0 to 631.33, the next bubble top is 639.0.\n * Chrome snaps a baseline to whole CSS px, so the ink can land a third of a point off the capture even\n * when the box is exactly right; the gaps below are the box positions, not the rasterised ink.\n */\nexport type DateSeparatorVariant = \"first\" | \"mid\";\nexport type DateSeparatorMetrics = {\n  fontSize: number; lineHeight: number; weight: number; dayWeight: number;\n  /** Space between the previous row and the line box, and between the line box and the next row. */\n  gapAbove: number; gapBelow: number;\n  /** The same two gaps for a mid-list header, which has no bubble above it to lean on. */\n  midGapAbove: number; midGapBelow: number;\n};\n\nexport const dateSeparatorMetrics: Record<Platform, DateSeparatorMetrics> = {\n  ios: { fontSize: 11, lineHeight: 14, weight: 400, dayWeight: 500, gapAbove: 0, gapBelow: 7.6, midGapAbove: 13.33, midGapBelow: 6.67 },\n  // No macOS capture of a mid-list header yet: it reuses the between-cluster gap above and the measured 6 below.\n  macos: { fontSize: 9, lineHeight: 11, weight: 400, dayWeight: 500, gapAbove: 0, gapBelow: 6, midGapAbove: 11.5, midGapBelow: 6 },\n};\n\nexport type DateLabel = {\n  /** \"Today\", \"Yesterday\", a weekday name, or \"Sep 8, 2026\". */\n  day: string;\n  /** \"1:25 AM\" (narrow no-break space before the period, as Apple renders it). */\n  time: string;\n  /** \" \" for relative days, \" at \" before absolute dates. */\n  joiner: \" \" | \" at \";\n  text: string;\n  iso: string;\n};\n\nconst WEEKDAYS = [\"Sunday\", \"Monday\", \"Tuesday\", \"Wednesday\", \"Thursday\", \"Friday\", \"Saturday\"];\nconst MONTHS = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"];\n\nfunction startOfDay(d: Date) { return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); }\n\nexport function formatClockTime(value: Date | number): string {\n  const d = new Date(value);\n  const hours = d.getHours();\n  const h12 = hours % 12 || 12;\n  return `${h12}:${String(d.getMinutes()).padStart(2, \"0\")} ${hours < 12 ? \"AM\" : \"PM\"}`;\n}\n\n/** Apple's relative date style: Today / Yesterday / weekday within a week / \"Sep 8, 2026 at 1:25 AM\". */\nexport function formatDateLabel(value: Date | number, now: Date | number = Date.now()): DateLabel {\n  const d = new Date(value);\n  const days = Math.round((startOfDay(new Date(now)) - startOfDay(d)) / 86_400_000);\n  const time = formatClockTime(d);\n  let day: string;\n  let joiner: DateLabel[\"joiner\"] = \" \";\n  if (days === 0) day = \"Today\";\n  else if (days === 1) day = \"Yesterday\";\n  else if (days > 1 && days < 7) day = WEEKDAYS[d.getDay()];\n  else { day = `${MONTHS[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`; joiner = \" at \"; }\n  return { day, time, joiner, text: `${day}${joiner}${time}`, iso: d.toISOString() };\n}\n\nexport type DateSeparatorProps = Omit<ComponentProps<\"div\">, \"children\"> & {\n  /** The moment the header describes. Omit to render `children` verbatim. */\n  date?: Date | number;\n  /** Reference time for \"Today\"/\"Yesterday\"; defaults to the current time. */\n  now?: Date | number;\n  /** iOS shows the conversation's service (\"iMessage\", \"Text Message\") above the date. Ignored on macOS. */\n  service?: ReactNode;\n  /** \"first\" opens the conversation; \"mid\" is inserted after an hour-long gap and gets its own spacing. */\n  variant?: DateSeparatorVariant;\n  platform?: Platform;\n  /** Custom label used instead of the formatted date. */\n  children?: ReactNode;\n  dateTime?: string;\n};\n\nexport function DateSeparator({ date, now, service, variant = \"first\", platform: platformProp, children, dateTime, className, style, ...props }: DateSeparatorProps) {\n  const contextPlatform = usePlatform();\n  const platform = platformProp ?? contextPlatform;\n  const m = dateSeparatorMetrics[platform];\n  const mid = variant === \"mid\";\n  const label = date === undefined ? undefined : formatDateLabel(date, now);\n  return (\n    <div data-slot=\"date-separator\" data-platform={platform} data-variant={variant} className={cn(\"text-center\", className)}\n      style={{ fontFamily: fontStack, fontSize: m.fontSize, lineHeight: `${m.lineHeight}px`, fontWeight: m.weight, letterSpacing: 0, color: \"var(--im-secondary, #8a8a8e)\", paddingTop: mid ? m.midGapAbove : m.gapAbove, paddingBottom: mid ? m.midGapBelow : m.gapBelow, ...style }} {...props}>\n      {platform === \"ios\" && !mid && service ? <div data-slot=\"service\" style={{ fontWeight: m.dayWeight }}>{service}</div> : null}\n      <time dateTime={dateTime ?? label?.iso}>\n        {children ?? (label ? <><span data-slot=\"day\" style={{ fontWeight: m.dayWeight }}>{label.day}</span>{label.joiner}{label.time}</> : null)}\n      </time>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/message-ui/date-separator.tsx"
    }
  ],
  "type": "registry:ui"
}
