Skip to content
ilinxa/pro-ui

Team Task Claim

alphav0.2.1

Task autonomy control — an open-for-anyone toggle, a volunteer claim button, and an assignee chip with neutral release and reassign.

Category: GamificationUpdated: 2026-08-11Created: 2026-07-01Author: ilinxa

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

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Install the component
pnpm dlx shadcn@latest add @ilinxa/team-task-claim

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/team-task-claim-fixtures

CLI 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.

Open for anyone
TFAssigned to Theo Fischer
Assigned to Theo Fischer
Unassigned

Legal edge — assigned AND open

Resolves to claimed; the open-for-anyone flag stays visible and toggleable alongside the chip.

ABAssigned to Ama Boateng
Assigned to Ama Boateng

Density — compact (kanban) vs comfortable (rich card)

Compact collapses labels to icons + truncates the assignee name (avatar floor).

TFAssigned to Theo Fischer
Assigned to Theo Fischer
TFAssigned to Theo Fischer
Assigned to Theo Fischer

Read-only (display-only)

Omit the callbacks (or pass readOnly) → all actions hide; the state still shows. No dead buttons.

TFAssigned to Theo Fischer
Assigned to Theo Fischer

À-la-carte sub-parts (no assembly)

Each flat export mounts standalone — the toggle, the claim action, and the assignee chip on their own.

U-Assigned to u-ghost

Demo source

demo.tsxtsx
"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. value drives 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. onEvent emits task-claim.interaction on meaningful interactions only (toggle / claim / reassign / release) — not on render.
  • Portable. No next/*, no other registry import; own types.ts slice; only the shadcn switch / button / avatar / popover / command primitives.

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

Tags

team-task-claimgamificationautonomyassignmentvolunteerteamcooperativetelemetry

Dependencies

shadcn primitives: switch, button, avatar, popover, command
npm peer deps: lucide-react@^1.11.0