{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "team-progress-bar",
  "title": "Team Progress Bar",
  "author": "ilinxa",
  "description": "Read-only team progress bar showing milestone completion — optional ticks and numeric readout, cooperative by design.",
  "dependencies": [],
  "registryDependencies": [
    "progress",
    "@ilinxa/gamification-kit"
  ],
  "files": [
    {
      "path": "src/registry/components/gamification/team-progress-bar/team-progress-bar.tsx",
      "content": "\"use client\";\n\nimport { TeamProgressBarLabel } from \"./parts/team-progress-bar-label\";\nimport { TeamProgressBarRoot } from \"./parts/team-progress-bar-root\";\nimport { TeamProgressBarTrack } from \"./parts/team-progress-bar-track\";\nimport type { TeamProgressBarProps } from \"./types\";\n\n/**\n * Tier A — the batteries-included assembly: `Root` + `Label` (gated by\n * `showLabel`) + `Track` (threading `showTicks`). Contains **no logic the parts\n * don't** — a hand-assembled layout (`TeamProgressBarRoot` + a subset of parts)\n * gets identical resolution + identical single-fire telemetry.\n *\n * One team, one milestone-completion %, always visible, read-only. No comparison,\n * no ranking, no second series — ever (system §5.3 / D-08).\n */\nexport function TeamProgressBar({\n  milestones,\n  value,\n  team,\n  showLabel = true,\n  showTicks = false,\n  labelFormat = \"percent\",\n  onEvent,\n  className,\n  \"aria-label\": ariaLabel,\n}: TeamProgressBarProps) {\n  return (\n    <TeamProgressBarRoot\n      milestones={milestones}\n      value={value}\n      team={team}\n      labelFormat={labelFormat}\n      onEvent={onEvent}\n      className={className}\n    >\n      {showLabel ? <TeamProgressBarLabel /> : null}\n      <TeamProgressBarTrack showTicks={showTicks} aria-label={ariaLabel} />\n    </TeamProgressBarRoot>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/team-progress-bar/team-progress-bar.tsx"
    },
    {
      "path": "src/registry/components/gamification/team-progress-bar/index.ts",
      "content": "// Tier A — batteries-included assembly.\nexport { TeamProgressBar } from \"./team-progress-bar\";\n\n// Tier B — headless provider + flat à-la-carte parts.\nexport { TeamProgressBarRoot } from \"./parts/team-progress-bar-root\";\nexport { TeamProgressBarTrack } from \"./parts/team-progress-bar-track\";\nexport { TeamProgressBarLabel } from \"./parts/team-progress-bar-label\";\n\n// Tier C — context-free primitive.\nexport { ProgressTrack } from \"./parts/team-progress-bar-track\";\n\n// Context consumer for hand-assembled layouts.\nexport { useTeamProgressBar } from \"./hooks/use-team-progress-bar\";\n\n// Public types.\nexport type {\n  TeamProgressBarProps,\n  TeamProgressBarRootProps,\n  TeamProgressBarBaseProps,\n  TeamProgressBarContextValue,\n  ProgressTrackProps,\n  Milestone,\n  TeamProgressBarTeam,\n  GamificationEvent,\n  ProgressLabelFormat,\n  ProgressTick,\n  ResolvedProgress,\n} from \"./types\";\n",
      "type": "registry:component",
      "target": "components/team-progress-bar/index.ts"
    },
    {
      "path": "src/registry/components/gamification/team-progress-bar/types.ts",
      "content": "import type { ReactNode } from \"react\";\n\n/**\n * `team-progress-bar` — public type surface.\n *\n * Framework-free (no `\"use client\"`): importable from a server component's\n * type position. Per system D-03 every type this component needs is\n * **re-declared locally** — it imports nothing from another registry component.\n * The `gamification-system` description (§4 domain model, §6 telemetry union) is\n * the source of truth these slices are copied from, not imported.\n */\n\n/**\n * A planned team milestone — the shared spine progress derives from (system D-09).\n * Re-declared locally per D-03; do not fork the system's canonical shape.\n */\nexport interface Milestone {\n  id: string;\n  /** Human label — shown as a tick-mark tooltip. e.g. \"First playable build\". */\n  label: string;\n  /** Counts toward the numerator when true. */\n  done: boolean;\n  /** ISO 8601 — reserved; not rendered by the bar. */\n  doneAt?: string;\n  /** Left-to-right tick ordering on the track. */\n  order: number;\n}\n\n/**\n * This team — the identity subset (system D-15). `id` scopes the telemetry\n * payload; `name` is the optional leading label. Never a bare `teamId`+`teamName`\n * pair because this component renders the team name as text.\n */\nexport interface TeamProgressBarTeam {\n  id: string;\n  name?: string;\n}\n\n/**\n * The single semantic event this component emits — a narrow local slice of the\n * system §6 `GamificationEvent` union. A consumer's `onEvent` is correctly typed\n * to receive only what this bar sends.\n */\nexport type GamificationEvent = {\n  type: \"progress-bar.checked\";\n  teamId: string;\n};\n\n/** Numeric readout style. `\"fraction\"` requires `milestones`. */\nexport type ProgressLabelFormat = \"percent\" | \"fraction\";\n\n/** One tick notch — derived from a milestone. */\nexport interface ProgressTick {\n  done: boolean;\n  label: string;\n}\n\n/** Pure resolver output (see `lib/resolve-progress.ts`). SSR-deterministic. */\nexport interface ResolvedProgress {\n  /** Clamped 0..100 integer. */\n  pct: number;\n  /** Numerator in milestone mode; `null` in direct-value mode. */\n  doneCount: number | null;\n  /** Denominator in milestone mode; `null` in direct-value mode. */\n  total: number | null;\n  /** Ordered ticks; `null` when no milestones supplied. */\n  ticks: ProgressTick[] | null;\n}\n\n/** Data + telemetry surface shared by the assembly and the headless `Root`. */\nexport interface TeamProgressBarBaseProps {\n  /** Milestones for this team; % = done/total. Enables ticks + the fraction readout. */\n  milestones?: Milestone[];\n  /** Direct 0–100 percentage; wins over `milestones` if both supplied. */\n  value?: number;\n  /** This team (system D-15). `id` → telemetry payload + scope; `name` → label. */\n  team: TeamProgressBarTeam;\n  /** Readout style. Default `\"percent\"`. `\"fraction\"` falls back to percent without milestones. */\n  labelFormat?: ProgressLabelFormat;\n  /** Telemetry — emits `{ type: \"progress-bar.checked\"; teamId }` once per mount. */\n  onEvent?: (event: GamificationEvent) => void;\n  className?: string;\n}\n\n/** Props for the headless provider `TeamProgressBarRoot`. */\nexport interface TeamProgressBarRootProps extends TeamProgressBarBaseProps {\n  children: ReactNode;\n}\n\n/** Props for the batteries-included `TeamProgressBar` assembly. */\nexport interface TeamProgressBarProps extends TeamProgressBarBaseProps {\n  /** Numeric readout. Default `true`. */\n  showLabel?: boolean;\n  /** Per-milestone tick notches. Default `false` (needs `milestones`). */\n  showTicks?: boolean;\n  \"aria-label\"?: string;\n}\n\n/** Props for the context-free Tier-C `ProgressTrack` primitive. */\nexport interface ProgressTrackProps {\n  /** Clamped 0..100 fill percentage. */\n  pct: number;\n  /** Ordered ticks to overlay (when `showTicks`). */\n  ticks?: ProgressTick[] | null;\n  /** Render the tick notches. Default `false`. */\n  showTicks?: boolean;\n  className?: string;\n  \"aria-label\"?: string;\n}\n\n/** The resolved value every context part reads — one source of truth. */\nexport interface TeamProgressBarContextValue {\n  pct: number;\n  doneCount: number | null;\n  total: number | null;\n  ticks: ProgressTick[] | null;\n  team: TeamProgressBarTeam;\n  /** The *effective* format (after the fraction→percent fallback). */\n  labelFormat: ProgressLabelFormat;\n}\n",
      "type": "registry:component",
      "target": "components/team-progress-bar/types.ts"
    },
    {
      "path": "src/registry/components/gamification/team-progress-bar/lib/resolve-progress.ts",
      "content": "import { clamp } from \"../../_shared/gamification-kit\";\nimport type { Milestone, ProgressLabelFormat, ResolvedProgress } from \"../types\";\n\n/**\n * The one piece of real logic — pure, SSR-deterministic (derives only from\n * props; never reads `Date`, layout, or env at module scope). Test-ready.\n * `clamp` moved to the shared `gamification-kit` (P3.5 / D-04) once the\n * surface was proven across ≥2 components — the rest stays standalone.\n */\n\n/** Dev-only warning. The `NODE_ENV` guard is the one sanctioned env reference\n *  (erased in production builds); kept inside the function body, never at module scope. */\nfunction devWarn(message: string): void {\n  if (process.env.NODE_ENV !== \"production\") {\n    console.warn(message);\n  }\n}\n\nfunction ticksFrom(milestones: Milestone[]): ResolvedProgress[\"ticks\"] {\n  // Sort a *copy* — never mutate the prop array.\n  return [...milestones]\n    .sort((a, b) => a.order - b.order)\n    .map((m) => ({ done: m.done, label: m.label }));\n}\n\n/**\n * Resolution order (deterministic, locked — description §4 / plan §5):\n *   value present   → pct = clamp(round(value), 0, 100); counts null; ticks from milestones if any\n *   milestones      → pct = total===0 ? 0 : round(done/total*100); counts + ticks set\n *   neither         → 0% (always-visible rule — never hidden, never NaN)\n */\nexport function resolveProgress(args: {\n  value?: number;\n  milestones?: Milestone[];\n}): ResolvedProgress {\n  const { value, milestones } = args;\n\n  if (value != null) {\n    if (milestones?.length) {\n      devWarn(\n        \"[team-progress-bar] both `value` and `milestones` supplied; `value` takes precedence.\",\n      );\n    }\n    return {\n      pct: clamp(Math.round(value), 0, 100),\n      doneCount: null,\n      total: null,\n      // Ticks follow milestones when present even in value-mode; the fill follows `value` (§9).\n      ticks: milestones?.length ? ticksFrom(milestones) : null,\n    };\n  }\n\n  if (milestones?.length) {\n    const total = milestones.length;\n    const doneCount = milestones.filter((m) => m.done).length;\n    return {\n      pct: total === 0 ? 0 : Math.round((doneCount / total) * 100),\n      doneCount,\n      total,\n      ticks: ticksFrom(milestones),\n    };\n  }\n\n  // Empty / uninitialised → 0%, bar still renders.\n  return { pct: 0, doneCount: null, total: null, ticks: null };\n}\n\n/**\n * Resolve the *effective* label format. `\"fraction\"` requires milestones\n * (a non-null `total`); without them it falls back to `\"percent\"` + dev-warns,\n * so the readout is never `\"NaN / NaN\"`.\n */\nexport function resolveLabelFormat(\n  requested: ProgressLabelFormat,\n  total: number | null,\n): ProgressLabelFormat {\n  if (requested === \"fraction\" && total == null) {\n    devWarn(\n      \"[team-progress-bar] labelFormat='fraction' requires `milestones`; falling back to 'percent'.\",\n    );\n    return \"percent\";\n  }\n  return requested;\n}\n",
      "type": "registry:component",
      "target": "components/team-progress-bar/lib/resolve-progress.ts"
    },
    {
      "path": "src/registry/components/gamification/team-progress-bar/lib/event.ts",
      "content": "import type { GamificationEvent } from \"../types\";\n\n/**\n * The single place the `progress-bar.checked` event shape is constructed.\n * Kept as a standalone pure factory (kit-extraction candidate, system §7.3) —\n * the component emits only this semantic event; the host adds the envelope\n * (timestamp, anonymized IDs, app variant) at its transport layer.\n */\nexport function progressBarCheckedEvent(teamId: string): GamificationEvent {\n  return { type: \"progress-bar.checked\", teamId };\n}\n",
      "type": "registry:component",
      "target": "components/team-progress-bar/lib/event.ts"
    },
    {
      "path": "src/registry/components/gamification/team-progress-bar/hooks/use-team-progress-bar.ts",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport type { TeamProgressBarContextValue } from \"../types\";\n\nexport const TeamProgressBarContext =\n  React.createContext<TeamProgressBarContextValue | null>(null);\n\n/**\n * Read the resolved progress from a surrounding `TeamProgressBarRoot`.\n * For hand-assembled layouts; throws if used outside a `Root`.\n */\nexport function useTeamProgressBar(): TeamProgressBarContextValue {\n  const ctx = React.useContext(TeamProgressBarContext);\n  if (ctx === null) {\n    throw new Error(\n      \"useTeamProgressBar must be used within a <TeamProgressBarRoot>.\",\n    );\n  }\n  return ctx;\n}\n",
      "type": "registry:component",
      "target": "components/team-progress-bar/hooks/use-team-progress-bar.ts"
    },
    {
      "path": "src/registry/components/gamification/team-progress-bar/hooks/use-progress-telemetry.ts",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { useLatestRef } from \"../../_shared/gamification-kit\";\nimport { progressBarCheckedEvent } from \"../lib/event\";\nimport type { GamificationEvent } from \"../types\";\n\n/**\n * \"Viewed once per mount\" telemetry — fires `progress-bar.checked` the first\n * time the bar scrolls ≥ 1px into the viewport, then disconnects. Pure side\n * effect (never sets state → never re-renders). SSR-safe (observer is created\n * in an effect, never during render).\n *\n * Footguns handled:\n *  - **No `onEvent` → no observer** (pay-for-what-you-use).\n *  - **Double-emit guard** via a `hasFired` ref (covers StrictMode + re-entry).\n *  - **Stable `onEvent`** read through a ref, so an inline handler (new identity\n *    each render) does not re-subscribe the observer.\n */\nexport function useProgressTelemetry(args: {\n  teamId: string;\n  onEvent?: (event: GamificationEvent) => void;\n}): React.RefObject<HTMLDivElement | null> {\n  const { teamId, onEvent } = args;\n\n  const targetRef = React.useRef<HTMLDivElement | null>(null);\n  const hasFired = React.useRef(false);\n  const onEventRef = useLatestRef(onEvent);\n\n  const hasHandler = Boolean(onEvent);\n\n  React.useEffect(() => {\n    if (!hasHandler) return;\n    const node = targetRef.current;\n    if (!node || hasFired.current) return;\n\n    const fire = () => {\n      if (hasFired.current) return;\n      hasFired.current = true;\n      onEventRef.current?.(progressBarCheckedEvent(teamId));\n    };\n\n    // Old/SSR-ish environments with no IntersectionObserver: fire once on mount.\n    if (typeof IntersectionObserver === \"undefined\") {\n      fire();\n      return;\n    }\n\n    const observer = new IntersectionObserver(\n      (entries) => {\n        for (const entry of entries) {\n          if (entry.isIntersecting) {\n            fire();\n            observer.disconnect();\n            break;\n          }\n        }\n      },\n      { threshold: 0 },\n    );\n    observer.observe(node);\n    return () => observer.disconnect();\n    // `onEventRef` is a stable ref object — listed for exhaustive-deps only.\n  }, [teamId, hasHandler, onEventRef]);\n\n  return targetRef;\n}\n",
      "type": "registry:component",
      "target": "components/team-progress-bar/hooks/use-progress-telemetry.ts"
    },
    {
      "path": "src/registry/components/gamification/team-progress-bar/parts/team-progress-bar-root.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { useProgressTelemetry } from \"../hooks/use-progress-telemetry\";\nimport { TeamProgressBarContext } from \"../hooks/use-team-progress-bar\";\nimport { resolveLabelFormat, resolveProgress } from \"../lib/resolve-progress\";\nimport type {\n  TeamProgressBarContextValue,\n  TeamProgressBarRootProps,\n} from \"../types\";\n\n/**\n * Tier B — headless provider. The **single** place the percentage is resolved\n * and the **single** place `progress-bar.checked` fires, so every part and any\n * hand-assembly read one resolved value (the math + the emit can't drift or\n * double-fire). No data/persistence state, no layout opinion — renders children.\n */\nexport function TeamProgressBarRoot({\n  value,\n  milestones,\n  team,\n  labelFormat = \"percent\",\n  onEvent,\n  className,\n  children,\n}: TeamProgressBarRootProps) {\n  const resolved = React.useMemo(\n    () => resolveProgress({ value, milestones }),\n    [value, milestones],\n  );\n\n  const effectiveLabelFormat = React.useMemo(\n    () => resolveLabelFormat(labelFormat, resolved.total),\n    [labelFormat, resolved.total],\n  );\n\n  // Telemetry target — IntersectionObserver attaches here (client-only, in an effect).\n  const targetRef = useProgressTelemetry({ teamId: team.id, onEvent });\n\n  const contextValue = React.useMemo<TeamProgressBarContextValue>(\n    () => ({\n      pct: resolved.pct,\n      doneCount: resolved.doneCount,\n      total: resolved.total,\n      ticks: resolved.ticks,\n      team,\n      labelFormat: effectiveLabelFormat,\n    }),\n    [resolved, team, effectiveLabelFormat],\n  );\n\n  return (\n    <TeamProgressBarContext.Provider value={contextValue}>\n      <div\n        ref={targetRef}\n        className={cn(\"reveal-up flex w-full flex-col gap-1.5\", className)}\n      >\n        {children}\n      </div>\n    </TeamProgressBarContext.Provider>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/team-progress-bar/parts/team-progress-bar-root.tsx"
    },
    {
      "path": "src/registry/components/gamification/team-progress-bar/parts/team-progress-bar-track.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { Progress } from \"@/components/ui/progress\";\nimport { cn } from \"@/lib/utils\";\n\nimport { useTeamProgressBar } from \"../hooks/use-team-progress-bar\";\nimport type { ProgressTrackProps } from \"../types\";\n\n/**\n * Tier C — dumb, context-free fill bar over the shadcn `progress` primitive.\n * Usable anywhere with a 0–100 `pct`. The signal-lime fill comes from the\n * primitive's `bg-primary` indicator; the fill transition is gated by\n * `prefers-reduced-motion` (targeting the indicator's `data-slot`). The\n * underlying Radix `Progress` already provides `role=\"progressbar\"` +\n * `aria-valuenow/min/max` — the single accessible representation of the value.\n *\n * Ticks are rendered as a sibling overlay (NOT inside the primitive, whose Root\n * is `overflow-x-hidden` and would clip the edge notches). They are decorative\n * (`aria-hidden`) — the progressbar value is the source of truth.\n */\nexport function ProgressTrack({\n  pct,\n  ticks,\n  showTicks = false,\n  className,\n  \"aria-label\": ariaLabel,\n}: ProgressTrackProps) {\n  const renderTicks = showTicks && ticks != null && ticks.length > 0;\n\n  return (\n    <div className={cn(\"group relative w-full\", className)}>\n      <Progress\n        value={pct}\n        aria-label={ariaLabel}\n        className={cn(\n          // Hover: the lime fill brightens + the bar gains a soft lime glow.\n          \"h-2.5 bg-muted transition-shadow duration-200 group-hover:shadow-[0_0_10px] group-hover:shadow-primary/30\",\n          \"group-hover:**:data-[slot=progress-indicator]:brightness-110\",\n          \"motion-reduce:**:data-[slot=progress-indicator]:transition-none\",\n        )}\n      />\n      {renderTicks ? (\n        <div className=\"pointer-events-none absolute inset-0\" aria-hidden>\n          {ticks.map((tick, i) => (\n            <span\n              key={i}\n              title={tick.label}\n              className={cn(\n                \"absolute top-0 h-full rounded-full\",\n                // Non-color cue: done notches are thicker + solid; pending are hairline.\n                tick.done\n                  ? \"w-0.5 bg-primary-foreground/60\"\n                  : \"w-px bg-muted-foreground/50\",\n              )}\n              style={{\n                insetInlineStart: `${((i + 1) / ticks.length) * 100}%`,\n                transform: \"translateX(-50%)\",\n              }}\n            />\n          ))}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n\n/**\n * Tier B — context wrapper. Reads the resolved `pct` + `ticks` and renders the\n * Tier-C `ProgressTrack`. Builds a default `aria-label` from the team name + %.\n */\nexport function TeamProgressBarTrack({\n  showTicks = false,\n  className,\n  \"aria-label\": ariaLabel,\n}: {\n  showTicks?: boolean;\n  className?: string;\n  \"aria-label\"?: string;\n}) {\n  const { pct, ticks, team } = useTeamProgressBar();\n  const label = ariaLabel ?? `${team.name ?? \"Team\"} progress: ${pct}%`;\n\n  return (\n    <ProgressTrack\n      pct={pct}\n      ticks={ticks}\n      showTicks={showTicks}\n      className={className}\n      aria-label={label}\n    />\n  );\n}\n",
      "type": "registry:component",
      "target": "components/team-progress-bar/parts/team-progress-bar-track.tsx"
    },
    {
      "path": "src/registry/components/gamification/team-progress-bar/parts/team-progress-bar-label.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { useTeamProgressBar } from \"../hooks/use-team-progress-bar\";\n\n/**\n * Tier B — the numeric readout. Reads the resolved progress from context\n * (never re-derives it) and renders `\"62%\"` or `\"5 / 8 milestones\"`, optionally\n * prefixed with the team name (`\"Team Aurora — 62%\"`). The number is the\n * load-bearing value: it uses JetBrains Mono (`font-mono`), `tabular-nums`, and\n * never truncates — only the team name truncates when space is tight.\n */\nexport function TeamProgressBarLabel({ className }: { className?: string }) {\n  const { pct, doneCount, total, team, labelFormat } = useTeamProgressBar();\n\n  const readout =\n    labelFormat === \"fraction\" && total != null\n      ? `${doneCount} / ${total} milestones`\n      : `${pct}%`;\n\n  return (\n    <div className={cn(\"flex items-baseline gap-1.5 text-sm\", className)}>\n      {team.name ? (\n        <>\n          <span className=\"min-w-0 truncate text-muted-foreground\">\n            {team.name}\n          </span>\n          <span className=\"text-muted-foreground\" aria-hidden>\n            —\n          </span>\n        </>\n      ) : null}\n      <span className=\"shrink-0 font-mono font-medium tabular-nums text-foreground\">\n        {readout}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/team-progress-bar/parts/team-progress-bar-label.tsx"
    }
  ],
  "categories": [
    "gamification"
  ],
  "type": "registry:block"
}