{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"status-page","type":"registry:block","title":"Status Page","description":"Service status block with an overall banner derived from today's worst status, per-service 90-day uptime bars that grow in on view and reveal a date, status and incident tooltip on hover or arrow-key focus, computed uptime percentages, and an incident timeline whose entries expand into their update history, all formatted in UTC from deterministic data.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/status-page.tsx","type":"registry:component","target":"@components/motion/status-page.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/blocks/status-page\n\nimport { AlertTriangle, CheckCircle2, ChevronDown, Wrench, XCircle } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type KeyboardEvent, type ReactNode, useId, useMemo, useRef, useState } from \"react\";\nimport { Badge, type BadgeVariant } from \"@/components/motion/badge\";\nimport {\n  Collapsible,\n  CollapsibleContent,\n  CollapsibleTrigger,\n} from \"@/components/motion/collapsible\";\nimport { EASE_OUT, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ServiceDayStatus =\n  | \"operational\"\n  | \"degraded\"\n  | \"partial\"\n  | \"major\"\n  | \"maintenance\"\n  | \"nodata\";\n\nexport interface ServiceDay {\n  /** Calendar day, `YYYY-MM-DD` (read as UTC so server and client agree). */\n  date: string;\n  status: ServiceDayStatus;\n  /** Incident titles that touched this day; shown in the bar tooltip. */\n  incidents?: string[];\n}\n\nexport interface StatusService {\n  id: string;\n  name: string;\n  description?: string;\n  /** Oldest first; typically 90 entries. The last day is \"today\". */\n  days: ServiceDay[];\n  /** Override the computed uptime percentage. */\n  uptime?: number;\n}\n\nexport type IncidentStatus = \"investigating\" | \"identified\" | \"monitoring\" | \"resolved\";\nexport type IncidentImpact = \"minor\" | \"major\" | \"critical\" | \"maintenance\";\n\nexport interface IncidentUpdate {\n  id: string;\n  status: IncidentStatus;\n  message: string;\n  /** ISO timestamp. */\n  at: string;\n}\n\nexport interface StatusIncident {\n  id: string;\n  title: string;\n  impact: IncidentImpact;\n  status: IncidentStatus;\n  /** Newest first. */\n  updates: IncidentUpdate[];\n  /** Affected service names. */\n  services?: string[];\n}\n\nexport interface StatusPageProps {\n  services: StatusService[];\n  incidents?: StatusIncident[];\n  title?: ReactNode;\n  /** Pre-formatted \"last updated\" text (never computed, so markup stays deterministic). */\n  lastUpdated?: ReactNode;\n  /** Ids of incidents expanded on mount. Default: unresolved ones. */\n  defaultExpandedIncidents?: string[];\n  locale?: string;\n  /** IANA time zone for incident timestamps. Default \"UTC\" so SSR matches. */\n  timeZone?: string;\n  className?: string;\n}\n\nconst DAY_TONE: Record<ServiceDayStatus, string> = {\n  operational: \"bg-success\",\n  degraded: \"bg-warning/70\",\n  partial: \"bg-warning\",\n  major: \"bg-destructive\",\n  maintenance: \"bg-chart-2\",\n  nodata: \"bg-foreground/10\",\n};\n\nconst DAY_LABEL: Record<ServiceDayStatus, string> = {\n  operational: \"No downtime\",\n  degraded: \"Degraded performance\",\n  partial: \"Partial outage\",\n  major: \"Major outage\",\n  maintenance: \"Scheduled maintenance\",\n  nodata: \"No data\",\n};\n\n// Share of the day counted as up, for the computed uptime figure.\nconst DAY_UPTIME: Record<ServiceDayStatus, number | null> = {\n  operational: 1,\n  degraded: 0.998,\n  partial: 0.99,\n  major: 0.95,\n  maintenance: 1,\n  nodata: null,\n};\n\nconst SEVERITY: ServiceDayStatus[] = [\"operational\", \"maintenance\", \"degraded\", \"partial\", \"major\"];\n\nconst IMPACT_BADGE: Record<IncidentImpact, BadgeVariant> = {\n  minor: \"warning\",\n  major: \"destructive\",\n  critical: \"destructive\",\n  maintenance: \"secondary\",\n};\n\nconst STATUS_TEXT: Record<IncidentStatus, string> = {\n  investigating: \"Investigating\",\n  identified: \"Identified\",\n  monitoring: \"Monitoring\",\n  resolved: \"Resolved\",\n};\n\nexport function computeUptime(days: ServiceDay[]) {\n  const counted = days\n    .map((d) => DAY_UPTIME[d.status])\n    .filter((v): v is number => v != null);\n  if (counted.length === 0) return 100;\n  return (counted.reduce((a, b) => a + b, 0) / counted.length) * 100;\n}\n\nfunction overallOf(services: StatusService[]) {\n  let worst: ServiceDayStatus = \"operational\";\n  for (const s of services) {\n    const today = s.days[s.days.length - 1]?.status ?? \"operational\";\n    if (SEVERITY.indexOf(today) > SEVERITY.indexOf(worst)) worst = today;\n  }\n  return worst;\n}\n\nconst BANNER: Record<\n  ServiceDayStatus,\n  { text: string; icon: ReactNode; className: string }\n> = {\n  operational: {\n    text: \"All systems operational\",\n    icon: <CheckCircle2 aria-hidden=\"true\" />,\n    className: \"border-success/30 bg-success/10 text-[color-mix(in_oklab,var(--success)_70%,var(--foreground))]\",\n  },\n  nodata: {\n    text: \"All systems operational\",\n    icon: <CheckCircle2 aria-hidden=\"true\" />,\n    className: \"border-success/30 bg-success/10 text-[color-mix(in_oklab,var(--success)_70%,var(--foreground))]\",\n  },\n  maintenance: {\n    text: \"Scheduled maintenance in progress\",\n    icon: <Wrench aria-hidden=\"true\" />,\n    className: \"border-chart-2/30 bg-chart-2/10 text-foreground\",\n  },\n  degraded: {\n    text: \"Degraded performance\",\n    icon: <AlertTriangle aria-hidden=\"true\" />,\n    className: \"border-warning/40 bg-warning/10 text-[color-mix(in_oklab,var(--warning)_60%,var(--foreground))]\",\n  },\n  partial: {\n    text: \"Partial system outage\",\n    icon: <AlertTriangle aria-hidden=\"true\" />,\n    className: \"border-warning/40 bg-warning/15 text-[color-mix(in_oklab,var(--warning)_60%,var(--foreground))]\",\n  },\n  major: {\n    text: \"Major system outage\",\n    icon: <XCircle aria-hidden=\"true\" />,\n    className: \"border-destructive/40 bg-destructive/10 text-[color-mix(in_oklab,var(--destructive)_70%,var(--foreground))]\",\n  },\n};\n\nexport function StatusPage({\n  services,\n  incidents = [],\n  title = \"System status\",\n  lastUpdated,\n  defaultExpandedIncidents,\n  locale = \"en-US\",\n  timeZone = \"UTC\",\n  className,\n}: StatusPageProps) {\n  const reduce = useReducedMotion() ?? false;\n  const overall = overallOf(services);\n  const banner = BANNER[overall];\n  const dayFormat = useMemo(\n    () => new Intl.DateTimeFormat(locale, { month: \"short\", day: \"numeric\", year: \"numeric\", timeZone: \"UTC\" }),\n    [locale],\n  );\n  const stampFormat = useMemo(\n    () =>\n      new Intl.DateTimeFormat(locale, {\n        month: \"short\",\n        day: \"numeric\",\n        hour: \"numeric\",\n        minute: \"2-digit\",\n        timeZone,\n        timeZoneName: \"short\",\n      }),\n    [locale, timeZone],\n  );\n\n  return (\n    <div className={cn(\"flex w-full flex-col gap-4 text-sm\", className)}>\n      <div className=\"flex items-end justify-between gap-3\">\n        <h2 className=\"text-base font-semibold text-foreground\">{title}</h2>\n        {lastUpdated && <p className=\"text-xs text-muted-foreground\">{lastUpdated}</p>}\n      </div>\n\n      <div\n        role=\"status\"\n        className={cn(\n          \"relative flex items-center gap-2.5 overflow-hidden rounded-xl border border-border px-4 py-3 font-medium [&_svg]:size-5 [&_svg]:shrink-0\",\n          banner.className,\n        )}\n      >\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            key={overall}\n            className=\"flex items-center gap-2.5\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}\n            transition={SPRING_SWAP}\n          >\n            {banner.icon}\n            {banner.text}\n          </motion.span>\n        </AnimatePresence>\n      </div>\n\n      <div className=\"divide-y divide-border rounded-xl border border-border bg-card\">\n        {services.map((service, i) => (\n          <ServiceRow\n            key={service.id}\n            service={service}\n            dayFormat={dayFormat}\n            reduce={reduce}\n            index={i}\n          />\n        ))}\n        <div className=\"flex items-center justify-between px-4 py-2 text-[11px] text-muted-foreground\">\n          <span>{services[0]?.days.length ?? 90} days ago</span>\n          <span className=\"flex items-center gap-3\">\n            {([\"operational\", \"degraded\", \"major\"] as const).map((s) => (\n              <span key={s} className=\"inline-flex items-center gap-1\">\n                <span aria-hidden=\"true\" className={cn(\"size-2 rounded-sm\", DAY_TONE[s])} />\n                {s === \"operational\" ? \"Up\" : s === \"degraded\" ? \"Degraded\" : \"Outage\"}\n              </span>\n            ))}\n          </span>\n          <span>Today</span>\n        </div>\n      </div>\n\n      {incidents.length > 0 && (\n        <section aria-label=\"Incidents\" className=\"flex flex-col gap-2\">\n          <h3 className=\"text-xs font-medium uppercase tracking-wide text-muted-foreground\">\n            Recent incidents\n          </h3>\n          <ol className=\"flex flex-col gap-2\">\n            {incidents.map((incident) => (\n              <IncidentItem\n                key={incident.id}\n                incident={incident}\n                stampFormat={stampFormat}\n                defaultOpen={\n                  defaultExpandedIncidents\n                    ? defaultExpandedIncidents.includes(incident.id)\n                    : incident.status !== \"resolved\"\n                }\n                reduce={reduce}\n              />\n            ))}\n          </ol>\n        </section>\n      )}\n    </div>\n  );\n}\n\nfunction ServiceRow({\n  service,\n  dayFormat,\n  reduce,\n  index,\n}: {\n  service: StatusService;\n  dayFormat: Intl.DateTimeFormat;\n  reduce: boolean;\n  index: number;\n}) {\n  const id = useId();\n  const [hover, setHover] = useState<number | null>(null);\n  const [focusIndex, setFocusIndex] = useState(service.days.length - 1);\n  const barsRef = useRef<HTMLFieldSetElement>(null);\n  const uptime = service.uptime ?? computeUptime(service.days);\n  const today = service.days[service.days.length - 1]?.status ?? \"operational\";\n  const count = service.days.length;\n  const active = hover != null ? service.days[hover] : undefined;\n\n  const formatDay = (date: string) => dayFormat.format(new Date(`${date}T00:00:00Z`));\n\n  // One tab stop per row; arrows walk the days.\n  const onKeyDown = (event: KeyboardEvent<HTMLFieldSetElement>) => {\n    const step: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1, Home: -count, End: count };\n    const delta = step[event.key];\n    if (delta == null) return;\n    event.preventDefault();\n    const next = Math.max(0, Math.min(count - 1, focusIndex + delta));\n    setFocusIndex(next);\n    setHover(next);\n    barsRef.current?.querySelectorAll<HTMLButtonElement>(\"button\")[next]?.focus();\n  };\n\n  return (\n    <div className=\"flex flex-col gap-2 px-4 py-3\">\n      <div className=\"flex items-center gap-2\">\n        <span className=\"font-medium text-foreground\">{service.name}</span>\n        {service.description && (\n          <span className=\"hidden truncate text-xs text-muted-foreground sm:inline\">{service.description}</span>\n        )}\n        <span className=\"ml-auto font-mono text-xs tabular-nums text-muted-foreground\">\n          {uptime.toFixed(2)}% uptime\n        </span>\n        <span\n          className={cn(\"size-2 rounded-full\", DAY_TONE[today === \"nodata\" ? \"operational\" : today])}\n          aria-label={DAY_LABEL[today]}\n          role=\"img\"\n        />\n      </div>\n      <div className=\"relative\">\n        <fieldset\n          ref={barsRef}\n          aria-label={`${service.name}, last ${count} days`}\n          onKeyDown={onKeyDown}\n          onPointerLeave={() => setHover(null)}\n          className=\"m-0 flex h-8 min-w-0 items-end gap-px border-0 p-0\"\n        >\n          {service.days.map((day, i) => (\n            <motion.button\n              key={day.date}\n              type=\"button\"\n              tabIndex={i === focusIndex ? 0 : -1}\n              aria-label={`${formatDay(day.date)}: ${DAY_LABEL[day.status]}${\n                day.incidents?.length ? `, ${day.incidents.join(\"; \")}` : \"\"\n              }`}\n              aria-describedby={hover === i ? `${id}-tip` : undefined}\n              onPointerEnter={() => setHover(i)}\n              onFocus={() => {\n                setHover(i);\n                setFocusIndex(i);\n              }}\n              onBlur={() => setHover(null)}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, scaleY: 0.2 }}\n              whileInView={{ opacity: 1, scaleY: 1 }}\n              viewport={{ once: true }}\n              transition={{ duration: 0.35, ease: EASE_OUT, delay: index * 0.06 + i * 0.004 }}\n              className={cn(\n                \"h-full min-w-0 flex-1 origin-bottom rounded-xs outline-none transition-opacity\",\n                \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-card\",\n                DAY_TONE[day.status],\n                hover != null && hover !== i && \"opacity-60\",\n              )}\n            />\n          ))}\n        </fieldset>\n        <AnimatePresence>\n          {active && hover != null && (\n            <motion.div\n              id={`${id}-tip`}\n              role=\"tooltip\"\n              key=\"tip\"\n              style={{ left: `${((hover + 0.5) / count) * 100}%` }}\n              initial={{ opacity: 0, y: reduce ? 0 : 4 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, transition: { duration: 0.1 } }}\n              transition={{ duration: 0.14, ease: EASE_OUT }}\n              className={cn(\n                \"pointer-events-none absolute bottom-full z-20 mb-2 w-48 rounded-lg border border-border bg-popover p-2.5 text-xs text-popover-foreground shadow-md\",\n                hover < count * 0.2 ? \"translate-x-[-10%]\" : hover > count * 0.8 ? \"-translate-x-[90%]\" : \"-translate-x-1/2\",\n              )}\n            >\n              <p className=\"font-medium text-foreground\">{formatDay(active.date)}</p>\n              <p className=\"mt-0.5 flex items-center gap-1.5 text-muted-foreground\">\n                <span aria-hidden=\"true\" className={cn(\"size-2 rounded-sm\", DAY_TONE[active.status])} />\n                {DAY_LABEL[active.status]}\n              </p>\n              {active.incidents?.length ? (\n                <ul className=\"mt-1.5 space-y-0.5 border-t border-border pt-1.5 text-foreground\">\n                  {active.incidents.map((t) => (\n                    <li key={t} className=\"truncate\">\n                      {t}\n                    </li>\n                  ))}\n                </ul>\n              ) : null}\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n    </div>\n  );\n}\n\nfunction IncidentItem({\n  incident,\n  stampFormat,\n  defaultOpen,\n  reduce,\n}: {\n  incident: StatusIncident;\n  stampFormat: Intl.DateTimeFormat;\n  defaultOpen: boolean;\n  reduce: boolean;\n}) {\n  const [open, setOpen] = useState(defaultOpen);\n  const latest = incident.updates[0];\n  const resolved = incident.status === \"resolved\";\n\n  return (\n    <li className=\"rounded-xl border border-border bg-card\">\n      <Collapsible open={open} onOpenChange={setOpen}>\n        <CollapsibleTrigger className=\"flex w-full items-start gap-3 rounded-xl px-4 py-3 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring\">\n          <span\n            aria-hidden=\"true\"\n            className={cn(\n              \"mt-1.5 size-2 shrink-0 rounded-full\",\n              resolved ? \"bg-success\" : incident.impact === \"maintenance\" ? \"bg-chart-2\" : \"bg-warning\",\n            )}\n          />\n          <span className=\"min-w-0 flex-1\">\n            <span className=\"block font-medium text-foreground\">{incident.title}</span>\n            <span className=\"mt-1 flex flex-wrap items-center gap-1.5\">\n              <Badge size=\"sm\" variant={resolved ? \"success\" : \"warning\"} dot={resolved ? true : \"pulse\"}>\n                {STATUS_TEXT[incident.status]}\n              </Badge>\n              <Badge size=\"sm\" variant={IMPACT_BADGE[incident.impact]}>\n                {incident.impact === \"maintenance\" ? \"Maintenance\" : `${incident.impact[0]?.toUpperCase()}${incident.impact.slice(1)} impact`}\n              </Badge>\n              {latest && (\n                <time dateTime={latest.at} className=\"text-xs text-muted-foreground\">\n                  {stampFormat.format(new Date(latest.at))}\n                </time>\n              )}\n            </span>\n          </span>\n          <motion.span\n            aria-hidden=\"true\"\n            animate={{ rotate: open ? 180 : 0 }}\n            transition={reduce ? { duration: 0 } : SPRING_SWAP}\n            className=\"mt-0.5 text-muted-foreground\"\n          >\n            <ChevronDown className=\"size-4\" />\n          </motion.span>\n        </CollapsibleTrigger>\n        <CollapsibleContent innerClassName=\"px-4 pb-4\">\n          <ol className=\"relative ml-1 flex flex-col gap-3 border-l border-border pl-4\">\n            {incident.updates.map((update, i) => (\n              <li key={update.id} className=\"relative\">\n                <span\n                  aria-hidden=\"true\"\n                  className={cn(\n                    \"absolute -left-[21px] top-1 size-2.5 rounded-full border-2 border-card\",\n                    i === 0 ? (resolved ? \"bg-success\" : \"bg-warning\") : \"bg-foreground/25\",\n                  )}\n                />\n                <p className=\"text-xs\">\n                  <span className=\"font-medium text-foreground\">{STATUS_TEXT[update.status]}</span>\n                  <span className=\"text-muted-foreground\"> · </span>\n                  <time dateTime={update.at} className=\"text-muted-foreground\">\n                    {stampFormat.format(new Date(update.at))}\n                  </time>\n                </p>\n                <p className=\"mt-0.5 text-muted-foreground\">{update.message}</p>\n              </li>\n            ))}\n          </ol>\n          {incident.services?.length ? (\n            <p className=\"mt-3 text-xs text-muted-foreground\">\n              Affected: <span className=\"text-foreground\">{incident.services.join(\", \")}</span>\n            </p>\n          ) : null}\n        </CollapsibleContent>\n      </Collapsible>\n    </li>\n  );\n}\n"},{"path":"components/motion/badge.tsx","type":"registry:component","target":"@components/motion/badge.tsx","content":"\"use client\";\n\nimport { X } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  PresenceContext,\n  useAnimate,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport {\n  type KeyboardEvent as ReactKeyboardEvent,\n  type ReactNode,\n  useContext,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_PANEL, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type BadgeVariant =\n  | \"default\"\n  | \"secondary\"\n  | \"outline\"\n  | \"destructive\"\n  | \"success\"\n  | \"warning\";\n\nexport type BadgeSize = \"sm\" | \"md\" | \"lg\";\n\n/** Soft tinted surface or a filled one. Only the status variants differ. */\nexport type BadgeTone = \"soft\" | \"solid\";\n\nconst BASE: Record<\"default\" | \"secondary\" | \"outline\", string> = {\n  default: \"border-transparent bg-primary text-primary-foreground\",\n  secondary: \"border-transparent bg-foreground/[0.07] text-foreground\",\n  outline: \"border-border-strong bg-transparent text-foreground\",\n};\n\n// Status tints mix the token toward --foreground/--background (as `alert`\n// does), so the text keeps its contrast in both themes and every design style.\nconst SOFT: Record<\"destructive\" | \"success\" | \"warning\", string> = {\n  destructive:\n    \"border-[color-mix(in_oklab,var(--destructive)_30%,transparent)] bg-[color-mix(in_oklab,var(--destructive)_10%,var(--background))] text-[color-mix(in_oklab,var(--destructive)_78%,var(--foreground))]\",\n  success:\n    \"border-[color-mix(in_oklab,var(--success)_32%,transparent)] bg-[color-mix(in_oklab,var(--success)_11%,var(--background))] text-[color-mix(in_oklab,var(--success)_60%,var(--foreground))]\",\n  warning:\n    \"border-[color-mix(in_oklab,var(--warning)_40%,transparent)] bg-[color-mix(in_oklab,var(--warning)_13%,var(--background))] text-[color-mix(in_oklab,var(--warning)_52%,var(--foreground))]\",\n};\n\n// Filled status badges. Red reads best with light text in both themes;\n// green and amber are light hues, so they always get dark text.\nconst SOLID: Record<\"destructive\" | \"success\" | \"warning\", string> = {\n  destructive:\n    \"border-transparent bg-destructive text-background dark:text-foreground\",\n  success:\n    \"border-transparent bg-success text-[color-mix(in_oklab,var(--success)_22%,var(--foreground))] dark:text-[color-mix(in_oklab,var(--success)_22%,var(--background))]\",\n  warning:\n    \"border-transparent bg-warning text-[color-mix(in_oklab,var(--warning)_22%,var(--foreground))] dark:text-[color-mix(in_oklab,var(--warning)_22%,var(--background))]\",\n};\n\nconst SIZE: Record<BadgeSize, string> = {\n  sm: \"h-5 gap-1 px-1.5 text-[11px] [&_svg]:size-3\",\n  md: \"h-6 gap-1.5 px-2 text-xs [&_svg]:size-3.5\",\n  lg: \"h-7 gap-1.5 px-2.5 text-[13px] [&_svg]:size-3.5\",\n};\n\n// Count badges are circles at one digit and pills beyond.\nconst COUNT_SIZE: Record<BadgeSize, string> = {\n  sm: \"h-4 min-w-4 px-1 text-[10px]\",\n  md: \"h-5 min-w-5 px-1.5 text-[11px]\",\n  lg: \"h-6 min-w-6 px-1.5 text-xs\",\n};\n\nfunction variantClass(variant: BadgeVariant, tone: BadgeTone) {\n  if (variant === \"default\" || variant === \"secondary\" || variant === \"outline\") {\n    return BASE[variant];\n  }\n  return tone === \"solid\" ? SOLID[variant] : SOFT[variant];\n}\n\nexport interface BadgeProps {\n  children?: ReactNode;\n  variant?: BadgeVariant;\n  /** Status variants only (destructive/success/warning). Default \"soft\". */\n  tone?: BadgeTone;\n  size?: BadgeSize;\n  /** Leading status dot in the text color; `\"pulse\"` adds a radiating ring. */\n  dot?: boolean | \"pulse\";\n  /** Leading icon (sized to the badge). Ignored when `dot` is set. */\n  icon?: ReactNode;\n  /**\n   * Makes the badge removable: an × button plays the exit, then calls this.\n   * Focus moves to the neighbouring chip's × when the removal came from it.\n   */\n  onRemove?: () => void;\n  /** Accessible name of the × button. Default `Remove <text>`. */\n  removeLabel?: string;\n  /**\n   * Count mode: renders a compact number that rolls up or down as it changes\n   * and bumps when it rises. Hidden at 0 unless `showZero`.\n   */\n  count?: number;\n  /** Counts above this read \"max+\". Default 99. */\n  max?: number;\n  showZero?: boolean;\n  /** Spoken label for count mode, e.g. `(n) => \\`${n} unread\\``. */\n  countLabel?: (count: number) => string;\n  className?: string;\n}\n\n/**\n * General-purpose badge: six variants, three sizes, an optional (pulsing) dot\n * or icon, removable chips with an exit animation, and a count mode with\n * rolling digits capped at \"99+\". For animated status transitions\n * (loading → success) use `AnimatedBadge` instead.\n */\nexport function Badge(props: BadgeProps) {\n  if (props.count !== undefined) return <CountBadge {...props} count={props.count} />;\n  return <LabelBadge {...props} />;\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                 Label badge                                */\n/* -------------------------------------------------------------------------- */\n\nfunction LabelBadge({\n  children,\n  variant = \"default\",\n  tone = \"soft\",\n  size = \"md\",\n  dot = false,\n  icon,\n  onRemove,\n  removeLabel,\n  className,\n}: BadgeProps) {\n  const reduce = useReducedMotion() ?? false;\n  // Inside an AnimatePresence the badge pops in when added; anywhere else it\n  // renders as-is so server HTML is visible before hydration.\n  const inPresence = useContext(PresenceContext) !== null;\n  const rootRef = useRef<HTMLSpanElement>(null);\n  const [leaving, setLeaving] = useState(false);\n  const refocus = useRef<HTMLElement | null>(null);\n  const removable = Boolean(onRemove);\n\n  const hidden = reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8 };\n\n  const startRemove = (fromKeyboardFocus: boolean) => {\n    if (leaving) return;\n    if (fromKeyboardFocus) refocus.current = neighbourRemoveButton(rootRef.current);\n    setLeaving(true);\n  };\n\n  const finishRemove = () => {\n    if (!leaving) return;\n    const target = refocus.current;\n    refocus.current = null;\n    onRemove?.();\n    if (target) requestAnimationFrame(() => target.focus({ preventScroll: true }));\n  };\n\n  const text = typeof children === \"string\" ? children : undefined;\n\n  return (\n    <motion.span\n      ref={rootRef}\n      data-badge=\"\"\n      // Siblings glide into the gap a removed chip leaves (transform-only).\n      layout={removable && !reduce ? \"position\" : false}\n      initial={inPresence ? hidden : false}\n      animate={\n        leaving\n          ? { ...hidden, pointerEvents: \"none\" }\n          : { opacity: 1, scale: 1, pointerEvents: \"auto\" }\n      }\n      // Already faded out by the × — leave at once so siblings close the gap.\n      exit={{\n        ...hidden,\n        pointerEvents: \"none\",\n        transition: { duration: leaving ? 0 : 0.12, ease: EASE_OUT },\n      }}\n      transition={\n        leaving\n          ? { duration: 0.14, ease: EASE_OUT }\n          : reduce\n            ? { duration: 0.12 }\n            : { ...SPRING_PANEL, layout: SPRING_LAYOUT }\n      }\n      onAnimationComplete={finishRemove}\n      className={cn(\n        \"inline-flex shrink-0 select-none items-center whitespace-nowrap rounded-full border font-medium leading-none\",\n        SIZE[size],\n        variantClass(variant, tone),\n        removable && (size === \"sm\" ? \"pr-0.5\" : \"pr-1\"),\n        className,\n      )}\n    >\n      {dot ? (\n        <Dot pulse={dot === \"pulse\" && !reduce} />\n      ) : icon ? (\n        <span aria-hidden=\"true\" className=\"inline-flex shrink-0\">\n          {icon}\n        </span>\n      ) : null}\n      <span className=\"min-w-0 truncate\">{children}</span>\n      {removable ? (\n        <button\n          type=\"button\"\n          data-badge-remove=\"\"\n          aria-label={removeLabel ?? (text ? `Remove ${text}` : \"Remove\")}\n          onClick={(event) => startRemove(event.detail === 0)}\n          onKeyDown={(event: ReactKeyboardEvent<HTMLButtonElement>) => {\n            if (event.key === \"Backspace\" || event.key === \"Delete\") {\n              event.preventDefault();\n              startRemove(true);\n            }\n          }}\n          className={cn(\n            \"inline-flex shrink-0 items-center justify-center rounded-full opacity-70 outline-none transition-[opacity,background-color] hover:bg-current/15 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-current/40\",\n            size === \"sm\" ? \"size-4\" : \"size-[18px]\",\n          )}\n        >\n          <X aria-hidden=\"true\" strokeWidth={2.4} className=\"size-3!\" />\n        </button>\n      ) : null}\n    </motion.span>\n  );\n}\n\n/** The × of the next chip in the row, else the previous one. */\nfunction neighbourRemoveButton(node: HTMLElement | null) {\n  if (!node) return null;\n  const pick = (el: Element | null) =>\n    el?.querySelector<HTMLElement>(\"[data-badge-remove]\") ?? null;\n  let next = node.nextElementSibling;\n  while (next && !pick(next)) next = next.nextElementSibling;\n  if (next) return pick(next);\n  let prev = node.previousElementSibling;\n  while (prev && !pick(prev)) prev = prev.previousElementSibling;\n  return prev ? pick(prev) : null;\n}\n\n// One pulse cycle of the dot's radiating ring, in seconds. Slow on purpose:\n// an ambient \"live\" signal, not feedback, so it sits well outside UI timing.\nconst PULSE_DURATION = 1.6;\n\nfunction Dot({ pulse }: { pulse: boolean }) {\n  return (\n    <span aria-hidden=\"true\" className=\"relative inline-flex size-1.5 shrink-0\">\n      {pulse ? (\n        <motion.span\n          className=\"absolute inset-0 rounded-full bg-current\"\n          initial={{ scale: 1, opacity: 0.55 }}\n          animate={{ scale: 2.6, opacity: 0 }}\n          transition={{\n            duration: PULSE_DURATION,\n            ease: EASE_OUT,\n            repeat: Number.POSITIVE_INFINITY,\n          }}\n        />\n      ) : null}\n      <span className=\"relative size-1.5 rounded-full bg-current\" />\n    </span>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                 Count badge                                */\n/* -------------------------------------------------------------------------- */\n\nfunction CountBadge({\n  count,\n  max = 99,\n  showZero = false,\n  countLabel,\n  variant = \"destructive\",\n  tone = \"solid\",\n  size = \"md\",\n  className,\n}: BadgeProps & { count: number }) {\n  const reduce = useReducedMotion() ?? false;\n  const [scope, animate] = useAnimate<HTMLSpanElement>();\n  const value = Math.max(0, Math.round(count));\n  const visible = value > 0 || showZero;\n  const text = value > max ? `${max}+` : String(value);\n\n  // Direction is derived during render (not in an effect) so the very render\n  // that swaps the digits already rolls them the right way.\n  const [seen, setSeen] = useState(value);\n  const [direction, setDirection] = useState<1 | -1>(1);\n  if (value !== seen) {\n    setSeen(value);\n    setDirection(value > seen ? 1 : -1);\n  }\n\n  // Rising counts bump the pill (not on the pop-in from zero, which has its\n  // own entrance).\n  const previous = useRef(value);\n  useEffect(() => {\n    const prev = previous.current;\n    previous.current = value;\n    if (value > prev && prev > 0 && !reduce && scope.current) {\n      animate(scope.current, { scale: [1, 1.18, 1] }, { duration: 0.32, ease: EASE_OUT });\n    }\n  }, [value, reduce, animate, scope]);\n\n  return (\n    <AnimatePresence initial={false}>\n      {visible ? (\n        <motion.span\n          key=\"count\"\n          initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4 }}\n          animate={{ opacity: 1, scale: 1 }}\n          exit={\n            reduce\n              ? { opacity: 0, transition: { duration: 0.1 } }\n              : { opacity: 0, scale: 0.4, transition: { duration: 0.14, ease: EASE_OUT } }\n          }\n          transition={reduce ? { duration: 0.12 } : SPRING_PANEL}\n          className={cn(\"inline-flex shrink-0\", className)}\n        >\n          <span\n            ref={scope}\n            data-badge-count=\"\"\n            className={cn(\n              \"inline-flex items-center justify-center rounded-full border font-semibold leading-none tabular-nums\",\n              COUNT_SIZE[size],\n              variantClass(variant, tone),\n            )}\n          >\n            <span className=\"sr-only\">{countLabel ? countLabel(value) : text}</span>\n            <RollingCount text={text} direction={direction} reduce={reduce} />\n          </span>\n        </motion.span>\n      ) : null}\n    </AnimatePresence>\n  );\n}\n\nconst roll: Variants = {\n  enter: (dir: 1 | -1) => ({ y: dir > 0 ? \"100%\" : \"-100%\", opacity: 0 }),\n  center: { y: \"0%\", opacity: 1 },\n  exit: (dir: 1 | -1) => ({\n    y: dir > 0 ? \"-100%\" : \"100%\",\n    opacity: 0,\n    transition: { duration: 0.14, ease: EASE_OUT },\n  }),\n};\n\nconst fade: Variants = {\n  enter: { opacity: 0 },\n  center: { opacity: 1 },\n  exit: { opacity: 0, transition: { duration: 0.1 } },\n};\n\n/**\n * Direction-aware digit roll. `NumberTicker` spins each digit along a 0–9\n * strip, so 9 → 10 would roll the ones digit backwards; here every changed\n * glyph slides in from the direction of change instead.\n */\nfunction RollingCount({\n  text,\n  direction,\n  reduce,\n}: {\n  text: string;\n  direction: 1 | -1;\n  reduce: boolean;\n}) {\n  const chars = text.split(\"\");\n  // Keyed by place value from the right so a digit keeps its slot as the\n  // number grows on the left.\n  const glyphs = chars.map((char, i) => ({ char, place: chars.length - 1 - i }));\n  return (\n    <span aria-hidden=\"true\" className=\"inline-flex\">\n      {glyphs.map(({ char, place }) => (\n        <span\n          key={place}\n          className=\"relative inline-flex h-[1.2em] items-center justify-center overflow-hidden\"\n        >\n          <span className=\"invisible\">{char}</span>\n          <AnimatePresence initial={false} custom={direction}>\n            <motion.span\n              key={char}\n              custom={direction}\n              variants={reduce ? fade : roll}\n              initial=\"enter\"\n              animate=\"center\"\n              exit=\"exit\"\n              transition={reduce ? { duration: 0.12 } : SPRING_SWAP}\n              className=\"absolute inset-0 flex items-center justify-center\"\n            >\n              {char}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      ))}\n    </span>\n  );\n}\n"},{"path":"components/motion/collapsible.tsx","type":"registry:component","target":"@components/motion/collapsible.tsx","content":"\"use client\";\n\nimport { motion, useReducedMotion, type Transition } from \"motion/react\";\nimport {\n  cloneElement,\n  createContext,\n  forwardRef,\n  isValidElement,\n  useCallback,\n  useContext,\n  useId,\n  useState,\n  type ButtonHTMLAttributes,\n  type HTMLAttributes,\n  type MouseEvent,\n  type ReactElement,\n  type ReactNode,\n} from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\n// Collapsible is the unstyled disclosure primitive: one trigger, one panel.\n// `Accordion` (accordion.tsx) is built from it; `BouncyAccordion` is the\n// springy, single-open icon-row variant. Reach for this when a single region\n// should show or hide in flow (\"Show 3 more\", an expandable activity row).\n\n/** Entrance is a touch longer than the exit — exits should feel faster. */\nconst OPEN_TRANSITION: Transition = { duration: 0.28, ease: EASE_OUT };\nconst CLOSE_TRANSITION: Transition = { duration: 0.2, ease: EASE_OUT };\n\ninterface CollapsibleContextValue {\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  disabled: boolean;\n  triggerId: string;\n  contentId: string;\n}\n\nconst CollapsibleContext = createContext<CollapsibleContextValue | null>(null);\n\nexport function useCollapsible(component = \"useCollapsible\") {\n  const context = useContext(CollapsibleContext);\n  if (!context) {\n    throw new Error(`${component} must be used within <Collapsible>.`);\n  }\n  return context;\n}\n\nexport interface CollapsibleProps\n  extends Omit<HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n  /** Controlled open state. */\n  open?: boolean;\n  /** Initial open state when uncontrolled. */\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  disabled?: boolean;\n  /** Override the generated trigger id (the content is labelled by it). */\n  triggerId?: string;\n  /** Override the generated content id (the trigger controls it). */\n  contentId?: string;\n  children?: ReactNode;\n}\n\nexport const Collapsible = forwardRef<HTMLDivElement, CollapsibleProps>(\n  function Collapsible(\n    {\n      open: openProp,\n      defaultOpen = false,\n      onOpenChange,\n      disabled = false,\n      triggerId: triggerIdProp,\n      contentId: contentIdProp,\n      className,\n      children,\n      ...rest\n    },\n    ref,\n  ) {\n    const [internal, setInternal] = useState(defaultOpen);\n    const controlled = openProp !== undefined;\n    const open = controlled ? openProp : internal;\n    const setOpen = useCallback(\n      (next: boolean) => {\n        if (!controlled) setInternal(next);\n        onOpenChange?.(next);\n      },\n      [controlled, onOpenChange],\n    );\n    const baseId = useId();\n    const triggerId = triggerIdProp ?? `${baseId}-trigger`;\n    const contentId = contentIdProp ?? `${baseId}-content`;\n\n    return (\n      <CollapsibleContext.Provider\n        value={{ open, setOpen, disabled, triggerId, contentId }}\n      >\n        <div\n          ref={ref}\n          data-state={open ? \"open\" : \"closed\"}\n          data-disabled={disabled ? \"\" : undefined}\n          className={cn(className)}\n          {...rest}\n        >\n          {children}\n        </div>\n      </CollapsibleContext.Provider>\n    );\n  },\n);\n\ntype ClonableChild = ReactElement<{\n  onClick?: (event: MouseEvent<HTMLElement>) => void;\n  [key: string]: unknown;\n}>;\n\nexport interface CollapsibleTriggerProps\n  extends ButtonHTMLAttributes<HTMLButtonElement> {\n  /** Merge trigger behaviour onto the single child element instead of rendering a `<button>`. */\n  asChild?: boolean;\n}\n\n/** Toggles the nearest `Collapsible`. Unstyled `<button>` by default. */\nexport const CollapsibleTrigger = forwardRef<\n  HTMLButtonElement,\n  CollapsibleTriggerProps\n>(function CollapsibleTrigger(\n  { asChild, children, onClick, className, disabled: disabledProp, ...rest },\n  ref,\n) {\n  const context = useCollapsible(\"CollapsibleTrigger\");\n  const disabled = context.disabled || Boolean(disabledProp);\n  const shared = {\n    id: context.triggerId,\n    \"aria-expanded\": context.open,\n    \"aria-controls\": context.contentId,\n    \"data-state\": context.open ? \"open\" : \"closed\",\n    \"data-disabled\": disabled ? \"\" : undefined,\n  };\n\n  if (asChild && isValidElement(children)) {\n    const child = children as ClonableChild;\n    return cloneElement(child, {\n      ...shared,\n      onClick: (event: MouseEvent<HTMLElement>) => {\n        child.props.onClick?.(event);\n        if (!event.defaultPrevented && !disabled) context.setOpen(!context.open);\n      },\n    });\n  }\n\n  return (\n    <button\n      ref={ref}\n      type=\"button\"\n      disabled={disabled}\n      className={cn(className)}\n      {...rest}\n      {...shared}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) context.setOpen(!context.open);\n      }}\n    >\n      {children}\n    </button>\n  );\n});\n\nexport interface CollapsibleContentProps extends HTMLAttributes<HTMLDivElement> {\n  /** Class for the inner wrapper that holds the children (padding goes here,\n   * never on the animated outer element, so the closed height is truly 0). */\n  innerClassName?: string;\n  /** Set to give the panel `role=\"region\"` labelled by its trigger. */\n  region?: boolean;\n  children?: ReactNode;\n}\n\n/** The disclosed panel. Stays mounted (inert while closed) so `aria-controls` always resolves. */\nexport function CollapsibleContent({\n  className,\n  innerClassName,\n  region = false,\n  children,\n  style,\n  ...rest\n}: CollapsibleContentProps) {\n  const { open, triggerId, contentId } = useCollapsible(\"CollapsibleContent\");\n  const reduce = useReducedMotion();\n  const fade = open ? OPEN_TRANSITION : CLOSE_TRANSITION;\n\n  return (\n    // Height is animated on purpose: disclosure is a layout job — the panel\n    // has to push the content below it down in flow, which transforms can't.\n    // Reduced motion snaps the height and keeps only the opacity fade.\n    <motion.div\n      {...(rest as Record<string, unknown>)}\n      id={contentId}\n      role={region ? \"region\" : undefined}\n      aria-labelledby={region ? triggerId : undefined}\n      data-state={open ? \"open\" : \"closed\"}\n      inert={!open}\n      initial={false}\n      animate={{ height: open ? \"auto\" : 0, opacity: open ? 1 : 0 }}\n      transition={\n        reduce ? { height: { duration: 0 }, opacity: { duration: 0.15 } } : fade\n      }\n      style={style}\n      className={cn(\"overflow-hidden\", className)}\n    >\n      <div className={innerClassName}>{children}</div>\n    </motion.div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}