{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "team-trophy-shelf",
  "title": "Team Trophy Shelf",
  "author": "ilinxa",
  "description": "Gallery of earned team badges with honest locked slots, an optional count header, and a brief skippable reveal for new badges.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "tooltip",
    "badge",
    "separator",
    "@ilinxa/gamification-kit"
  ],
  "files": [
    {
      "path": "src/registry/components/gamification/team-trophy-shelf/team-trophy-shelf.tsx",
      "content": "\"use client\";\n\nimport { useTeamTrophyShelf } from \"./hooks/use-team-trophy-shelf\";\nimport { TeamTrophyShelfEmpty } from \"./parts/team-trophy-shelf-empty\";\nimport { TeamTrophyShelfGrid } from \"./parts/team-trophy-shelf-grid\";\nimport { TeamTrophyShelfHeader } from \"./parts/team-trophy-shelf-header\";\nimport { TeamTrophyShelfRoot } from \"./parts/team-trophy-shelf-root\";\nimport type { TeamTrophyShelfProps } from \"./types\";\n\n/** Emptiness gate — routes to Grid or the designed Empty state. Reads context only. */\nfunction TeamTrophyShelfBody() {\n  const { slots } = useTeamTrophyShelf();\n  return slots.length === 0 ? <TeamTrophyShelfEmpty /> : <TeamTrophyShelfGrid />;\n}\n\n/**\n * Tier A — the batteries-included assembly: `Root` + `showHeader`-gated `Header`\n * + (`Grid` | `Empty`). Contains **no logic the parts don't** — a hand-assembled\n * layout (`TeamTrophyShelfRoot` + a subset of parts) behaves identically.\n *\n * A durable gallery of ONE team's earned milestone badges, honest locked slots for\n * what's ahead, and a brief non-blocking reveal when a badge is newly earned. No\n * ranking, no per-member split, no comparison — ever (system §5.3 / D-08).\n */\nexport function TeamTrophyShelf({\n  badges,\n  team,\n  showLocked = true,\n  showHeader = true,\n  title,\n  size = \"md\",\n  animateAward = true,\n  onEvent,\n  onBadgeOpen,\n  renderBadgeIcon,\n  className,\n  \"aria-label\": ariaLabel,\n}: TeamTrophyShelfProps) {\n  return (\n    <TeamTrophyShelfRoot\n      badges={badges}\n      team={team}\n      showLocked={showLocked}\n      title={title}\n      size={size}\n      animateAward={animateAward}\n      onEvent={onEvent}\n      onBadgeOpen={onBadgeOpen}\n      renderBadgeIcon={renderBadgeIcon}\n      className={className}\n      aria-label={ariaLabel}\n    >\n      {showHeader ? <TeamTrophyShelfHeader /> : null}\n      <TeamTrophyShelfBody />\n    </TeamTrophyShelfRoot>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/team-trophy-shelf/team-trophy-shelf.tsx"
    },
    {
      "path": "src/registry/components/gamification/team-trophy-shelf/index.ts",
      "content": "// Tier A — batteries-included assembly.\nexport { TeamTrophyShelf } from \"./team-trophy-shelf\";\n\n// Tier B — headless provider + flat à-la-carte parts.\nexport { TeamTrophyShelfRoot } from \"./parts/team-trophy-shelf-root\";\nexport { TeamTrophyShelfGrid } from \"./parts/team-trophy-shelf-grid\";\nexport { TeamTrophyShelfHeader } from \"./parts/team-trophy-shelf-header\";\nexport { TeamTrophyShelfEmpty } from \"./parts/team-trophy-shelf-empty\";\n\n// Tier C — context-free primitives. The award overlay is the lazy-chunk host;\n// the Grid pulls it via React.lazy, so importing it here for standalone use does\n// not force it into a bare-token consumer's bundle (unused re-exports tree-shake).\nexport { TeamMilestoneBadge } from \"./parts/team-milestone-badge\";\nexport { default as BadgeAwardOverlay } from \"./parts/badge-award-overlay\";\n\n// Context consumer for hand-assembled layouts.\nexport { useTeamTrophyShelf } from \"./hooks/use-team-trophy-shelf\";\n\n// Public types.\nexport type {\n  TeamTrophyShelfProps,\n  TeamTrophyShelfRootProps,\n  TeamTrophyShelfBaseProps,\n  TeamTrophyShelfContextValue,\n  TeamMilestoneBadgeProps,\n  BadgeAwardOverlayProps,\n  Badge,\n  Team,\n  BadgeSize,\n  GamificationEvent,\n} from \"./types\";\n",
      "type": "registry:component",
      "target": "components/team-trophy-shelf/index.ts"
    },
    {
      "path": "src/registry/components/gamification/team-trophy-shelf/types.ts",
      "content": "import type { ReactNode } from \"react\";\n\n/**\n * `team-trophy-shelf` — public type surface.\n *\n * Framework-free (no `\"use client\"`): importable from a server component's type\n * position. Per system D-03 every type is **re-declared locally** — this\n * component imports nothing from another registry component. The\n * `gamification-system` description (§4 domain model, §6 telemetry union) is the\n * source of truth these slices are copied from, not imported.\n */\n\n/**\n * A team milestone trophy. `awardedAt` is the **single discriminator** of\n * earned vs locked: present → earned; absent → a not-yet-earned (locked) slot.\n */\nexport interface Badge {\n  id: string;\n  /** Badge label — e.g. \"First playable build\". Truncates; full label in tooltip. */\n  label: string;\n  /** ISO 8601 award time. `undefined` → NOT yet earned (locked slot). */\n  awardedAt?: string;\n  /** The milestone that earned it (shared spine, D-09). Optional link target. */\n  milestoneId?: string;\n}\n\n/**\n * This team — identity subset (system D-15). `id` scopes telemetry; `name` is an\n * OPTIONAL header-title fallback (the name only feeds an optional title, so the\n * object stays `{ id; name? }`).\n */\nexport interface Team {\n  id: string;\n  name?: string;\n}\n\n/**\n * The system §6 union narrowed to this component's emit surface. `badgeId` is\n * present only on the per-badge \"opened\" event.\n */\nexport type GamificationEvent = {\n  type: \"badges.viewed\";\n  teamId: string;\n  badgeId?: string;\n};\n\nexport type BadgeSize = \"sm\" | \"md\";\n\n/** Data + telemetry surface shared by the assembly and the headless `Root`. */\nexport interface TeamTrophyShelfBaseProps {\n  /** This team's badges — earned AND (optionally) not-yet-earned slots. */\n  badges: Badge[];\n  /** The owning team (telemetry teamId + optional header-title fallback). D-15. */\n  team: Team;\n  /** Render not-yet-earned badges as locked slots. Default `true`. */\n  showLocked?: boolean;\n  /** Override the header title; defaults to `${team.name} trophies` / \"Team trophies\". */\n  title?: string;\n  /** Token size. Default `\"md\"`. */\n  size?: BadgeSize;\n  /**\n   * Animate newly-earned badges (diff vs previous render). Default `true`.\n   *\n   * **D-16 (celebration ownership):** a host that routes badge/milestone events to\n   * `team-feedback-loop` (E6) MUST set this `false` so the moment is not\n   * celebrated twice. Neither component triggers the other; the host wires exactly\n   * one path per event kind.\n   */\n  animateAward?: boolean;\n  /** Telemetry — `badges.viewed` (system D-07 / §6). */\n  onEvent?: (event: GamificationEvent) => void;\n  /** Fired when a badge is opened/inspected; the consumer wires what opens. */\n  onBadgeOpen?: (badge: Badge) => void;\n  /** Custom badge artwork; falls back to a lucide glyph. */\n  renderBadgeIcon?: (badge: Badge) => ReactNode;\n  className?: string;\n  \"aria-label\"?: string;\n}\n\n/** Props for the batteries-included `TeamTrophyShelf` assembly. */\nexport interface TeamTrophyShelfProps extends TeamTrophyShelfBaseProps {\n  /** Render the header (title + earned count). Default `true`. */\n  showHeader?: boolean;\n}\n\n/** Props for the headless provider `TeamTrophyShelfRoot`. */\nexport interface TeamTrophyShelfRootProps extends TeamTrophyShelfBaseProps {\n  children: ReactNode;\n}\n\n/** Props for the context-free Tier-C `TeamMilestoneBadge` token. */\nexport interface TeamMilestoneBadgeProps {\n  badge: Badge;\n  size?: BadgeSize;\n  /** Render the locked state when not earned. Default `true`. */\n  showLocked?: boolean;\n  onOpen?: (badge: Badge) => void;\n  renderIcon?: (badge: Badge) => ReactNode;\n  className?: string;\n}\n\n/** Props for the lazy Tier-C `BadgeAwardOverlay`. */\nexport interface BadgeAwardOverlayProps {\n  /** The settling token to wrap. */\n  children: ReactNode;\n  /** Run the burst (the Grid passes true only for a newly-earned id). Default `false`. */\n  active?: boolean;\n  /** Called when the < 1s reveal completes (for chaining / cleanup). */\n  onDone?: () => void;\n  className?: string;\n}\n\n/** The resolved state every context part reads — one source of truth. */\nexport interface TeamTrophyShelfContextValue {\n  /** Resolved list (earned + optional locked), in host order. */\n  slots: Badge[];\n  team: Team;\n  title?: string;\n  size: BadgeSize;\n  showLocked: boolean;\n  animateAward: boolean;\n  /** IDs of badges earned since the previous render. */\n  newAwards: ReadonlySet<string>;\n  earnedCount: number;\n  totalCount: number;\n  /** True when `onBadgeOpen` was supplied — tokens become interactive buttons. */\n  isInteractive: boolean;\n  /** Open/inspect a badge (fires telemetry + `onBadgeOpen`). */\n  openBadge: (badge: Badge) => void;\n  /** Clear a badge from `newAwards` once its reveal completes. */\n  onAwardDone: (badgeId: string) => void;\n  renderBadgeIcon?: (badge: Badge) => ReactNode;\n}\n",
      "type": "registry:component",
      "target": "components/team-trophy-shelf/types.ts"
    },
    {
      "path": "src/registry/components/gamification/team-trophy-shelf/lib/resolve.ts",
      "content": "import type { Badge } from \"../types\";\n\n/**\n * Earned-vs-locked resolution — pure, SSR-deterministic (derives only from\n * props; no `Date.now()`, no layout). `awardedAt` is the single discriminator.\n * Kit-extraction candidate (system §7.3) — keep standalone.\n */\n\nexport function isEarned(badge: Badge): boolean {\n  return badge.awardedAt != null;\n}\n\nexport function isLocked(badge: Badge): boolean {\n  return badge.awardedAt == null;\n}\n\n/** Apply `showLocked`: drop not-yet-earned badges from the rendered list when false. */\nexport function resolveSlots(badges: Badge[], showLocked: boolean): Badge[] {\n  return showLocked ? badges : badges.filter(isEarned);\n}\n\nexport function earnedCount(badges: Badge[]): number {\n  return badges.filter(isEarned).length;\n}\n\nexport function totalCount(badges: Badge[]): number {\n  return badges.length;\n}\n",
      "type": "registry:component",
      "target": "components/team-trophy-shelf/lib/resolve.ts"
    },
    {
      "path": "src/registry/components/gamification/team-trophy-shelf/lib/diff.ts",
      "content": "import { isEarned } from \"./resolve\";\nimport type { Badge } from \"../types\";\n\n/**\n * The newly-earned diff — pure. Given the current badges and a map of the\n * previous render's badges by id, returns the set of ids whose `awardedAt` went\n * absent → present (i.e. just earned).\n *\n * SSR safety is achieved by the caller, not a `mounted` flag: the Root seeds its\n * \"previous\" from the FIRST render's badges (via `useState`), so on the initial\n * (and hydration) render `prev === current` for every id → the set is empty →\n * nothing animates on load. The reveal only appears after a subsequent controlled\n * `badges` update flips a badge's `awardedAt` on (exactly the host action D-d\n * describes). This avoids a `set-state-in-effect` mounted flag (which the repo's\n * lint rule forbids) while staying hydration-safe.\n */\nexport function newlyEarned(\n  current: Badge[],\n  prevById: Map<string, Badge>,\n): Set<string> {\n  const result = new Set<string>();\n  for (const badge of current) {\n    if (isEarned(badge) && prevById.get(badge.id)?.awardedAt == null) {\n      result.add(badge.id);\n    }\n  }\n  return result;\n}\n\nexport function indexById(badges: Badge[]): Map<string, Badge> {\n  return new Map(badges.map((badge) => [badge.id, badge]));\n}\n",
      "type": "registry:component",
      "target": "components/team-trophy-shelf/lib/diff.ts"
    },
    {
      "path": "src/registry/components/gamification/team-trophy-shelf/hooks/use-team-trophy-shelf.ts",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport type { TeamTrophyShelfContextValue } from \"../types\";\n\nexport const TeamTrophyShelfContext =\n  React.createContext<TeamTrophyShelfContextValue | null>(null);\n\n/**\n * Read the resolved shelf state from a surrounding `TeamTrophyShelfRoot`.\n * For hand-assembled layouts; throws if used outside a `Root`.\n */\nexport function useTeamTrophyShelf(): TeamTrophyShelfContextValue {\n  const ctx = React.useContext(TeamTrophyShelfContext);\n  if (ctx === null) {\n    throw new Error(\n      \"useTeamTrophyShelf must be used within a <TeamTrophyShelfRoot>.\",\n    );\n  }\n  return ctx;\n}\n",
      "type": "registry:component",
      "target": "components/team-trophy-shelf/hooks/use-team-trophy-shelf.ts"
    },
    {
      "path": "src/registry/components/gamification/team-trophy-shelf/parts/team-trophy-shelf-root.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { useLatestRef } from \"../../_shared/gamification-kit\";\nimport { TeamTrophyShelfContext } from \"../hooks/use-team-trophy-shelf\";\nimport { indexById, newlyEarned } from \"../lib/diff\";\nimport { earnedCount, resolveSlots, totalCount } from \"../lib/resolve\";\nimport type {\n  Badge,\n  TeamTrophyShelfContextValue,\n  TeamTrophyShelfRootProps,\n} from \"../types\";\n\nconst EMPTY_AWARDS: ReadonlySet<string> = new Set();\n\n/**\n * Tier B — headless provider. Owns ALL state: the resolved slot list, the\n * **newly-earned diff** (SSR-safe), the `badges.viewed` telemetry, and the\n * open-badge callback path. Renders `children`. No layout opinion.\n *\n * The diff uses React's \"storing information from previous renders\" pattern\n * (setState **during render**, not in an effect): `prevBadges` is seeded from the\n * first render's `badges`, so the initial/hydration render sees `prev === current`\n * → no award animates on load. A later controlled `badges` update that flips a\n * badge's `awardedAt` on computes exactly one fresh award set. This avoids a\n * `set-state-in-effect` mounted flag (forbidden by the repo lint rule) and never\n * reads a ref during render.\n */\nexport function TeamTrophyShelfRoot({\n  badges,\n  team,\n  showLocked = true,\n  title,\n  size = \"md\",\n  animateAward = true,\n  onEvent,\n  onBadgeOpen,\n  renderBadgeIcon,\n  className,\n  \"aria-label\": ariaLabel,\n  children,\n}: TeamTrophyShelfRootProps) {\n  // --- SSR-safe newly-earned diff (setState during render) ---\n  const [prevBadges, setPrevBadges] = React.useState<Badge[]>(badges);\n  const [newAwards, setNewAwards] = React.useState<ReadonlySet<string>>(EMPTY_AWARDS);\n\n  if (badges !== prevBadges) {\n    setPrevBadges(badges);\n    if (!animateAward) {\n      setNewAwards(EMPTY_AWARDS);\n    } else {\n      // v0.1.2 (review §6 medium) — UNION the fresh diff with the ids still\n      // mid-reveal instead of replacing wholesale: a second `badges` update\n      // during a reveal (e.g. two awards landing in quick succession) used to\n      // cancel the first badge's animation. `onAwardDone` retires each id\n      // individually, so still-pending ids simply finish their reveal.\n      const fresh = newlyEarned(badges, indexById(prevBadges));\n      setNewAwards((pending) => {\n        if (fresh.size === 0) return pending;\n        const next = new Set(pending);\n        for (const id of fresh) next.add(id);\n        return next;\n      });\n    }\n  }\n\n  // --- Derived (pure) ---\n  const slots = React.useMemo(\n    () => resolveSlots(badges, showLocked),\n    [badges, showLocked],\n  );\n  const earned = React.useMemo(() => earnedCount(badges), [badges]);\n  const total = React.useMemo(() => totalCount(badges), [badges]);\n\n  // --- Telemetry: badges.viewed (no id) once on first view ---\n  const firedView = React.useRef(false);\n  const onEventRef = useLatestRef(onEvent);\n  const hasEventHandler = Boolean(onEvent);\n  React.useEffect(() => {\n    if (firedView.current || !hasEventHandler) return;\n    firedView.current = true;\n    onEventRef.current?.({ type: \"badges.viewed\", teamId: team.id });\n    // `onEventRef` is a stable ref object — listed for exhaustive-deps only.\n  }, [team.id, hasEventHandler, onEventRef]);\n\n  // --- Interaction ---\n  const onBadgeOpenRef = useLatestRef(onBadgeOpen);\n  const isInteractive = Boolean(onBadgeOpen);\n\n  const openBadge = React.useCallback(\n    (badge: Badge) => {\n      onEventRef.current?.({\n        type: \"badges.viewed\",\n        teamId: team.id,\n        badgeId: badge.id,\n      });\n      onBadgeOpenRef.current?.(badge);\n    },\n    // `onBadgeOpenRef`/`onEventRef` are stable ref objects — listed for\n    // exhaustive-deps only.\n    [team.id, onBadgeOpenRef, onEventRef],\n  );\n\n  const onAwardDone = React.useCallback((badgeId: string) => {\n    setNewAwards((prev) => {\n      if (!prev.has(badgeId)) return prev;\n      const next = new Set(prev);\n      next.delete(badgeId);\n      return next;\n    });\n  }, []);\n\n  const contextValue = React.useMemo<TeamTrophyShelfContextValue>(\n    () => ({\n      slots,\n      team,\n      title,\n      size,\n      showLocked,\n      animateAward,\n      newAwards,\n      earnedCount: earned,\n      totalCount: total,\n      isInteractive,\n      openBadge,\n      onAwardDone,\n      renderBadgeIcon,\n    }),\n    [\n      slots,\n      team,\n      title,\n      size,\n      showLocked,\n      animateAward,\n      newAwards,\n      earned,\n      total,\n      isInteractive,\n      openBadge,\n      onAwardDone,\n      renderBadgeIcon,\n    ],\n  );\n\n  return (\n    <TeamTrophyShelfContext.Provider value={contextValue}>\n      <section\n        className={cn(\"flex w-full flex-col gap-3\", className)}\n        aria-label={ariaLabel ?? \"Team trophies\"}\n      >\n        {children}\n      </section>\n    </TeamTrophyShelfContext.Provider>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/team-trophy-shelf/parts/team-trophy-shelf-root.tsx"
    },
    {
      "path": "src/registry/components/gamification/team-trophy-shelf/parts/team-trophy-shelf-grid.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { useTeamTrophyShelf } from \"../hooks/use-team-trophy-shelf\";\nimport { TeamMilestoneBadge } from \"./team-milestone-badge\";\n\n// The ONLY reference to the overlay is this lazy import — its chunk loads solely\n// when a newly-earned badge renders (never for the bare-token path or\n// `animateAward={false}`). See the compound rule's lazy-boundary requirement.\nconst BadgeAwardOverlay = React.lazy(() => import(\"./badge-award-overlay\"));\n\n/**\n * Tier B — the responsive shelf grid (`role=\"list\"`). Reads the resolved slots +\n * per-badge new-award flags from context, staggers a `reveal-up` entrance across\n * tokens, applies a cheap CSS \"pop\" to newly-earned tokens (works without the\n * lazy chunk), and wraps them in the lazy `BadgeAwardOverlay` for the richer burst.\n */\nexport function TeamTrophyShelfGrid({ className }: { className?: string }) {\n  const {\n    slots,\n    size,\n    showLocked,\n    animateAward,\n    newAwards,\n    isInteractive,\n    openBadge,\n    onAwardDone,\n    renderBadgeIcon,\n  } = useTeamTrophyShelf();\n\n  return (\n    <ul role=\"list\" className={cn(\"flex flex-wrap gap-4\", className)}>\n      {slots.map((badge, i) => {\n        const isNew = animateAward && newAwards.has(badge.id);\n        const token = (\n          <TeamMilestoneBadge\n            badge={badge}\n            size={size}\n            showLocked={showLocked}\n            onOpen={isInteractive ? openBadge : undefined}\n            renderIcon={renderBadgeIcon}\n            className={\n              isNew\n                ? \"motion-safe:animate-in motion-safe:zoom-in-50 motion-safe:duration-500\"\n                : undefined\n            }\n          />\n        );\n\n        return (\n          <li\n            key={badge.id}\n            role=\"listitem\"\n            className=\"reveal-up\"\n            style={{ animationDelay: `${i * 60}ms` }}\n          >\n            {isNew ? (\n              <React.Suspense fallback={token}>\n                <BadgeAwardOverlay active onDone={() => onAwardDone(badge.id)}>\n                  {token}\n                </BadgeAwardOverlay>\n              </React.Suspense>\n            ) : (\n              token\n            )}\n          </li>\n        );\n      })}\n    </ul>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/team-trophy-shelf/parts/team-trophy-shelf-grid.tsx"
    },
    {
      "path": "src/registry/components/gamification/team-trophy-shelf/parts/team-trophy-shelf-header.tsx",
      "content": "\"use client\";\n\nimport { Badge } from \"@/components/ui/badge\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { cn } from \"@/lib/utils\";\n\nimport { useTeamTrophyShelf } from \"../hooks/use-team-trophy-shelf\";\n\n/**\n * Tier B — the optional header: the shelf title + an earned-count pill\n * (\"4 / 9\"). The count is \"N of THIS team's M\", never a rank or comparison.\n */\nexport function TeamTrophyShelfHeader({ className }: { className?: string }) {\n  const { team, title, earnedCount, totalCount } = useTeamTrophyShelf();\n\n  const heading =\n    title ?? (team.name ? `${team.name} trophies` : \"Team trophies\");\n\n  return (\n    <div className={cn(\"flex items-center gap-3\", className)}>\n      <h3 className=\"text-sm font-semibold text-foreground\">{heading}</h3>\n      <Separator orientation=\"vertical\" className=\"h-4\" />\n      <Badge variant=\"secondary\" className=\"font-mono tabular-nums\">\n        {earnedCount} / {totalCount}\n      </Badge>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/team-trophy-shelf/parts/team-trophy-shelf-header.tsx"
    },
    {
      "path": "src/registry/components/gamification/team-trophy-shelf/parts/team-trophy-shelf-empty.tsx",
      "content": "\"use client\";\n\nimport { Trophy } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { useTeamTrophyShelf } from \"../hooks/use-team-trophy-shelf\";\n\n/**\n * Tier B — the designed empty state (no badges defined, or none earned with\n * `showLocked={false}`). Encouraging, Competence-positive — never an error or a\n * dead panel. Renders only when the resolved slot list is empty.\n */\nexport function TeamTrophyShelfEmpty({ className }: { className?: string }) {\n  // Subscribe so the part throws if mis-mounted outside a Root (consistency).\n  useTeamTrophyShelf();\n\n  return (\n    <div\n      className={cn(\n        \"flex flex-col items-center gap-2 rounded-xl border border-dashed border-border bg-muted/30 px-6 py-10 text-center\",\n        className,\n      )}\n    >\n      <Trophy className=\"size-8 text-muted-foreground/60\" aria-hidden />\n      <p className=\"text-sm font-medium text-foreground\">No trophies yet</p>\n      <p className=\"max-w-xs text-xs text-muted-foreground\">\n        Milestones your team completes will land here.\n      </p>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/team-trophy-shelf/parts/team-trophy-shelf-empty.tsx"
    },
    {
      "path": "src/registry/components/gamification/team-trophy-shelf/parts/team-milestone-badge.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { Lock, Trophy } from \"lucide-react\";\n\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\n\nimport { isEarned } from \"../lib/resolve\";\nimport type { TeamMilestoneBadgeProps } from \"../types\";\n\nconst MONTHS = [\n  \"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\",\n  \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\",\n];\n\n/**\n * Deterministic, locale/TZ-independent date format (UTC parts + fixed month\n * names) so the awarded-date reads identically on server + client — no hydration\n * mismatch. Returns null for an unparseable `awardedAt`.\n */\nfunction formatAwardedDate(iso: string): string | null {\n  const d = new Date(iso);\n  if (Number.isNaN(d.getTime())) return null;\n  return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`;\n}\n\n/**\n * Tier C — the load-bearing standalone token. Earned (lime medallion + glyph +\n * awarded-date tooltip) vs locked (desaturated, dashed, `Lock` glyph). Usable\n * anywhere with zero shelf scaffolding. Interactive only when `onOpen` is passed\n * (a real `<button>`); otherwise a non-focusable `role=\"img\"` (no dead affordances).\n * State is conveyed by glyph + text, never color alone.\n */\nexport function TeamMilestoneBadge({\n  badge,\n  size = \"md\",\n  showLocked = true,\n  onOpen,\n  renderIcon,\n  className,\n}: TeamMilestoneBadgeProps) {\n  const earned = isEarned(badge);\n  if (!earned && !showLocked) return null;\n\n  const awardedText =\n    earned && badge.awardedAt ? formatAwardedDate(badge.awardedAt) : null;\n  const ariaLabel = earned\n    ? `${badge.label} — earned${awardedText ? ` ${awardedText}` : \"\"}`\n    : `${badge.label} — locked`;\n\n  const interactive = typeof onOpen === \"function\";\n  const medallionSize = size === \"sm\" ? \"size-10\" : \"size-16\";\n  const glyphSize = size === \"sm\" ? \"size-4\" : \"size-6\";\n\n  const icon = renderIcon ? (\n    renderIcon(badge)\n  ) : earned ? (\n    <Trophy className={glyphSize} aria-hidden />\n  ) : (\n    <Lock className={glyphSize} aria-hidden />\n  );\n\n  const inner = (\n    <>\n      <span\n        className={cn(\n          \"flex items-center justify-center rounded-2xl transition-[transform,box-shadow,background-color,border-color] duration-200 ease-out\",\n          medallionSize,\n          earned\n            ? \"bg-primary text-primary-foreground shadow-sm group-hover:shadow-md group-hover:shadow-primary/25 motion-safe:group-hover:-translate-y-0.5\"\n            : \"border border-dashed border-border bg-muted text-muted-foreground group-hover:border-solid group-hover:bg-muted/70 motion-safe:group-hover:-translate-y-0.5\",\n        )}\n      >\n        {icon}\n      </span>\n      <span\n        className={cn(\n          // Fixed 2-line footprint so cells stay uniform regardless of name length.\n          \"line-clamp-2 min-h-8 w-full wrap-break-word text-center text-xs leading-tight transition-colors\",\n          earned\n            ? \"text-foreground/90 group-hover:text-foreground\"\n            : \"text-muted-foreground\",\n        )}\n      >\n        {badge.label}\n      </span>\n    </>\n  );\n\n  // Fixed, size-driven cell width → every token occupies the same footprint, so\n  // the shelf grid keeps an even rhythm no matter how long the label is.\n  const wrapperCls = cn(\n    \"group inline-flex w-24 flex-col items-center gap-1.5 rounded-2xl p-1 outline-none\",\n    size === \"sm\" && \"w-20\",\n    interactive &&\n      \"cursor-pointer focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n    className,\n  );\n\n  // Awarded-date tooltip only for earned badges (client-only render → SSR-safe).\n  // F-cross-13: no `asChild`. Interactive token → the TooltipTrigger IS the\n  // <button>. Passive token → a <button> trigger would be a dead affordance, so\n  // a local hover-only CSS tip stands in (the date already lives in aria-label).\n  if (earned && awardedText && interactive) {\n    return (\n      <TooltipProvider>\n        <Tooltip>\n          <TooltipTrigger\n            type=\"button\"\n            onClick={() => onOpen?.(badge)}\n            aria-label={ariaLabel}\n            className={wrapperCls}\n          >\n            {inner}\n          </TooltipTrigger>\n          <TooltipContent>Earned {awardedText}</TooltipContent>\n        </Tooltip>\n      </TooltipProvider>\n    );\n  }\n\n  const token = interactive ? (\n    <button\n      type=\"button\"\n      onClick={() => onOpen?.(badge)}\n      aria-label={ariaLabel}\n      className={wrapperCls}\n    >\n      {inner}\n    </button>\n  ) : (\n    <span role=\"img\" aria-label={ariaLabel} title={badge.label} className={wrapperCls}>\n      {inner}\n    </span>\n  );\n\n  if (earned && awardedText) {\n    return (\n      <span className=\"group/tip relative inline-flex\">\n        {token}\n        <span\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute bottom-full left-1/2 z-50 mb-1.5 hidden -translate-x-1/2 whitespace-nowrap rounded-md bg-foreground px-3 py-1.5 text-xs text-background group-hover/tip:inline-flex\"\n        >\n          Earned {awardedText}\n        </span>\n      </span>\n    );\n  }\n\n  return token;\n}\n",
      "type": "registry:component",
      "target": "components/team-trophy-shelf/parts/team-milestone-badge.tsx"
    },
    {
      "path": "src/registry/components/gamification/team-trophy-shelf/parts/badge-award-overlay.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { useLatestRef } from \"../../_shared/gamification-kit\";\nimport type { BadgeAwardOverlayProps } from \"../types\";\n\n/**\n * Tier C (lazy) — the brief (< 1s), non-blocking award reveal. Wraps the settling\n * token and layers a hand-rolled spark burst over it (zero npm animation dep —\n * built from Tailwind's `animate-ping`). The burst is `aria-hidden`, never traps\n * or steals focus, never blocks input/scroll (D-10), and is `motion-reduce:hidden`\n * so `prefers-reduced-motion` skips straight to the settled token.\n *\n * DEFAULT export so the Grid can pull it via `React.lazy(() => import(...))` — this\n * is the ONLY weight-bearing module; the bare-token path and `animateAward={false}`\n * never load its chunk.\n */\nexport default function BadgeAwardOverlay({\n  children,\n  active = false,\n  onDone,\n  className,\n}: BadgeAwardOverlayProps) {\n  const onDoneRef = useLatestRef(onDone);\n\n  React.useEffect(() => {\n    if (!active) return;\n    const timer = setTimeout(() => onDoneRef.current?.(), 900);\n    return () => clearTimeout(timer);\n    // `onDoneRef` is a stable ref object — listed for exhaustive-deps only.\n  }, [active, onDoneRef]);\n\n  return (\n    <span className={cn(\"relative inline-flex\", className)}>\n      {children}\n      {active ? (\n        <span\n          aria-hidden\n          className=\"pointer-events-none absolute inset-0 z-10 motion-reduce:hidden\"\n        >\n          <span className=\"absolute left-1/2 top-1/2 size-14 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/25 animate-ping\" />\n          {SPARKS.map((spark, i) => (\n            <span\n              key={i}\n              className=\"absolute left-1/2 top-1/2 size-1.5 rounded-full bg-primary animate-ping\"\n              style={{\n                transform: `translate(-50%, -50%) translate(${spark.x}px, ${spark.y}px)`,\n                animationDelay: `${spark.delay}ms`,\n                animationDuration: \"700ms\",\n              }}\n            />\n          ))}\n        </span>\n      ) : null}\n    </span>\n  );\n}\n\n// Fixed spark offsets (no Math.random → deterministic, SSR-safe).\nconst SPARKS = [\n  { x: 0, y: -26, delay: 0 },\n  { x: 22, y: -14, delay: 60 },\n  { x: 26, y: 8, delay: 120 },\n  { x: 14, y: 24, delay: 60 },\n  { x: -14, y: 24, delay: 120 },\n  { x: -26, y: 8, delay: 0 },\n  { x: -22, y: -14, delay: 60 },\n];\n",
      "type": "registry:component",
      "target": "components/team-trophy-shelf/parts/badge-award-overlay.tsx"
    }
  ],
  "categories": [
    "gamification"
  ],
  "type": "registry:block"
}