{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "team-quest-log",
  "title": "Team Quest Log",
  "author": "ilinxa",
  "description": "Team quest overlay — an editable skippable quest name plus a milestone chapter timeline with done, current, and upcoming beats.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "button",
    "input",
    "@ilinxa/gamification-kit"
  ],
  "files": [
    {
      "path": "src/registry/components/gamification/team-quest-log/team-quest-log.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { TeamQuestNameEditor } from \"./parts/team-quest-name-editor\";\nimport { TeamQuestChapters } from \"./parts/team-quest-chapters\";\nimport { TeamQuestLogRoot } from \"./parts/team-quest-log-root\";\nimport type { TeamQuestLogHandle, TeamQuestLogProps } from \"./types\";\n\n/**\n * Tier A — the batteries-included assembly: `Root` + `TeamQuestNameEditor`\n * (gated by `showNameEditor`) + `TeamQuestChapters` (gated by `showChapters`).\n * Contains **no logic the parts don't** — a hand-assembled layout (`Root` + a\n * subset of parts) gets identical title resolution, beat derivation, and\n * fire-once telemetry. Forwards the imperative handle from `Root`.\n *\n * A light team narrative overlay: a skippable quest name (never forced) + a\n * milestone-chapter timeline (done / current / upcoming), team-scoped.\n */\nexport const TeamQuestLog = React.forwardRef<\n  TeamQuestLogHandle,\n  TeamQuestLogProps\n>(function TeamQuestLog(\n  { showNameEditor = true, showChapters = true, ...rest },\n  ref,\n) {\n  return (\n    <TeamQuestLogRoot ref={ref} {...rest}>\n      {showNameEditor ? <TeamQuestNameEditor /> : null}\n      {showChapters ? <TeamQuestChapters /> : null}\n    </TeamQuestLogRoot>\n  );\n});\n",
      "type": "registry:component",
      "target": "components/team-quest-log/team-quest-log.tsx"
    },
    {
      "path": "src/registry/components/gamification/team-quest-log/index.ts",
      "content": "// Tier A — batteries-included assembly.\nexport { TeamQuestLog } from \"./team-quest-log\";\n\n// Tier B — headless provider + flat à-la-carte context parts.\nexport { TeamQuestLogRoot } from \"./parts/team-quest-log-root\";\nexport { TeamQuestNameEditor } from \"./parts/team-quest-name-editor\";\nexport { TeamQuestChapters } from \"./parts/team-quest-chapters\";\n\n// Tier C — standalone, context-free primitives.\nexport { QuestTitle, QuestNameField } from \"./parts/team-quest-name-editor\";\nexport { ChapterRail, EmptyNarrative } from \"./parts/team-quest-chapters\";\nexport { ChapterBeat } from \"./parts/chapter-beat\";\n\n// Context consumer for hand-assembled layouts.\nexport { useTeamQuestLog } from \"./hooks/use-team-quest-log\";\n\n// Pure helpers (a hand-assembly resolves identically).\nexport { resolveQuestTitle } from \"./lib/resolve-title\";\nexport { deriveBeats } from \"./lib/derive-beats\";\n\n// Public types.\nexport type {\n  Team,\n  Milestone,\n  NarrativeChapter,\n  GamificationEvent,\n  BeatState,\n  DerivedBeat,\n  TeamQuestLogProps,\n  TeamQuestLogBaseProps,\n  TeamQuestLogRootProps,\n  TeamQuestLogHandle,\n  TeamQuestLogContextValue,\n  QuestTitleProps,\n  QuestNameFieldProps,\n  ChapterBeatProps,\n  ChapterRailProps,\n} from \"./types\";\n",
      "type": "registry:component",
      "target": "components/team-quest-log/index.ts"
    },
    {
      "path": "src/registry/components/gamification/team-quest-log/types.ts",
      "content": "import type { ReactNode } from \"react\";\n\n/**\n * `team-quest-log` — 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 (only the rendered fields), byte-faithful, not imported.\n */\n\n/** This team (D-15 subset). `name` is the fallback title; `questName` the chosen one. */\nexport interface Team {\n  id: string;\n  name: string;\n  /** The chosen quest title; empty/blank → falls back to `name` (never forced). */\n  questName?: string;\n}\n\n/** A host-owned milestone — the shared spine each chapter frames (D-09). */\nexport interface Milestone {\n  id: string;\n  label: string;\n  done: boolean;\n  /** ISO 8601 — optional \"completed on\" affordance. */\n  doneAt?: string;\n  order: number;\n}\n\n/** A narrative beat framing a milestone. */\nexport interface NarrativeChapter {\n  id: string;\n  title: string;\n  /** Links the beat to its `Milestone` (the shared spine, D-09). */\n  milestoneId: string;\n  order: number;\n}\n\n/** The local slice of the system §6 union — the one event this component emits. */\nexport type GamificationEvent = {\n  type: \"narrative.chapter-viewed\";\n  teamId: string;\n  chapterId: string;\n};\n\n/** Resolved beat state (see `lib/derive-beats.ts`). */\nexport type BeatState = \"done\" | \"current\" | \"upcoming\";\n\n/** One derived beat — the render-ready unit. */\nexport interface DerivedBeat {\n  chapter: NarrativeChapter;\n  /** Resolved by `milestoneId`; `undefined` → unresolved (renders gracefully + warns). */\n  milestone?: Milestone;\n  state: BeatState;\n  /** `true` → `milestoneId` matched no milestone. */\n  unresolved: boolean;\n}\n\n/** Data + behavioral surface shared by the assembly and the headless Root. */\nexport interface TeamQuestLogBaseProps {\n  /** The team — literal name (fallback) + optional quest name (D-15 subset). */\n  team: Team;\n  /** Host-owned milestones — the shared spine (D-09). */\n  milestones: Milestone[];\n  /** The narrative beats; each frames a milestone. Empty → quiet empty state. */\n  chapters: NarrativeChapter[];\n  /** Allow inline name editing. Default `true`; `false` → display-only title. */\n  editableName?: boolean;\n  /** Fires on save. Empty/blank ⇒ revert to the team's literal name (never forced). */\n  onQuestNameChange?: (questName: string) => void;\n  /** Telemetry (system §6, D-07) — emits `narrative.chapter-viewed`, fire-once per mount. */\n  onEvent?: (event: GamificationEvent) => void;\n  /** Fires when a chapter beat is activated; also drives the telemetry fallback when IO is unavailable. */\n  onChapterClick?: (chapter: NarrativeChapter) => void;\n  /** Override an individual beat's render. */\n  renderChapter?: (ctx: {\n    chapter: NarrativeChapter;\n    state: BeatState;\n  }) => ReactNode;\n  className?: string;\n  \"aria-label\"?: string;\n}\n\n/** Props for the batteries-included `TeamQuestLog` assembly. */\nexport interface TeamQuestLogProps extends TeamQuestLogBaseProps {\n  /** Show the quest-name editor. Default `true`. */\n  showNameEditor?: boolean;\n  /** Show the chapter timeline. Default `true`. */\n  showChapters?: boolean;\n}\n\n/** Props for the headless provider `TeamQuestLogRoot`. */\nexport interface TeamQuestLogRootProps extends TeamQuestLogBaseProps {\n  children: ReactNode;\n}\n\n/** Light imperative handle (exposed via `forwardRef` on Root + the assembly). */\nexport interface TeamQuestLogHandle {\n  /** Enter edit mode + focus the quest-name input. */\n  focusNameEditor(): void;\n  /** Scroll the matching chapter beat into view. */\n  scrollToChapter(chapterId: string): void;\n}\n\n/** The value every context part reads — one source of truth. */\nexport interface TeamQuestLogContextValue {\n  team: Team;\n  /** Resolved title = `questName?.trim() || name`. */\n  title: string;\n  /** `true` when falling back to the literal team name. */\n  isDefaultTitle: boolean;\n  beats: DerivedBeat[];\n  editableName: boolean;\n  /** `true` when the edit affordance should show (editable + a save handler wired). */\n  canEditName: boolean;\n  // Name draft/edit state (transient UI state — not data state, D-06).\n  editing: boolean;\n  draft: string;\n  startEditing(): void;\n  setDraft(value: string): void;\n  saveDraft(): void;\n  cancelEditing(): void;\n  // Chapter interaction + telemetry.\n  onChapterClick?: (chapter: NarrativeChapter) => void;\n  onEvent?: (event: GamificationEvent) => void;\n  renderChapter?: (ctx: {\n    chapter: NarrativeChapter;\n    state: BeatState;\n  }) => ReactNode;\n  /** Registers the quest-name input node for focus + the chapter rail node for scroll. */\n  registerNameInput(node: HTMLInputElement | null): void;\n  registerRail(node: HTMLElement | null): void;\n}\n\n// Tier-C primitive prop types.\n\nexport interface QuestTitleProps {\n  title: string;\n  isDefault?: boolean;\n  className?: string;\n}\n\nexport interface QuestNameFieldProps {\n  value: string;\n  onChange: (value: string) => void;\n  onSave: () => void;\n  onCancel: () => void;\n  inputRef?: (node: HTMLInputElement | null) => void;\n  className?: string;\n}\n\nexport interface ChapterBeatProps {\n  chapter: NarrativeChapter;\n  state: BeatState;\n  milestone?: Milestone;\n  onClick?: (chapter: NarrativeChapter) => void;\n  className?: string;\n}\n\nexport interface ChapterRailProps {\n  beats: DerivedBeat[];\n  onBeatClick?: (chapter: NarrativeChapter) => void;\n  renderChapter?: (ctx: {\n    chapter: NarrativeChapter;\n    state: BeatState;\n  }) => ReactNode;\n  className?: string;\n}\n",
      "type": "registry:component",
      "target": "components/team-quest-log/types.ts"
    },
    {
      "path": "src/registry/components/gamification/team-quest-log/lib/resolve-title.ts",
      "content": "import type { Team } from \"../types\";\n\n/**\n * Resolve the displayed quest title (the never-forced core, system §5.2 / D-A1).\n * A blank or whitespace-only `questName` is treated as \"no quest name\" → falls\n * back to the literal team name. Pure, framework-free, SSR-safe, test-ready.\n */\nexport function resolveQuestTitle(team: Pick<Team, \"name\" | \"questName\">): {\n  title: string;\n  isDefault: boolean;\n} {\n  const custom = team.questName?.trim();\n  return custom\n    ? { title: custom, isDefault: false }\n    : { title: team.name, isDefault: true };\n}\n",
      "type": "registry:component",
      "target": "components/team-quest-log/lib/resolve-title.ts"
    },
    {
      "path": "src/registry/components/gamification/team-quest-log/lib/derive-beats.ts",
      "content": "import type { DerivedBeat, Milestone, NarrativeChapter } from \"../types\";\n\n/**\n * Derive each chapter's beat state from its milestone (LOCK — D-A2). Pure,\n * framework-free, SSR-safe, test-ready, order-independent (stable sort on\n * `NarrativeChapter.order`, `Milestone.order` tie-break):\n *\n *   done     = milestone.done === true\n *   current  = the FIRST not-done beat by order (\"you are here\")\n *   upcoming = the rest\n *\n * An unresolved `milestoneId` (no matching milestone) is treated as not-done\n * (so it can become current/upcoming) and flagged `unresolved` — the part warns\n * (dev-only) + renders it gracefully; **never throws**.\n */\nexport function deriveBeats(\n  chapters: NarrativeChapter[],\n  milestones: Milestone[],\n): DerivedBeat[] {\n  const byId = new Map(milestones.map((m) => [m.id, m]));\n\n  const ordered = [...chapters].sort(\n    (a, b) =>\n      a.order - b.order ||\n      (byId.get(a.milestoneId)?.order ?? 0) -\n        (byId.get(b.milestoneId)?.order ?? 0),\n  );\n\n  let currentAssigned = false;\n  return ordered.map((chapter) => {\n    const milestone = byId.get(chapter.milestoneId);\n    const unresolved = milestone === undefined;\n    const done = milestone?.done === true;\n\n    let state: DerivedBeat[\"state\"];\n    if (done) {\n      state = \"done\";\n    } else if (!currentAssigned) {\n      state = \"current\";\n      currentAssigned = true;\n    } else {\n      state = \"upcoming\";\n    }\n\n    return { chapter, milestone, state, unresolved };\n  });\n}\n",
      "type": "registry:component",
      "target": "components/team-quest-log/lib/derive-beats.ts"
    },
    {
      "path": "src/registry/components/gamification/team-quest-log/hooks/use-team-quest-log.ts",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport type { TeamQuestLogContextValue } from \"../types\";\n\nexport const TeamQuestLogContext =\n  React.createContext<TeamQuestLogContextValue | null>(null);\n\n/**\n * Read the quest-log state from a surrounding `TeamQuestLogRoot`.\n * For hand-assembled layouts; throws if used outside a `Root`.\n */\nexport function useTeamQuestLog(): TeamQuestLogContextValue {\n  const ctx = React.useContext(TeamQuestLogContext);\n  if (ctx === null) {\n    throw new Error(\"useTeamQuestLog must be used within a <TeamQuestLogRoot>.\");\n  }\n  return ctx;\n}\n",
      "type": "registry:component",
      "target": "components/team-quest-log/hooks/use-team-quest-log.ts"
    },
    {
      "path": "src/registry/components/gamification/team-quest-log/hooks/use-chapter-view-telemetry.ts",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { useLatestRef } from \"../../_shared/gamification-kit\";\nimport type { GamificationEvent } from \"../types\";\n\n/**\n * Visibility-based `narrative.chapter-viewed` telemetry (LOCK — D-A3). ONE\n * IntersectionObserver over all beat nodes (not one per beat), threshold 0.5;\n * **fire-once per `chapterId` per mount** via a ref Set (re-render / re-entry /\n * scroll-back never re-emit). SSR/jsdom-safe (observer built in an effect,\n * guarded on `typeof IntersectionObserver`). No `onEvent` → no observer.\n *\n * Returns a `containerRef` to place on the rail wrapper (beat nodes are found by\n * `[data-chapter-id]` within it) + a `fireForChapter(id)` fallback the click\n * path calls when IO is unavailable (still fire-once via the same Set).\n */\nexport function useChapterViewTelemetry(args: {\n  teamId: string;\n  /** A stable key of the current chapter ids — re-observes when the beat set changes. */\n  chapterKey: string;\n  onEvent?: (event: GamificationEvent) => void;\n}): {\n  containerRef: React.RefObject<HTMLDivElement | null>;\n  fireForChapter: (chapterId: string) => void;\n} {\n  const { teamId, chapterKey, onEvent } = args;\n\n  const containerRef = React.useRef<HTMLDivElement | null>(null);\n  const firedRef = React.useRef<Set<string>>(new Set());\n  const onEventRef = useLatestRef(onEvent);\n\n  const emit = React.useCallback(\n    (chapterId: string) => {\n      if (firedRef.current.has(chapterId)) return;\n      firedRef.current.add(chapterId);\n      onEventRef.current?.({\n        type: \"narrative.chapter-viewed\",\n        teamId,\n        chapterId,\n      });\n    },\n    // `onEventRef` is a stable ref object — listed for exhaustive-deps only.\n    [teamId, onEventRef],\n  );\n\n  const hasHandler = Boolean(onEvent);\n\n  React.useEffect(() => {\n    if (!hasHandler) return;\n    const container = containerRef.current;\n    if (!container) return;\n    // IO unavailable (SSR snapshot / jsdom / ancient) → the click fallback covers it.\n    if (typeof IntersectionObserver === \"undefined\") return;\n\n    const observer = new IntersectionObserver(\n      (entries) => {\n        for (const entry of entries) {\n          if (!entry.isIntersecting) continue;\n          const id = (entry.target as HTMLElement).dataset.chapterId;\n          if (id) emit(id);\n        }\n      },\n      { threshold: 0.5 },\n    );\n\n    const nodes = container.querySelectorAll<HTMLElement>(\"[data-chapter-id]\");\n    nodes.forEach((n) => observer.observe(n));\n    return () => observer.disconnect();\n    // `chapterKey` re-runs the effect when beats are added/removed.\n  }, [hasHandler, emit, chapterKey]);\n\n  return { containerRef, fireForChapter: emit };\n}\n",
      "type": "registry:component",
      "target": "components/team-quest-log/hooks/use-chapter-view-telemetry.ts"
    },
    {
      "path": "src/registry/components/gamification/team-quest-log/parts/team-quest-log-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 { TeamQuestLogContext } from \"../hooks/use-team-quest-log\";\nimport { deriveBeats } from \"../lib/derive-beats\";\nimport { resolveQuestTitle } from \"../lib/resolve-title\";\nimport type {\n  TeamQuestLogContextValue,\n  TeamQuestLogHandle,\n  TeamQuestLogRootProps,\n} from \"../types\";\n\n/**\n * Tier B — headless provider. The single source of state: the resolved quest\n * title (`questName?.trim() || name` — the never-forced core), the transient\n * name draft/edit state (UI state, not data state — controlled, D-06), the\n * memoized beat derivation, chapter interaction + telemetry pass-through, and\n * the imperative handle. Renders `children`, no layout opinion beyond a labelled\n * region + `reveal-up`.\n */\nexport const TeamQuestLogRoot = React.forwardRef<\n  TeamQuestLogHandle,\n  TeamQuestLogRootProps\n>(function TeamQuestLogRoot(\n  {\n    team,\n    milestones,\n    chapters,\n    editableName = true,\n    onQuestNameChange,\n    onEvent,\n    onChapterClick,\n    renderChapter,\n    children,\n    className,\n    \"aria-label\": ariaLabel,\n  },\n  ref,\n) {\n  const { title, isDefault } = React.useMemo(\n    () => resolveQuestTitle(team),\n    [team],\n  );\n\n  const beats = React.useMemo(\n    () => deriveBeats(chapters, milestones),\n    [chapters, milestones],\n  );\n\n  const canEditName = editableName && typeof onQuestNameChange === \"function\";\n\n  // Transient draft/edit state (UI, not data — D-06 intact).\n  const [editing, setEditing] = React.useState(false);\n  const [draft, setDraft] = React.useState(\"\");\n\n  const startEditing = React.useCallback(() => {\n    if (!canEditName) return;\n    setDraft(isDefault ? \"\" : title);\n    setEditing(true);\n  }, [canEditName, isDefault, title]);\n\n  const onQuestNameChangeRef = useLatestRef(onQuestNameChange);\n\n  const saveDraft = React.useCallback(() => {\n    // Blank/whitespace ⇒ \"\" ⇒ host clears questName ⇒ title reverts (the skip path).\n    onQuestNameChangeRef.current?.(draft.trim());\n    setEditing(false);\n    // `onQuestNameChangeRef` is a stable ref object — listed for exhaustive-deps only.\n  }, [draft, onQuestNameChangeRef]);\n\n  const cancelEditing = React.useCallback(() => setEditing(false), []);\n\n  // Node registries for the imperative handle.\n  const nameInputRef = React.useRef<HTMLInputElement | null>(null);\n  const railRef = React.useRef<HTMLElement | null>(null);\n  const registerNameInput = React.useCallback(\n    (node: HTMLInputElement | null) => {\n      nameInputRef.current = node;\n    },\n    [],\n  );\n  const registerRail = React.useCallback((node: HTMLElement | null) => {\n    railRef.current = node;\n  }, []);\n\n  React.useImperativeHandle(\n    ref,\n    () => ({\n      focusNameEditor() {\n        startEditing(); // QuestNameField auto-focuses its input on mount.\n      },\n      scrollToChapter(chapterId: string) {\n        const node = railRef.current?.querySelector<HTMLElement>(\n          `[data-chapter-id=\"${chapterId}\"]`,\n        );\n        node?.scrollIntoView({ block: \"nearest\" });\n      },\n    }),\n    [startEditing],\n  );\n\n  const contextValue = React.useMemo<TeamQuestLogContextValue>(\n    () => ({\n      team,\n      title,\n      isDefaultTitle: isDefault,\n      beats,\n      editableName,\n      canEditName,\n      editing,\n      draft,\n      startEditing,\n      setDraft,\n      saveDraft,\n      cancelEditing,\n      onChapterClick,\n      onEvent,\n      renderChapter,\n      registerNameInput,\n      registerRail,\n    }),\n    [\n      team,\n      title,\n      isDefault,\n      beats,\n      editableName,\n      canEditName,\n      editing,\n      draft,\n      startEditing,\n      saveDraft,\n      cancelEditing,\n      onChapterClick,\n      onEvent,\n      renderChapter,\n      registerNameInput,\n      registerRail,\n    ],\n  );\n\n  return (\n    <TeamQuestLogContext.Provider value={contextValue}>\n      <section\n        aria-label={ariaLabel ?? `${title} — team quest log`}\n        className={cn(\"reveal-up flex w-full flex-col gap-5\", className)}\n      >\n        {children}\n      </section>\n    </TeamQuestLogContext.Provider>\n  );\n});\n",
      "type": "registry:component",
      "target": "components/team-quest-log/parts/team-quest-log-root.tsx"
    },
    {
      "path": "src/registry/components/gamification/team-quest-log/parts/team-quest-name-editor.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { Check, Pencil, Sparkles, X } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { cn } from \"@/lib/utils\";\n\nimport { useTeamQuestLog } from \"../hooks/use-team-quest-log\";\nimport type { QuestNameFieldProps, QuestTitleProps } from \"../types\";\n\n/**\n * Tier C — dumb resolved-title display. `isDefault` (falling back to the literal\n * team name) is a subtle affordance hook, never a nag.\n */\nexport function QuestTitle({ title, isDefault = false, className }: QuestTitleProps) {\n  return (\n    <h2\n      className={cn(\n        \"truncate text-lg font-semibold\",\n        isDefault ? \"text-foreground\" : \"text-foreground\",\n        className,\n      )}\n      title={title}\n    >\n      {title}\n    </h2>\n  );\n}\n\n/**\n * Tier C — dumb controlled name input + save/cancel. `Enter` saves, `Escape`\n * cancels; a blank save is allowed (the revert/skip path). Auto-focuses on mount\n * (edit mode entered). Plain controlled `<input>` — no backend-specific props,\n * so it stays portable across the Radix↔Base-UI `input` (a simple wrapper).\n */\nexport function QuestNameField({\n  value,\n  onChange,\n  onSave,\n  onCancel,\n  inputRef,\n  className,\n}: QuestNameFieldProps) {\n  const localRef = React.useRef<HTMLInputElement | null>(null);\n  const setRef = React.useCallback(\n    (node: HTMLInputElement | null) => {\n      localRef.current = node;\n      inputRef?.(node);\n    },\n    [inputRef],\n  );\n\n  React.useEffect(() => {\n    localRef.current?.focus();\n    localRef.current?.select();\n  }, []);\n\n  return (\n    <div className={cn(\"flex items-center gap-1.5\", className)}>\n      <Input\n        ref={setRef}\n        value={value}\n        onChange={(e) => onChange(e.target.value)}\n        onKeyDown={(e) => {\n          if (e.key === \"Enter\") {\n            e.preventDefault();\n            onSave();\n          } else if (e.key === \"Escape\") {\n            e.preventDefault();\n            onCancel();\n          }\n        }}\n        aria-label=\"Quest name\"\n        placeholder=\"Name your quest…\"\n        className=\"max-w-xs\"\n      />\n      <Button type=\"button\" size=\"icon\" className=\"size-8\" aria-label=\"Save quest name\" onClick={onSave}>\n        <Check aria-hidden />\n      </Button>\n      <Button\n        type=\"button\"\n        size=\"icon\"\n        variant=\"ghost\"\n        className=\"size-8\"\n        aria-label=\"Cancel\"\n        onClick={onCancel}\n      >\n        <X aria-hidden />\n      </Button>\n    </div>\n  );\n}\n\n/**\n * Tier B — the quest-name surface. Display the resolved title + a quiet edit\n * affordance (a \"Name your quest\" invitation when on the default, an edit pencil\n * when custom). Never a blocking prompt. The edit affordance is gated on\n * `canEditName` (editable + a save handler wired). In edit mode → `QuestNameField`.\n */\nexport function TeamQuestNameEditor({ className }: { className?: string }) {\n  const {\n    title,\n    isDefaultTitle,\n    canEditName,\n    editing,\n    draft,\n    startEditing,\n    setDraft,\n    saveDraft,\n    cancelEditing,\n    registerNameInput,\n  } = useTeamQuestLog();\n\n  if (editing) {\n    return (\n      <QuestNameField\n        value={draft}\n        onChange={setDraft}\n        onSave={saveDraft}\n        onCancel={cancelEditing}\n        inputRef={registerNameInput}\n        className={className}\n      />\n    );\n  }\n\n  return (\n    <div className={cn(\"flex items-center gap-2\", className)}>\n      <QuestTitle title={title} isDefault={isDefaultTitle} />\n      {canEditName ? (\n        isDefaultTitle ? (\n          <Button\n            type=\"button\"\n            size=\"sm\"\n            variant=\"ghost\"\n            className=\"gap-1 text-muted-foreground\"\n            onClick={startEditing}\n          >\n            <Sparkles aria-hidden className=\"size-3.5\" /> Name your quest\n          </Button>\n        ) : (\n          <Button\n            type=\"button\"\n            size=\"icon\"\n            variant=\"ghost\"\n            className=\"size-7 text-muted-foreground\"\n            aria-label=\"Edit quest name\"\n            onClick={startEditing}\n          >\n            <Pencil aria-hidden className=\"size-3.5\" />\n          </Button>\n        )\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/team-quest-log/parts/team-quest-name-editor.tsx"
    },
    {
      "path": "src/registry/components/gamification/team-quest-log/parts/team-quest-chapters.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { BookOpen } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { useChapterViewTelemetry } from \"../hooks/use-chapter-view-telemetry\";\nimport { useTeamQuestLog } from \"../hooks/use-team-quest-log\";\nimport { ChapterBeat } from \"./chapter-beat\";\nimport type { ChapterRailProps } from \"../types\";\n\n/**\n * Tier C — quiet empty-state placeholder (no chapters authored). Never an error.\n */\nexport function EmptyNarrative({ className }: { className?: string }) {\n  return (\n    <div\n      className={cn(\n        \"flex flex-col items-center gap-2 rounded-lg border border-dashed border-border bg-muted/30 px-4 py-8 text-center\",\n        className,\n      )}\n    >\n      <BookOpen aria-hidden className=\"size-5 text-muted-foreground\" />\n      <p className=\"text-sm text-muted-foreground\">\n        Your team&apos;s story will appear here as milestones are reached.\n      </p>\n    </div>\n  );\n}\n\n/**\n * Tier C — the vertical rail: a connecting spine (the done portion accents in\n * signal-lime, the upcoming portion is muted) mapping `beats[]` to `ChapterBeat`s.\n * Prop-driven, context-free.\n */\nexport function ChapterRail({\n  beats,\n  onBeatClick,\n  renderChapter,\n  className,\n}: ChapterRailProps) {\n  return (\n    <div role=\"list\" className={cn(\"relative flex flex-col\", className)}>\n      {beats.map((beat, i) => {\n        const isLast = i === beats.length - 1;\n        return (\n          <div key={beat.chapter.id} className=\"relative\">\n            {!isLast ? (\n              <span\n                aria-hidden\n                className={cn(\n                  \"absolute top-7 bottom-0 left-[13px] w-px\",\n                  beat.state === \"done\" ? \"bg-primary\" : \"bg-border\",\n                )}\n              />\n            ) : null}\n            {renderChapter ? (\n              <div role=\"listitem\" data-chapter-id={beat.chapter.id}>\n                {renderChapter({ chapter: beat.chapter, state: beat.state })}\n              </div>\n            ) : (\n              <ChapterBeat\n                chapter={beat.chapter}\n                state={beat.state}\n                milestone={beat.milestone}\n                onClick={onBeatClick}\n              />\n            )}\n          </div>\n        );\n      })}\n    </div>\n  );\n}\n\n/**\n * Tier B — the chapter timeline surface. Reads the derived beats, attaches the\n * fire-once visibility telemetry (one IntersectionObserver over all beat nodes),\n * and renders the `ChapterRail` (or `EmptyNarrative` when there are no chapters).\n * Beats are interactive only when `onChapterClick` is wired (capability-gating).\n */\nexport function TeamQuestChapters({ className }: { className?: string }) {\n  const { team, beats, onChapterClick, onEvent, renderChapter, registerRail } =\n    useTeamQuestLog();\n\n  const chapterKey = beats.map((b) => b.chapter.id).join(\",\");\n  const { containerRef, fireForChapter } = useChapterViewTelemetry({\n    teamId: team.id,\n    chapterKey,\n    onEvent,\n  });\n\n  // Dev-only: surface any chapter whose milestoneId matched no milestone (it\n  // renders gracefully as upcoming, never crashes — §7). NODE_ENV is the one\n  // sanctioned env reference, kept in a function body (erased in prod builds).\n  React.useEffect(() => {\n    if (process.env.NODE_ENV === \"production\") return;\n    const missing = beats.filter((b) => b.unresolved);\n    if (missing.length > 0) {\n      console.warn(\n        `[team-quest-log] ${missing.length} chapter(s) reference an unknown milestoneId: ${missing\n          .map((b) => b.chapter.milestoneId)\n          .join(\", \")}`,\n      );\n    }\n  }, [beats]);\n\n  const setNode = React.useCallback(\n    (node: HTMLDivElement | null) => {\n      containerRef.current = node;\n      registerRail(node);\n    },\n    [containerRef, registerRail],\n  );\n\n  const handleBeatClick = React.useCallback(\n    (chapter: (typeof beats)[number][\"chapter\"]) => {\n      onChapterClick?.(chapter);\n      // Telemetry fallback when IO is unavailable — fire-once via the shared Set.\n      fireForChapter(chapter.id);\n    },\n    [onChapterClick, fireForChapter],\n  );\n\n  return (\n    <div ref={setNode} className={cn(\"w-full\", className)}>\n      {beats.length === 0 ? (\n        <EmptyNarrative />\n      ) : (\n        <ChapterRail\n          beats={beats}\n          onBeatClick={onChapterClick ? handleBeatClick : undefined}\n          renderChapter={renderChapter}\n        />\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/team-quest-log/parts/team-quest-chapters.tsx"
    },
    {
      "path": "src/registry/components/gamification/team-quest-log/parts/chapter-beat.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { Check, Circle, MapPin } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport type { ChapterBeatProps } 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 a `doneAt` reads identically on server + client — no hydration\n * mismatch. Returns null for an unparseable value.\n */\nfunction formatDoneAt(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\nconst STATE_LABEL = {\n  done: \"completed\",\n  current: \"current\",\n  upcoming: \"upcoming\",\n} as const;\n\n/**\n * Tier C — a single chapter beat: a state marker (with a **non-color** cue —\n * `Check` done / `MapPin` \"you are here\" current / hollow `Circle` upcoming, so\n * state reads without color, D-13/§10), the chapter title, the milestone label\n * subtitle, and an optional \"completed\" date. Interactive only when `onClick` is\n * passed (a real `<button>`); otherwise static. Tagged `data-chapter-id` for the\n * visibility telemetry + scroll-into-view.\n */\nexport function ChapterBeat({\n  chapter,\n  state,\n  milestone,\n  onClick,\n  className,\n}: ChapterBeatProps) {\n  const interactive = typeof onClick === \"function\";\n  const doneAtText =\n    state === \"done\" && milestone?.doneAt ? formatDoneAt(milestone.doneAt) : null;\n\n  const marker = (\n    <span\n      aria-hidden\n      className={cn(\n        \"relative z-10 flex size-7 shrink-0 items-center justify-center rounded-full border transition-[transform,box-shadow,border-color,background-color,color] duration-200 ease-out motion-safe:group-hover:scale-110\",\n        state === \"done\" &&\n          \"border-primary bg-primary text-primary-foreground group-hover:shadow-md group-hover:shadow-primary/30\",\n        state === \"current\" &&\n          \"border-primary bg-background text-primary ring-2 ring-primary/30 group-hover:ring-primary/50\",\n        state === \"upcoming\" &&\n          \"border-border bg-muted text-muted-foreground group-hover:border-primary/40 group-hover:text-foreground\",\n      )}\n    >\n      {state === \"done\" ? (\n        <Check className=\"size-4\" />\n      ) : state === \"current\" ? (\n        <MapPin className=\"size-4\" />\n      ) : (\n        <Circle className=\"size-3\" />\n      )}\n    </span>\n  );\n\n  const body = (\n    <span className=\"flex min-w-0 flex-col gap-0.5 pb-4 text-left\">\n      <span\n        className={cn(\n          \"truncate text-sm font-medium\",\n          state === \"upcoming\" ? \"text-muted-foreground\" : \"text-foreground\",\n        )}\n      >\n        {chapter.title}\n      </span>\n      {milestone?.label ? (\n        <span className=\"truncate text-xs text-muted-foreground\">\n          {milestone.label}\n        </span>\n      ) : null}\n      {doneAtText ? (\n        <span className=\"font-mono text-[11px] text-muted-foreground\">\n          Completed {doneAtText}\n        </span>\n      ) : null}\n    </span>\n  );\n\n  const accessibleName = `${chapter.title} — ${STATE_LABEL[state]}`;\n\n  const inner = (\n    <>\n      {marker}\n      {body}\n    </>\n  );\n\n  if (interactive) {\n    // v0.1.2 (review §6 medium) — the list-item role lives on a WRAPPER (the\n    // same structure as ChapterRail's renderChapter branch); the button keeps\n    // its native role. `role=\"listitem\"` directly on the <button> overwrote\n    // the button role, so AT announced a list item instead of a button.\n    return (\n      <div\n        role=\"listitem\"\n        data-chapter-id={chapter.id}\n        className={cn(\"w-full\", className)}\n      >\n        <button\n          type=\"button\"\n          aria-label={accessibleName}\n          onClick={() => onClick?.(chapter)}\n          className={cn(\n            \"group flex w-full items-start gap-3 rounded-md text-left outline-none\",\n            \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background\",\n          )}\n        >\n          {inner}\n        </button>\n      </div>\n    );\n  }\n\n  return (\n    <div\n      role=\"listitem\"\n      data-chapter-id={chapter.id}\n      aria-label={accessibleName}\n      className={cn(\"group flex items-start gap-3\", className)}\n    >\n      {inner}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/team-quest-log/parts/chapter-beat.tsx"
    }
  ],
  "categories": [
    "gamification"
  ],
  "type": "registry:block"
}