Team Task Claim
alphav0.2.1Task autonomy control — an open-for-anyone toggle, a volunteer claim button, and an assignee chip with neutral release and reassign.
Context
The Autonomy surface of the gamification pack (E4). Renders three states — open / claimed / unassigned (plus the legal assigned-and-open edge) — deterministically from a controlled TaskClaimState slice + this team's members. A member can open a task for anyone or volunteer for it by their own choice; the release/reassign path is a neutral, no-penalty transition (never destructive/red, never 'Drop'/'Abandon', never a penalty glyph or motion). Ships as a single-unit control (NOT a Root/context compound — nothing cross-cutting to hold, and D-06 forbids requiring a provider): flat à-la-carte sub-parts (OpenForAnyoneToggle, ClaimButton, AssigneeChip) under a logic-free TeamTaskClaim assembly, so a host can drop just the toggle or just the chip. The reassign picker is popover + command (searchable, keyboard-navigable, team-scoped). Emits task-claim.interaction via onEvent on meaningful interactions only. Controlled (D-06); capability-gated (omit a callback → that affordance hides); readOnly for display-only. Portable: zero next/*, own types.ts slice, imports no other registry component; SSR-safe. Fifth component of the gamification-system.
Installation
pnpm dlx shadcn@latest initpnpm dlx shadcn@latest add @ilinxa/team-task-claimAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/team-task-claim-fixturesCLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:
"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}Preview
Three states — live
Open (invite + I'll take this) · Claimed (chip + neutral Release + Reassign) · Unassigned (volunteer + open together). Try claiming, releasing, reassigning — nothing penalizes anyone.
Legal edge — assigned AND open
Resolves to claimed; the open-for-anyone flag stays visible and toggleable alongside the chip.
Density — compact (kanban) vs comfortable (rich card)
Compact collapses labels to icons + truncates the assignee name (avatar floor).
Read-only (display-only)
Omit the callbacks (or pass readOnly) → all actions hide; the state still shows. No dead buttons.
À-la-carte sub-parts (no assembly)
Each flat export mounts standalone — the toggle, the claim action, and the assignee chip on their own.
Demo source
"use client"; import * as React from "react"; import { TeamTaskClaim } from "./team-task-claim";import { AssigneeChip } from "./parts/assignee-chip";import { ClaimButton } from "./parts/claim-button";import { OpenForAnyoneToggle } from "./parts/open-for-anyone-toggle";import { CHOICE_ASSIGNED_AND_OPEN, CHOICE_CLAIMED, CHOICE_OPEN, CHOICE_STALE_ASSIGNEE, CHOICE_UNASSIGNED, CURRENT_MEMBER_ID, TASK_TEAM,} from "./dummy-data";import type { TeamTaskClaimProps, TaskClaimState } from "./types"; function Section({ title, hint, children,}: { title: string; hint?: string; children: React.ReactNode;}) { return ( <section className="flex flex-col gap-3"> <div className="flex flex-col gap-0.5"> <h3 className="text-sm font-semibold text-foreground">{title}</h3> {hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null} </div> {children} </section> );} /** A controlled host — wires the callbacks so the never-forced loop is live. */function LiveControl({ initial, density,}: { initial: TaskClaimState; density?: TeamTaskClaimProps["density"];}) { const [value, setValue] = React.useState<TaskClaimState>(initial); return ( <div className="rounded-lg border border-border bg-card p-4 transition-[border-color,box-shadow] duration-200 hover:border-primary/40 hover:shadow-md"> <TeamTaskClaim teamId="design-team" members={TASK_TEAM} value={value} currentMemberId={CURRENT_MEMBER_ID} density={density} onOpenForAnyoneChange={(open) => setValue((v) => ({ ...v, openForAnyone: open })) } onClaim={(memberId) => setValue((v) => ({ ...v, assigneeId: memberId, openForAnyone: false })) } onAssigneeChange={(memberId) => setValue((v) => ({ ...v, assigneeId: memberId })) } onEvent={(e) => console.info("[demo] gamification event", e)} /> </div> );} export default function TeamTaskClaimDemo() { return ( <div className="mx-auto flex w-full max-w-lg flex-col gap-8"> <Section title="Three states — live" hint="Open (invite + I'll take this) · Claimed (chip + neutral Release + Reassign) · Unassigned (volunteer + open together). Try claiming, releasing, reassigning — nothing penalizes anyone." > <div className="flex flex-col gap-4"> <LiveControl initial={CHOICE_OPEN} /> <LiveControl initial={CHOICE_CLAIMED} /> <LiveControl initial={CHOICE_UNASSIGNED} /> </div> </Section> <Section title="Legal edge — assigned AND open" hint="Resolves to claimed; the open-for-anyone flag stays visible and toggleable alongside the chip." > <LiveControl initial={CHOICE_ASSIGNED_AND_OPEN} /> </Section> <Section title="Density — compact (kanban) vs comfortable (rich card)" hint="Compact collapses labels to icons + truncates the assignee name (avatar floor)." > <div className="flex flex-col gap-4"> <LiveControl initial={CHOICE_CLAIMED} density="compact" /> <LiveControl initial={CHOICE_CLAIMED} density="comfortable" /> </div> </Section> <Section title="Read-only (display-only)" hint="Omit the callbacks (or pass readOnly) → all actions hide; the state still shows. No dead buttons." > <div className="rounded-lg border border-border bg-card p-4 transition-[border-color,box-shadow] duration-200 hover:border-primary/40 hover:shadow-md"> <TeamTaskClaim teamId="design-team" members={TASK_TEAM} value={CHOICE_CLAIMED} readOnly /> </div> </Section> <Section title="À-la-carte sub-parts (no assembly)" hint="Each flat export mounts standalone — the toggle, the claim action, and the assignee chip on their own." > <div className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4 transition-[border-color,box-shadow] duration-200 hover:border-primary/40 hover:shadow-md"> <OpenForAnyoneToggle open onOpenChange={() => {}} /> <ClaimButton memberId={CURRENT_MEMBER_ID} onClaim={() => {}} /> <AssigneeChip value={CHOICE_STALE_ASSIGNEE} members={TASK_TEAM} onAssigneeChange={() => {}} /> </div> </Section> </div> );} Usage
When to use
Drop TeamTaskClaim onto any team task card to give members an autonomy affordance: open a task for anyone, or volunteer for it — by their own choice, never told to. It is the E4 / Autonomy surface of the gamification pack. The rule that shapes it: choice is always available, never forced; releasing or reassigning never penalizes whoever held it before.
Basic example
import { TeamTaskClaim } from "@/components/team-task-claim"
<TeamTaskClaim
teamId={team.id}
members={team.members} // this team only
value={task.choice} // { taskId, openForAnyone, assigneeId }
currentMemberId={viewerId}
onOpenForAnyoneChange={(open) => update(task.id, { openForAnyone: open })}
onClaim={(id) => update(task.id, { assigneeId: id, openForAnyone: false })}
onAssigneeChange={(id) => update(task.id, { assigneeId: id })} // undefined = release
density="compact"
/>Three states
- Open — a friendly 🙌 Open-for-anyone invite plus an I'll take this claim.
- Claimed — an assignee chip + a neutral Release + a reassign picker. Release is
onAssigneeChange(undefined)— never a penalty. - Unassigned — Volunteer and Open-for-anyone offered side by side.
Capability-gating & read-only
Each affordance hides when its callback is omitted; readOnly is a global off-switch. They compose — omitting all callbacks produces the same display-only render as readOnly. Prefer readOnly to express intent.
À-la-carte parts
import {
OpenForAnyoneToggle, ClaimButton, AssigneeChip,
} from "@/components/team-task-claim"
// Mount just the part you need — each is flat-exported, no Root/context.
<OpenForAnyoneToggle open={task.choice.openForAnyone} onOpenChange={setOpen} />Notes
- Controlled.
valuedrives the render; the component holds no source-of-truth choice state. - Team-scoped. Only
members(this team) appear in the reassign picker; nothing public/inter-team. - Telemetry.
onEventemitstask-claim.interactionon meaningful interactions only (toggle / claim / reassign / release) — not on render. - Portable. No
next/*, no other registry import; owntypes.tsslice; only the shadcnswitch/button/avatar/popover/commandprimitives.
Features
- Three states — open (invite + 'I'll take this') / claimed (chip + neutral Release + Reassign) / unassigned (volunteer + open together)
- Never-forced by construction — no mandatory/locked state; choice is the default (readOnly is opt-in)
- No-penalty release — folded into onAssigneeChange(undefined); never destructive/red, never a cold verb or penalty motion
- Reassign via a searchable, keyboard-navigable, team-scoped popover + command picker
- Controlled + capability-gated — value drives render; omit a callback and that affordance hides; no dead buttons
- Telemetry — task-claim.interaction on meaningful interactions only (toggle / claim / reassign / release)
- Single-unit with flat à-la-carte parts — mount just OpenForAnyoneToggle, ClaimButton, or AssigneeChip; no Root/context