{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "status-dot",
  "type": "registry:ui",
  "title": "Status Dot",
  "description": "Presence dot whose shape morphs between states, so color is never the only signal.",
  "categories": [
    "notifications"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/status-dot.tsx",
      "content": "\"use client\";\nimport { Tooltip } from \"@base-ui/react/tooltip\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\nexport type Status = \"online\" | \"away\" | \"busy\" | \"offline\";\ntype Size = \"sm\" | \"md\" | \"lg\";\n\nexport const statusLabels: Record<Status, string> = {\n  online: \"Online\",\n  away: \"Away\",\n  busy: \"Busy\",\n  offline: \"Offline\",\n};\n\nconst tones: Record<Status, string> = {\n  online: \"text-success\",\n  away: \"text-warning\",\n  busy: \"text-danger\",\n  offline: \"text-fg-3\",\n};\n\nconst px: Record<Size, number> = { sm: 8, md: 10, lg: 12 };\n\n// Each status has its own shape, cut out of a 12-unit disc, so it never rests\n// on color alone: online is solid, away a crescent, busy a bar, offline a ring.\n// Switching status morphs one shape into the next instead of swapping icons.\nconst cuts: Record<Status, { hole: number; moon: number; bar: number }> = {\n  online: { hole: 0, moon: 0, bar: 0 },\n  away: { hole: 0, moon: 4.1, bar: 0 },\n  busy: { hole: 0, moon: 0, bar: 6.4 },\n  offline: { hole: 3.1, moon: 0, bar: 0 },\n};\n\nexport type StatusDotProps = Omit<React.ComponentProps<\"span\">, \"children\"> & {\n  status: Status;\n  size?: Size;\n  /** A soft ring that keeps breathing out, for live states: in a call, recording, streaming. Pauses off screen. */\n  ping?: boolean;\n  /** Show the status word beside the dot. */\n  showLabel?: boolean;\n  /** Override the words, e.g. { busy: \"Do not disturb\" }. */\n  labels?: Partial<Record<Status, string>>;\n  /** Extra detail on hover, like \"Back at 2:30 PM\". Also read out as part of the accessible name. */\n  tooltip?: string;\n  /** Draw a cutout ring so the dot sits cleanly on an avatar. Color comes from --dot-cutout (default --frame). */\n  ring?: boolean;\n};\n\nexport function StatusDot({\n  status,\n  size = \"md\",\n  ping = false,\n  showLabel = false,\n  labels,\n  tooltip,\n  ring = false,\n  className,\n  ...rest\n}: StatusDotProps) {\n  const reduce = useReducedMotion();\n  const words = { ...statusLabels, ...labels };\n  const word = words[status];\n  const mask = `status-dot-${useId().replace(/[^a-zA-Z0-9-]/g, \"\")}`;\n  const [el, setEl] = useState<HTMLSpanElement | null>(null);\n  const [onScreen, setOnScreen] = useState(true);\n  const live = ping && status !== \"offline\";\n  const cut = cuts[status];\n  const d = px[size];\n  const morph = reduce ? { duration: 0 } : spring.snappy;\n\n  // A breathing ring off screen is wasted work; stop it until it scrolls back.\n  useEffect(() => {\n    if (!el || !live) return;\n    const io = new IntersectionObserver(([e]) => setOnScreen(e.isIntersecting));\n    io.observe(el);\n    return () => io.disconnect();\n  }, [el, live]);\n\n  const name = tooltip ? `${word}, ${tooltip}` : word;\n\n  const dot = (\n    <span\n      aria-hidden\n      className={cn(\"relative inline-grid shrink-0 place-items-center rounded-full transition-colors duration-200\", tones[status], ring && \"shadow-[0_0_0_2px_var(--dot-cutout,var(--frame))]\")}\n      style={{ width: d, height: d }}\n    >\n      {live && (\n        <span\n          className=\"absolute inset-0 animate-ping-soft rounded-full bg-current motion-reduce:hidden\"\n          style={{ animationPlayState: onScreen ? \"running\" : \"paused\" }}\n        />\n      )}\n      <svg width={d} height={d} viewBox=\"0 0 12 12\" className=\"relative block overflow-visible\">\n        <defs>\n          <mask id={mask} maskUnits=\"userSpaceOnUse\" x=\"-1\" y=\"-1\" width=\"14\" height=\"14\">\n            <rect x=\"-1\" y=\"-1\" width=\"14\" height=\"14\" fill=\"white\" />\n            <motion.circle cx=\"6\" cy=\"6\" fill=\"black\" initial={false} animate={{ r: cut.hole }} transition={morph} />\n            <motion.circle cx=\"3.1\" cy=\"3.1\" fill=\"black\" initial={false} animate={{ r: cut.moon }} transition={morph} />\n            <motion.rect y=\"4.9\" height=\"2.2\" rx=\"1.1\" fill=\"black\" initial={false} animate={{ width: cut.bar, x: 6 - cut.bar / 2 }} transition={morph} />\n          </mask>\n        </defs>\n        <circle cx=\"6\" cy=\"6\" r=\"6\" fill=\"currentColor\" mask={`url(#${mask})`} />\n      </svg>\n    </span>\n  );\n\n  const label = showLabel && (\n    // Every word shares one grid cell, so the row is as wide as the longest and never shifts.\n    <span className=\"inline-grid text-[12.5px] leading-none text-fg-2\">\n      {(Object.keys(words) as Status[]).map((s) => (\n        <span key={s} aria-hidden className=\"invisible col-start-1 row-start-1 whitespace-nowrap\">\n          {words[s]}\n        </span>\n      ))}\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span\n          key={status}\n          className=\"col-start-1 row-start-1 whitespace-nowrap\"\n          initial={reduce ? { opacity: 0 } : { opacity: 0, y: 5, filter: \"blur(2px)\" }}\n          animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n          exit={reduce ? { opacity: 0 } : { opacity: 0, y: -5, filter: \"blur(2px)\", transition: { duration: 0.12, ease: ease.in } }}\n          transition={{ duration: reduce ? 0.12 : 0.2, ease: ease.out }}\n        >\n          {word}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n\n  const root = (\n    <span\n      ref={setEl}\n      data-slot=\"status-dot\"\n      data-status={status}\n      data-size={size}\n      role={showLabel ? undefined : \"img\"}\n      aria-label={showLabel ? undefined : name}\n      className={cn(\"inline-flex items-center gap-1.5 align-middle\", className)}\n      {...rest}\n    >\n      {dot}\n      {label}\n      {showLabel && tooltip && <span className=\"sr-only\">, {tooltip}</span>}\n    </span>\n  );\n\n  if (!tooltip) return root;\n  return (\n    <Tooltip.Root>\n      <Tooltip.Trigger delay={400} render={root} />\n      <Tooltip.Portal>\n        <Tooltip.Positioner side=\"top\" sideOffset={6} className=\"z-(--z-tooltip)\">\n          <Tooltip.Popup\n            className={cn(\n              \"flex max-w-60 items-center gap-1.5 rounded-md border border-line-2 bg-raised px-2 py-1 text-[12px] text-fg-2 shadow-pop\",\n              \"origin-(--transform-origin) transition-[opacity,scale] duration-150 ease-out-expo\",\n              \"data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-ending-style:duration-100 data-instant:transition-none\",\n            )}\n          >\n            <span className=\"font-medium text-fg\">{word}</span>\n            <span aria-hidden className=\"text-fg-4\">·</span>\n            <span className=\"min-w-0 truncate\">{tooltip}</span>\n          </Tooltip.Popup>\n        </Tooltip.Positioner>\n      </Tooltip.Portal>\n    </Tooltip.Root>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/status-dot"
}
