{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "team-task-claim",
  "title": "Team Task Claim",
  "author": "ilinxa",
  "description": "Task autonomy control — an open-for-anyone toggle, a volunteer claim button, and an assignee chip with neutral release and reassign.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "switch",
    "button",
    "avatar",
    "popover",
    "command",
    "@ilinxa/gamification-kit"
  ],
  "files": [
    {
      "path": "src/registry/components/gamification/team-task-claim/team-task-claim.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { AssigneeChip } from \"./parts/assignee-chip\";\nimport { ClaimButton } from \"./parts/claim-button\";\nimport { OpenForAnyoneToggle } from \"./parts/open-for-anyone-toggle\";\nimport { resolveMember } from \"./lib/members\";\nimport { resolveTaskClaimState } from \"./lib/state\";\nimport type { TeamTaskClaimProps } from \"./types\";\n\n/**\n * The single-unit assembly — a **logic-free fan-out**. It resolves the three-\n * state slice once (`resolveTaskClaimState`, exported) and renders the relevant\n * flat sub-parts for that state, passing each only what it needs. It owns **no**\n * state (the picker-open flag lives in `AssigneeChip`); a hand-assembly using\n * the resolver + parts behaves identically. **No `Root`, no context** (D-06 —\n * nothing cross-cutting to hold).\n *\n * Autonomy affordance (E4): choice is always available, never forced;\n * release/reassign never penalizes the prior assignee (§6). Telemetry fires on\n * meaningful committed interactions only (§8.3).\n */\nexport function TeamTaskClaim({\n  teamId,\n  members,\n  value,\n  currentMemberId,\n  onOpenForAnyoneChange,\n  onClaim,\n  onAssigneeChange,\n  onEvent,\n  readOnly = false,\n  density = \"comfortable\",\n  labels,\n  className,\n  \"aria-label\": ariaLabel,\n}: TeamTaskClaimProps) {\n  const state = resolveTaskClaimState(value);\n\n  const emit = React.useCallback(() => {\n    onEvent?.({ type: \"task-claim.interaction\", teamId, taskId: value.taskId });\n  }, [onEvent, teamId, value.taskId]);\n\n  // Wrap the host callbacks so telemetry fires after a committed interaction.\n  // An omitted base callback ⇒ omitted wrapper ⇒ the affordance capability-gates off.\n  const handleOpenChange = onOpenForAnyoneChange\n    ? (open: boolean) => {\n        onOpenForAnyoneChange(open);\n        emit();\n      }\n    : undefined;\n\n  const handleClaim = onClaim\n    ? (memberId: string) => {\n        onClaim(memberId);\n        emit();\n      }\n    : undefined;\n\n  const handleAssigneeChange = onAssigneeChange\n    ? (memberId: string | undefined) => {\n        onAssigneeChange(memberId);\n        emit();\n      }\n    : undefined;\n\n  // Neutral state sentence for the polite live region (never a negative frame).\n  const assignee = resolveMember(value.assigneeId, members);\n  const stateSentence =\n    state === \"claimed\"\n      ? `Assigned to ${assignee?.displayName ?? value.assigneeId ?? \"a teammate\"}`\n      : state === \"open\"\n        ? \"Open for anyone\"\n        : \"Unassigned\";\n\n  return (\n    <div\n      role=\"group\"\n      aria-label={ariaLabel ?? \"Task assignment choice\"}\n      className={cn(\n        \"reveal-up flex flex-wrap items-center gap-x-3 gap-y-2\",\n        density === \"compact\" ? \"text-xs\" : \"text-sm\",\n        className,\n      )}\n    >\n      {/* The open-flag is always renderable (stays visible + toggleable in \"claimed\"). */}\n      <OpenForAnyoneToggle\n        open={value.openForAnyone}\n        onOpenChange={handleOpenChange}\n        label={labels?.openForAnyone}\n        readOnly={readOnly}\n        density={density}\n      />\n\n      {state === \"claimed\" ? (\n        <AssigneeChip\n          value={value}\n          members={members}\n          onAssigneeChange={handleAssigneeChange}\n          readOnly={readOnly}\n          density={density}\n          releaseLabel={labels?.release}\n          reassignLabel={labels?.reassign}\n        />\n      ) : (\n        <ClaimButton\n          memberId={currentMemberId}\n          onClaim={handleClaim}\n          label={labels?.claim}\n          readOnly={readOnly}\n          density={density}\n        />\n      )}\n\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {stateSentence}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/team-task-claim/team-task-claim.tsx"
    },
    {
      "path": "src/registry/components/gamification/team-task-claim/index.ts",
      "content": "// Assembly — the logic-free fan-out (single-unit; no Root/context).\nexport { TeamTaskClaim } from \"./team-task-claim\";\n\n// Flat à-la-carte sub-parts (each mountable alone).\nexport { OpenForAnyoneToggle } from \"./parts/open-for-anyone-toggle\";\nexport { ClaimButton } from \"./parts/claim-button\";\nexport { AssigneeChip } from \"./parts/assignee-chip\";\n\n// Pure helpers (a hand-assembly resolves identically).\nexport { resolveTaskClaimState } from \"./lib/state\";\nexport { resolveMember, initialsFor } from \"./lib/members\";\n\n// Public types.\nexport type {\n  TaskClaimState,\n  TeamMember,\n  TaskClaimRenderState,\n  TaskClaimInteraction,\n  TaskClaimEvent,\n  TaskClaimDensity,\n  TaskClaimLabels,\n  TeamTaskClaimProps,\n  OpenForAnyoneToggleProps,\n  ClaimButtonProps,\n  AssigneeChipProps,\n} from \"./types\";\n",
      "type": "registry:component",
      "target": "components/team-task-claim/index.ts"
    },
    {
      "path": "src/registry/components/gamification/team-task-claim/types.ts",
      "content": "/**\n * `team-task-claim` — public type surface.\n *\n * Framework-free (no `\"use client\"`). Per system D-03 the domain slice is\n * **re-declared locally** — this component imports nothing from another registry\n * component (the `gamification-system` §4 model is the source of truth these are\n * copied from, byte-faithful, not imported). The autonomy affordance is generic\n * *over the host's task* only in that it consumes a slice, never a full task —\n * no `<T>` generic (that re-arms the generic-over-host-task drift risk).\n */\n\n/** The choice slice for ONE task (system §4). Controlled (D-06). */\nexport interface TaskClaimState {\n  taskId: string;\n  /** Open for anyone on the team to pick up. */\n  openForAnyone: boolean;\n  /** Assigned member id; `undefined` → unassigned. */\n  assigneeId?: string;\n}\n\n/** A team member — identity only (avatar resolution + the reassign picker). */\nexport type TeamMember = {\n  id: string;\n  displayName: string;\n  avatarUrl?: string;\n};\n\n/** The deterministic resolved render state (see `lib/state.ts`). */\nexport type TaskClaimRenderState = \"open\" | \"claimed\" | \"unassigned\";\n\n/**\n * Internal interaction discriminant → feeds `onEvent`. Kept internal (not a\n * public callback arg); the public telemetry event is intentionally coarse.\n */\nexport type TaskClaimInteraction =\n  | { kind: \"open-toggled\"; open: boolean }\n  | { kind: \"claimed\"; memberId: string }\n  | { kind: \"reassigned\"; memberId?: string }; // undefined = released\n\n/** The public telemetry event (system §6 / D-07 — exact union member). */\nexport type TaskClaimEvent = {\n  type: \"task-claim.interaction\";\n  teamId: string;\n  taskId: string;\n};\n\n/** Layout density — fits a kanban card vs a card-tree row. */\nexport type TaskClaimDensity = \"compact\" | \"comfortable\";\n\n/** Per-state copy overrides (D8 — one button, overridable). */\nexport interface TaskClaimLabels {\n  claim?: string; // default \"I'll take this\"\n  openForAnyone?: string; // default \"Open for anyone\"\n  release?: string; // default \"Release\"\n  reassign?: string; // default \"Reassign…\"\n}\n\n/** Props for the assembly `TeamTaskClaim`. */\nexport interface TeamTaskClaimProps {\n  /**\n   * Identity + telemetry scope (D-15: a scalar `teamId`, NOT a team object —\n   * this component renders NO team-identity text).\n   */\n  teamId: string;\n  /** This team's members — avatar resolution + the reassign picker (D-08). */\n  members: TeamMember[];\n  /** The choice slice for ONE task. Controlled (D-06). */\n  value: TaskClaimState;\n  /** The viewer — default subject of \"I'll take this\". */\n  currentMemberId?: string;\n\n  // Change callbacks — each optional → omit ⇒ that affordance hides.\n  onOpenForAnyoneChange?: (open: boolean) => void;\n  /** Volunteer / claim. `memberId` defaults to `currentMemberId` upstream. */\n  onClaim?: (memberId: string) => void;\n  /** Reassign; pass `undefined` to RELEASE (folded in — no separate onRelease, D10). Never penalizes the prior assignee. */\n  onAssigneeChange?: (memberId: string | undefined) => void;\n\n  /** Telemetry (D-07) — `{ type:\"task-claim.interaction\"; teamId; taskId }` on meaningful interactions only. */\n  onEvent?: (event: TaskClaimEvent) => void;\n\n  /** Display-only: shows state, hides ALL actions. Default `false` — choice is the default. */\n  readOnly?: boolean;\n  /** Density. Default `\"comfortable\"`. */\n  density?: TaskClaimDensity;\n  /** Per-state label overrides. */\n  labels?: TaskClaimLabels;\n\n  className?: string;\n  \"aria-label\"?: string;\n}\n\n/** Props for the à-la-carte `OpenForAnyoneToggle` sub-part. */\nexport interface OpenForAnyoneToggleProps {\n  open: boolean;\n  onOpenChange?: (open: boolean) => void;\n  label?: string;\n  readOnly?: boolean;\n  density?: TaskClaimDensity;\n  className?: string;\n}\n\n/** Props for the à-la-carte `ClaimButton` sub-part. */\nexport interface ClaimButtonProps {\n  /** The member who would claim; defaults to `currentMemberId` at the assembly. */\n  memberId?: string;\n  onClaim?: (memberId: string) => void;\n  label?: string;\n  readOnly?: boolean;\n  density?: TaskClaimDensity;\n  className?: string;\n}\n\n/** Props for the à-la-carte `AssigneeChip` sub-part. */\nexport interface AssigneeChipProps {\n  value: TaskClaimState;\n  members: TeamMember[];\n  onAssigneeChange?: (memberId: string | undefined) => void;\n  readOnly?: boolean;\n  density?: TaskClaimDensity;\n  releaseLabel?: string;\n  reassignLabel?: string;\n  className?: string;\n}\n",
      "type": "registry:component",
      "target": "components/team-task-claim/types.ts"
    },
    {
      "path": "src/registry/components/gamification/team-task-claim/lib/state.ts",
      "content": "import type { TaskClaimRenderState, TaskClaimState } from \"../types\";\n\n/**\n * The deterministic state resolver (description §4 table). Pure, framework-free,\n * SSR-safe, test-ready. Exported so a hand-assembly resolves identically to the\n * `TeamTaskClaim` assembly — a kit-extraction candidate (system §7.3).\n */\nexport function resolveTaskClaimState(\n  v: TaskClaimState,\n): TaskClaimRenderState {\n  // \"claimed\" wins — covers the legal `openForAnyone && assigneeId` edge.\n  if (v.assigneeId != null) return \"claimed\";\n  return v.openForAnyone ? \"open\" : \"unassigned\";\n}\n\n// Note: the open-flag is always renderable (even in \"claimed\" it stays visible +\n// toggleable) — enforced directly in the assembly, which always mounts the toggle.\n",
      "type": "registry:component",
      "target": "components/team-task-claim/lib/state.ts"
    },
    {
      "path": "src/registry/components/gamification/team-task-claim/lib/members.ts",
      "content": "import { initialsFromName } from \"../../_shared/gamification-kit\";\nimport type { TeamMember } from \"../types\";\n\n/**\n * Member resolution — pure, framework-free, SSR-safe. A stale `assigneeId` not\n * present in `members` returns `undefined` (the chip degrades gracefully to\n * id-initials, never crashes — §9).\n */\nexport function resolveMember(\n  id: string | undefined,\n  members: TeamMember[],\n): TeamMember | undefined {\n  return id == null ? undefined : members.find((m) => m.id === id);\n}\n\n/**\n * Deterministic (SSR-safe) initials for an avatar fallback — from the member's\n * display name, or the raw id when the member is unknown (stale-id path). The\n * name → initials core moved to the shared `gamification-kit` (P3.5 / D-04);\n * this wrapper keeps the member + fallback-id resolution local.\n */\nexport function initialsFor(\n  member: TeamMember | undefined,\n  fallbackId: string,\n): string {\n  return initialsFromName(member?.displayName ?? fallbackId);\n}\n",
      "type": "registry:component",
      "target": "components/team-task-claim/lib/members.ts"
    },
    {
      "path": "src/registry/components/gamification/team-task-claim/parts/open-for-anyone-toggle.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { Switch } from \"@/components/ui/switch\";\nimport { cn } from \"@/lib/utils\";\n\nimport type { OpenForAnyoneToggleProps } from \"../types\";\n\n/**\n * À-la-carte sub-part — the \"open for anyone\" on/off control. A `Switch` reads\n * as a *setting*, which is exactly what \"open for anyone\" is. The label is a\n * **friendly invite, never a warning** (D9): signal-lime accent when on, never a\n * red/alert treatment — releasing a task into \"open for anyone\" reads as\n * generosity. Capability-gated: no `onOpenChange` (or `readOnly`) → the switch\n * is non-interactive (display-only), no dead affordance.\n */\nexport function OpenForAnyoneToggle({\n  open,\n  onOpenChange,\n  label = \"Open for anyone\",\n  readOnly = false,\n  density = \"comfortable\",\n  className,\n}: OpenForAnyoneToggleProps) {\n  const id = React.useId();\n  const interactive = !readOnly && typeof onOpenChange === \"function\";\n\n  return (\n    <div className={cn(\"flex items-center gap-2\", className)}>\n      <Switch\n        id={id}\n        checked={open}\n        onCheckedChange={interactive ? onOpenChange : undefined}\n        disabled={!interactive}\n        size={density === \"compact\" ? \"sm\" : \"default\"}\n        aria-label={label}\n      />\n      <label\n        htmlFor={id}\n        className={cn(\n          \"flex select-none items-center gap-1\",\n          density === \"compact\" ? \"text-xs\" : \"text-sm\",\n          open ? \"font-medium text-foreground\" : \"text-muted-foreground\",\n          interactive && \"cursor-pointer\",\n        )}\n      >\n        <span aria-hidden>🙌</span> {label}\n      </label>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/team-task-claim/parts/open-for-anyone-toggle.tsx"
    },
    {
      "path": "src/registry/components/gamification/team-task-claim/parts/claim-button.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { Hand } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\nimport type { ClaimButtonProps } from \"../types\";\n\n/**\n * À-la-carte sub-part — the \"I'll take this\" volunteer/claim action. Warm,\n * volitional, autonomy-flavored (D8). Capability-gated: no `onClaim` (or\n * `readOnly`) → renders nothing (no dead button). If there is no `memberId` to\n * claim as (no viewer supplied), the click is a guarded no-op — the button\n * still renders so the affordance is discoverable, but the host must supply a\n * subject (documented in the guide).\n */\nexport function ClaimButton({\n  memberId,\n  onClaim,\n  label = \"I'll take this\",\n  readOnly = false,\n  density = \"comfortable\",\n  className,\n}: ClaimButtonProps) {\n  if (readOnly || typeof onClaim !== \"function\") return null;\n\n  const compact = density === \"compact\";\n\n  return (\n    <Button\n      type=\"button\"\n      size={compact ? \"icon\" : \"sm\"}\n      variant=\"default\"\n      aria-label={label}\n      title={compact ? label : undefined}\n      onClick={() => {\n        if (memberId != null) onClaim(memberId);\n      }}\n      className={cn(compact && \"size-8\", className)}\n    >\n      <Hand aria-hidden />\n      {compact ? null : label}\n    </Button>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/team-task-claim/parts/claim-button.tsx"
    },
    {
      "path": "src/registry/components/gamification/team-task-claim/parts/assignee-chip.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { Check, UserRoundPlus } from \"lucide-react\";\n\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n} from \"@/components/ui/command\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { cn } from \"@/lib/utils\";\n\nimport { initialsFor, resolveMember } from \"../lib/members\";\nimport type { AssigneeChipProps } from \"../types\";\n\n/**\n * À-la-carte sub-part — the assignee chip (avatar + name) with a **neutral**\n * Release + a reassign picker. The no-penalty core (§6): Release is a\n * `variant=\"ghost\"` muted action reading \"Release\" — **never** destructive/red,\n * **never** \"Drop\"/\"Abandon\", **never** a penalty glyph or motion. The prior\n * assignee is never named in a negative frame. A stale `assigneeId` (not in\n * `members`) degrades to id-initials, no crash. The picker is `popover` +\n * `command` (searchable, keyboard-nav, team-scoped) — `PopoverTrigger` is used\n * directly (no `asChild`) to stay clear of the Radix↔Base-UI divergence class.\n */\nexport function AssigneeChip({\n  value,\n  members,\n  onAssigneeChange,\n  readOnly = false,\n  density = \"comfortable\",\n  releaseLabel = \"Release\",\n  reassignLabel = \"Reassign…\",\n  className,\n}: AssigneeChipProps) {\n  const [pickerOpen, setPickerOpen] = React.useState(false);\n\n  const assigneeId = value.assigneeId;\n  const member = resolveMember(assigneeId, members);\n  const displayName = member?.displayName ?? assigneeId ?? \"Unassigned\";\n  const interactive = !readOnly && typeof onAssigneeChange === \"function\";\n  const compact = density === \"compact\";\n\n  return (\n    <div className={cn(\"flex items-center gap-2\", className)}>\n      <div className=\"flex min-w-0 items-center gap-2\">\n        <Avatar size={compact ? \"sm\" : \"default\"}>\n          {member?.avatarUrl ? (\n            <AvatarImage src={member.avatarUrl} alt=\"\" />\n          ) : null}\n          <AvatarFallback>\n            {initialsFor(member, assigneeId ?? \"?\")}\n          </AvatarFallback>\n        </Avatar>\n        <span\n          title={displayName}\n          className={cn(\n            \"truncate text-foreground\",\n            compact ? \"max-w-24 text-xs\" : \"text-sm\",\n          )}\n        >\n          {/* SR-readable, neutral framing — never \"X dropped this\". */}\n          <span className=\"sr-only\">Assigned to </span>\n          {displayName}\n        </span>\n      </div>\n\n      {interactive ? (\n        <div className=\"flex items-center gap-1\">\n          <Popover open={pickerOpen} onOpenChange={setPickerOpen}>\n            <PopoverTrigger\n              aria-label={reassignLabel}\n              title={reassignLabel}\n              className={cn(\n                \"inline-flex size-8 items-center justify-center rounded-md text-muted-foreground\",\n                \"hover:bg-muted hover:text-foreground\",\n                \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\",\n              )}\n            >\n              <UserRoundPlus className=\"size-4\" aria-hidden />\n            </PopoverTrigger>\n            <PopoverContent align=\"end\" className=\"w-56 p-0\">\n              <Command>\n                <CommandInput placeholder=\"Reassign to…\" />\n                <CommandList>\n                  <CommandEmpty>No teammates to assign.</CommandEmpty>\n                  <CommandGroup>\n                    {members.map((m) => (\n                      <CommandItem\n                        key={m.id}\n                        value={m.displayName}\n                        onSelect={() => {\n                          onAssigneeChange?.(m.id);\n                          setPickerOpen(false);\n                        }}\n                      >\n                        <Avatar size=\"sm\">\n                          {m.avatarUrl ? (\n                            <AvatarImage src={m.avatarUrl} alt=\"\" />\n                          ) : null}\n                          <AvatarFallback>\n                            {initialsFor(m, m.id)}\n                          </AvatarFallback>\n                        </Avatar>\n                        <span className=\"flex-1 truncate\">{m.displayName}</span>\n                        {m.id === assigneeId ? (\n                          <Check className=\"size-4\" aria-hidden />\n                        ) : null}\n                      </CommandItem>\n                    ))}\n                  </CommandGroup>\n                </CommandList>\n              </Command>\n            </PopoverContent>\n          </Popover>\n\n          {/* Neutral, no-penalty release — folds into onAssigneeChange(undefined). */}\n          <Button\n            type=\"button\"\n            size=\"sm\"\n            variant=\"ghost\"\n            className=\"text-muted-foreground\"\n            onClick={() => onAssigneeChange?.(undefined)}\n          >\n            {releaseLabel}\n          </Button>\n        </div>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/team-task-claim/parts/assignee-chip.tsx"
    }
  ],
  "categories": [
    "gamification"
  ],
  "type": "registry:block"
}