{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "content-composer",
  "title": "Content Composer",
  "author": "ilinxa",
  "description": "Multi-step content authoring shell — each content type is a JSON config composing form, rich text, and media editing steps.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "@ilinxa/json-form",
    "@ilinxa/rich-text-editor",
    "@ilinxa/media-editor",
    "@ilinxa/media-editor-capture",
    "@ilinxa/carousel-composer",
    "@ilinxa/news-card",
    "badge",
    "button",
    "command",
    "dialog",
    "input",
    "popover",
    "separator",
    "textarea"
  ],
  "files": [
    {
      "path": "src/registry/components/media/content-composer/content-composer.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport type {\n  ComposerCtx,\n  ComposerDraft,\n  ComposerStepCtx,\n  ComposerStepValue,\n  NewsCardItem,\n  ContentComposerHandle,\n  ContentComposerProps,\n  ExportMetadata,\n  GateResult,\n  MediaSlotValue,\n  SlotHandle,\n  SlotKind,\n  SlotValueFor,\n} from \"./types\";\nimport { makeEmptyDraft } from \"./lib/reducer\";\nimport { DEFAULT_SUBSTRATES } from \"./lib/substrates\";\nimport { evaluateStep } from \"./lib/gates\";\nimport { resolveUploader } from \"./lib/upload\";\nimport { resolvePublishCtaArms } from \"./lib/publish-cta\";\nimport { getAdapter } from \"./adapters/adapter-registry\";\nimport { useComposerState } from \"./hooks/use-composer-state\";\nimport { useSlotHandles } from \"./hooks/use-slot-handles\";\nimport { useAutosave } from \"./hooks/use-autosave\";\nimport { ComposerContext, ComposerStepContext } from \"./hooks/use-composer-context\";\nimport { ComposerShell } from \"./parts/composer-shell\";\nimport { ComposerDialog } from \"./parts/composer-dialog\";\nimport { SlotMount } from \"./parts/slot-mount\";\nimport { MediaSourceBlobCacheContext } from \"./parts/media-substrate\";\nimport {\n  CarouselLiveCacheContext,\n  carouselDisplacedUrls,\n} from \"./parts/media-carousel-substrate\";\nimport type { MediaCarouselItem } from \"@/registry/components/media/carousel-composer/carousel-composer\";\nimport { PublishBar } from \"./parts/publish-bar\";\n\nfunction errorMessage(e: unknown): string {\n  return e instanceof Error ? e.message : String(e);\n}\n\n/**\n * content-composer — the multi-step content-authoring SHELL.\n *\n * One JSON `ComposerConfig` per content type drives the steps; each step's slot\n * is rendered by a substrate (json-form / rich-text-editor / media-editor).\n * The shell owns step navigation + the blocking gates, autosave (draft-level\n * dirty), the draft → publish → schedule FSM, lazy upload-on-publish, and the\n * per-content-type adapter to the backend `NewsCardItem`.\n */\nexport const ContentComposer = React.forwardRef<\n  ContentComposerHandle,\n  ContentComposerProps\n>(function ContentComposer(props, ref) {\n  const { config } = props;\n\n  // ── Seed (T1): controlled value wins; else defaultValue; else inverse-adapter\n  //    from initialItem; the body always re-seeds via the separate initialBody leg.\n  const [initialDraft] = React.useState<ComposerDraft>(() => {\n    const base = props.defaultValue ?? makeEmptyDraft(config.id);\n    let seeded = base;\n    if (!props.defaultValue && props.initialItem) {\n      const adapter = getAdapter(config.adapterId);\n      if (adapter) {\n        const { draft: seed } = adapter.fromContentItem(props.initialItem);\n        seeded = {\n          ...base,\n          ...seed,\n          steps: { ...base.steps, ...(seed.steps ?? {}) },\n        };\n      }\n    }\n    if (props.initialBody) {\n      const bodyStep = config.steps.find((s) => s.slot === \"bodySlot\");\n      if (bodyStep) {\n        seeded = {\n          ...seeded,\n          steps: {\n            ...seeded.steps,\n            [bodyStep.id]: { slot: \"bodySlot\", value: props.initialBody },\n          },\n        };\n      }\n    }\n    return seeded;\n  });\n\n  const { draft, dispatch, phase, dispatchPhase } = useComposerState({\n    contentType: config.id,\n    value: props.value,\n    defaultValue: initialDraft,\n    onChange: props.onDraftChange,\n  });\n\n  const substrateMap = React.useMemo(\n    () => ({ ...DEFAULT_SUBSTRATES, ...props.substrates }),\n    [props.substrates],\n  );\n\n  const { registerHandle, getHandle } = useSlotHandles();\n  const { isDirty, markSaved } = useAutosave({\n    draft,\n    phase,\n    autosave: props.autosave,\n    onAutosave: props.onAutosave,\n    debounceMs: config.autosave?.debounceMs,\n    dispatchPhase,\n  });\n\n  const blobMap = React.useRef(new Map<string, Blob>());\n  // Source blob backing each mediaSlot step's editorState.imageSrc — lets a\n  // step-revisit re-mint the (revoked) object URL before loadState (1.3/1.4).\n  // Plain blobs, no revocation lifecycle; the map dies with the composer.\n  // Stable instance via lazy useState (a bare Map, not a ref — see\n  // MediaSourceBlobCacheContext).\n  const [mediaSourceBlobs] = React.useState(() => new Map<string, Blob>());\n  // Live carousel items (with blobs) per step — keeps mediaCarouselSlot media\n  // across step navigation (the carousel runs with revokeOnUnmount={false}, so\n  // its object URLs outlive a step unmount; we revoke them on composer unmount).\n  const carouselCache = React.useRef(new Map<string, MediaCarouselItem[]>());\n  React.useEffect(() => {\n    const cache = carouselCache.current;\n    return () => {\n      cache.forEach((items) =>\n        items.forEach((it) => {\n          if (it.url.startsWith(\"blob:\")) URL.revokeObjectURL(it.url);\n        }),\n      );\n      // F13: URLs displaced from the cache mid-session (re-edit of a\n      // cache-restored item) have no owner left — the substrate tombstoned\n      // them; revoke here. Re-revoking an already-released URL is a no-op.\n      const displaced = carouselDisplacedUrls(cache);\n      displaced.forEach((u) => URL.revokeObjectURL(u));\n      displaced.clear();\n      cache.clear();\n    };\n  }, []);\n  // Enter the editing phase on mount (FSM `idle → editing`, T0). Without this the\n  // phase stays \"idle\" and every gate-guarded action silently no-ops: `goToStep`\n  // refuses (`phase !== \"editing\"`), and save/publish never leave idle. `start`\n  // is idempotent (only acts on \"idle\"), so the dev double-invoke is harmless.\n  React.useEffect(() => {\n    dispatchPhase({ type: \"start\" });\n  }, [dispatchPhase]);\n\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const [announcement, setAnnouncement] = React.useState(\"\");\n  // Visible (not just sr-only) lifecycle error — e.g. a save/publish that hits an\n  // unregistered adapter or a rejected callback. Cleared on the next nav/save.\n  const [lifecycleError, setLifecycleError] = React.useState<string | null>(null);\n  const [stepErrors, setStepErrors] = React.useState<Record<string, string[]>>({});\n  const [scheduleValue, setScheduleValue] = React.useState(\"\");\n\n  const presentation = props.presentation ?? config.presentation ?? \"auto\";\n  const resolvedMode: \"inline\" | \"dialog\" =\n    presentation === \"dialog\" ? \"dialog\" : \"inline\";\n\n  // ── Focus + announce on a blocked gate ──────────────────────────────────\n  const jumpToFirstInvalid = React.useCallback(\n    (stepIndex: number, res: GateResult) => {\n      const step = config.steps[stepIndex];\n      if (!step) return;\n      dispatch({ type: \"set-cursor\", cursor: stepIndex });\n      const msg =\n        res.errors?.[step.id]?.[0] ??\n        `Please complete “${step.title}” before continuing.`;\n      setStepErrors({ [step.id]: [msg] });\n      setAnnouncement(msg);\n      requestAnimationFrame(() => {\n        const el = rootRef.current?.querySelector<HTMLElement>(\n          '[data-composer-step-body] :is(input,textarea,select,button,[tabindex],[contenteditable=\"true\"])',\n        );\n        el?.focus();\n      });\n    },\n    [config, dispatch],\n  );\n\n  // ── Pull-only media capture at step-leave (blob stored, uploaded at publish) ─\n  const captureMediaBlob = React.useCallback(\n    async (stepId: string) => {\n      const handle = getHandle(stepId);\n      if (!handle?.export) return;\n      const sv = draft.steps[stepId];\n      const mv = sv?.slot === \"mediaSlot\" ? sv.value : undefined;\n      if (mv?.exportedUrl) return; // re-edit of an existing hero — no re-export\n      if (!handle.getIsDirty() && mv?.pendingBlobRef) return; // unchanged capture\n      try {\n        const { blob, metadata } = await handle.export();\n        blobMap.current.set(stepId, blob);\n        // Pull the FRESH slot value (editorState included): the draft copy\n        // only refreshes on dirty flips, so overlay edits made since the last\n        // flip would otherwise be missing from the persisted snapshot the\n        // step-revisit restore (1.4) replays.\n        const fresh = handle.getValue() as MediaSlotValue | undefined;\n        dispatch({\n          type: \"set-step-value\",\n          stepId,\n          value: {\n            slot: \"mediaSlot\",\n            value: {\n              ...mv,\n              ...fresh,\n              pendingBlobRef: stepId,\n              exportMetadata: metadata,\n            },\n          },\n        });\n      } catch {\n        // export failed — leave the value as-is; publish surfaces the error.\n      }\n    },\n    [draft, getHandle, dispatch],\n  );\n\n  // ── Navigation: backward free, forward gated ────────────────────────────\n  const goToStep = React.useCallback(\n    async (target: number): Promise<GateResult> => {\n      if (phase !== \"editing\") return { ok: false };\n      setLifecycleError(null);\n      const max = config.steps.length - 1;\n      const clamped = Math.max(0, Math.min(target, max));\n      const from = draft.cursor;\n      if (clamped <= from) {\n        dispatch({ type: \"set-cursor\", cursor: clamped });\n        return { ok: true };\n      }\n      for (let i = from; i < clamped; i++) {\n        const activeHandle =\n          i === from ? getHandle(config.steps[i].id) : undefined;\n        const res = await evaluateStep(config, i, draft, { activeHandle });\n        if (!res.ok) {\n          jumpToFirstInvalid(i, res);\n          return res;\n        }\n        if (i === from && config.steps[i].slot === \"mediaSlot\") {\n          await captureMediaBlob(config.steps[i].id);\n        }\n      }\n      setStepErrors({});\n      dispatch({ type: \"set-cursor\", cursor: clamped });\n      requestAnimationFrame(() => {\n        rootRef.current\n          ?.querySelector<HTMLElement>(\n            '[data-composer-step-body] :is(input,textarea,select,button,[tabindex],[contenteditable=\"true\"])',\n          )\n          ?.focus();\n      });\n      return { ok: true };\n    },\n    [phase, draft, config, getHandle, dispatch, jumpToFirstInvalid, captureMediaBlob],\n  );\n\n  // ── Publish/Schedule re-run ALL gates ───────────────────────────────────\n  const runAllGates = React.useCallback(async (): Promise<GateResult> => {\n    for (let i = 0; i < config.steps.length; i++) {\n      const activeHandle =\n        i === draft.cursor ? getHandle(config.steps[i].id) : undefined;\n      const res = await evaluateStep(config, i, draft, { activeHandle });\n      if (!res.ok) {\n        jumpToFirstInvalid(i, res);\n        return res;\n      }\n    }\n    return { ok: true };\n  }, [config, draft, getHandle, jumpToFirstInvalid]);\n\n  // ── Upload-on-publish (QP-10) + adapter assembly ────────────────────────\n  const uploadHero = React.useCallback(\n    async (snapshot: ComposerDraft): Promise<ComposerDraft> => {\n      const uploader = resolveUploader(props.uploader, props.uploadUrl);\n      let next = snapshot;\n      for (const step of config.steps) {\n        if (step.slot !== \"mediaSlot\") continue;\n        const sv = next.steps[step.id];\n        const mv = sv?.slot === \"mediaSlot\" ? sv.value : undefined;\n        if (!mv || mv.exportedUrl) continue; // none, or already uploaded\n        let exported: { blob: Blob; metadata: ExportMetadata } | undefined;\n        if (mv.pendingBlobRef && blobMap.current.has(mv.pendingBlobRef) && mv.exportMetadata) {\n          exported = {\n            blob: blobMap.current.get(mv.pendingBlobRef)!,\n            metadata: mv.exportMetadata,\n          };\n        } else {\n          const handle = getHandle(step.id);\n          if (handle?.export) exported = await handle.export();\n        }\n        if (!exported) continue; // no hero → toContentItem throws (image required)\n        if (!uploader) {\n          throw new Error(\n            \"content-composer: no `uploader`/`uploadUrl` provided to upload the hero.\",\n          );\n        }\n        const { url } = await uploader(exported.blob, exported.metadata);\n        next = {\n          ...next,\n          steps: {\n            ...next.steps,\n            [step.id]: {\n              slot: \"mediaSlot\",\n              value: { ...mv, exportedUrl: url, exportMetadata: exported.metadata },\n            },\n          },\n        };\n      }\n      return next;\n    },\n    [props.uploader, props.uploadUrl, config, getHandle],\n  );\n\n  const assembleItem = React.useCallback(\n    async (\n      snapshot: ComposerDraft,\n    ): Promise<{ item: NewsCardItem; draft: ComposerDraft }> => {\n      const adapter = getAdapter(config.adapterId);\n      if (!adapter) {\n        throw new Error(\n          `content-composer: no adapter registered for \"${config.adapterId}\".`,\n        );\n      }\n      const uploaded = await uploadHero(snapshot);\n      const item = adapter.toContentItem(uploaded, { now: new Date() });\n      const finalDraft = uploaded.contentId\n        ? uploaded\n        : { ...uploaded, contentId: item.id };\n      dispatch({ type: \"replace\", draft: finalDraft });\n      return { item, draft: finalDraft };\n    },\n    [config, uploadHero, dispatch],\n  );\n\n  // ── Lifecycle exits (FSM T7–T17) ────────────────────────────────────────\n  const saveDraft = React.useCallback(async () => {\n    const cb = props.onSaveDraft;\n    if (!cb) return;\n    const snapshot: ComposerDraft = { ...draft, status: \"draft\" };\n    setLifecycleError(null);\n    dispatchPhase({ type: \"validate-begin\" });\n    dispatchPhase({ type: \"intent-accepted\", intent: { mode: \"draft\" } });\n    try {\n      const { item, draft: saved } = await assembleItem(snapshot);\n      await cb(item);\n      markSaved(saved);\n      setAnnouncement(\"Draft saved.\");\n    } catch (e) {\n      setAnnouncement(errorMessage(e));\n      setLifecycleError(errorMessage(e));\n    } finally {\n      dispatchPhase({ type: \"draft-ack\" });\n    }\n  }, [props.onSaveDraft, draft, assembleItem, dispatchPhase, markSaved]);\n\n  const publish = React.useCallback(async () => {\n    const cb = props.onPublish;\n    if (!cb) return;\n    setLifecycleError(null);\n    dispatchPhase({ type: \"validate-begin\" });\n    const gate = await runAllGates();\n    if (!gate.ok) {\n      dispatchPhase({ type: \"gate-fail\" });\n      return;\n    }\n    dispatchPhase({ type: \"intent-accepted\", intent: { mode: \"publish\" } });\n    try {\n      const snapshot: ComposerDraft = { ...draft, status: \"published\" };\n      const { item, draft: saved } = await assembleItem(snapshot);\n      await cb(item);\n      markSaved(saved);\n      setAnnouncement(\"Published.\");\n      dispatchPhase({ type: \"publish-resolved\" });\n    } catch (e) {\n      setAnnouncement(errorMessage(e));\n      setLifecycleError(errorMessage(e));\n      dispatchPhase({ type: \"publish-rejected\" });\n    }\n  }, [props.onPublish, draft, runAllGates, assembleItem, dispatchPhase, markSaved]);\n\n  const schedule = React.useCallback(\n    async (at: Date) => {\n      const cb = props.onSchedule;\n      if (!cb) return;\n      if (!(at instanceof Date) || Number.isNaN(at.getTime()) || at.getTime() <= Date.now()) {\n        setAnnouncement(\"Pick a future time to schedule.\");\n        return;\n      }\n      setLifecycleError(null);\n      dispatchPhase({ type: \"validate-begin\" });\n      const gate = await runAllGates();\n      if (!gate.ok) {\n        dispatchPhase({ type: \"gate-fail\" });\n        return;\n      }\n      dispatchPhase({ type: \"intent-accepted\", intent: { mode: \"schedule\", publishAt: at } });\n      try {\n        const snapshot: ComposerDraft = {\n          ...draft,\n          status: \"scheduled\",\n          scheduledFor: at.toISOString(),\n        };\n        const { item, draft: saved } = await assembleItem(snapshot);\n        await cb(item, at);\n        markSaved(saved);\n        setAnnouncement(\"Scheduled.\");\n        dispatchPhase({ type: \"schedule-resolved\" });\n      } catch (e) {\n        setAnnouncement(errorMessage(e));\n        setLifecycleError(errorMessage(e));\n        dispatchPhase({ type: \"publish-rejected\" });\n      }\n    },\n    [props.onSchedule, draft, runAllGates, assembleItem, dispatchPhase, markSaved],\n  );\n\n  // ── Imperative handle ───────────────────────────────────────────────────\n  React.useImperativeHandle(\n    ref,\n    (): ContentComposerHandle => ({\n      saveDraft,\n      publish,\n      schedule,\n      goToStep,\n      getIsDirty: () => isDirty,\n      getDraft: () => draft,\n      loadDraft: (d) => {\n        dispatch({ type: \"replace\", draft: d });\n        // Re-seed the substrate that stays mounted (review 1.4 — the\n        // SlotHandle.loadValue contract had no caller): substrates seed from\n        // the draft only at mount, so a loadDraft that keeps the cursor on the\n        // same step would otherwise leave the visible slot showing the old\n        // value. A cursor change remounts the new step's substrate, which\n        // self-seeds from the replaced draft.\n        const clamped = Math.max(\n          0,\n          Math.min(d.cursor ?? 0, config.steps.length - 1),\n        );\n        if (clamped !== draft.cursor) return;\n        const step = config.steps[clamped];\n        const sv = d.steps[step.id];\n        const handle = getHandle(step.id);\n        if (handle && sv && sv.slot === step.slot) handle.loadValue(sv.value);\n      },\n    }),\n    [saveDraft, publish, schedule, goToStep, isDirty, draft, dispatch, config, getHandle],\n  );\n\n  // ── Context + active step ───────────────────────────────────────────────\n  const ctx = React.useMemo<ComposerCtx>(\n    () => ({\n      contentType: config.id,\n      phase,\n      cursor: draft.cursor,\n      steps: config.steps,\n      isDirty,\n      stepErrors,\n      publishModes: config.publishModes,\n      goToStep,\n      saveDraft,\n      publish,\n      schedule,\n    }),\n    [config, phase, draft.cursor, isDirty, stepErrors, goToStep, saveDraft, publish, schedule],\n  );\n\n  const activeStep = config.steps[draft.cursor];\n  const activeStepValue = activeStep ? draft.steps[activeStep.id]?.value : undefined;\n\n  const stepCtx = React.useMemo<ComposerStepCtx | null>(\n    () =>\n      activeStep\n        ? {\n            stepId: activeStep.id,\n            contentType: config.id,\n            mode: resolvedMode,\n            isDirty,\n            stepErrors: stepErrors[activeStep.id] ?? [],\n          }\n        : null,\n    [activeStep, config.id, resolvedMode, isDirty, stepErrors],\n  );\n\n  const handleSlotChange = React.useCallback(\n    (next: SlotValueFor<SlotKind>) => {\n      if (!activeStep) return;\n      dispatch({\n        type: \"set-step-value\",\n        stepId: activeStep.id,\n        value: { slot: activeStep.slot, value: next } as ComposerStepValue,\n      });\n    },\n    [activeStep, dispatch],\n  );\n\n  // ── Publish bar ─────────────────────────────────────────────────────────\n  const scheduleReady = (() => {\n    if (!scheduleValue) return false;\n    const t = new Date(scheduleValue).getTime();\n    return !Number.isNaN(t) && t > Date.now();\n  })();\n  const arms = resolvePublishCtaArms({\n    publishModes: config.publishModes,\n    phase,\n    hasOnSaveDraft: !!props.onSaveDraft,\n    hasOnPublish: !!props.onPublish,\n    hasOnSchedule: !!props.onSchedule,\n    scheduleReady,\n  });\n\n  // Publish / Schedule are terminal actions → only on the final step. Save draft\n  // stays on every step (save progress anytime). The shell renders a Next button\n  // on non-final steps; the footer sits beside it.\n  const isLastStep = draft.cursor >= config.steps.length - 1;\n  const placedArms = isLastStep\n    ? arms\n    : arms.filter((a) => a.mode === \"draft\");\n\n  const footer = props.renderPublishCTA\n    ? props.renderPublishCTA(ctx)\n    : placedArms.length > 0\n      ? (\n          <PublishBar\n            arms={placedArms}\n            onSaveDraft={() => void saveDraft()}\n            onPublish={() => void publish()}\n            onSchedule={() => void schedule(new Date(scheduleValue))}\n            scheduleValue={scheduleValue}\n            onScheduleValueChange={setScheduleValue}\n          />\n        )\n      : null;\n\n  const slotNode =\n    activeStep && stepCtx ? (\n      <ComposerStepContext.Provider value={stepCtx}>\n        <SlotMount\n          substrates={substrateMap}\n          step={activeStep}\n          value={activeStepValue}\n          onChange={handleSlotChange}\n          ctx={stepCtx}\n          // Dispatch-boundary erasure: the per-step registry stores\n          // SlotHandle<unknown> (SlotHandle is invariant in TValue via\n          // loadValue), so the typed handleRef is cast here — runtime-correct,\n          // the substrate populates the concrete handle.\n          handleRef={\n            registerHandle(activeStep.id) as React.Ref<\n              SlotHandle<SlotValueFor<SlotKind>>\n            >\n          }\n        />\n      </ComposerStepContext.Provider>\n    ) : null;\n\n  const dialogDescription = `${config.steps.length}-step ${config.title} composer. Use the step navigation, then save, publish, or schedule.`;\n\n  return (\n    <MediaSourceBlobCacheContext.Provider value={mediaSourceBlobs}>\n    <CarouselLiveCacheContext.Provider value={carouselCache}>\n    <ComposerContext.Provider value={ctx}>\n      {resolvedMode === \"dialog\" ? (\n        <ComposerDialog\n          open={props.isOpen ?? false}\n          onOpenChange={(o) => {\n            if (!o) props.onClose?.();\n          }}\n          title={config.title}\n          description={dialogDescription}\n        >\n          <div ref={rootRef}>\n            <ComposerShell\n              ctx={ctx}\n              mode=\"dialog\"\n              footer={footer}\n              announcement={announcement}\n              error={lifecycleError}\n              onDismissError={() => setLifecycleError(null)}\n            >\n              {slotNode}\n            </ComposerShell>\n          </div>\n        </ComposerDialog>\n      ) : (\n        <div\n          ref={rootRef}\n          data-slot=\"content-composer\"\n          data-content-type={config.id}\n        >\n          <ComposerShell\n            ctx={ctx}\n            mode=\"inline\"\n            footer={footer}\n            announcement={announcement}\n            error={lifecycleError}\n            onDismissError={() => setLifecycleError(null)}\n            renderStepChrome={props.renderStepChrome}\n          >\n            {slotNode}\n          </ComposerShell>\n        </div>\n      )}\n    </ComposerContext.Provider>\n    </CarouselLiveCacheContext.Provider>\n    </MediaSourceBlobCacheContext.Provider>\n  );\n});\n",
      "type": "registry:component",
      "target": "components/content-composer/content-composer.tsx"
    },
    {
      "path": "src/registry/components/media/content-composer/index.ts",
      "content": "export { ContentComposer } from \"./content-composer\";\n\n// Headless state (description §10) — for consumers building a custom shell.\nexport {\n  useComposerState,\n  type UseComposerStateReturn,\n} from \"./hooks/use-composer-state\";\n\n// Per-mount + per-step context hooks (throw outside their subtree). Exposed so\n// consumers' custom field/slot renderers can read the live composer state.\nexport {\n  useComposerContext,\n  useComposerStep,\n} from \"./hooks/use-composer-context\";\n\n// Config hydration layer (QP-6) — for consumers deserializing JSON configs and\n// re-attaching function escape-hatches before building a ComposerConfig.\nexport {\n  hydrateSchema,\n  stripHydration,\n  type ComposerConfigHydration,\n  type FieldHydration,\n  type SchemaHydration,\n  type ConditionFn,\n} from \"./lib/hydration\";\n\n// Composer-owned custom json-form field renderers (the two json-form gaps).\n// Exported for `fieldRegistry` reuse in consumer schemas.\nexport { tagsFieldRenderer } from \"./parts/field-tags\";\nexport {\n  authorPickerFieldRenderer,\n  type AuthorEntity,\n  type AuthorSourceConfig,\n} from \"./parts/field-author-picker\";\n\n// Default substrate registry + lookup — spreadable + overridable via the\n// `substrates` prop. The three records map each closed SlotKind to its mount.\nexport {\n  DEFAULT_SUBSTRATES,\n  findSubstrate,\n  jsonFormSubstrate,\n  richTextSubstrate,\n  mediaEditorSubstrate,\n  mediaCarouselSubstrate,\n} from \"./lib/substrates\";\nexport { CarouselLiveCacheContext } from \"./parts/media-carousel-substrate\";\n\n// Content-type configs + the runtime adapter registry. The news config is a\n// factory (inject `authorSource`); `newsComposerConfig` is the default instance.\nexport {\n  createNewsComposerConfig,\n  newsComposerConfig,\n  newsContentItemAdapter,\n  type NewsComposerConfigOptions,\n} from \"./configs/news-composer.config\";\n// post config — modeled + deferred (clamp proof; ships behind media-editor v0.2).\nexport { postComposerConfig } from \"./configs/post-composer.config\";\nexport { getAdapter, ADAPTER_REGISTRY } from \"./adapters/adapter-registry\";\n\n// Public type surface (description §9/§10). Implementation-internal hooks +\n// substrate records (useComposerState, default substrates, findSubstrate, …)\n// are added to this barrel as they land across the C3–C12 chain.\nexport type {\n  // Component\n  ContentComposerProps,\n  ContentComposerHandle,\n  // Config + steps\n  ComposerConfig,\n  ComposerStep,\n  StepValidation,\n  StepValidationRule,\n  SlotKind,\n  MetadataSlotConfig,\n  BodySlotConfig,\n  MediaSlotConfig,\n  MediaCarouselSlotConfig,\n  // Draft + values\n  ComposerDraft,\n  ComposerStepValue,\n  BodySlotValue,\n  MediaSlotValue,\n  MediaCarouselSlotValue,\n  MediaCarouselItemRef,\n  SerializableMediaEditorState,\n  // Substrate registry\n  SlotSubstrate,\n  SlotSubstrateMap,\n  SlotRenderArgs,\n  SlotHandle,\n  SlotConfigFor,\n  SlotValueFor,\n  // Adapter\n  ContentTypeAdapter,\n  AdapterRegistry,\n  // Lifecycle\n  PublishMode,\n  PublishIntent,\n  ComposerPhase,\n  GateResult,\n  // Context\n  ComposerCtx,\n  ComposerStepCtx,\n  // Hook + helper signatures\n  UseComposerStateArgs,\n  UseComposerContext,\n  UseComposerStep,\n  FindSubstrate,\n  // Re-exported substrate types\n  NewsCardItem,\n  RichTextValue,\n  InitialSource,\n  ExportMetadata,\n  MediaEditorState,\n} from \"./types\";\n",
      "type": "registry:component",
      "target": "components/content-composer/index.ts"
    },
    {
      "path": "src/registry/components/media/content-composer/types.ts",
      "content": "import type { ReactNode, Ref } from \"react\";\nimport type {\n  MediaEditorProps,\n  MediaEditorHandle,\n  MediaEditorState,\n  ExportMetadata,\n  InitialSource,\n  ComposerMode,\n  EditTool,\n  MediaSource,\n  AspectRatio,\n} from \"@/registry/components/media/media-editor/media-editor\";\nimport type { RichTextValue } from \"@/registry/components/data/rich-text-editor/rich-text-editor\";\nimport type {\n  NewsCardItem,\n  ContentStatus,\n  NewsArticleAuthor,\n  NewsPublisher,\n  NewsVisibility,\n  ContentSensitivity,\n  ContentPaywall,\n} from \"@/registry/components/data/news-card/news-card\";\nimport type { FormSchema, Condition } from \"@/registry/components/forms/json-form/json-form\";\n\n// Re-export the substrate types the public surface leans on, so consumers\n// import them from the content-composer barrel without reaching into the\n// substrate procomps directly. Centralizing the news-card type\n// import here keeps the cross-procomp `/types` reference in ONE place (the\n// config + adapter then read these via the local barrel, not cross-procomp).\nexport type { MediaEditorState, ExportMetadata, InitialSource, RichTextValue, NewsCardItem, ContentStatus };\nexport type { MediaEditorProps, MediaEditorHandle };\nexport type {\n  NewsArticleAuthor,\n  NewsPublisher,\n  NewsVisibility,\n  ContentSensitivity,\n  ContentPaywall,\n};\n\n// ─── Slot kinds + config (description §15) ──────────────────────────────\n\nexport type SlotKind =\n  | \"metadataFields\"\n  | \"bodySlot\"\n  | \"mediaSlot\"\n  | \"mediaCarouselSlot\";\n\nexport interface ComposerConfig {\n  /** \"news\" | \"post\" | \"event\" | \"project\" */\n  id: string;\n  /** config schema version (semver) */\n  version: string;\n  title: string;\n  /** runtime adapter-registry key */\n  adapterId: string;\n  steps: ComposerStep[];\n  /** subset of [\"draft\",\"publish\",\"schedule\"] */\n  publishModes: PublishMode[];\n  presentation?: \"inline\" | \"dialog\" | \"auto\";\n  autosave?: { enabled: boolean; debounceMs?: number };\n}\n\nexport interface ComposerStep {\n  id: string;\n  title: string;\n  /** substrate-registry lookup key */\n  slot: SlotKind;\n  /** discriminated by `slot` at the consumer site (JSON — no TS narrowing) */\n  slotConfig:\n    | MetadataSlotConfig\n    | BodySlotConfig\n    | MediaSlotConfig\n    | MediaCarouselSlotConfig;\n  /** BLOCKING gate before advance/publish */\n  validation?: StepValidation;\n  optional?: boolean;\n  /** json-form Condition DSL, reused for whole-step visibility (serializable object form only) */\n  visibleWhen?: Condition;\n}\n\nexport interface StepValidationRule {\n  field: string;\n  /** bodySlot: min Plate/plaintext length */\n  minLength?: number;\n  /** mediaSlot: require a hero (exportedUrl present OR dirty) */\n  mediaRequired?: boolean;\n  message: string;\n}\n\nexport interface StepValidation {\n  /**\n   * \"all-fields-valid\" → delegate to json-form trigger(); \"custom\" → run `rules`.\n   * NOTE: advisory for metadataFields steps — a metadata slot can only validate via\n   * trigger()+isValid(), so `mode` there is effectively \"all-fields-valid\" and a\n   * `custom` rule declared on a metadata step is ignored. `custom`+`rules` change\n   * behavior only for bodySlot (minLength) and mediaSlot (mediaRequired) steps.\n   */\n  mode: \"all-fields-valid\" | \"custom\";\n  rules?: StepValidationRule[];\n}\n\n// ─── The three discriminated slot configs (description §4) ──────────────\n\n/** metadataFields → a json-form FormSchema fragment. */\nexport interface MetadataSlotConfig {\n  columns?: 1 | 2;\n  /** { fields: FieldDefinition[] } fragment */\n  schema: FormSchema;\n}\n\n/** bodySlot → rich-text-editor (Plate) OR shadcn <Textarea> plaintext fallback. */\nexport interface BodySlotConfig {\n  substrate: \"plate\" | \"plaintext\";\n  fieldName: string;\n  placeholder?: string;\n  /** plate-only sentinel; plaintext ignores it and uses \"\" — defaults to RICH_TEXT_EMPTY_VALUE */\n  emptyValue?: RichTextValue;\n}\n\n/**\n * mediaSlot → 1:1 passthrough of media-editor's dials. Every key is a verified\n * MediaEditorProps prop. The shell spreads these straight onto <MediaEditor>,\n * EXCEPT `mediaSources` which the substrate CLAMPS to the real MediaSource union\n * before spreading (drops not-yet-valid \"library\" — §clamp).\n */\nexport interface MediaSlotConfig {\n  fieldName: string;\n  enabledModes: ComposerMode[];\n  enabledTools: EditTool[];\n  /** broad so a config can declare \"library\" ahead of media-editor v0.2; the substrate clamps it. */\n  mediaSources: (MediaSource | (string & {}))[];\n  aspect: AspectRatio;\n  /** default \"inline\" inside the composer (§2) */\n  presentation?: \"inline\" | \"dialog\" | \"auto\";\n  cropAspects?: AspectRatio[];\n  maxFileSizeMb?: number;\n}\n\n// ─── Body value + draft (description §6) — JSON-clean, NO blob ───────────\n\n/** The asymmetry the SlotHandle hides: richtext = RichTextValue (Plate node array); plaintext = string. */\nexport type BodySlotValue =\n  | { kind: \"richtext\"; value: RichTextValue }\n  | { kind: \"plaintext\"; value: string };\n\n/**\n * MediaEditorState minus the non-serializable live Blob (videoBlob: Blob). The draft\n * persists THIS, never the raw MediaEditorState. On re-edit the shell re-attaches the\n * blob from its Map (or re-fetches from exportedUrl) before calling handle.loadState().\n */\nexport type SerializableMediaEditorState = Omit<MediaEditorState, \"videoBlob\"> & {\n  videoBlob: null;\n};\n\nexport interface MediaSlotValue {\n  /** durable uploaded https URL (post-upload) — the persistable handle */\n  exportedUrl?: string;\n  /** transient key into the shell-held Map<string,Blob>; consumed once at upload */\n  pendingBlobRef?: string;\n  /** .metadata leg of export() */\n  exportMetadata?: ExportMetadata;\n  /** blob-free editor snapshot for re-edit */\n  editorState?: SerializableMediaEditorState;\n}\n\n// ─── mediaCarouselSlot (content-composer v0.2) ──────────────────────────\n// A multi-item media step backed by carousel-composer. The post config\n// uses this instead of the single mediaSlot. `news` keeps the single mediaSlot\n// — this kind is strictly additive.\n\n/** mediaCarouselSlot → carousel-composer dials (subset). */\nexport interface MediaCarouselSlotConfig {\n  fieldName: string;\n  /** default 10 (Instagram parity). */\n  maxItems?: number;\n  maxFileSizeMb?: number;\n  /** default [\"image\",\"video\"]. */\n  accept?: (\"image\" | \"video\")[];\n  /** \"auto\" derives the shared aspect from item 1. Default \"auto\". */\n  aspect?: AspectRatio | \"auto\";\n  /** forwarded to the per-item edit panel. */\n  enabledTools?: EditTool[];\n}\n\n/**\n * One serializable carousel item in the draft (NO blob — JSON-clean, mirroring\n * MediaSlotValue). Local (not-yet-uploaded) items carry `editorState` but no\n * `exportedUrl`; the durable upload-at-publish of N blobs rides with the post\n * backend adapter (deferred, same as the single mediaSlot's `\"library\"` source).\n */\nexport interface MediaCarouselItemRef {\n  id: string;\n  kind: \"image\" | \"video\";\n  /** durable https URL once uploaded / when re-edit-seeded from the backend. */\n  exportedUrl?: string;\n  /** blob-free editor snapshot for re-edit (photo path). */\n  editorState?: SerializableMediaEditorState;\n  exportMetadata?: ExportMetadata;\n}\n\nexport interface MediaCarouselSlotValue {\n  items: MediaCarouselItemRef[];\n}\n\nexport type ComposerStepValue =\n  | { slot: \"metadataFields\"; value: Record<string, unknown> }\n  | { slot: \"bodySlot\"; value: BodySlotValue }\n  | { slot: \"mediaSlot\"; value: MediaSlotValue }\n  | { slot: \"mediaCarouselSlot\"; value: MediaCarouselSlotValue };\n\nexport interface ComposerDraft {\n  contentType: string;\n  /** keyed by step id; discriminated by slot kind */\n  steps: Record<string, ComposerStepValue>;\n  /** \"draft\"|\"scheduled\"|\"published\"|\"archived\" → NewsCardItem.status (same CLOSED enum) */\n  status: ContentStatus;\n  /** ISO; set by the schedule arm */\n  scheduledFor?: string;\n  /** set on first publish; preserved on re-edit so a PATCH targets the row */\n  contentId?: string;\n  /** PERSISTED so re-open resumes the step */\n  cursor: number;\n}\n\n// ─── Substrate registry + render args (description §3) ──────────────────\n\nexport type SlotConfigFor<K extends SlotKind> = K extends \"metadataFields\"\n  ? MetadataSlotConfig\n  : K extends \"bodySlot\"\n    ? BodySlotConfig\n    : K extends \"mediaSlot\"\n      ? MediaSlotConfig\n      : K extends \"mediaCarouselSlot\"\n        ? MediaCarouselSlotConfig\n        : never;\n\nexport type SlotValueFor<K extends SlotKind> = K extends \"metadataFields\"\n  ? Record<string, unknown>\n  : K extends \"bodySlot\"\n    ? BodySlotValue\n    : K extends \"mediaSlot\"\n      ? MediaSlotValue\n      : K extends \"mediaCarouselSlot\"\n        ? MediaCarouselSlotValue\n        : never;\n\nexport interface SlotRenderArgs<K extends SlotKind = SlotKind> {\n  slotConfig: SlotConfigFor<K>;\n  value: SlotValueFor<K> | undefined;\n  onChange: (next: SlotValueFor<K>) => void;\n  ctx: ComposerStepCtx;\n  /** the shell threads a ref the substrate populates with a uniform SlotHandle */\n  handleRef: Ref<SlotHandle<SlotValueFor<K>>>;\n}\n\nexport interface SlotSubstrate<K extends SlotKind = SlotKind> {\n  kind: K;\n  render: (args: SlotRenderArgs<K>) => ReactNode;\n}\n\n/** Strongly-typed map keyed by the 3 closed kinds (NOT Map<string,Any> like kanban). */\nexport type SlotSubstrateMap = Partial<{ [K in SlotKind]: SlotSubstrate<K> }>;\n\n// ─── SlotHandle (description §3/§9) — uniform across all three slots ─────\n\nexport interface SlotHandle<TValue = unknown> {\n  /** current slot value for the ComposerDraft snapshot */\n  getValue: () => TValue;\n  /** aggregated into the shell's OR-of-three dirty */\n  getIsDirty: () => boolean;\n  /** BLOCKING gate primitive (QP-9). metadata → await trigger() then isValid() */\n  validate: () => Promise<boolean>;\n  /** re-seed on autosave-restore / re-edit. RESETS the dirty baseline */\n  loadValue: (value: TValue) => void;\n  /**\n   * mediaSlot ONLY — pull-only export so the shell can upload the captured hero\n   * at publish/schedule (QP-10 lazy upload). Other slots omit it; the shell\n   * duck-types its presence.\n   */\n  export?: () => Promise<{ blob: Blob; metadata: ExportMetadata }>;\n}\n\n// ─── Adapter (description §5) — pure forward+inverse fns, NOT components ──\n\nexport interface ContentTypeAdapter<TItem> {\n  contentType: string;\n  toContentItem: (\n    draft: ComposerDraft,\n    ctx: { now: Date; currentUser?: { id: string; name: string } },\n  ) => TItem;\n  fromContentItem: (item: TItem) => {\n    draft: Partial<ComposerDraft>;\n    mediaInitialSource?: InitialSource;\n  };\n}\n\nexport type AdapterRegistry = Record<string, ContentTypeAdapter<NewsCardItem>>;\n\n// ─── Publish intent + FSM surface (description §7) ──────────────────────\n\nexport type PublishMode = \"draft\" | \"publish\" | \"schedule\";\n\nexport type PublishIntent =\n  | { mode: \"draft\" }\n  | { mode: \"publish\" }\n  | { mode: \"schedule\"; publishAt: Date };\n\nexport type ComposerPhase =\n  | \"idle\"\n  | \"editing\"\n  | \"autosaving\"\n  | \"validating\"\n  | \"draft-saved\"\n  | \"scheduling\"\n  | \"scheduled\"\n  | \"publishing\"\n  | \"published\"\n  | \"publish-error\";\n\nexport interface GateResult {\n  ok: boolean;\n  firstInvalidStepId?: string;\n  firstInvalidField?: string;\n  /** per-step error messages */\n  errors?: Record<string, string[]>;\n}\n\n// ─── Per-mount context (description §10) — workspace useAreaContext model ─\n\nexport interface ComposerCtx {\n  contentType: string;\n  phase: ComposerPhase;\n  cursor: number;\n  steps: ComposerStep[];\n  isDirty: boolean;\n  stepErrors: Record<string, string[]>;\n  publishModes: PublishMode[];\n  goToStep: (n: number) => Promise<GateResult>;\n  saveDraft: () => Promise<void>;\n  publish: () => Promise<void>;\n  schedule: (at: Date) => Promise<void>;\n}\n\n/** Returned by useComposerStep() — throws outside a step subtree. */\nexport interface ComposerStepCtx {\n  stepId: string;\n  contentType: string;\n  mode: \"inline\" | \"dialog\";\n  isDirty: boolean;\n  stepErrors: string[];\n}\n\n// ─── Public component props + handle (description §9) ───────────────────\n\nexport interface ContentComposerProps {\n  // The config (JSON)\n  config: ComposerConfig;\n  /** defaults shipped + spreadable + overridable */\n  substrates?: SlotSubstrateMap;\n\n  // Re-edit\n  /** drives the INVERSE adapter (item.image → media initialSource) */\n  initialItem?: NewsCardItem;\n  /** persisted body — NOT on NewsCardItem (§5) */\n  initialBody?: BodySlotValue;\n\n  // Presentation\n  presentation?: \"inline\" | \"dialog\" | \"auto\";\n  isOpen?: boolean;\n  onClose?: () => void;\n\n  // Draft state (controlled triplet — copy useKanbanState)\n  value?: ComposerDraft;\n  defaultValue?: ComposerDraft;\n  onChange?: (draft: ComposerDraft) => void;\n\n  // Autosave (split: per-mutation vs debounced — QP-4)\n  autosave?: boolean;\n  /** per mutation */\n  onDraftChange?: (draft: ComposerDraft) => void;\n  /** debounced (~800ms) */\n  onAutosave?: (draft: ComposerDraft) => void | Promise<void>;\n\n  // Lifecycle exits (emit the assembled ContentItem; affordance-gated by callback presence)\n  onSaveDraft?: (item: NewsCardItem) => void | Promise<void>;\n  onPublish?: (item: NewsCardItem) => void | Promise<void>;\n  onSchedule?: (item: NewsCardItem, publishAt: Date) => void | Promise<void>;\n\n  // The SHELL owns upload (DELTA vs media-editor — QP-8)\n  /** primary upload contract */\n  uploader?: (blob: Blob, meta: ExportMetadata) => Promise<{ url: string }>;\n  /** convenience shorthand */\n  uploadUrl?: string;\n\n  // Slots (escape hatches)\n  renderPublishCTA?: (ctx: ComposerCtx) => ReactNode;\n  renderStepChrome?: (ctx: ComposerCtx) => ReactNode;\n\n  ref?: Ref<ContentComposerHandle>;\n}\n\nexport interface ContentComposerHandle {\n  saveDraft(): Promise<void>;\n  publish(): Promise<void>;\n  schedule(at: Date): Promise<void>;\n  goToStep(n: number): Promise<GateResult>;\n  getIsDirty(): boolean;\n  getDraft(): ComposerDraft;\n  loadDraft(draft: ComposerDraft): void;\n}\n\n// ─── Exported hook + helper signatures (description §10) ─────────────────\n\nexport interface UseComposerStateArgs {\n  contentType: string;\n  value?: ComposerDraft;\n  defaultValue?: ComposerDraft;\n  /** === onDraftChange (per-mutation, both modes) */\n  onChange?: (next: ComposerDraft) => void;\n}\n\n/** throws outside the composer subtree */\nexport type UseComposerContext = () => ComposerCtx;\n/** throws outside a step subtree */\nexport type UseComposerStep = () => ComposerStepCtx;\nexport type FindSubstrate = <K extends SlotKind>(\n  substrates: SlotSubstrateMap,\n  key: K,\n) => SlotSubstrate<K> | undefined;\n",
      "type": "registry:component",
      "target": "components/content-composer/types.ts"
    },
    {
      "path": "src/registry/components/media/content-composer/adapters/adapter-registry.ts",
      "content": "import type {\n  AdapterRegistry,\n  NewsCardItem,\n  ContentTypeAdapter,\n} from \"../types\";\nimport { newsContentItemAdapter } from \"../configs/news-composer.config\";\n\n/**\n * Runtime adapter registry keyed by `config.adapterId`. Each content-type config\n * module contributes its adapter pair here. The shell resolves\n * `getAdapter(config.adapterId)` to map the draft ↔ the backend item at the\n * lifecycle exits.\n */\nexport const ADAPTER_REGISTRY: AdapterRegistry = {\n  \"news-content-item\": newsContentItemAdapter,\n};\n\nexport function getAdapter(\n  adapterId: string,\n): ContentTypeAdapter<NewsCardItem> | undefined {\n  return ADAPTER_REGISTRY[adapterId];\n}\n",
      "type": "registry:component",
      "target": "components/content-composer/adapters/adapter-registry.ts"
    },
    {
      "path": "src/registry/components/media/content-composer/configs/news-composer.config.ts",
      "content": "import type { FieldConfig, FormSchema } from \"@/registry/components/forms/json-form/json-form\";\nimport type {\n  ComposerConfig,\n  ComposerDraft,\n  NewsCardItem,\n  ContentPaywall,\n  ContentSensitivity,\n  ContentTypeAdapter,\n  InitialSource,\n  MediaSlotValue,\n  NewsArticleAuthor,\n  NewsVisibility,\n} from \"../types\";\nimport type { AuthorSourceConfig } from \"../parts/field-author-picker\";\n\n/**\n * The news content type — one `ComposerConfig` (5 steps) + a co-located adapter\n * pair (QP-7). The config SHAPE is JSON-round-trippable; the one function value\n * (`authorSource`) is injected via the factory rather than carried in JSON\n * (consumer-specific), so the static config needs zero hydration entries.\n *\n * Steps: headline (metadata) → hero (media) → body (Plate) → details (metadata)\n * → visibility & gates (metadata, optional).\n */\n\nconst NEWS_STEP = {\n  headline: \"headline\",\n  hero: \"hero\",\n  body: \"body\",\n  meta: \"meta\",\n  gates: \"gates\",\n} as const;\n\n// ─── json-form schema fragments ─────────────────────────────────────────────\n\nconst headlineSchema: FormSchema = {\n  fields: [\n    {\n      name: \"title\",\n      type: \"text\",\n      label: \"Headline\",\n      placeholder: \"Write the headline…\",\n      validators: { required: \"A headline is required.\" },\n    },\n    {\n      name: \"slug\",\n      type: \"text\",\n      label: \"Slug\",\n      placeholder: \"auto / custom-url-slug\",\n      description: \"Optional. Leave blank to let the backend derive it.\",\n    },\n    {\n      name: \"excerpt\",\n      type: \"textarea\",\n      label: \"Excerpt / lead\",\n      placeholder: \"A short summary shown on cards and in search…\",\n      rows: 3,\n    },\n  ],\n};\n\nconst gatesSchema: FormSchema = {\n  fields: [\n    {\n      name: \"visibility\",\n      type: \"select\",\n      label: \"Visibility\",\n      defaultValue: \"public\",\n      options: [\n        { value: \"public\", label: \"Public\" },\n        { value: \"members\", label: \"Members\" },\n        { value: \"subscribers\", label: \"Subscribers\" },\n        { value: \"staff\", label: \"Staff\" },\n        { value: \"unlisted\", label: \"Unlisted\" },\n      ],\n    },\n    { name: \"isBreaking\", type: \"switch\", label: \"Breaking news\" },\n    { name: \"isFeatured\", type: \"switch\", label: \"Featured\" },\n    { name: \"isPinned\", type: \"switch\", label: \"Pinned\" },\n    { name: \"isExclusive\", type: \"switch\", label: \"Exclusive\" },\n    { name: \"isSponsored\", type: \"switch\", label: \"Sponsored\" },\n    {\n      name: \"sponsorLabel\",\n      type: \"text\",\n      label: \"Sponsor\",\n      placeholder: \"Sponsored by…\",\n      visibleWhen: { field: \"isSponsored\", truthy: true },\n    },\n    { name: \"sensitivity.isSensitive\", type: \"switch\", label: \"Sensitive content\" },\n    {\n      name: \"sensitivity.reason\",\n      type: \"text\",\n      label: \"Sensitivity reason\",\n      placeholder: \"e.g. graphic imagery\",\n      visibleWhen: { field: \"sensitivity.isSensitive\", truthy: true },\n    },\n    { name: \"paywall.isPaywalled\", type: \"switch\", label: \"Paywall\" },\n    {\n      name: \"paywall.tier\",\n      type: \"text\",\n      label: \"Paywall tier\",\n      placeholder: \"subscribers\",\n      visibleWhen: { field: \"paywall.isPaywalled\", truthy: true },\n    },\n  ],\n};\n\nfunction metaSchema(authorSource?: AuthorSourceConfig): FormSchema {\n  return {\n    fields: [\n      { name: \"category\", type: \"text\", label: \"Category\", placeholder: \"World, Tech, Sport…\" },\n      {\n        name: \"authorEntity\",\n        type: \"author-picker\",\n        label: \"Author\",\n        dependsOn: [],\n        // authorSource is composer-owned (NOT a json-form FieldConfig key, which\n        // is closed). Injected here via the factory; cast suppresses the\n        // excess-property check.\n        config: { authorSource } as FieldConfig,\n      },\n      { name: \"topics\", type: \"tags\", label: \"Topics\", dependsOn: [] },\n      { name: \"tags\", type: \"tags\", label: \"Tags\", dependsOn: [] },\n      { name: \"readTime\", type: \"number\", label: \"Read time (min)\", min: 0 },\n      { name: \"language\", type: \"text\", label: \"Language (BCP-47)\", placeholder: \"en\" },\n    ],\n  };\n}\n\n// ─── Config factory ─────────────────────────────────────────────────────────\n\nexport interface NewsComposerConfigOptions {\n  /** async author loader for the author-picker field; absent → read-only chip */\n  authorSource?: AuthorSourceConfig;\n}\n\nexport function createNewsComposerConfig(\n  opts: NewsComposerConfigOptions = {},\n): ComposerConfig {\n  return {\n    id: \"news\",\n    version: \"1.0.0\",\n    title: \"News article\",\n    adapterId: \"news-content-item\",\n    presentation: \"auto\",\n    autosave: { enabled: true, debounceMs: 800 },\n    publishModes: [\"draft\", \"publish\", \"schedule\"],\n    steps: [\n      {\n        id: NEWS_STEP.headline,\n        title: \"Headline\",\n        slot: \"metadataFields\",\n        slotConfig: { columns: 1, schema: headlineSchema },\n        validation: { mode: \"all-fields-valid\" },\n      },\n      {\n        id: NEWS_STEP.hero,\n        title: \"Cover image\",\n        slot: \"mediaSlot\",\n        slotConfig: {\n          fieldName: \"hero\",\n          enabledModes: [\"photo\"],\n          enabledTools: [\"crop\", \"filters\", \"adjust\"],\n          mediaSources: [\"camera\", \"upload\"],\n          aspect: \"16:9\",\n          presentation: \"inline\",\n        },\n        validation: {\n          mode: \"custom\",\n          rules: [{ field: \"hero\", mediaRequired: true, message: \"A cover image is required.\" }],\n        },\n      },\n      {\n        id: NEWS_STEP.body,\n        title: \"Article\",\n        slot: \"bodySlot\",\n        slotConfig: { substrate: \"plate\", fieldName: \"body\", placeholder: \"Write the article…\" },\n        validation: {\n          mode: \"custom\",\n          rules: [{ field: \"body\", minLength: 1, message: \"Write the article body.\" }],\n        },\n      },\n      {\n        id: NEWS_STEP.meta,\n        title: \"Details\",\n        slot: \"metadataFields\",\n        slotConfig: { columns: 2, schema: metaSchema(opts.authorSource) },\n      },\n      {\n        id: NEWS_STEP.gates,\n        title: \"Visibility & gates\",\n        slot: \"metadataFields\",\n        slotConfig: { columns: 2, schema: gatesSchema },\n        optional: true,\n      },\n    ],\n  };\n}\n\n/** Default news config (no author loader — the author field is read-only). */\nexport const newsComposerConfig: ComposerConfig = createNewsComposerConfig();\n\n// ─── Coercion helpers ───────────────────────────────────────────────────────\n\nfunction str(v: unknown): string | undefined {\n  return typeof v === \"string\" && v.trim().length > 0 ? v : undefined;\n}\nfunction num(v: unknown): number | undefined {\n  if (typeof v === \"number\" && Number.isFinite(v)) return v;\n  if (typeof v === \"string\" && v.trim() && Number.isFinite(Number(v))) return Number(v);\n  return undefined;\n}\nfunction bool(v: unknown): boolean {\n  return v === true;\n}\nfunction strArr(v: unknown): string[] | undefined {\n  return Array.isArray(v) && v.length > 0 && v.every((x) => typeof x === \"string\")\n    ? (v as string[])\n    : undefined;\n}\nfunction toIso(v: string | Date | number): string {\n  if (v instanceof Date) return v.toISOString();\n  if (typeof v === \"number\") return new Date(v).toISOString();\n  return v;\n}\n\nfunction metaBag(draft: ComposerDraft, stepId: string): Record<string, unknown> {\n  const sv = draft.steps[stepId];\n  return sv?.slot === \"metadataFields\" ? sv.value : {};\n}\nfunction mediaValue(draft: ComposerDraft, stepId: string): MediaSlotValue | undefined {\n  const sv = draft.steps[stepId];\n  return sv?.slot === \"mediaSlot\" ? sv.value : undefined;\n}\n\nfunction buildAuthorEntity(meta: Record<string, unknown>): NewsArticleAuthor | undefined {\n  const a = meta.authorEntity as Record<string, unknown> | undefined;\n  if (!a) return undefined;\n  const id = str(a.id);\n  const name = str(a.name);\n  if (!id || !name) return undefined;\n  return { id, name, ...(str(a.avatar) ? { avatar: str(a.avatar)! } : {}) };\n}\n\nfunction buildSensitivity(gates: Record<string, unknown>): ContentSensitivity | undefined {\n  const s = gates.sensitivity as Record<string, unknown> | undefined;\n  if (!s || bool(s.isSensitive) !== true) return undefined;\n  return { isSensitive: true, ...(str(s.reason) ? { reason: str(s.reason)! } : {}) };\n}\n\nfunction buildPaywall(gates: Record<string, unknown>): ContentPaywall | undefined {\n  const p = gates.paywall as Record<string, unknown> | undefined;\n  if (!p || bool(p.isPaywalled) !== true) return undefined;\n  return { isPaywalled: true, ...(str(p.tier) ? { tier: str(p.tier)! } : {}) };\n}\n\nfunction timestampsForStatus(draft: ComposerDraft, ctx: { now: Date }) {\n  const nowIso = ctx.now.toISOString();\n  switch (draft.status) {\n    case \"published\":\n      return { publishedAt: nowIso, updatedAt: nowIso };\n    case \"scheduled\":\n      return draft.scheduledFor\n        ? { scheduledFor: draft.scheduledFor, updatedAt: nowIso }\n        : { updatedAt: nowIso };\n    default:\n      // \"draft\" + \"archived\" (archived is reachable only via inverse re-seed):\n      return { updatedAt: nowIso };\n  }\n}\n\n// ─── Adapter (forward + inverse) ─────────────────────────────────────────────\n\nfunction toContentItem(\n  draft: ComposerDraft,\n  ctx: { now: Date; currentUser?: { id: string; name: string } },\n): NewsCardItem {\n  const headline = metaBag(draft, NEWS_STEP.headline);\n  const meta = metaBag(draft, NEWS_STEP.meta);\n  const gates = metaBag(draft, NEWS_STEP.gates);\n  const media = mediaValue(draft, NEWS_STEP.hero);\n\n  const id = draft.contentId ?? `news-${ctx.now.getTime()}`;\n  const title = str(headline.title);\n  if (!title) {\n    throw new Error(\"news adapter: `title` is required — the gate should have blocked publish.\");\n  }\n  const image = str(media?.exportedUrl);\n  if (!image) {\n    throw new Error(\n      \"news adapter: a cover image is required (upload the hero before publishing).\",\n    );\n  }\n\n  const authorEntity = buildAuthorEntity(meta);\n  const sensitivity = buildSensitivity(gates);\n  const paywall = buildPaywall(gates);\n\n  return {\n    id,\n    title,\n    image,\n    status: draft.status,\n    ...(str(headline.slug) ? { slug: str(headline.slug)! } : {}),\n    ...(str(headline.excerpt) ? { excerpt: str(headline.excerpt)! } : {}),\n    ...(str(meta.category) ? { category: str(meta.category)! } : {}),\n    ...(num(meta.readTime) !== undefined ? { readTime: num(meta.readTime)! } : {}),\n    ...(str(meta.language) ? { language: str(meta.language)! } : {}),\n    ...(strArr(meta.topics) ? { topics: strArr(meta.topics)! } : {}),\n    ...(strArr(meta.tags) ? { tags: strArr(meta.tags)! } : {}),\n    ...(str(gates.visibility) ? { visibility: str(gates.visibility)! as NewsVisibility } : {}),\n    ...(bool(gates.isPinned) ? { isPinned: true } : {}),\n    ...(bool(gates.isFeatured) ? { isFeatured: true } : {}),\n    ...(bool(gates.isBreaking) ? { isBreaking: true } : {}),\n    ...(bool(gates.isExclusive) ? { isExclusive: true } : {}),\n    ...(bool(gates.isSponsored) ? { isSponsored: true } : {}),\n    ...(str(gates.sponsorLabel) ? { sponsorLabel: str(gates.sponsorLabel)! } : {}),\n    ...(authorEntity ? { authorEntity } : {}),\n    ...(sensitivity ? { sensitivity } : {}),\n    ...(paywall ? { paywall } : {}),\n    ...timestampsForStatus(draft, ctx),\n    // OMITTED (never zeroed): likeCount / commentCount / shareCount /\n    // bookmarkCount / views / isLiked / isBookmarked / quotedArticle — assigning\n    // them would clobber real engagement on the page's PATCH/merge re-edit.\n  };\n}\n\nfunction fromContentItem(item: NewsCardItem): {\n  draft: Partial<ComposerDraft>;\n  mediaInitialSource?: InitialSource;\n} {\n  const draft: Partial<ComposerDraft> = {\n    contentType: \"news\",\n    contentId: item.id,\n    status: item.status ?? \"draft\",\n    ...(item.scheduledFor !== undefined ? { scheduledFor: toIso(item.scheduledFor) } : {}),\n    steps: {\n      [NEWS_STEP.headline]: {\n        slot: \"metadataFields\",\n        value: {\n          title: item.title,\n          ...(item.slug !== undefined ? { slug: item.slug } : {}),\n          ...(item.excerpt !== undefined ? { excerpt: item.excerpt } : {}),\n        },\n      },\n      [NEWS_STEP.meta]: {\n        slot: \"metadataFields\",\n        value: {\n          ...(item.category !== undefined ? { category: item.category } : {}),\n          ...(item.topics !== undefined ? { topics: item.topics } : {}),\n          ...(item.tags !== undefined ? { tags: item.tags } : {}),\n          ...(item.readTime !== undefined ? { readTime: item.readTime } : {}),\n          ...(item.language !== undefined ? { language: item.language } : {}),\n          ...(item.authorEntity ? { authorEntity: item.authorEntity } : {}),\n        },\n      },\n      [NEWS_STEP.gates]: {\n        slot: \"metadataFields\",\n        value: {\n          ...(item.visibility !== undefined ? { visibility: item.visibility } : {}),\n          ...(item.isPinned !== undefined ? { isPinned: item.isPinned } : {}),\n          ...(item.isFeatured !== undefined ? { isFeatured: item.isFeatured } : {}),\n          ...(item.isBreaking !== undefined ? { isBreaking: item.isBreaking } : {}),\n          ...(item.isExclusive !== undefined ? { isExclusive: item.isExclusive } : {}),\n          ...(item.isSponsored !== undefined ? { isSponsored: item.isSponsored } : {}),\n          ...(item.sponsorLabel !== undefined ? { sponsorLabel: item.sponsorLabel } : {}),\n          ...(item.sensitivity ? { sensitivity: item.sensitivity } : {}),\n          ...(item.paywall ? { paywall: item.paywall } : {}),\n        },\n      },\n      [NEWS_STEP.hero]: {\n        slot: \"mediaSlot\",\n        value: { exportedUrl: item.image },\n      },\n      // NO body step — the body re-seeds via the separate initialBody leg.\n    },\n  };\n  return { draft, mediaInitialSource: { kind: \"url\", url: item.image, mode: \"photo\" } };\n}\n\nexport const newsContentItemAdapter: ContentTypeAdapter<NewsCardItem> = {\n  contentType: \"news\",\n  toContentItem,\n  fromContentItem,\n};\n",
      "type": "registry:component",
      "target": "components/content-composer/configs/news-composer.config.ts"
    },
    {
      "path": "src/registry/components/media/content-composer/configs/post-composer.config.ts",
      "content": "import type { FormSchema } from \"@/registry/components/forms/json-form/json-form\";\nimport type { ComposerConfig } from \"../types\";\n\n/**\n * The `post` content type. Adding a content type is mostly config-only\n * divergence: a different step set, a plaintext body, no gates step, and — the\n * Instagram-feed difference — a MULTI-media step backed by\n * carousel-composer (`mediaCarouselSlot`) instead of news's single\n * `mediaSlot`. Authoring (drop/browse N mixed photo+video → reorder → per-item\n * edit) is fully live in the media step.\n *\n * `adapterId: \"post-content-item\"` is intentionally NOT registered yet — the\n * post → backend mapping (a social-post item) and the durable upload-at-publish\n * of the carousel's N blobs ship together behind the post backend. Publishing a\n * post today surfaces a \"no adapter\" error; the media STEP is live (the draft\n * persists each item's structure + editorState; local-only blobs that aren't yet\n * uploaded don't survive a full reload — that rides with the upload-at-publish).\n */\n\nconst captionSchema: FormSchema = {\n  fields: [\n    {\n      name: \"caption\",\n      type: \"textarea\",\n      label: \"Caption\",\n      placeholder: \"What's happening?\",\n      rows: 3,\n      validators: { required: \"Write a caption.\" },\n    },\n    { name: \"tags\", type: \"tags\", label: \"Hashtags\", dependsOn: [] },\n  ],\n};\n\nexport const postComposerConfig: ComposerConfig = {\n  id: \"post\",\n  version: \"1.0.0\",\n  title: \"Post\",\n  adapterId: \"post-content-item\",\n  presentation: \"auto\",\n  autosave: { enabled: true, debounceMs: 800 },\n  publishModes: [\"draft\", \"publish\"],\n  steps: [\n    {\n      id: \"media\",\n      title: \"Media\",\n      // Posts are multi-media (Instagram-feed semantics) → the carousel slot,\n      // backed by carousel-composer. News keeps the single mediaSlot.\n      slot: \"mediaCarouselSlot\",\n      slotConfig: {\n        fieldName: \"media\",\n        maxItems: 10,\n        accept: [\"image\", \"video\"],\n        aspect: \"auto\",\n        enabledTools: [\"crop\", \"filters\", \"adjust\", \"text\", \"stickers\"],\n      },\n      validation: {\n        mode: \"custom\",\n        rules: [\n          { field: \"media\", mediaRequired: true, message: \"Add at least one photo or video.\" },\n        ],\n      },\n    },\n    {\n      id: \"caption\",\n      title: \"Caption\",\n      slot: \"metadataFields\",\n      slotConfig: { columns: 1, schema: captionSchema },\n      validation: { mode: \"all-fields-valid\" },\n    },\n    // No gates step — posts have a simpler lifecycle than news articles.\n  ],\n};\n",
      "type": "registry:component",
      "target": "components/content-composer/configs/post-composer.config.ts"
    },
    {
      "path": "src/registry/components/media/content-composer/hooks/use-autosave.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { ComposerDraft, ComposerPhase } from \"../types\";\nimport type { PhaseAction } from \"../lib/phase-reducer\";\n\nexport interface UseAutosaveArgs {\n  draft: ComposerDraft;\n  phase: ComposerPhase;\n  /** false disables autosave (affordance-gate); default true */\n  autosave?: boolean;\n  onAutosave?: (draft: ComposerDraft) => void | Promise<void>;\n  debounceMs?: number;\n  dispatchPhase: (action: PhaseAction) => void;\n}\n\nexport interface UseAutosaveReturn {\n  /** draft-level dirty — true iff the draft differs from the last saved snapshot */\n  isDirty: boolean;\n  /** mark a draft saved (called by explicit saveDraft / publish so autosave doesn't re-fire) */\n  markSaved: (draft: ComposerDraft) => void;\n}\n\n/**\n * Debounced autosave (QP-4). The split is structural: `onDraftChange` is the\n * per-mutation callback inside `useComposerState` dispatch; THIS is the\n * downstream debounced effect watching the draft.\n *\n * Dirty is DRAFT-LEVEL (`draft !== savedDraft`) — every slot mutation flows into\n * the draft via per-mutation `onChange`, so the draft is the single dirty\n * signal. This both is correct for single-step mounting (only the active slot\n * is mounted) and structurally averts the Plate autosave loop (no handle-\n * aggregate-dirty to mis-baseline). After a successful autosave the saved\n * snapshot advances, so a settled idle draft never re-fires.\n */\nexport function useAutosave({\n  draft,\n  phase,\n  autosave,\n  onAutosave,\n  debounceMs,\n  dispatchPhase,\n}: UseAutosaveArgs): UseAutosaveReturn {\n  // savedDraft is STATE (render-safe) so `isDirty` is a clean render value.\n  const [savedDraft, setSavedDraft] = useState<ComposerDraft>(draft);\n  const isDirty = draft !== savedDraft;\n\n  const onAutosaveRef = useRef(onAutosave);\n  useEffect(() => {\n    onAutosaveRef.current = onAutosave;\n  }, [onAutosave]);\n\n  // fire-time phase guard — closes the React-batching window where a Publish/\n  // Schedule could flip phase between the timer firing and the cleanup running.\n  const phaseRef = useRef(phase);\n  useEffect(() => {\n    phaseRef.current = phase;\n  }, [phase]);\n\n  useEffect(() => {\n    if (autosave === false || !onAutosaveRef.current) return; // affordance-gate\n    if (phase !== \"editing\" && phase !== \"draft-saved\") return; // only while editing/draft\n    if (draft === savedDraft) return; // dirty-gated (draft-level)\n\n    const ms = debounceMs ?? 800;\n    const id = setTimeout(async () => {\n      if (phaseRef.current !== \"editing\" && phaseRef.current !== \"draft-saved\") {\n        return;\n      }\n      const snapshot = draft;\n      dispatchPhase({ type: \"autosave-begin\" }); // T5\n      try {\n        await onAutosaveRef.current?.(snapshot);\n        setSavedDraft(snapshot); // advance the saved baseline → settles dirty\n      } finally {\n        dispatchPhase({ type: \"autosave-end\" }); // T6\n      }\n    }, ms);\n    return () => clearTimeout(id);\n  }, [draft, savedDraft, phase, autosave, debounceMs, dispatchPhase]);\n\n  const markSaved = useCallback((d: ComposerDraft) => setSavedDraft(d), []);\n\n  return { isDirty, markSaved };\n}\n",
      "type": "registry:component",
      "target": "components/content-composer/hooks/use-autosave.ts"
    },
    {
      "path": "src/registry/components/media/content-composer/hooks/use-body-dirty.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef } from \"react\";\nimport { RICH_TEXT_EMPTY_VALUE } from \"@/registry/components/data/rich-text-editor/rich-text-editor\";\nimport type { BodySlotConfig, BodySlotValue } from \"../types\";\n\n/**\n * Stable content key for a body value. `<RichTextEditor>` (Plate) exposes NO\n * dirty signal, so the shell derives bodySlot dirty by baseline JSON-compare\n * over this key. Plate JSON has no functions/cycles, so `JSON.stringify` is\n * adequate; over-sync (never skip) on the cycle edge.\n */\nexport function bodyContentKey(v: BodySlotValue): string {\n  try {\n    return v.kind === \"plaintext\" ? `p:${v.value}` : `r:${JSON.stringify(v.value)}`;\n  } catch {\n    return `x:${Math.random()}`;\n  }\n}\n\n/** Flattened plain text of a body value (Plate node tree → text, or the raw string). */\nexport function flattenPlainText(v: BodySlotValue): string {\n  if (v.kind === \"plaintext\") return v.value;\n  let out = \"\";\n  const walk = (n: unknown) => {\n    if (n && typeof n === \"object\") {\n      const node = n as { text?: unknown; children?: unknown };\n      if (typeof node.text === \"string\") out += node.text;\n      if (Array.isArray(node.children)) node.children.forEach(walk);\n    }\n  };\n  if (Array.isArray(v.value)) v.value.forEach(walk);\n  return out;\n}\n\n/** Empty = no non-whitespace flattened text (covers both substrates + the empty sentinel). */\nexport function isBodyEmpty(v: BodySlotValue): boolean {\n  return flattenPlainText(v).trim().length === 0;\n}\n\n/** The CONFIGURED minLength gate (used shell-side in evaluateStepGate). */\nexport function bodyMinLengthValid(v: BodySlotValue, minLength: number): boolean {\n  return flattenPlainText(v).trim().length >= minLength;\n}\n\n/** The empty value for a body slot, per its substrate. */\nexport function defaultBodyValue(slotConfig: BodySlotConfig): BodySlotValue {\n  return slotConfig.substrate === \"plaintext\"\n    ? { kind: \"plaintext\", value: \"\" }\n    : {\n        kind: \"richtext\",\n        value: slotConfig.emptyValue ?? RICH_TEXT_EMPTY_VALUE,\n      };\n}\n\n/**\n * Baseline-compare dirty tracking for a body slot (#1 implementation trap).\n *\n * The `baselineRef` MUST reset after every successful save/autosave AND on\n * `loadValue()` — otherwise the body reports permanently-dirty, the aggregate\n * dirty stays true, and autosave loops forever. `rebaseline()` is the reset.\n */\nexport function useBodyDirty(current: BodySlotValue) {\n  const valueRef = useRef(current);\n  useEffect(() => {\n    valueRef.current = current;\n  });\n\n  const baselineRef = useRef(bodyContentKey(current));\n\n  const getIsDirty = useCallback(\n    () => bodyContentKey(valueRef.current) !== baselineRef.current,\n    [],\n  );\n  const rebaseline = useCallback((v?: BodySlotValue) => {\n    baselineRef.current = bodyContentKey(v ?? valueRef.current);\n  }, []);\n\n  return { valueRef, getIsDirty, rebaseline };\n}\n",
      "type": "registry:component",
      "target": "components/content-composer/hooks/use-body-dirty.ts"
    },
    {
      "path": "src/registry/components/media/content-composer/hooks/use-composer-context.ts",
      "content": "\"use client\";\n\nimport { createContext, useContext } from \"react\";\nimport type { ComposerCtx, ComposerStepCtx } from \"../types\";\n\n// Two contexts (workspace useAreaContext model): the per-mount composer context\n// (phase / cursor / steps / lifecycle actions) and the per-step context (active\n// step id + its error slice). Both throw when read outside their subtree so a\n// mis-mounted custom field/slot fails loudly rather than silently no-op-ing.\n\nexport const ComposerContext = createContext<ComposerCtx | null>(null);\nexport const ComposerStepContext = createContext<ComposerStepCtx | null>(null);\n\nexport function useComposerContext(): ComposerCtx {\n  const ctx = useContext(ComposerContext);\n  if (!ctx) {\n    throw new Error(\n      \"useComposerContext must be called inside a <ContentComposer> subtree\",\n    );\n  }\n  return ctx;\n}\n\nexport function useComposerStep(): ComposerStepCtx {\n  const ctx = useContext(ComposerStepContext);\n  if (!ctx) {\n    throw new Error(\n      \"useComposerStep must be called inside a composer step's slot subtree\",\n    );\n  }\n  return ctx;\n}\n",
      "type": "registry:component",
      "target": "components/content-composer/hooks/use-composer-context.ts"
    },
    {
      "path": "src/registry/components/media/content-composer/hooks/use-composer-state.ts",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport type {\n  ComposerDraft,\n  ComposerPhase,\n  UseComposerStateArgs,\n} from \"../types\";\nimport { composerReducer, makeEmptyDraft, type ComposerAction } from \"../lib/reducer\";\nimport { phaseReducer, type PhaseAction } from \"../lib/phase-reducer\";\n\nexport interface UseComposerStateReturn {\n  draft: ComposerDraft;\n  dispatch: (action: ComposerAction) => void;\n  phase: ComposerPhase;\n  dispatchPhase: (action: PhaseAction) => void;\n}\n\n/**\n * The controlled/uncontrolled draft triplet + the ephemeral FSM phase. A\n * verbatim fork of kanban-board's `useKanbanState`:\n *   - an internal reducer is ALWAYS kept (used only when uncontrolled);\n *   - `dispatch` reduces over the DERIVED draft (not `internal`) for\n *     controlled-mode correctness, and always fires the per-mutation\n *     `onChange` (= `onDraftChange`) in BOTH modes;\n *   - the FSM phase is a SEPARATE reducer so the draft stays pure JSON (§6).\n */\nexport function useComposerState({\n  contentType,\n  value,\n  defaultValue,\n  onChange,\n}: UseComposerStateArgs): UseComposerStateReturn {\n  // ALWAYS keep an internal reducer (kanban pattern) — used only when uncontrolled.\n  const [internal, internalDispatch] = React.useReducer(\n    composerReducer,\n    defaultValue ?? makeEmptyDraft(contentType),\n  );\n  const isControlled = value !== undefined;\n  const draft = value ?? internal; // === isControlled ? value : internal, type-safe\n\n  // latest-onChange ref — stable dispatch identity across parent re-renders.\n  const onChangeRef = React.useRef(onChange);\n  React.useEffect(() => {\n    onChangeRef.current = onChange;\n  });\n\n  const dispatch = React.useCallback(\n    (action: ComposerAction) => {\n      // GOTCHA: reduce over the DERIVED draft, NOT `internal` — controlled-mode correctness.\n      const next = composerReducer(value ?? internal, action);\n      if (!isControlled) internalDispatch(action); // internal store only when uncontrolled\n      onChangeRef.current?.(next); // per-mutation onDraftChange — BOTH modes\n    },\n    [isControlled, value, internal],\n  );\n\n  // ephemeral FSM phase — NOT persisted (kept out of ComposerDraft, §6).\n  const [phase, dispatchPhase] = React.useReducer(\n    phaseReducer,\n    \"idle\" as ComposerPhase,\n  );\n\n  return { draft, dispatch, phase, dispatchPhase };\n}\n",
      "type": "registry:component",
      "target": "components/content-composer/hooks/use-composer-state.ts"
    },
    {
      "path": "src/registry/components/media/content-composer/hooks/use-slot-handles.ts",
      "content": "\"use client\";\n\nimport { useCallback, useRef } from \"react\";\nimport type { RefCallback } from \"react\";\nimport type { SlotHandle } from \"../types\";\n\n/**\n * Registry of the live `SlotHandle`s keyed by step id. The shell renders only\n * the ACTIVE step's slot, so at any moment the map holds one entry (the active\n * step's handle). The shell reads it for the active-step gate (full json-form\n * validation) and for the pull-only media export at step-leave / publish.\n *\n * `registerHandle(stepId)` returns a STABLE callback ref per step id (so React\n * doesn't detach/reattach on every render). Dirty tracking is intentionally NOT\n * here — it's draft-level (`draft !== savedDraft` in useAutosave), which both is\n * correct for single-step mounting and structurally averts the Plate autosave\n * loop (the handle-aggregate-dirty trap).\n */\nexport function useSlotHandles() {\n  const handles = useRef(new Map<string, SlotHandle<unknown>>());\n  const callbacks = useRef(new Map<string, RefCallback<SlotHandle<unknown>>>());\n\n  const registerHandle = useCallback(\n    (stepId: string): RefCallback<SlotHandle<unknown>> => {\n      let cb = callbacks.current.get(stepId);\n      if (!cb) {\n        cb = (h) => {\n          if (h) handles.current.set(stepId, h);\n          else handles.current.delete(stepId);\n        };\n        callbacks.current.set(stepId, cb);\n      }\n      return cb;\n    },\n    [],\n  );\n\n  const getHandle = useCallback(\n    (stepId: string) => handles.current.get(stepId),\n    [],\n  );\n\n  return { registerHandle, getHandle };\n}\n",
      "type": "registry:component",
      "target": "components/content-composer/hooks/use-slot-handles.ts"
    },
    {
      "path": "src/registry/components/media/content-composer/lib/assign-ref.ts",
      "content": "import type { Ref, RefCallback } from \"react\";\n\n/**\n * Assign a value to a React ref in either form. Substrate mounts capture their\n * uniform `SlotHandle` into the shell-provided `handleRef` (typed `Ref<…>`,\n * which can be a callback or an object ref), so they go through this helper\n * rather than assuming `.current`.\n */\nexport function assignRef<T>(ref: Ref<T> | undefined, value: T): void {\n  if (typeof ref === \"function\") {\n    (ref as RefCallback<T>)(value);\n  } else if (ref) {\n    (ref as { current: T }).current = value;\n  }\n}\n",
      "type": "registry:component",
      "target": "components/content-composer/lib/assign-ref.ts"
    },
    {
      "path": "src/registry/components/media/content-composer/lib/clamp-media-sources.ts",
      "content": "import type { MediaSource } from \"@/registry/components/media/media-editor/media-editor\";\n\nconst KNOWN: readonly MediaSource[] = [\"camera\", \"upload\"];\n\n/**\n * Drop any mediaSource media-editor v0.1.x doesn't understand (e.g.\n * `\"library\"`, which a `post` config declares ahead of media-editor v0.2).\n * Never throws — mirrors the dial's own no-crash membership-only degradation.\n * Falls back to `[\"upload\"]` if the filtered set is empty so the slot is never\n * sourceless. The ONLY transformed dial — every other dial is membership-\n * filtered inside media-editor, so over-declared arrays degrade the same way.\n */\nexport function clampMediaSources(\n  declared: readonly string[] | undefined,\n): MediaSource[] {\n  const known = (declared ?? KNOWN).filter(\n    (s): s is MediaSource => s === \"camera\" || s === \"upload\",\n  );\n  return known.length ? known : [\"upload\"];\n}\n",
      "type": "registry:component",
      "target": "components/content-composer/lib/clamp-media-sources.ts"
    },
    {
      "path": "src/registry/components/media/content-composer/lib/gates.ts",
      "content": "import type { FormSchema } from \"@/registry/components/forms/json-form/json-form\";\nimport { evaluateCondition } from \"@/registry/components/forms/json-form/lib/condition-evaluator\";\nimport { getByPath } from \"@/registry/components/forms/json-form/lib/path\";\nimport type {\n  BodySlotValue,\n  ComposerConfig,\n  ComposerDraft,\n  ComposerStep,\n  GateResult,\n  MediaCarouselSlotValue,\n  MediaSlotValue,\n  MetadataSlotConfig,\n  SlotHandle,\n  StepValidation,\n} from \"../types\";\nimport { bodyMinLengthValid, isBodyEmpty } from \"../hooks/use-body-dirty\";\n\n/**\n * Config-sourced BLOCKING gates (QP-9), evaluated imperatively at advance/\n * publish. Forward-gated / backward-free. DISTINCT from the non-blocking\n * missing-substrate fallback.\n *\n * Gate decisions run against the persisted draft values — the draft is ALWAYS\n * current (per-mutation onChange), and the news config has THREE metadataFields\n * steps so only the active step is ever mounted. For the active metadata step\n * the live handle's full json-form validation is preferred (catches pattern/\n * custom validators, not just required-presence); non-active steps fall back to\n * the value-based required-presence check (they were fully validated when the\n * user last left them forward).\n */\n\n/** Compile-time exhaustiveness: a new SlotKind without a switch arm fails here. */\nfunction assertNever(value: never): void {\n  void value; // purely a type-level guard — no runtime effect\n}\n\nfunction isValueEmpty(v: unknown): boolean {\n  if (v == null) return true;\n  if (typeof v === \"string\") return v.trim().length === 0;\n  if (Array.isArray(v)) return v.length === 0;\n  return false;\n}\n\n/** Merge every metadataFields step's value bag — the values namespace shared by\n *  step-level visibleWhen conditions. */\nexport function aggregateMetaValues(draft: ComposerDraft): Record<string, unknown> {\n  const out: Record<string, unknown> = {};\n  for (const sv of Object.values(draft.steps)) {\n    if (sv.slot === \"metadataFields\") Object.assign(out, sv.value);\n  }\n  return out;\n}\n\n/** Headless required-presence check (the value-based metadata gate). */\nexport function evaluateMetadataValue(\n  schema: FormSchema,\n  value: Record<string, unknown>,\n): boolean {\n  for (const f of schema.fields) {\n    if (f.type === \"section\" || f.type === \"divider\" || f.type === \"hidden\") {\n      continue;\n    }\n    if (!f.validators?.required) continue;\n    if (isValueEmpty(getByPath(value, f.name))) return false;\n  }\n  return true;\n}\n\nexport function evaluateBodyValue(\n  value: BodySlotValue | undefined,\n  validation: StepValidation | undefined,\n): boolean {\n  const rule = validation?.rules?.find((r) => r.minLength != null);\n  if (!rule?.minLength) return true;\n  if (!value) return false;\n  return bodyMinLengthValid(value, rule.minLength);\n}\n\n/**\n * Does a persisted editor snapshot actually CONTAIN media? The substrate\n * snapshots editorState on every dirty flip — including the flip back to a\n * post-reset empty state — so truthiness alone is not evidence (a discarded\n * hero would sail through the gate and publish; adversarial-review N1).\n * \"Has media\" = a canvas image, a video, or authored text-mode content.\n */\nexport function editorStateHasMedia(\n  es: MediaSlotValue[\"editorState\"] | undefined,\n): boolean {\n  if (!es) return false;\n  if (es.imageSrc || es.videoBlob) return true;\n  return (\n    es.mode === \"text\" &&\n    (!!es.textContent?.trim() || (es.textOverlays?.length ?? 0) > 0)\n  );\n}\n\nexport function evaluateMediaValue(\n  value: MediaSlotValue | undefined,\n  validation: StepValidation | undefined,\n  isDirty: boolean,\n): boolean {\n  if (!validation?.rules?.some((r) => r.mediaRequired)) return true;\n  // At publish the media step is re-gated WITHOUT an active handle (single-step\n  // mount → isDirty false), so a freshly captured hero must satisfy the gate\n  // via its persisted evidence: an exported blob awaiting upload\n  // (pendingBlobRef) or a CONTENT-BEARING capture snapshot count as \"has\n  // media\" just like an already-uploaded exportedUrl. (Review 1.1 + N1.)\n  return (\n    !!value?.exportedUrl ||\n    !!value?.pendingBlobRef ||\n    editorStateHasMedia(value?.editorState) ||\n    isDirty\n  );\n}\n\n/** mediaCarouselSlot gate — value-based (the draft is always current). When\n *  `mediaRequired`, at least one item must be present. */\nexport function evaluateMediaCarouselValue(\n  value: MediaCarouselSlotValue | undefined,\n  validation: StepValidation | undefined,\n): boolean {\n  if (!validation?.rules?.some((r) => r.mediaRequired)) return true;\n  return (value?.items?.length ?? 0) > 0;\n}\n\n/** Whole-step visibility (serializable Condition object form only). */\nexport function isStepVisible(\n  step: ComposerStep,\n  allValues: Record<string, unknown>,\n): boolean {\n  if (!step.visibleWhen) return true;\n  return evaluateCondition(step.visibleWhen, allValues);\n}\n\n/** Is the step empty (for optional-skip)? */\nexport function isStepEmpty(step: ComposerStep, draft: ComposerDraft): boolean {\n  const sv = draft.steps[step.id];\n  if (!sv) return true;\n  switch (sv.slot) {\n    case \"metadataFields\":\n      return (\n        Object.keys(sv.value).length === 0 ||\n        Object.values(sv.value).every(isValueEmpty)\n      );\n    case \"bodySlot\":\n      return isBodyEmpty(sv.value);\n    case \"mediaSlot\":\n      return (\n        !sv.value.exportedUrl &&\n        !editorStateHasMedia(sv.value.editorState) &&\n        !sv.value.pendingBlobRef\n      );\n    case \"mediaCarouselSlot\":\n      return (sv.value.items?.length ?? 0) === 0;\n    default:\n      assertNever(sv);\n      return true;\n  }\n}\n\n/**\n * Evaluate a single step's blocking gate. `activeHandle` is supplied ONLY for\n * the currently-mounted step (so metadata gets full json-form validation +\n * media gets live dirty); non-mounted steps validate against stored values.\n */\nexport async function evaluateStep(\n  config: ComposerConfig,\n  stepIndex: number,\n  draft: ComposerDraft,\n  opts: { activeHandle?: SlotHandle<unknown> } = {},\n): Promise<GateResult> {\n  const step = config.steps[stepIndex];\n  if (!step) return { ok: true };\n\n  // Hidden step → skip the gate (NON-blocking).\n  if (!isStepVisible(step, aggregateMetaValues(draft))) return { ok: true };\n  // Optional + untouched → pass.\n  if (step.optional && isStepEmpty(step, draft)) return { ok: true };\n\n  const sv = draft.steps[step.id];\n  const fail = (): GateResult => ({ ok: false, firstInvalidStepId: step.id });\n\n  switch (step.slot) {\n    case \"metadataFields\": {\n      if (opts.activeHandle) {\n        // Active mounted step → full json-form validation (trigger + isValid),\n        // which also surfaces inline field errors as a side effect.\n        return (await opts.activeHandle.validate()) ? { ok: true } : fail();\n      }\n      const schema = (step.slotConfig as MetadataSlotConfig).schema;\n      const value = sv?.slot === \"metadataFields\" ? sv.value : {};\n      return evaluateMetadataValue(schema, value) ? { ok: true } : fail();\n    }\n    case \"bodySlot\": {\n      const value = sv?.slot === \"bodySlot\" ? sv.value : undefined;\n      return evaluateBodyValue(value, step.validation) ? { ok: true } : fail();\n    }\n    case \"mediaSlot\": {\n      const value = sv?.slot === \"mediaSlot\" ? sv.value : undefined;\n      const isDirty = opts.activeHandle?.getIsDirty() ?? false;\n      return evaluateMediaValue(value, step.validation, isDirty)\n        ? { ok: true }\n        : fail();\n    }\n    case \"mediaCarouselSlot\": {\n      const value = sv?.slot === \"mediaCarouselSlot\" ? sv.value : undefined;\n      return evaluateMediaCarouselValue(value, step.validation)\n        ? { ok: true }\n        : fail();\n    }\n    default:\n      assertNever(step.slot);\n      return { ok: true };\n  }\n}\n",
      "type": "registry:component",
      "target": "components/content-composer/lib/gates.ts"
    },
    {
      "path": "src/registry/components/media/content-composer/lib/hydration.ts",
      "content": "import type {\n  FieldDefinition,\n  FormSchema,\n  FieldOptionsResolver,\n} from \"@/registry/components/forms/json-form/json-form\";\n\n/**\n * Config hydration layer (QP-6). A `ComposerConfig` stays a pure `.json` file\n * only while its json-form fragment is purely declarative. Function-valued\n * escape-hatches (`validate` / `validateAsync` / `compute` / async `options` /\n * the FN arm of `visibleWhen`/`enabledWhen`/`requiredWhen`) can't live in JSON.\n * This layer re-attaches them onto a deserialized `FormSchema` (by field name)\n * BEFORE the schema reaches `<JsonForm>`, and strips them back out to recover\n * the pure JSON shape (round-trip invariant).\n *\n * The OBJECT form of a `Condition` survives JSON, so `visibleWhen` etc. only\n * need hydration when authored as a function. `expression` (string) survives;\n * `compute` is its fn escape-hatch.\n *\n * The news v0.1 config needs ZERO hydration entries — `slug` uses\n * `expression: \"{title}\"` and `sensitivity.reason` uses the Condition OBJECT\n * form. The seam exists for future `post`/`event`/`project` configs.\n */\n\n/** The fn arm of json-form's `ConditionOrFn` (the OBJECT arm survives JSON). */\nexport type ConditionFn = (args: { values: Record<string, unknown> }) => boolean;\n\nexport interface FieldHydration {\n  validate?: FieldDefinition[\"validate\"];\n  validateAsync?: FieldDefinition[\"validateAsync\"];\n  compute?: FieldDefinition[\"compute\"];\n  options?: FieldOptionsResolver;\n  visibleWhen?: ConditionFn;\n  enabledWhen?: ConditionFn;\n  requiredWhen?: ConditionFn;\n}\n\nexport interface SchemaHydration {\n  validate?: FormSchema[\"validate\"];\n  zodSchema?: FormSchema[\"zodSchema\"];\n}\n\nexport interface ComposerConfigHydration {\n  /** keyed by `field.name` */\n  fields: Record<string, FieldHydration>;\n  __schema__?: SchemaHydration;\n}\n\n/**\n * Re-attach function escape-hatches onto a deserialized `FormSchema`, by field\n * name. Pure. Called BEFORE the schema reaches `<JsonForm>` (so RHF/Zod see the\n * real fns). Returns `plain` unchanged when there is no hydration.\n */\nexport function hydrateSchema(\n  plain: FormSchema,\n  hydration?: ComposerConfigHydration,\n): FormSchema {\n  if (!hydration) return plain;\n  const fields = plain.fields.map((f) => {\n    const h = hydration.fields[f.name];\n    return h ? { ...f, ...h } : f;\n  });\n  const sh = hydration.__schema__;\n  return {\n    ...plain,\n    fields,\n    ...(sh?.validate ? { validate: sh.validate } : {}),\n    ...(sh?.zodSchema ? { zodSchema: sh.zodSchema } : {}),\n  };\n}\n\n/**\n * Inverse — strip all function keys to recover the pure JSON shape. Round-trip\n * invariant: `stripHydration(hydrateSchema(plain, h)).plain` ≡ `plain` (object-\n * form conditions + static-array options are preserved on the plain field).\n */\nexport function stripHydration(schema: FormSchema): {\n  plain: FormSchema;\n  hydration: ComposerConfigHydration;\n} {\n  const hydration: ComposerConfigHydration = { fields: {} };\n\n  const plainFields: FieldDefinition[] = schema.fields.map((f) => {\n    const {\n      validate,\n      validateAsync,\n      compute,\n      options,\n      visibleWhen,\n      enabledWhen,\n      requiredWhen,\n      ...rest\n    } = f;\n\n    const fh: FieldHydration = {};\n    if (typeof validate === \"function\") fh.validate = validate;\n    if (typeof validateAsync === \"function\") fh.validateAsync = validateAsync;\n    if (typeof compute === \"function\") fh.compute = compute;\n    if (typeof options === \"function\") fh.options = options;\n    if (typeof visibleWhen === \"function\") fh.visibleWhen = visibleWhen;\n    if (typeof enabledWhen === \"function\") fh.enabledWhen = enabledWhen;\n    if (typeof requiredWhen === \"function\") fh.requiredWhen = requiredWhen;\n    if (Object.keys(fh).length > 0) hydration.fields[f.name] = fh;\n\n    // Carry the serializable arms back onto the plain field.\n    const plainField: FieldDefinition = { ...rest };\n    if (visibleWhen && typeof visibleWhen !== \"function\")\n      plainField.visibleWhen = visibleWhen;\n    if (enabledWhen && typeof enabledWhen !== \"function\")\n      plainField.enabledWhen = enabledWhen;\n    if (requiredWhen && typeof requiredWhen !== \"function\")\n      plainField.requiredWhen = requiredWhen;\n    if (options && typeof options !== \"function\") plainField.options = options;\n    return plainField;\n  });\n\n  const sh: SchemaHydration = {};\n  const { validate: schemaValidate, zodSchema, ...schemaRest } = schema;\n  if (typeof schemaValidate === \"function\") sh.validate = schemaValidate;\n  if (zodSchema) sh.zodSchema = zodSchema;\n  if (sh.validate || sh.zodSchema) hydration.__schema__ = sh;\n\n  return { plain: { ...schemaRest, fields: plainFields }, hydration };\n}\n",
      "type": "registry:component",
      "target": "components/content-composer/lib/hydration.ts"
    },
    {
      "path": "src/registry/components/media/content-composer/lib/phase-reducer.ts",
      "content": "import type { ComposerPhase, PublishIntent } from \"../types\";\n\n/**\n * Phase reducer — the EPHEMERAL FSM phase, deliberately kept OUT of the\n * serializable `ComposerDraft` (§6 \"ONE JSON-serializable draft\"). Orthogonal\n * to the draft's persisted `status` axis and to the step `cursor`.\n *\n * `schedule` is NOT a separate terminal machine: one `intent-accepted` action\n * fans out to `publishing` vs `scheduling` by `PublishIntent.mode`.\n */\nexport type PhaseAction =\n  | { type: \"start\" }\n  | { type: \"autosave-begin\" }\n  | { type: \"autosave-end\" }\n  | { type: \"validate-begin\" }\n  | { type: \"gate-fail\" }\n  | { type: \"intent-accepted\"; intent: PublishIntent }\n  | { type: \"draft-ack\" }\n  | { type: \"publish-resolved\" }\n  | { type: \"schedule-resolved\" }\n  | { type: \"publish-rejected\" }\n  | { type: \"retry\" }\n  | { type: \"dismiss-error\" };\n\nexport function phaseReducer(\n  phase: ComposerPhase,\n  action: PhaseAction,\n): ComposerPhase {\n  switch (action.type) {\n    case \"start\":\n      return phase === \"idle\" ? \"editing\" : phase;\n    case \"autosave-begin\":\n      return phase === \"editing\" ? \"autosaving\" : phase;\n    case \"autosave-end\":\n      return phase === \"autosaving\" ? \"editing\" : phase;\n    case \"validate-begin\":\n      return phase === \"editing\" || phase === \"publish-error\"\n        ? \"validating\"\n        : phase;\n    case \"gate-fail\":\n      return phase === \"validating\" ? \"editing\" : phase;\n    case \"intent-accepted\": {\n      if (phase !== \"validating\") return phase;\n      // schedule = publish + future publishAt; ONE action fans out — NOT a separate terminal machine.\n      return action.intent.mode === \"draft\"\n        ? \"draft-saved\"\n        : action.intent.mode === \"publish\"\n          ? \"publishing\"\n          : \"scheduling\";\n    }\n    case \"draft-ack\":\n      return phase === \"draft-saved\" ? \"editing\" : phase;\n    case \"publish-resolved\":\n      return phase === \"publishing\" ? \"published\" : phase;\n    case \"schedule-resolved\":\n      return phase === \"scheduling\" ? \"scheduled\" : phase;\n    case \"publish-rejected\":\n      return phase === \"publishing\" || phase === \"scheduling\"\n        ? \"publish-error\"\n        : phase;\n    case \"retry\":\n      return phase === \"publish-error\" ? \"validating\" : phase;\n    case \"dismiss-error\":\n      return phase === \"publish-error\" ? \"editing\" : phase;\n    default: {\n      const _exhaustive: never = action;\n      return _exhaustive ?? phase;\n    }\n  }\n}\n",
      "type": "registry:component",
      "target": "components/content-composer/lib/phase-reducer.ts"
    },
    {
      "path": "src/registry/components/media/content-composer/lib/publish-cta.ts",
      "content": "import type { ComposerPhase, PublishMode } from \"../types\";\n\nexport interface PublishCtaArm {\n  mode: PublishMode;\n  label: string;\n  variant: \"default\" | \"outline\" | \"secondary\";\n  disabled: boolean;\n  busy: boolean;\n}\n\nexport interface ResolvePublishCtaArgs {\n  publishModes: PublishMode[];\n  phase: ComposerPhase;\n  /** affordance-gates: only show an arm whose lifecycle callback is wired */\n  hasOnSaveDraft: boolean;\n  hasOnPublish: boolean;\n  hasOnSchedule: boolean;\n  /** a future scheduledFor is picked (the schedule arm stays disabled until then) */\n  scheduleReady: boolean;\n}\n\n/**\n * Map `config.publishModes` → the publish-bar arms. Each arm is shown only when\n * its lifecycle callback is wired (affordance-gate), disabled while a conflicting\n * phase is in flight, and `busy` while its own action runs. The schedule arm\n * also waits on a future `scheduledFor`.\n */\nexport function resolvePublishCtaArms({\n  publishModes,\n  phase,\n  hasOnSaveDraft,\n  hasOnPublish,\n  hasOnSchedule,\n  scheduleReady,\n}: ResolvePublishCtaArgs): PublishCtaArm[] {\n  const inFlight =\n    phase === \"validating\" ||\n    phase === \"publishing\" ||\n    phase === \"scheduling\" ||\n    phase === \"autosaving\";\n  const arms: PublishCtaArm[] = [];\n\n  if (publishModes.includes(\"draft\") && hasOnSaveDraft) {\n    arms.push({\n      mode: \"draft\",\n      label: \"Save draft\",\n      variant: \"outline\",\n      disabled: inFlight,\n      busy: phase === \"draft-saved\",\n    });\n  }\n  if (publishModes.includes(\"schedule\") && hasOnSchedule) {\n    arms.push({\n      mode: \"schedule\",\n      label: \"Schedule\",\n      variant: \"secondary\",\n      disabled: inFlight || !scheduleReady,\n      busy: phase === \"scheduling\",\n    });\n  }\n  if (publishModes.includes(\"publish\") && hasOnPublish) {\n    arms.push({\n      mode: \"publish\",\n      label: \"Publish\",\n      variant: \"default\",\n      disabled: inFlight,\n      busy: phase === \"publishing\",\n    });\n  }\n  return arms;\n}\n",
      "type": "registry:component",
      "target": "components/content-composer/lib/publish-cta.ts"
    },
    {
      "path": "src/registry/components/media/content-composer/lib/reducer.ts",
      "content": "import type { ComposerDraft, ComposerStepValue, ContentStatus } from \"../types\";\n\n/**\n * Draft reducer — owns the SERIALIZABLE draft only (never the ephemeral FSM\n * phase, which lives in `phase-reducer.ts`). Mirrors kanban-board's reducer\n * shape: a `replace` arm for full hydration/restore + targeted mutations.\n */\nexport type ComposerAction =\n  // hydration / loadDraft / fromContentItem re-seed / autosave restore (kanban `replace` arm)\n  | { type: \"replace\"; draft: ComposerDraft }\n  // per-mutation (T4)\n  | { type: \"set-step-value\"; stepId: string; value: ComposerStepValue }\n  // cursor-under-editing (gated forward, enforced by the shell before dispatch)\n  | { type: \"set-cursor\"; cursor: number }\n  // persisted-status projection (T9/T13/T14)\n  | { type: \"set-status\"; status: ContentStatus }\n  | { type: \"set-scheduled-for\"; scheduledFor: string | undefined }\n  | { type: \"set-content-id\"; contentId: string };\n\nexport function composerReducer(\n  state: ComposerDraft,\n  action: ComposerAction,\n): ComposerDraft {\n  switch (action.type) {\n    case \"replace\":\n      // full swap — no merge (kanban semantics)\n      return action.draft;\n    case \"set-step-value\":\n      return {\n        ...state,\n        steps: { ...state.steps, [action.stepId]: action.value },\n      };\n    case \"set-cursor\":\n      return state.cursor === action.cursor\n        ? state\n        : { ...state, cursor: action.cursor };\n    case \"set-status\":\n      return state.status === action.status\n        ? state\n        : { ...state, status: action.status };\n    case \"set-scheduled-for\":\n      return { ...state, scheduledFor: action.scheduledFor };\n    case \"set-content-id\":\n      return { ...state, contentId: action.contentId };\n    default: {\n      const _exhaustive: never = action;\n      return _exhaustive ?? state;\n    }\n  }\n}\n\n/** kanban EMPTY analog — the uncontrolled-mode seed. */\nexport function makeEmptyDraft(contentType: string): ComposerDraft {\n  return { contentType, steps: {}, status: \"draft\", cursor: 0 };\n}\n",
      "type": "registry:component",
      "target": "components/content-composer/lib/reducer.ts"
    },
    {
      "path": "src/registry/components/media/content-composer/lib/substrates.tsx",
      "content": "\"use client\";\n\nimport type { SlotSubstrate, SlotSubstrateMap, FindSubstrate } from \"../types\";\nimport { JsonFormSubstrateMount } from \"../parts/json-form-substrate\";\nimport { BodySubstrateMount } from \"../parts/body-substrate\";\nimport { MediaSubstrateMount } from \"../parts/media-substrate\";\nimport { MediaCarouselSubstrateMount } from \"../parts/media-carousel-substrate\";\n\n/**\n * The three default substrate records. Each maps a closed `SlotKind` to a\n * `render` function that mounts the real substrate procomp. The `render` bodies\n * are fleshed out across the substrate-layer chain:\n *\n *   - jsonFormSubstrate    → C6 (mounts <JsonForm> + hydration + custom field renderers)\n *   - richTextSubstrate → C7 (lazy <RichTextEditor> / eager <Textarea> per slotConfig.substrate)\n *   - mediaEditorSubstrate → C8 (mounts <MediaEditor> + clampMediaSources)\n *\n * At C4 they are registered stubs so the registry + slot-mount machinery is in\n * place; each returns `null` until its mount lands. The records remain spreadable\n * + overridable via the `substrates` prop (defaults merge under consumer overrides).\n */\n\nexport const jsonFormSubstrate: SlotSubstrate<\"metadataFields\"> = {\n  kind: \"metadataFields\",\n  render: (args) => <JsonFormSubstrateMount {...args} />,\n};\n\nexport const richTextSubstrate: SlotSubstrate<\"bodySlot\"> = {\n  kind: \"bodySlot\",\n  render: (args) => <BodySubstrateMount {...args} />,\n};\n\nexport const mediaEditorSubstrate: SlotSubstrate<\"mediaSlot\"> = {\n  kind: \"mediaSlot\",\n  render: (args) => <MediaSubstrateMount {...args} />,\n};\n\nexport const mediaCarouselSubstrate: SlotSubstrate<\"mediaCarouselSlot\"> = {\n  kind: \"mediaCarouselSlot\",\n  render: (args) => <MediaCarouselSubstrateMount {...args} />,\n};\n\nexport const DEFAULT_SUBSTRATES: SlotSubstrateMap = {\n  metadataFields: jsonFormSubstrate,\n  bodySlot: richTextSubstrate,\n  mediaSlot: mediaEditorSubstrate,\n  mediaCarouselSlot: mediaCarouselSubstrate,\n};\n\n/**\n * Keyed-MAP object-index lookup (analogous to kanban's `findRenderer`, but a\n * Partial<Record<…>> index — NOT an array `.find()`). There are exactly three\n * closed slot-kinds, so this is O(1) and compile-time prop-flow-safe.\n */\nexport const findSubstrate: FindSubstrate = (substrates, key) => substrates[key];\n",
      "type": "registry:component",
      "target": "components/content-composer/lib/substrates.tsx"
    },
    {
      "path": "src/registry/components/media/content-composer/lib/upload.ts",
      "content": "import type { ExportMetadata } from \"@/registry/components/media/media-editor/media-editor\";\n\nexport type Uploader = (\n  blob: Blob,\n  meta: ExportMetadata,\n) => Promise<{ url: string }>;\n\n/**\n * Resolve a concrete uploader from the primary `uploader` fn or the `uploadUrl`\n * shorthand (QP-8). The shorthand POSTs a multipart form to `uploadUrl` and\n * expects a JSON `{ url }` back. Returns `undefined` when neither is provided —\n * the shell then can't produce a hero URL and surfaces an error at assembly.\n */\nexport function resolveUploader(\n  uploader?: Uploader,\n  uploadUrl?: string,\n): Uploader | undefined {\n  if (uploader) return uploader;\n  if (uploadUrl) {\n    return async (blob, meta) => {\n      const form = new FormData();\n      form.append(\"file\", blob, fileNameFor(meta));\n      const res = await fetch(uploadUrl, { method: \"POST\", body: form });\n      if (!res.ok) throw new Error(`content-composer: upload failed (${res.status}).`);\n      const data: unknown = await res.json().catch(() => ({}));\n      const url =\n        data && typeof data === \"object\" && typeof (data as { url?: unknown }).url === \"string\"\n          ? (data as { url: string }).url\n          : \"\";\n      if (!url) throw new Error(\"content-composer: upload response missing `url`.\");\n      return { url };\n    };\n  }\n  return undefined;\n}\n\nfunction fileNameFor(meta: ExportMetadata): string {\n  const mime = meta.mimeType ?? \"\";\n  const ext = mime.includes(\"video\")\n    ? \"mp4\"\n    : mime.includes(\"png\")\n      ? \"png\"\n      : mime.includes(\"webp\")\n        ? \"webp\"\n        : \"jpg\";\n  return `hero.${ext}`;\n}\n",
      "type": "registry:component",
      "target": "components/content-composer/lib/upload.ts"
    },
    {
      "path": "src/registry/components/media/content-composer/parts/body-substrate-plaintext.tsx",
      "content": "\"use client\";\n\nimport { Textarea } from \"@/components/ui/textarea\";\n\nexport interface BodySubstratePlaintextProps {\n  value: string;\n  onChange: (next: string) => void;\n  placeholder?: string;\n  labelledBy?: string;\n  rows?: number;\n}\n\n/**\n * Plaintext body substrate — eager shadcn `<Textarea>`. The fallback when a\n * config's `bodySlot.substrate` is `\"plaintext\"` (e.g. the post config). No\n * Plate bundle cost.\n */\nexport function BodySubstratePlaintext({\n  value,\n  onChange,\n  placeholder,\n  labelledBy,\n  rows = 8,\n}: BodySubstratePlaintextProps) {\n  return (\n    <Textarea\n      value={value}\n      aria-labelledby={labelledBy}\n      placeholder={placeholder ?? \"Write…\"}\n      rows={rows}\n      onChange={(e) => onChange(e.target.value)}\n      className=\"resize-y\"\n    />\n  );\n}\n",
      "type": "registry:component",
      "target": "components/content-composer/parts/body-substrate-plaintext.tsx"
    },
    {
      "path": "src/registry/components/media/content-composer/parts/body-substrate-plate.tsx",
      "content": "\"use client\";\n\n// Cross-procomp import via the `.tsx` component-file path (NOT `./types`, NOT\n// the barrel) — the F-S1 precedent (`json-form/parts/field-richtext.tsx`).\n// shadcn's path rewriter preserves the component-file path but mangles\n// `/types` + `/index` to the current slug. `rich-text-editor.tsx` re-exports\n// the symbols we need at its tail.\nimport {\n  RichTextEditor,\n  RICH_TEXT_EMPTY_VALUE,\n  type RichTextValue,\n} from \"@/registry/components/data/rich-text-editor/rich-text-editor\";\nimport type { ImageUploader } from \"@/registry/components/data/rich-text-editor/rich-text-editor\";\n\nexport interface BodySubstratePlateProps {\n  value: RichTextValue;\n  onChange: (next: RichTextValue) => void;\n  placeholder?: string;\n  labelledBy?: string;\n  /**\n   * Optional inline-image uploader (`(file) => Promise<{ src }>`). Unwired in\n   * v0.1 — the composer's media `uploader` is `ExportMetadata`-shaped (media-\n   * export-specific), which is semantically wrong to fabricate for inline\n   * article images. A dedicated body-image uploader prop is a v0.1.1 follow-up;\n   * until then Plate falls back to its URL-prompt image insertion.\n   */\n  onImageUpload?: ImageUploader;\n}\n\n/**\n * Richtext body substrate — wraps `@ilinxa/rich-text-editor`'s\n * `<RichTextEditor>` (Plate). MUST `export default` — `lib/substrates.tsx`\n * `React.lazy`-loads it so configs without a richtext body don't pay the\n * ~165 KB Plate chunk. Plate owns a contenteditable (no `id`), so the wrapper\n * binds via `role=\"group\"` + `aria-labelledby`.\n */\nexport default function BodySubstratePlate({\n  value,\n  onChange,\n  placeholder,\n  labelledBy,\n  onImageUpload,\n}: BodySubstratePlateProps) {\n  const safe = Array.isArray(value) ? value : RICH_TEXT_EMPTY_VALUE;\n  return (\n    <div role=\"group\" aria-labelledby={labelledBy}>\n      <RichTextEditor\n        value={safe}\n        onChange={onChange}\n        placeholder={placeholder ?? \"Write the article…\"}\n        onImageUpload={onImageUpload}\n      />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/content-composer/parts/body-substrate-plate.tsx"
    },
    {
      "path": "src/registry/components/media/content-composer/parts/body-substrate.tsx",
      "content": "\"use client\";\n\nimport { Suspense, lazy, useEffect, useRef } from \"react\";\nimport { RICH_TEXT_EMPTY_VALUE } from \"@/registry/components/data/rich-text-editor/rich-text-editor\";\nimport type { BodySlotValue, SlotHandle, SlotRenderArgs } from \"../types\";\nimport { assignRef } from \"../lib/assign-ref\";\nimport {\n  bodyContentKey,\n  defaultBodyValue,\n  isBodyEmpty,\n  useBodyDirty,\n} from \"../hooks/use-body-dirty\";\nimport { BodySubstratePlaintext } from \"./body-substrate-plaintext\";\n\n// Lazy-load the Plate bundle (~165 KB) — only paid when a richtext body mounts.\n// The default-export requirement lives on body-substrate-plate.tsx.\nconst BodySubstratePlate = lazy(() => import(\"./body-substrate-plate\"));\n\n/**\n * `bodySlot` substrate mount. Dispatches plate-vs-plaintext on\n * `slotConfig.substrate`, wraps the lazy Plate editor in `<Suspense>`, and\n * populates the uniform `SlotHandle`. Dirty is derived by JSON baseline-compare\n * (Plate has no dirty signal) — `loadValue` resets the baseline (#1 trap).\n */\nexport function BodySubstrateMount({\n  slotConfig,\n  value,\n  onChange,\n  ctx,\n  handleRef,\n}: SlotRenderArgs<\"bodySlot\">) {\n  const current = value ?? defaultBodyValue(slotConfig);\n  const { valueRef, getIsDirty, rebaseline } = useBodyDirty(current);\n\n  const onChangeRef = useRef(onChange);\n  useEffect(() => {\n    onChangeRef.current = onChange;\n  }, [onChange]);\n\n  useEffect(() => {\n    const handle: SlotHandle<BodySlotValue> = {\n      getValue: () => valueRef.current,\n      getIsDirty,\n      // Structural non-empty self-check (for headless use). The shell's gate\n      // layers the CONFIGURED minLength rule — it owns step.validation.\n      validate: async () => !isBodyEmpty(valueRef.current),\n      loadValue: (v) => {\n        if (bodyContentKey(v) !== bodyContentKey(valueRef.current)) {\n          onChangeRef.current(v);\n        }\n        rebaseline(v); // reset baseline (#1 trap)\n      },\n    };\n    assignRef(handleRef, handle);\n  }, [handleRef, getIsDirty, rebaseline, valueRef]);\n\n  const labelledBy = `composer-step-${ctx.stepId}-label`;\n\n  if (slotConfig.substrate === \"plaintext\") {\n    return (\n      <BodySubstratePlaintext\n        value={current.kind === \"plaintext\" ? current.value : \"\"}\n        onChange={(s) => onChange({ kind: \"plaintext\", value: s })}\n        placeholder={slotConfig.placeholder}\n        labelledBy={labelledBy}\n      />\n    );\n  }\n\n  return (\n    <Suspense fallback={<BodyLoading />}>\n      <BodySubstratePlate\n        value={\n          current.kind === \"richtext\" ? current.value : RICH_TEXT_EMPTY_VALUE\n        }\n        onChange={(v) => onChange({ kind: \"richtext\", value: v })}\n        placeholder={slotConfig.placeholder}\n        labelledBy={labelledBy}\n      />\n    </Suspense>\n  );\n}\n\nfunction BodyLoading() {\n  return (\n    <div className=\"flex min-h-32 items-center justify-center rounded-md border border-dashed text-sm text-muted-foreground\">\n      Loading editor…\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/content-composer/parts/body-substrate.tsx"
    },
    {
      "path": "src/registry/components/media/content-composer/parts/composer-dialog.tsx",
      "content": "\"use client\";\n\nimport type { ReactNode } from \"react\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface ComposerDialogProps {\n  open: boolean;\n  /** forwarded to shadcn Dialog; the root routes close through the discard guard (C11) */\n  onOpenChange: (open: boolean) => void;\n  title: string;\n  /** REQUIRED — Radix/Base-UI a11y needs a Title AND a Description on DialogContent\n   *  (the story-composer v0.1.2 lesson; missing one logs a console warning). */\n  description: string;\n  children: ReactNode;\n  className?: string;\n}\n\n/**\n * presentation=\"dialog\" wrapper. shadcn Dialog already traps focus + handles\n * Escape; we widen the default `sm:max-w-sm` cap for the multi-step composer\n * surface. Uses only the stable Dialog/DialogContent/DialogTitle/DialogDescription\n * API (no `asChild`/anchor) so it stays clear of the F-cross-13 divergence class.\n */\nexport function ComposerDialog({\n  open,\n  onOpenChange,\n  title,\n  description,\n  children,\n  className,\n}: ComposerDialogProps) {\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent\n        className={cn(\n          \"max-h-[90vh] gap-4 overflow-y-auto sm:max-w-2xl\",\n          className,\n        )}\n      >\n        <DialogHeader>\n          <DialogTitle>{title}</DialogTitle>\n          <DialogDescription>{description}</DialogDescription>\n        </DialogHeader>\n        {children}\n      </DialogContent>\n    </Dialog>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/content-composer/parts/composer-dialog.tsx"
    },
    {
      "path": "src/registry/components/media/content-composer/parts/composer-shell.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AlertCircle, X } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { cn } from \"@/lib/utils\";\nimport type { ComposerCtx } from \"../types\";\nimport { StepIndicator } from \"./step-indicator\";\n\nexport interface ComposerShellProps {\n  ctx: ComposerCtx;\n  mode: \"inline\" | \"dialog\";\n  /** the mounted slot for the active step (`<SlotMount>`) */\n  children: React.ReactNode;\n  /** publish region (draft/publish/schedule arms) — supplied by the root at C11 */\n  footer?: React.ReactNode;\n  /** screen-reader announcement (gate failures, save acks) — managed by the root */\n  announcement?: string;\n  /** visible lifecycle error (save/publish failure) — rendered as a dismissable alert */\n  error?: string | null;\n  /** dismiss handler for the visible error alert */\n  onDismissError?: () => void;\n  /** escape hatch — extra chrome above the step body */\n  renderStepChrome?: (ctx: ComposerCtx) => React.ReactNode;\n  className?: string;\n}\n\n/**\n * The inline presentation frame: step nav + the active step's slot + a footer\n * with backward/forward navigation and the publish region. The root wraps the\n * `children` (the active `<SlotMount>`) in the per-step context, so this frame\n * only renders chrome. The orchestrated `reveal-up` fires once per step\n * transition (keyed on the active step id) — the single reveal per surface\n * mandated by the design system.\n */\nexport function ComposerShell({\n  ctx,\n  mode,\n  children,\n  footer,\n  announcement,\n  error,\n  onDismissError,\n  renderStepChrome,\n  className,\n}: ComposerShellProps) {\n  const { steps, cursor, goToStep } = ctx;\n  const step = steps[cursor];\n  const isFirst = cursor <= 0;\n  const isLast = cursor >= steps.length - 1;\n\n  return (\n    <div\n      data-slot=\"composer-shell\"\n      data-mode={mode}\n      className={cn(\"flex flex-col gap-4\", className)}\n    >\n      <StepIndicator\n        steps={steps}\n        cursor={cursor}\n        stepErrors={ctx.stepErrors}\n        onStepClick={(i) => void goToStep(i)}\n      />\n\n      {renderStepChrome?.(ctx)}\n\n      <div\n        key={step?.id}\n        data-composer-step-body\n        className=\"reveal-up flex min-h-0 flex-col gap-3\"\n      >\n        {step && (\n          <h3\n            id={`composer-step-${step.id}-label`}\n            className=\"font-heading text-sm font-medium text-foreground\"\n          >\n            {step.title}\n          </h3>\n        )}\n        {children}\n      </div>\n\n      {/* gate-failure / save-ack announcements */}\n      <div role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </div>\n\n      {error ? (\n        <div\n          role=\"alert\"\n          className=\"flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-foreground\"\n        >\n          <AlertCircle\n            className=\"mt-0.5 size-4 shrink-0 text-destructive\"\n            aria-hidden\n          />\n          <p className=\"flex-1\">{error}</p>\n          {onDismissError ? (\n            <button\n              type=\"button\"\n              aria-label=\"Dismiss\"\n              onClick={onDismissError}\n              className=\"shrink-0 rounded p-0.5 text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n            >\n              <X className=\"size-4\" aria-hidden />\n            </button>\n          ) : null}\n        </div>\n      ) : null}\n\n      <Separator />\n\n      <div className=\"flex items-center justify-between gap-2\">\n        <Button\n          type=\"button\"\n          variant=\"ghost\"\n          disabled={isFirst}\n          onClick={() => void goToStep(cursor - 1)}\n        >\n          Back\n        </Button>\n        <div className=\"flex items-center gap-2\">\n          {footer}\n          {!isLast && (\n            <Button type=\"button\" onClick={() => void goToStep(cursor + 1)}>\n              Next\n            </Button>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/content-composer/parts/composer-shell.tsx"
    },
    {
      "path": "src/registry/components/media/content-composer/parts/field-author-picker.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { Check, ChevronsUpDown, Loader2, User } from \"lucide-react\";\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\";\nimport {\n  defineFieldRenderer,\n  type NarrowedRendererArgs,\n} from \"@/registry/components/forms/json-form/lib/define-field-renderer\";\n\nexport type AuthorEntity = { id: string; name: string; avatar?: string };\n\n/**\n * The composer-owned async loader convention. NOT a json-form built-in:\n * the real `FieldConfig` is closed (code/date/rating/richText). The second\n * `defineFieldRenderer` generic carries this so `field.config.authorSource`\n * reads STRONGLY TYPED (not an implicit `unknown` widening).\n */\nexport type AuthorSourceConfig = (query: string) => Promise<AuthorEntity[]>;\n\nfunction isAuthorEntity(v: unknown): v is AuthorEntity {\n  return (\n    !!v &&\n    typeof v === \"object\" &&\n    typeof (v as AuthorEntity).id === \"string\" &&\n    typeof (v as AuthorEntity).name === \"string\"\n  );\n}\n\n// Hook-using body in a proper uppercase component (json-form renders the\n// registry entry as a component, so hooks are legal at runtime — naming it this\n// way also satisfies react-hooks/rules-of-hooks).\nfunction AuthorPickerFieldImpl({\n  value,\n  onChange,\n  onBlur,\n  disabled,\n  readOnly,\n  ariaProps,\n  field,\n}: NarrowedRendererArgs<AuthorEntity | null, AuthorSourceConfig>) {\n  const authorSource = field.config?.authorSource;\n  const selected = isAuthorEntity(value) ? value : null;\n  const locked = disabled || readOnly;\n\n  const [open, setOpen] = useState(false);\n  const [query, setQuery] = useState(\"\");\n  const [results, setResults] = useState<AuthorEntity[]>([]);\n  const [loading, setLoading] = useState(false);\n  const genRef = useRef(0);\n\n  // Debounced async search with a generation guard (drops stale responses).\n  // setLoading lives inside the timeout (not synchronously in the effect body)\n  // so it can't trigger a cascading render.\n  useEffect(() => {\n    if (!open || !authorSource) return;\n    const gen = ++genRef.current;\n    const id = setTimeout(() => {\n      setLoading(true);\n      authorSource(query)\n        .then((list) => {\n          if (gen === genRef.current) setResults(list);\n        })\n        .catch(() => {\n          if (gen === genRef.current) setResults([]);\n        })\n        .finally(() => {\n          if (gen === genRef.current) setLoading(false);\n        });\n    }, 200);\n    return () => clearTimeout(id);\n  }, [open, query, authorSource]);\n\n  // No loader configured → read-only display chip.\n  if (!authorSource) {\n    return (\n      <div\n        role=\"group\"\n        aria-labelledby={ariaProps.labelledBy}\n        className=\"flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-sm\"\n      >\n        <User className=\"size-4 text-muted-foreground\" />\n        {selected ? (\n          <span>{selected.name}</span>\n        ) : (\n          <span className=\"text-muted-foreground\">No author selected</span>\n        )}\n      </div>\n    );\n  }\n\n  return (\n    <Popover\n      open={open}\n      onOpenChange={(o) => {\n        setOpen(o);\n        if (!o) onBlur();\n      }}\n    >\n      <PopoverTrigger\n        disabled={locked}\n        aria-labelledby={ariaProps.labelledBy}\n        aria-invalid={ariaProps[\"aria-invalid\"]}\n        className={cn(\n          \"flex h-9 w-full items-center justify-between gap-2 rounded-md border bg-background px-3 text-sm\",\n          \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\",\n          \"disabled:pointer-events-none disabled:opacity-50\",\n        )}\n      >\n        <span\n          className={cn(\n            \"flex items-center gap-2 truncate\",\n            !selected && \"text-muted-foreground\",\n          )}\n        >\n          <User className=\"size-4 shrink-0\" />\n          {selected ? selected.name : \"Select author…\"}\n        </span>\n        <ChevronsUpDown className=\"size-4 shrink-0 opacity-50\" />\n      </PopoverTrigger>\n      <PopoverContent\n        align=\"start\"\n        className=\"w-(--radix-popover-trigger-width) min-w-56 p-0\"\n      >\n        <Command shouldFilter={false}>\n          <CommandInput\n            value={query}\n            onValueChange={setQuery}\n            placeholder=\"Search authors…\"\n          />\n          <CommandList>\n            {loading ? (\n              <div className=\"flex items-center gap-2 p-3 text-sm text-muted-foreground\">\n                <Loader2 className=\"size-4 animate-spin\" /> Searching…\n              </div>\n            ) : (\n              <>\n                <CommandEmpty>No authors found.</CommandEmpty>\n                <CommandGroup>\n                  {results.map((a) => (\n                    <CommandItem\n                      key={a.id}\n                      value={a.id}\n                      onSelect={() => {\n                        onChange(a);\n                        setOpen(false);\n                        onBlur();\n                      }}\n                    >\n                      <User className=\"size-4\" />\n                      <span className=\"flex-1 truncate\">{a.name}</span>\n                      {selected?.id === a.id && <Check className=\"size-4\" />}\n                    </CommandItem>\n                  ))}\n                </CommandGroup>\n              </>\n            )}\n          </CommandList>\n        </Command>\n      </PopoverContent>\n    </Popover>\n  );\n}\n\n/**\n * `author-picker` field renderer — an entity-picker combobox that manages its\n * OWN async fetch outside json-form's `options` machinery (json-form has no\n * built-in entity-picker). Registered via `fieldRegistry` and referenced from\n * JSON by `type: \"author-picker\"`. Exported for `fieldRegistry` reuse.\n *\n * The async loader comes from `field.config.authorSource`; when absent the\n * field renders a read-only display chip. Set `dependsOn: []` on the field.\n * Uses `PopoverTrigger` directly as the trigger button (no `asChild`) to stay\n * clear of the F-cross-13 divergence class.\n */\nexport const authorPickerFieldRenderer = defineFieldRenderer<\n  AuthorEntity | null,\n  AuthorSourceConfig\n>({\n  displayName: \"ComposerAuthorPickerField\",\n  impl: (args) => <AuthorPickerFieldImpl {...args} />,\n});\n",
      "type": "registry:component",
      "target": "components/content-composer/parts/field-author-picker.tsx"
    },
    {
      "path": "src/registry/components/media/content-composer/parts/field-tags.tsx",
      "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { X } from \"lucide-react\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n  defineFieldRenderer,\n  type NarrowedRendererArgs,\n} from \"@/registry/components/forms/json-form/lib/define-field-renderer\";\n\n// The hook-using body lives in a proper uppercase component (json-form renders\n// the registry entry as a component, so hooks are legal at runtime — naming it\n// this way also satisfies react-hooks/rules-of-hooks).\nfunction TagsFieldImpl({\n  value,\n  onChange,\n  onBlur,\n  disabled,\n  readOnly,\n  ariaProps,\n}: NarrowedRendererArgs<string[], unknown>) {\n  // RHF holds whatever it holds — defineFieldRenderer narrows types only, not\n  // runtime — so guard the array.\n  const tags = Array.isArray(value) ? value : [];\n  const [draft, setDraft] = useState(\"\");\n  const locked = disabled || readOnly;\n\n  const add = () => {\n    const t = draft.trim();\n    if (t && !tags.includes(t)) onChange([...tags, t]);\n    setDraft(\"\");\n  };\n\n  return (\n    <div\n      role=\"group\"\n      aria-labelledby={ariaProps.labelledBy}\n      aria-describedby={ariaProps[\"aria-describedby\"]}\n      data-aria-invalid={ariaProps[\"aria-invalid\"] ? \"true\" : undefined}\n      className=\"flex flex-wrap items-center gap-1.5\"\n    >\n      {tags.map((t) => (\n        <Badge key={t} variant=\"secondary\" className=\"gap-1 pr-1\">\n          {t}\n          <button\n            type=\"button\"\n            aria-label={`Remove ${t}`}\n            disabled={locked}\n            onClick={() => onChange(tags.filter((x) => x !== t))}\n            className=\"rounded-full p-0.5 hover:bg-foreground/10 disabled:pointer-events-none disabled:opacity-50\"\n          >\n            <X className=\"size-3\" />\n          </button>\n        </Badge>\n      ))}\n      <Input\n        value={draft}\n        disabled={locked}\n        className=\"h-7 w-32 flex-1\"\n        placeholder=\"Add tag…\"\n        onChange={(e) => setDraft(e.target.value)}\n        onBlur={onBlur}\n        onKeyDown={(e) => {\n          if (e.key === \"Enter\" || e.key === \",\") {\n            e.preventDefault();\n            add();\n          } else if (e.key === \"Backspace\" && draft === \"\" && tags.length > 0) {\n            onChange(tags.slice(0, -1));\n          }\n        }}\n      />\n    </div>\n  );\n}\n\n/**\n * `tags` field renderer — chip-input-with-create. json-form's built-in\n * `FieldType` has no chip-input, so the composer ships this as a content-\n * composer-owned custom `FieldRenderer`, registered via `fieldRegistry` and\n * referenced from JSON by `type: \"tags\"`. Exported for `fieldRegistry` reuse.\n *\n * Set `dependsOn: []` on the field in JSON (it doesn't read `allValues`) to opt\n * into json-form's snapshot subscription mode.\n */\nexport const tagsFieldRenderer = defineFieldRenderer<string[]>({\n  displayName: \"ComposerTagsField\",\n  impl: (args) => <TagsFieldImpl {...args} />,\n});\n",
      "type": "registry:component",
      "target": "components/content-composer/parts/field-tags.tsx"
    },
    {
      "path": "src/registry/components/media/content-composer/parts/json-form-substrate.tsx",
      "content": "\"use client\";\n\nimport { useMemo } from \"react\";\nimport { JsonForm } from \"@/registry/components/forms/json-form/json-form\";\nimport type {\n  FieldRenderer,\n  JsonFormHandle,\n} from \"@/registry/components/forms/json-form/json-form\";\nimport type { SlotHandle, SlotRenderArgs } from \"../types\";\nimport { assignRef } from \"../lib/assign-ref\";\nimport { tagsFieldRenderer } from \"./field-tags\";\nimport { authorPickerFieldRenderer } from \"./field-author-picker\";\n\nconst NOOP = () => {};\n\n/**\n * Build the uniform `SlotHandle` over a json-form imperative handle. The shell\n * reads all three substrates through this same shape.\n */\nfunction makeJsonFormSlotHandle(\n  formApi: JsonFormHandle,\n): SlotHandle<Record<string, unknown>> {\n  return {\n    getValue: () => formApi.getValues(),\n    getIsDirty: () => formApi.isDirty(),\n    validate: async () => {\n      // trigger() force-validates ALL fields first — validationMode \"onTouched\"\n      // would otherwise report a never-touched required field as valid — THEN\n      // read isValid().\n      const ok = await formApi.trigger();\n      return ok && formApi.isValid();\n    },\n    loadValue: (v) => formApi.reset(v), // reset() clears dirty + history (re-baseline)\n  };\n}\n\n/**\n * `metadataFields` substrate mount. Mounts `<JsonForm>` controlled by the slot\n * value, captures its imperative handle via `onReady` into the shell's\n * `handleRef`, and registers the two composer-owned custom field renderers\n * (`tags` + `author-picker`). The shell owns the publish CTA, so the form's own\n * submit button is disabled and `onSubmit` is a no-op.\n *\n * Controlled `values` round-trip is loop-safe: json-form's ChangeBridge carries\n * a stableStringify structural-equality guard that breaks the controlled-mode\n * echo (so `onChangeDebounce={0}` per-mutation emit is safe).\n */\nexport function JsonFormSubstrateMount({\n  slotConfig,\n  value,\n  onChange,\n  handleRef,\n}: SlotRenderArgs<\"metadataFields\">) {\n  const registry = useMemo<Record<string, FieldRenderer>>(\n    () => ({\n      tags: tagsFieldRenderer,\n      \"author-picker\": authorPickerFieldRenderer,\n    }),\n    [],\n  );\n\n  return (\n    <JsonForm\n      schema={slotConfig.schema}\n      columns={slotConfig.columns}\n      fieldRegistry={registry}\n      submitButton={false}\n      onSubmit={NOOP}\n      onChangeDebounce={0}\n      values={value}\n      onChange={({ values }) => onChange(values)}\n      onReady={({ formApi }) =>\n        assignRef(handleRef, makeJsonFormSlotHandle(formApi))\n      }\n    />\n  );\n}\n",
      "type": "registry:component",
      "target": "components/content-composer/parts/json-form-substrate.tsx"
    },
    {
      "path": "src/registry/components/media/content-composer/parts/media-substrate.tsx",
      "content": "\"use client\";\n\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { MediaEditor } from \"@/registry/components/media/media-editor/media-editor\";\nimport type {\n  InitialSource,\n  MediaEditorHandle,\n  MediaEditorState,\n} from \"@/registry/components/media/media-editor/media-editor\";\n// Capture feature slice (P3 S3) — the shipped news config enables \"camera\"\n// in mediaSources for the hero step (configs/news-composer.config.ts), so\n// the mount below wires the extension. Relative (not the `@/registry/...`\n// alias used above) — media-editor-capture is a sub-item of media-editor's\n// own folder, siblings-under-install-root the same way story-composer's\n// media-editor import already is.\nimport { mediaCapture } from \"../../media-editor/features/capture\";\nimport type { MediaSlotValue, SlotHandle, SlotRenderArgs } from \"../types\";\nimport { assignRef } from \"../lib/assign-ref\";\nimport { clampMediaSources } from \"../lib/clamp-media-sources\";\nimport { editorStateHasMedia } from \"../lib/gates\";\n\n/**\n * Shell-held cache of the source blob backing each mediaSlot step's\n * `editorState.imageSrc`, keyed by step id. The object URL inside a persisted\n * editorState dies with the capturing editor instance (single-step mount), so\n * a step-revisit needs the blob to re-mint a live URL before `loadState`\n * (review 1.3 / 1.4). Provided by the composer root as a stable bare Map (NOT\n * a `{ current }` wrapper — the React Compiler treats `.current` reads inside\n * memoized callbacks as ref access and bails); blobs are plain memory — no\n * revocation lifecycle, the map dies with the composer.\n */\nexport const MediaSourceBlobCacheContext = createContext<Map<\n  string,\n  Blob\n> | null>(null);\n\n/**\n * Compose the shell-side MediaSlotValue from the live editor state. `exportedUrl`\n * / `pendingBlobRef` / `exportMetadata` are shell concepts media-editor doesn't\n * know — preserve them from `prev`. The non-serializable live `videoBlob` is\n * nulled (the SerializableMediaEditorState contract) before it can reach the\n * draft JSON.\n */\nfunction snapshotMediaValue(\n  handle: MediaEditorHandle | null,\n  prev: MediaSlotValue | undefined,\n): MediaSlotValue {\n  if (!handle) return prev ?? {};\n  const state = handle.getState();\n  const editorState = { ...state, videoBlob: null };\n  if (!editorStateHasMedia(editorState)) {\n    // Reset/discard flip: the capture is gone, so the stale pending-export\n    // marker must go with it — otherwise the mediaRequired gate passes on a\n    // DISCARDED hero and publish uploads it (adversarial-review N1). The\n    // orphaned blobMap entry on the shell side is harmless: uploadHero only\n    // reads it through pendingBlobRef.\n    const rest: MediaSlotValue = { ...prev };\n    delete rest.pendingBlobRef;\n    delete rest.exportMetadata;\n    return { ...rest, editorState };\n  }\n  return { ...prev, editorState };\n}\n\n/**\n * `mediaSlot` substrate mount. 1:1 dial passthrough onto `<MediaEditor>` —\n * `mediaSources` is the ONLY transformed dial (clamped to the real MediaSource\n * union). `presentation=\"inline\"` is forced inside the composer (never portals a\n * dialog over the composer surface). Export is PULL-ONLY: the shell calls\n * `handle.export()` at publish/schedule; the substrate never exports on its own.\n */\nexport function MediaSubstrateMount({\n  slotConfig,\n  value,\n  onChange,\n  ctx,\n  handleRef,\n}: SlotRenderArgs<\"mediaSlot\">) {\n  const mediaRef = useRef<MediaEditorHandle | null>(null);\n  const sourceBlobCache = useContext(MediaSourceBlobCacheContext);\n  const stepId = ctx.stepId;\n\n  const valueRef = useRef(value);\n  useEffect(() => {\n    valueRef.current = value;\n  });\n\n  const onChangeRef = useRef(onChange);\n  useEffect(() => {\n    onChangeRef.current = onChange;\n  }, [onChange]);\n\n  // Mirror the blob backing the current canvas image into the shell cache so\n  // the NEXT mount of this step can re-materialize the editorState snapshot.\n  // Called on every dirty flip (capture / gallery / reset+retake) and at every\n  // shell pull (getValue / export), so crop-apply source swaps are picked up\n  // at step-leave too.\n  const stashSourceBlob = useCallback(() => {\n    const blob = mediaRef.current?.getSourceBlob();\n    if (blob) sourceBlobCache?.set(stepId, blob);\n  }, [sourceBlobCache, stepId]);\n\n  // Re-seed the editor from a persisted editorState snapshot. Shared by the\n  // step-revisit mount effect (review 1.4) and the handle's loadValue.\n  const restoreEditorState = useCallback(\n    (v: MediaSlotValue) => {\n      const es = v.editorState;\n      if (!es || !mediaRef.current) return;\n      const sourceBlob = sourceBlobCache?.get(stepId) ?? null;\n      if (sourceBlob) {\n        // Full restore: loadState re-mints a live object URL from the blob\n        // (the snapshot's imageSrc is dead — review 1.3) and rebuilds the\n        // draft so the edit tools work exactly as before the step unmounted.\n        mediaRef.current.loadState(\n          { ...es, videoBlob: null } satisfies MediaEditorState,\n          { sourceBlob },\n        );\n        return;\n      }\n      const deadImageSrc = !!es.imageSrc && es.imageSrc.startsWith(\"blob:\");\n      if (!deadImageSrc) {\n        // Snapshot's imageSrc is durable (or absent) — replay it verbatim.\n        mediaRef.current.loadState({\n          ...es,\n          videoBlob: null,\n        } satisfies MediaEditorState);\n        return;\n      }\n      if (v.exportedUrl) {\n        // No cached blob (e.g. externally persisted draft in a fresh session)\n        // but a flattened upload exists: show it WITHOUT replaying overlays —\n        // they're already baked into the export (double-overlay hazard).\n        mediaRef.current.loadState({\n          ...es,\n          videoBlob: null,\n          imageSrc: v.exportedUrl,\n          textOverlays: [],\n          stickers: [],\n          drawingStrokes: [],\n          filter: null,\n          adjustments: { brightness: 0, contrast: 0, saturation: 0, blur: 0 },\n          crop: null,\n        } satisfies MediaEditorState);\n      }\n      // else: nothing recoverable — leave the editor empty (documented limit:\n      // durable cross-reload persistence rides with the upload backend).\n    },\n    [sourceBlobCache, stepId],\n  );\n\n  // Re-edit: a re-seeded hero URL (fromContentItem) becomes the initial source.\n  // Mount-only — a lazy useState initializer runs once, so the editor isn't\n  // re-mounted when the draft updates (and it's render-safe to read). When an\n  // editorState snapshot exists, the restore effect below owns seeding instead\n  // (passing initialSource TOO would race its async fetch against loadState).\n  const [initialSource] = useState<InitialSource | undefined>(() =>\n    value?.exportedUrl && !value?.editorState\n      ? { kind: \"url\", url: value.exportedUrl, mode: \"photo\" }\n      : undefined,\n  );\n\n  // Step-revisit restore (review 1.4): a fresh capture persists editorState\n  // but no exportedUrl — previously this mounted an EMPTY editor and the\n  // user's hero vanished. Runs once, after the editor mounts.\n  const restoredRef = useRef(false);\n  useEffect(() => {\n    if (restoredRef.current) return;\n    restoredRef.current = true;\n    const v = valueRef.current;\n    if (v?.editorState) restoreEditorState(v);\n  }, [restoreEditorState]);\n\n  useEffect(() => {\n    const handle: SlotHandle<MediaSlotValue> = {\n      getValue: () => {\n        stashSourceBlob();\n        return snapshotMediaValue(mediaRef.current, valueRef.current);\n      },\n      getIsDirty: () => mediaRef.current?.getIsDirty() ?? false,\n      // Structural self-check (a hero exists OR capture/edit in progress). The\n      // shell's gate layers the CONFIGURED mediaRequired rule. Content-aware\n      // (not truthiness) — see editorStateHasMedia (N1).\n      validate: async () =>\n        !!valueRef.current?.exportedUrl ||\n        !!valueRef.current?.pendingBlobRef ||\n        editorStateHasMedia(valueRef.current?.editorState) ||\n        (mediaRef.current?.getIsDirty() ?? false),\n      loadValue: (v) => {\n        onChangeRef.current(v);\n        // Same re-materialization as the mount restore — the persisted\n        // snapshot's imageSrc can't be trusted (review 1.3).\n        if (v.editorState) restoreEditorState(v);\n      },\n      // mediaSlot-only — pull-only export for the shell's upload-on-publish.\n      export: async () => {\n        if (!mediaRef.current) {\n          throw new Error(\n            \"content-composer: media export requested before the editor mounted.\",\n          );\n        }\n        stashSourceBlob();\n        return mediaRef.current.export();\n      },\n    };\n    assignRef(handleRef, handle);\n  }, [handleRef, restoreEditorState, stashSourceBlob]);\n\n  return (\n    <MediaEditor\n      ref={mediaRef}\n      enabledModes={slotConfig.enabledModes}\n      enabledTools={slotConfig.enabledTools}\n      mediaSources={clampMediaSources(slotConfig.mediaSources)}\n      capture={mediaCapture}\n      aspect={slotConfig.aspect}\n      cropAspects={slotConfig.cropAspects}\n      maxFileSizeMb={slotConfig.maxFileSizeMb}\n      presentation=\"inline\"\n      initialSource={initialSource}\n      onDirtyChange={() => {\n        stashSourceBlob();\n        const snap = snapshotMediaValue(mediaRef.current, valueRef.current);\n        if (!editorStateHasMedia(snap.editorState)) {\n          // Discarded capture: the cached source blob belongs to the dropped\n          // image — clear it so a later restore can't resurrect it (N1).\n          sourceBlobCache?.delete(stepId);\n        }\n        onChangeRef.current(snap);\n      }}\n    />\n  );\n}\n",
      "type": "registry:component",
      "target": "components/content-composer/parts/media-substrate.tsx"
    },
    {
      "path": "src/registry/components/media/content-composer/parts/media-carousel-substrate.tsx",
      "content": "\"use client\";\n\nimport { createContext, useContext, useEffect, useRef, useState } from \"react\";\nimport { CarouselComposer } from \"@/registry/components/media/carousel-composer/carousel-composer\";\nimport type { MediaCarouselItem } from \"@/registry/components/media/carousel-composer/carousel-composer\";\n// ↑ import from the .tsx component-file path (the F-01-safe entry — its tail band\n//   re-exports the types; a barrel/`/types` import gets mangled by the rewriter).\nimport type {\n  MediaCarouselItemRef,\n  MediaCarouselSlotValue,\n  SlotHandle,\n  SlotRenderArgs,\n} from \"../types\";\nimport { assignRef } from \"../lib/assign-ref\";\n\n/** A ref to a per-composer map of live carousel items (with blobs), keyed by step id. */\nexport type CarouselLiveCache = { current: Map<string, MediaCarouselItem[]> };\n\n/**\n * Live-items cache provided by the composer root. The carousel's local blobs\n * can't live in the JSON-clean draft, so navigating away from the media step\n * would otherwise drop them. The shell stays mounted across steps, so caching\n * the live items here (and running the carousel with `revokeOnUnmount={false}`\n * so its object URLs survive the step unmount) makes step navigation lossless.\n * Durable cross-RELOAD persistence still rides with the upload-at-publish\n * backend (deferred); the composer root revokes any cached blob URLs on unmount.\n */\nexport const CarouselLiveCacheContext = createContext<CarouselLiveCache | null>(\n  null,\n);\n\n// Blob URLs displaced OUT of a live cache (an edit-apply replaced an item's\n// url). The carousel instance only revokes URLs it minted itself, so a\n// cache-RESTORED item's pre-edit URL belongs to a dead instance and would leak\n// (carousel F13). The substrate tombstones them here; the composer root\n// revokes + clears the set on unmount. WeakMap-keyed by the cache Map so the\n// registry dies with the composer that owns it.\nconst displacedCacheUrls = new WeakMap<\n  Map<string, MediaCarouselItem[]>,\n  Set<string>\n>();\n\n/** Tombstone set for a given live cache — created on first access. */\nexport function carouselDisplacedUrls(\n  cache: Map<string, MediaCarouselItem[]>,\n): Set<string> {\n  let set = displacedCacheUrls.get(cache);\n  if (!set) {\n    set = new Set();\n    displacedCacheUrls.set(cache, set);\n  }\n  return set;\n}\n\n/** Live carousel items → JSON-clean draft refs. Object URLs aren't durable, so\n *  only real (https/remote) URLs persist; local items keep no recoverable URL\n *  (and therefore no `editorState` — it would be unreconstructable bloat). */\nfunction serialize(items: MediaCarouselItem[]): MediaCarouselSlotValue {\n  return {\n    items: items.map((it): MediaCarouselItemRef => {\n      const durable = !it.url.startsWith(\"blob:\");\n      return {\n        id: it.id,\n        kind: it.kind,\n        exportedUrl: durable ? it.url : undefined,\n        editorState:\n          durable && it.editorState\n            ? { ...it.editorState, videoBlob: null }\n            : undefined,\n        exportMetadata: durable ? it.exportMeta : undefined,\n      };\n    }),\n  };\n}\n\n/** Rebuild live items from the draft. Only refs with a durable URL can be\n *  restored; returns the count of dropped (local, un-uploaded) refs so the\n *  caller can surface the loss instead of silently truncating. */\nfunction reconstruct(value: MediaCarouselSlotValue | undefined): {\n  items: MediaCarouselItem[];\n  dropped: number;\n} {\n  if (!value?.items?.length) return { items: [], dropped: 0 };\n  const items: MediaCarouselItem[] = [];\n  let dropped = 0;\n  for (const r of value.items) {\n    if (r.exportedUrl) {\n      items.push({\n        id: r.id,\n        kind: r.kind,\n        url: r.exportedUrl,\n        editorState: r.editorState ?? undefined,\n        exportMeta: r.exportMetadata,\n      });\n    } else {\n      dropped += 1;\n    }\n  }\n  return { items, dropped };\n}\n\n/**\n * `mediaCarouselSlot` substrate mount. The substrate CONTROLS the carousel: it\n * holds the live `MediaCarouselItem[]` (with blobs) and feeds it the blob-free\n * serialized view to the draft on every change. On (re)mount it prefers the\n * shell's live cache (lossless step-revisit) and falls back to reconstructing\n * from the persisted draft.\n */\nexport function MediaCarouselSubstrateMount({\n  slotConfig,\n  value,\n  onChange,\n  ctx,\n  handleRef,\n}: SlotRenderArgs<\"mediaCarouselSlot\">) {\n  const cache = useContext(CarouselLiveCacheContext);\n  const stepId = ctx.stepId;\n\n  const [items, setItems] = useState<MediaCarouselItem[]>(() => {\n    const cached = cache?.current.get(stepId);\n    if (cached) return cached;\n    const { items: rebuilt, dropped } = reconstruct(value);\n    if (dropped > 0 && process.env.NODE_ENV !== \"production\") {\n      console.warn(\n        `content-composer: ${dropped} local carousel item(s) couldn't be restored ` +\n          `(not yet uploaded). Durable persistence rides with the upload-at-publish backend.`,\n      );\n    }\n    return rebuilt;\n  });\n\n  const itemsRef = useRef(items);\n  useEffect(() => {\n    itemsRef.current = items;\n  });\n\n  const onChangeRef = useRef(onChange);\n  useEffect(() => {\n    onChangeRef.current = onChange;\n  }, [onChange]);\n\n  // Dirty baseline — a freshly restored/cached draft is NOT dirty until edited\n  // (length-as-dirty would falsely flag a reopened post draft + churn autosave).\n  const dirtyRef = useRef(false);\n\n  // Mirror live items into the shell cache so a step-revisit restores them\n  // exactly (blobs included), not just the durable subset. Blob URLs that this\n  // update displaces from the cache are tombstoned for the composer root's\n  // unmount revoke (F13) — a re-revoke of a URL the carousel already released\n  // is a harmless no-op, but a cache-restored item's pre-edit URL has no other\n  // owner left.\n  useEffect(() => {\n    if (!cache) return;\n    const prev = cache.current.get(stepId);\n    if (prev && prev !== items) {\n      const next = new Set(items.map((it) => it.url));\n      const tombs = carouselDisplacedUrls(cache.current);\n      for (const it of prev) {\n        if (it.url.startsWith(\"blob:\") && !next.has(it.url)) tombs.add(it.url);\n      }\n    }\n    cache.current.set(stepId, items);\n  }, [cache, stepId, items]);\n\n  useEffect(() => {\n    const handle: SlotHandle<MediaCarouselSlotValue> = {\n      getValue: () => serialize(itemsRef.current),\n      getIsDirty: () => dirtyRef.current,\n      validate: async () => itemsRef.current.length > 0,\n      loadValue: (v) => {\n        const { items: rebuilt } = reconstruct(v);\n        itemsRef.current = rebuilt;\n        dirtyRef.current = false;\n        setItems(rebuilt);\n      },\n    };\n    assignRef(handleRef, handle);\n  }, [handleRef]);\n\n  return (\n    <CarouselComposer\n      value={items}\n      onChange={(next) => {\n        dirtyRef.current = true;\n        setItems(next);\n        onChangeRef.current(serialize(next));\n      }}\n      // The shell cache + composer-root cleanup own URL revocation across step\n      // nav; don't let the carousel revoke on its (frequent) step unmounts.\n      revokeOnUnmount={false}\n      maxItems={slotConfig.maxItems ?? 10}\n      maxFileSizeMb={slotConfig.maxFileSizeMb}\n      accept={slotConfig.accept ?? [\"image\", \"video\"]}\n      aspect={slotConfig.aspect ?? \"auto\"}\n      editorProps={\n        slotConfig.enabledTools\n          ? { enabledTools: slotConfig.enabledTools }\n          : undefined\n      }\n    />\n  );\n}\n",
      "type": "registry:component",
      "target": "components/content-composer/parts/media-carousel-substrate.tsx"
    },
    {
      "path": "src/registry/components/media/content-composer/parts/missing-substrate.tsx",
      "content": "\"use client\";\n\nimport { TriangleAlert } from \"lucide-react\";\n\n// Module-level dedupe set — warns once per slot-kind per process, NOT per render\n// (verbatim port of kanban-board/parts/missing-renderer.tsx).\nconst warned = new Set<string>();\n\nexport function warnMissingSubstrate(slotKind: string) {\n  if (warned.has(slotKind)) return;\n  warned.add(slotKind);\n  if (typeof console !== \"undefined\") {\n    console.warn(\n      `[content-composer] No substrate registered for slot=\"${slotKind}\". ` +\n        `Provide one via the substrates prop (defaults ship for metadataFields/bodySlot/mediaSlot).`,\n    );\n  }\n}\n\nexport function MissingSubstrateFallback({ slotKind }: { slotKind: string }) {\n  return (\n    <div className=\"flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-2.5 text-xs\">\n      <TriangleAlert className=\"mt-0.5 size-3.5 shrink-0 text-destructive\" />\n      <div className=\"flex flex-col gap-0.5\">\n        <span className=\"font-medium text-destructive\">Substrate not found</span>\n        <span className=\"font-mono text-[10px] text-muted-foreground\">{slotKind}</span>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/content-composer/parts/missing-substrate.tsx"
    },
    {
      "path": "src/registry/components/media/content-composer/parts/publish-bar.tsx",
      "content": "\"use client\";\n\nimport { Loader2 } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { cn } from \"@/lib/utils\";\nimport type { PublishCtaArm } from \"../lib/publish-cta\";\n\nexport interface PublishBarProps {\n  arms: PublishCtaArm[];\n  onSaveDraft: () => void;\n  onPublish: () => void;\n  onSchedule: () => void;\n  /** local datetime-local string (\"\" until picked) */\n  scheduleValue: string;\n  onScheduleValueChange: (value: string) => void;\n}\n\n/**\n * Renders the resolved publish arms (`config.publishModes` → buttons). When a\n * `schedule` arm is present, a `datetime-local` input precedes the buttons; the\n * schedule arm stays disabled until a future time is picked (resolved upstream\n * via `scheduleReady`).\n */\nexport function PublishBar({\n  arms,\n  onSaveDraft,\n  onPublish,\n  onSchedule,\n  scheduleValue,\n  onScheduleValueChange,\n}: PublishBarProps) {\n  const hasSchedule = arms.some((a) => a.mode === \"schedule\");\n  const handlers: Record<PublishCtaArm[\"mode\"], () => void> = {\n    draft: onSaveDraft,\n    publish: onPublish,\n    schedule: onSchedule,\n  };\n\n  return (\n    <div className=\"flex flex-wrap items-center justify-end gap-2\">\n      {hasSchedule && (\n        <Input\n          type=\"datetime-local\"\n          aria-label=\"Schedule publish time\"\n          value={scheduleValue}\n          onChange={(e) => onScheduleValueChange(e.target.value)}\n          className=\"h-8 w-auto\"\n        />\n      )}\n      {arms.map((arm) => (\n        <Button\n          key={arm.mode}\n          type=\"button\"\n          variant={arm.variant}\n          disabled={arm.disabled}\n          onClick={handlers[arm.mode]}\n        >\n          {arm.busy && <Loader2 className={cn(\"size-4 animate-spin\")} />}\n          {arm.label}\n        </Button>\n      ))}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/content-composer/parts/publish-bar.tsx"
    },
    {
      "path": "src/registry/components/media/content-composer/parts/slot-mount.tsx",
      "content": "\"use client\";\n\nimport type { Ref } from \"react\";\nimport type {\n  ComposerStep,\n  ComposerStepCtx,\n  SlotHandle,\n  SlotKind,\n  SlotSubstrateMap,\n  SlotValueFor,\n} from \"../types\";\nimport { findSubstrate } from \"../lib/substrates\";\nimport {\n  MissingSubstrateFallback,\n  warnMissingSubstrate,\n} from \"./missing-substrate\";\n\nexport interface SlotMountProps {\n  /** the merged substrate map (DEFAULT_SUBSTRATES under consumer overrides) */\n  substrates: SlotSubstrateMap;\n  step: ComposerStep;\n  value: SlotValueFor<SlotKind> | undefined;\n  onChange: (next: SlotValueFor<SlotKind>) => void;\n  ctx: ComposerStepCtx;\n  /** the shell threads a ref the substrate populates with a uniform SlotHandle */\n  handleRef: Ref<SlotHandle<SlotValueFor<SlotKind>>>;\n}\n\n/**\n * Looks up the substrate for a step's slot-kind and renders it — or a degraded\n * fallback if none is registered. The fallback is a RENDER concern (NON-blocking):\n * navigation still works and the validation gate passes on a missing substrate\n * (see the gate's `if (!handle) return { ok: true }`). DISTINCT from the blocking\n * validation gate (mirrors kanban's `item-renderer.tsx`).\n */\nexport function SlotMount({\n  substrates,\n  step,\n  value,\n  onChange,\n  ctx,\n  handleRef,\n}: SlotMountProps) {\n  const substrate = findSubstrate(substrates, step.slot);\n  if (!substrate) {\n    warnMissingSubstrate(step.slot);\n    return <MissingSubstrateFallback slotKind={step.slot} />;\n  }\n  return (\n    <>\n      {substrate.render({\n        slotConfig: step.slotConfig,\n        value,\n        onChange,\n        ctx,\n        handleRef,\n      })}\n    </>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/content-composer/parts/slot-mount.tsx"
    },
    {
      "path": "src/registry/components/media/content-composer/parts/step-indicator.tsx",
      "content": "\"use client\";\n\nimport { Check, TriangleAlert } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport type { ComposerStep } from \"../types\";\n\nexport interface StepIndicatorProps {\n  steps: ComposerStep[];\n  /** index into `steps` of the active step */\n  cursor: number;\n  /** per-step error slices (keyed by step id) */\n  stepErrors: Record<string, string[]>;\n  /** invoked with the target step index; the shell runs the gate (forward) or jumps free (backward) */\n  onStepClick: (index: number) => void;\n}\n\n/**\n * Labeled step nav (`<nav aria-label=\"Composer steps\">`) with `aria-current=\"step\"`\n * on the active step. Past steps render a check; errored steps render a warning.\n * Backward steps are always reachable; forward jumps run the blocking gate (the\n * shell decides — this is a presentational click surface).\n */\nexport function StepIndicator({\n  steps,\n  cursor,\n  stepErrors,\n  onStepClick,\n}: StepIndicatorProps) {\n  return (\n    <nav aria-label=\"Composer steps\">\n      <ol className=\"flex flex-wrap items-center gap-1.5\">\n        {steps.map((step, i) => {\n          const isCurrent = i === cursor;\n          const isComplete = i < cursor;\n          const hasError = (stepErrors[step.id]?.length ?? 0) > 0;\n          return (\n            <li key={step.id} className=\"flex items-center gap-1.5\">\n              <button\n                type=\"button\"\n                aria-current={isCurrent ? \"step\" : undefined}\n                onClick={() => onStepClick(i)}\n                className={cn(\n                  \"group flex items-center gap-2 rounded-full py-1 pr-3 pl-1 text-sm transition-colors\",\n                  \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\",\n                  isCurrent\n                    ? \"bg-primary/15 text-foreground\"\n                    : \"text-muted-foreground hover:bg-muted hover:text-foreground\",\n                )}\n              >\n                <span\n                  className={cn(\n                    \"flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-medium tabular-nums\",\n                    hasError\n                      ? \"bg-destructive/15 text-destructive\"\n                      : isCurrent\n                        ? \"bg-primary text-primary-foreground\"\n                        : isComplete\n                          ? \"bg-primary/25 text-foreground\"\n                          : \"bg-muted text-muted-foreground\",\n                  )}\n                >\n                  {hasError ? (\n                    <TriangleAlert className=\"size-3.5\" />\n                  ) : isComplete ? (\n                    <Check className=\"size-3.5\" />\n                  ) : (\n                    i + 1\n                  )}\n                </span>\n                <span className={cn(\"font-medium\", isCurrent && \"text-foreground\")}>\n                  {step.title}\n                </span>\n              </button>\n              {i < steps.length - 1 && (\n                <span\n                  aria-hidden\n                  className=\"h-px w-4 shrink-0 bg-border sm:w-6\"\n                />\n              )}\n            </li>\n          );\n        })}\n      </ol>\n    </nav>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/content-composer/parts/step-indicator.tsx"
    }
  ],
  "categories": [
    "media"
  ],
  "type": "registry:block"
}