{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "comment-pin",
  "title": "Comment Pin",
  "description": "Figma-style annotation pins that spring in over any surface and expand into a threaded comment popover with a reply field.",
  "dependencies": ["framer-motion"],
  "registryDependencies": ["@godui/godui-theme"],
  "files": [
    {
      "path": "packages/components/src/comment-pin/comment-pin.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion } from \"framer-motion\";\nimport * as React from \"react\";\nimport { initials, presenceColor } from \"../lib/presence\";\n\nexport type Comment = {\n  id: string;\n  author: string;\n  avatar?: string;\n  body: string;\n  time?: string;\n};\n\nexport type CommentPinProps = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"onSubmit\"\n> & {\n  /** Horizontal position as a percentage of the container (0–100). */\n  x: number;\n  /** Vertical position as a percentage of the container (0–100). */\n  y: number;\n  /** Thread comments. */\n  comments?: Comment[];\n  /** Pin face label — defaults to the first author's initials, else a dot. */\n  label?: React.ReactNode;\n  color?: string;\n  resolved?: boolean;\n  defaultOpen?: boolean;\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Show a reply field and fire on submit. */\n  onReply?: (text: string) => void;\n};\n\nconst CommentPin = React.forwardRef<HTMLDivElement, CommentPinProps>(\n  (\n    {\n      x,\n      y,\n      comments = [],\n      label,\n      color,\n      resolved = false,\n      defaultOpen = false,\n      open: controlledOpen,\n      onOpenChange,\n      onReply,\n      className,\n      style,\n      ...props\n    },\n    ref,\n  ) => {\n    const isControlled = controlledOpen !== undefined;\n    const [internalOpen, setInternalOpen] = React.useState(defaultOpen);\n    const open = isControlled ? controlledOpen : internalOpen;\n    const [reply, setReply] = React.useState(\"\");\n\n    const setOpen = (next: boolean) => {\n      if (!isControlled) setInternalOpen(next);\n      onOpenChange?.(next);\n    };\n\n    const pinColor = color ?? presenceColor(comments[0]?.author ?? `${x},${y}`);\n    const face = label ?? (comments[0] ? initials(comments[0].author) : null);\n\n    return (\n      <div\n        ref={ref}\n        data-slot=\"comment-pin\"\n        data-resolved={resolved ? \"\" : undefined}\n        data-open={open ? \"\" : undefined}\n        className={`absolute z-raised ${className ?? \"\"}`}\n        style={{ left: `${x}%`, top: `${y}%`, ...style }}\n        {...props}\n      >\n        <motion.button\n          type=\"button\"\n          layout\n          initial={{ scale: 0 }}\n          animate={{ scale: resolved ? 0.85 : 1 }}\n          transition={{ type: \"spring\", stiffness: 520, damping: 32 }}\n          onClick={() => setOpen(!open)}\n          aria-label={resolved ? \"Resolved comment\" : \"Open comment thread\"}\n          aria-expanded={open}\n          className={`flex size-7 items-center justify-center rounded-full rounded-bl-sm text-xs font-semibold text-white shadow-md ring-2 ring-background transition-[filter,opacity] ${resolved ? \"opacity-60 grayscale\" : \"\"}`}\n          style={{ backgroundColor: pinColor }}\n        >\n          {face ?? <span className=\"size-1.5 rounded-full bg-white\" />}\n        </motion.button>\n\n        <AnimatePresence>\n          {open ? (\n            <motion.div\n              initial={{ opacity: 0, scale: 0.85, y: 4 }}\n              animate={{ opacity: 1, scale: 1, y: 0 }}\n              exit={{ opacity: 0, scale: 0.85, y: 4 }}\n              transition={{\n                type: \"spring\",\n                stiffness: 320,\n                damping: 32,\n                mass: 0.9,\n              }}\n              className=\"absolute left-0 top-9 z-popover w-72 origin-top-left overflow-hidden rounded-xl border border-border bg-popover shadow-xl\"\n            >\n              <div className=\"flex max-h-64 flex-col gap-3 overflow-y-auto p-3\">\n                {comments.length === 0 ? (\n                  <p className=\"text-sm text-muted-foreground\">\n                    No comments yet.\n                  </p>\n                ) : (\n                  comments.map((comment) => (\n                    <div key={comment.id} className=\"flex gap-2.5\">\n                      <span\n                        className=\"mt-0.5 flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-full text-[10px] font-semibold text-white\"\n                        style={{\n                          backgroundColor: presenceColor(comment.author),\n                        }}\n                      >\n                        {comment.avatar ? (\n                          <img\n                            src={comment.avatar}\n                            alt={comment.author}\n                            className=\"size-full object-cover\"\n                          />\n                        ) : (\n                          initials(comment.author)\n                        )}\n                      </span>\n                      <div className=\"min-w-0\">\n                        <div className=\"flex items-baseline gap-2\">\n                          <span className=\"truncate text-xs font-medium text-foreground\">\n                            {comment.author}\n                          </span>\n                          {comment.time ? (\n                            <span className=\"shrink-0 text-[10px] text-muted-foreground\">\n                              {comment.time}\n                            </span>\n                          ) : null}\n                        </div>\n                        <p className=\"text-sm leading-snug text-foreground [overflow-wrap:anywhere]\">\n                          {comment.body}\n                        </p>\n                      </div>\n                    </div>\n                  ))\n                )}\n              </div>\n              {onReply ? (\n                <form\n                  className=\"flex items-center gap-2 border-t border-border p-2\"\n                  onSubmit={(e) => {\n                    e.preventDefault();\n                    if (!reply.trim()) return;\n                    onReply(reply.trim());\n                    setReply(\"\");\n                  }}\n                >\n                  <input\n                    value={reply}\n                    onChange={(e) => setReply(e.target.value)}\n                    placeholder=\"Reply…\"\n                    aria-label=\"Reply\"\n                    className=\"min-w-0 flex-1 rounded-lg bg-muted px-2.5 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n                  />\n                  <button\n                    type=\"submit\"\n                    disabled={!reply.trim()}\n                    aria-label=\"Send reply\"\n                    className=\"inline-flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground transition-opacity hover:bg-primary/90 disabled:opacity-40\"\n                  >\n                    <SendIcon className=\"size-4\" />\n                  </button>\n                </form>\n              ) : null}\n            </motion.div>\n          ) : null}\n        </AnimatePresence>\n      </div>\n    );\n  },\n);\nCommentPin.displayName = \"CommentPin\";\n\nfunction SendIcon({ className }: { className?: string }) {\n  return (\n    <svg\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={2}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n      aria-hidden=\"true\"\n    >\n      <path d=\"M22 2 11 13M22 2l-7 20-4-9-9-4z\" />\n    </svg>\n  );\n}\n\nexport { CommentPin };\n",
      "type": "registry:ui",
      "target": "components/godui/comment-pin.tsx"
    },
    {
      "path": "packages/components/src/lib/presence.ts",
      "content": "/**\n * Shared per-user presence palette.\n *\n * Used by collaboration components (LiveCursors, PresenceFacepile, CommentPin)\n * so the same user is rendered in a stable, distinct color everywhere.\n */\n\n/** The presence color ramp — distinct, accessible hues in oklch. */\nexport const PRESENCE_COLORS = [\n  \"oklch(0.62 0.2 25)\", // red\n  \"oklch(0.7 0.17 60)\", // amber\n  \"oklch(0.72 0.16 145)\", // green\n  \"oklch(0.65 0.15 195)\", // teal\n  \"oklch(0.6 0.18 250)\", // blue\n  \"oklch(0.58 0.22 300)\", // violet\n  \"oklch(0.62 0.22 350)\", // pink\n  \"oklch(0.68 0.15 110)\", // lime\n] as const;\n\n/** Deterministic 32-bit hash of a string. */\nfunction hashString(value: string): number {\n  let hash = 0;\n  for (let i = 0; i < value.length; i++) {\n    hash = (hash << 5) - hash + value.charCodeAt(i);\n    hash |= 0;\n  }\n  return Math.abs(hash);\n}\n\n/**\n * Resolve a stable presence color for an identity (id, name, email, …).\n * The same key always maps to the same color.\n */\nexport function presenceColor(key: string): string {\n  return PRESENCE_COLORS[hashString(key) % PRESENCE_COLORS.length];\n}\n\n/** Two-letter initials from a display name. */\nexport function initials(name: string): string {\n  const parts = name.trim().split(/\\s+/).filter(Boolean);\n  if (parts.length === 0) return \"?\";\n  if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();\n  return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();\n}\n",
      "type": "registry:lib",
      "target": "components/godui/presence.ts"
    }
  ],
  "type": "registry:ui"
}
