{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "story-composer",
  "title": "Story Composer",
  "author": "ilinxa",
  "description": "Instagram-style story creation surface — a locked 9:16 wrapper around the media editor.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "@ilinxa/media-editor",
    "@ilinxa/media-editor-capture",
    "button"
  ],
  "files": [
    {
      "path": "src/registry/components/media/story-composer/story-composer.tsx",
      "content": "\"use client\";\n\nimport {\n  forwardRef,\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  MediaEditor,\n  type MediaEditorHandle,\n  type MediaEditorLabels,\n  type EditorCtx,\n} from \"../media-editor\";\n// Capture feature slice (P3 S3) — story-composer always requests \"camera\" in\n// mediaSources (below), so it wires the extension unconditionally.\nimport { mediaCapture } from \"../media-editor/features/capture\";\nimport { ComposerPublishBar } from \"./parts/composer-publish-bar\";\nimport { PublishingProgressOverlay } from \"./parts/publishing-progress-overlay\";\nimport { useImageUploader } from \"./hooks/use-image-uploader\";\nimport {\n  DEFAULT_STORY_COMPOSER_LABELS,\n  type ComposerCtx,\n  type ComposerMode,\n  type PublishedStory,\n  type PublishMetadata,\n  type StoryComposerHandle,\n  type StoryComposerLabels,\n  type StoryComposerProps,\n} from \"./types\";\n\n/**\n * StoryComposer v0.2.0 — thin wrapper around `MediaEditor`.\n *\n * Maps the v0.1.5 props 1:1 onto the media-editor surface, fixes\n * `aspect=\"9:16\"` (Instagram-canonical), and overlays the story-shaped\n * publish flow (XHR upload + progress + onPublished/onPublishError).\n *\n * Public API preserved 100%: every v0.1.5 prop is accepted and every\n * v0.1.5 ref-handle method resolves. Internal orchestration that lived\n * in this file in v0.1.5 (camera, tool panels, discard guard, history,\n * text-only canvas) now lives inside MediaEditor — landed in the R1–R4\n * backfill commits.\n *\n * Known v0.2.0 polish gaps (none break the type contract):\n *   - `labels` overrides pass through verbatim as the v0.1.5 flat shape;\n *     full MediaEditorLabels nested-shape mapping lands at C17.\n *   - `editorBackground` prop is accepted but not forwarded — the\n *     editor canvas always uses \"#000\" (matches the v0.1.5 default).\n *     Re-exposed in v0.2.1 if needed.\n *   - `renderPublishingOverlay` honored — consumer slot replaces the default\n *     `PublishingProgressOverlay` during publish (slot wiring at C21 review).\n */\n\nconst ALL_MODES: ComposerMode[] = [\"photo\", \"video\", \"text\"];\n\ntype PublishStatus = \"idle\" | \"compositing\" | \"uploading\" | \"done\" | \"error\";\n\nexport const StoryComposer = forwardRef<\n  StoryComposerHandle,\n  StoryComposerProps\n>(function StoryComposer(props, ref) {\n  const {\n    isOpen,\n    onClose,\n    defaultMode,\n    hideModes,\n    defaultFacing,\n    recordAudio,\n    maxFileSizeMb,\n    maxVideoDuration,\n    presentation = \"auto\",\n    confirmOnDiscard = true,\n    enabledTools = [\"text\", \"draw\", \"stickers\", \"filters\", \"adjust\"],\n    stickers,\n    replaceBuiltinStickers,\n    filterPresets,\n    replaceBuiltinFilters,\n    cropAspects,\n    editorBackground,\n    fonts,\n    colorPresets,\n    labels: labelOverrides,\n    onValidationError,\n    onPermissionDenied,\n    renderTopBar,\n    renderBottomToolbar,\n    renderEmpty,\n    renderPermissionDenied,\n    renderPublishingOverlay,\n    uploadUrl,\n    uploader,\n    uploadFields,\n    onPublished,\n    onPublishError,\n  } = props;\n\n  const editorRef = useRef<MediaEditorHandle>(null);\n\n  // hideModes (subtract) → enabledModes (positive). Empty hideModes ⇒ all\n  // three modes enabled, matching the v0.1.5 default.\n  const enabledModes = useMemo<ComposerMode[]>(\n    () => ALL_MODES.filter((m) => !hideModes?.includes(m)),\n    [hideModes],\n  );\n\n  // story-composer's `presentation` enum vs media-editor's. \"fullscreen\" and\n  // \"modal\" both map to \"dialog\" since media-editor's dialog mode is\n  // mobile-fullscreen + desktop-modal. \"auto\" passes through.\n  const editorPresentation = useMemo<\"inline\" | \"dialog\" | \"auto\">(() => {\n    if (presentation === \"fullscreen\" || presentation === \"modal\") {\n      return \"dialog\";\n    }\n    return \"auto\";\n  }, [presentation]);\n\n  const labels = useMemo<Required<StoryComposerLabels>>(\n    () => ({ ...DEFAULT_STORY_COMPOSER_LABELS, ...labelOverrides }),\n    [labelOverrides],\n  );\n\n  const uploader_ = useImageUploader({ uploadUrl, uploader, uploadFields });\n\n  const [publishStatus, setPublishStatus] = useState<PublishStatus>(\"idle\");\n  const [uploadProgress, setUploadProgress] = useState(0);\n\n  // Mirror uploader_.progress (0..1) into uploadProgress's upload half\n  // (0.5..1) so the progress bar advances smoothly through the upload phase\n  // instead of jumping from 0.5 to 1.\n  useEffect(() => {\n    if (publishStatus !== \"uploading\") return;\n    setUploadProgress(0.5 + uploader_.progress * 0.5);\n  }, [publishStatus, uploader_.progress]);\n\n  // Cancel pressed while the export/composite is still running: export() can't\n  // be aborted, so when it eventually resolves this ref stops the flow from\n  // resurrecting the upload after the user already dismissed the overlay.\n  const publishCancelledRef = useRef(false);\n\n  const handlePublish = useCallback(async () => {\n    if (!editorRef.current) return;\n    publishCancelledRef.current = false;\n    try {\n      setPublishStatus(\"compositing\");\n      setUploadProgress(0);\n      const { blob, metadata } = await editorRef.current.export({\n        // Export takes the first half of the progress bar; upload the second.\n        onProgress: (p) => setUploadProgress(p * 0.5),\n      });\n      if (publishCancelledRef.current) return;\n      // ExportMetadata → PublishMetadata (drop the editor-only `crop` field).\n      const publishMeta: PublishMetadata = {\n        mode: metadata.mode,\n        width: metadata.width,\n        height: metadata.height,\n        durationMs: metadata.durationMs,\n        mimeType: metadata.mimeType,\n        textOverlays: metadata.textOverlays,\n        stickers: metadata.stickers,\n        drawingStrokes: metadata.drawingStrokes,\n        appliedFilter: metadata.appliedFilter,\n        adjustments: metadata.adjustments,\n      };\n      setPublishStatus(\"uploading\");\n      // Upload runs the second half of the bar; uploader_.progress is mirrored\n      // into uploadProgress via the effect below.\n      const result = await uploader_.upload(blob, publishMeta);\n      if (publishCancelledRef.current) return;\n      setUploadProgress(1);\n\n      const story: PublishedStory = {\n        id: `story-${Date.now()}`,\n        createdAt: new Date().toISOString(),\n        items: [\n          {\n            id: `item-${Date.now()}`,\n            type: metadata.mode === \"video\" ? \"video\" : \"image\",\n            src: result.url,\n            duration:\n              metadata.mode === \"video\"\n                ? Math.round((metadata.durationMs ?? 0) / 1000) || undefined\n                : undefined,\n            thumbnailUrl: result.thumbnailUrl,\n          },\n        ],\n      };\n      setPublishStatus(\"done\");\n      onPublished(story);\n    } catch (err) {\n      const e = err instanceof Error ? err : new Error(String(err));\n      // Cancellation isn't a failure: the overlay's Cancel already reset the\n      // status, and the aborted XHR now rejects with AbortError.\n      if (publishCancelledRef.current || e.name === \"AbortError\") return;\n      setPublishStatus(\"error\");\n      onPublishError?.(e);\n    }\n  }, [uploader_, onPublished, onPublishError]);\n\n  // EditorCtx → ComposerCtx translation for slot consumers. story-composer's\n  // ctx assumes a non-null mode + has publishing/cancel/publish helpers.\n  const toComposerCtx = useCallback(\n    (ctx: EditorCtx): ComposerCtx => ({\n      mode: ctx.mode ?? \"photo\",\n      stage: ctx.stage,\n      isDirty: ctx.isDirty,\n      publishing: {\n        active:\n          publishStatus === \"compositing\" || publishStatus === \"uploading\",\n        progress: uploadProgress,\n      },\n      setMode: (m) => {\n        // No public setter on the handle yet (v0.3.0 may expose setMode());\n        // round-trip through the editor's typed state surface to flip just the\n        // mode. getState() is non-null, so no cast/fallback is needed.\n        const editor = editorRef.current;\n        if (editor) editor.loadState({ ...editor.getState(), mode: m });\n      },\n      cancel: () => onClose(),\n      publish: handlePublish,\n    }),\n    [publishStatus, uploadProgress, handlePublish, onClose],\n  );\n\n  useImperativeHandle(\n    ref,\n    (): StoryComposerHandle => ({\n      open: () => editorRef.current?.open(),\n      close: () => editorRef.current?.close(),\n      reset: () => {\n        editorRef.current?.reset();\n        setPublishStatus(\"idle\");\n        setUploadProgress(0);\n      },\n      switchCamera: async () => {\n        await editorRef.current?.switchCamera();\n      },\n      takePhoto: async () => {\n        await editorRef.current?.takePhoto();\n      },\n      startRecording: async () => {\n        await editorRef.current?.startRecording();\n      },\n      stopRecording: async () => {\n        await editorRef.current?.stopRecording();\n      },\n      importFromGallery: () => editorRef.current?.importFromGallery(),\n      addText: (text) => editorRef.current?.addText(text),\n      addSticker: (sticker) => editorRef.current?.addSticker(sticker),\n      setAdjustments: (adj) => editorRef.current?.setAdjustments(adj),\n      applyFilter: (name) => editorRef.current?.applyFilter(name),\n      publish: handlePublish,\n      exportBlob: async () => {\n        if (!editorRef.current) {\n          throw new Error(\"story-composer: editor not mounted\");\n        }\n        const out = await editorRef.current.export();\n        const publishMeta: PublishMetadata = {\n          mode: out.metadata.mode,\n          width: out.metadata.width,\n          height: out.metadata.height,\n          durationMs: out.metadata.durationMs,\n          mimeType: out.metadata.mimeType,\n          textOverlays: out.metadata.textOverlays,\n          stickers: out.metadata.stickers,\n          drawingStrokes: out.metadata.drawingStrokes,\n          appliedFilter: out.metadata.appliedFilter,\n          adjustments: out.metadata.adjustments,\n        };\n        return { blob: out.blob, metadata: publishMeta };\n      },\n    }),\n    [handlePublish],\n  );\n\n  /*\n   * `editorBackground` has never been applied. StoryComposer is a thin wrapper\n   * that renders `<MediaEditor>` directly — there is no container of its own to\n   * paint, and MediaEditor exposes no background prop to forward to, so\n   * honouring this would be a cross-component feature rather than a wiring fix.\n   *\n   * Until then it says so out loud. A styling prop that silently does nothing\n   * is the most expensive kind to debug: the consumer assumes their value is\n   * wrong, not that the prop is dead.\n   */\n  useEffect(() => {\n    if (process.env.NODE_ENV === \"production\") return;\n    if (editorBackground === undefined) return;\n    console.warn(\n      \"story-composer: `editorBackground` is not implemented and has no effect — StoryComposer renders MediaEditor directly and has no surface of its own to paint. Style the editor from your own layout instead.\",\n    );\n  }, [editorBackground]);\n\n  return (\n    <>\n      <MediaEditor\n        ref={editorRef}\n        aspect=\"9:16\"\n        enabledModes={enabledModes}\n        enabledTools={enabledTools}\n        mediaSources={[\"camera\", \"upload\"]}\n        capture={mediaCapture}\n        presentation={editorPresentation}\n        isOpen={isOpen}\n        onClose={onClose}\n        confirmOnDiscard={confirmOnDiscard}\n        defaultMode={defaultMode}\n        defaultFacing={defaultFacing}\n        recordAudio={recordAudio}\n        maxFileSizeMb={maxFileSizeMb}\n        maxVideoDuration={maxVideoDuration}\n        stickers={stickers}\n        replaceBuiltinStickers={replaceBuiltinStickers}\n        filterPresets={filterPresets}\n        replaceBuiltinFilters={replaceBuiltinFilters}\n        cropAspects={cropAspects}\n        fonts={fonts}\n        colorPresets={colorPresets}\n        // Pass-through of label overrides — MediaEditor accepts as flat shape\n        // via INTERNAL_LABELS_FALLBACK merge (R2). C17 will refactor onto\n        // the nested MediaEditorLabels surface.\n        labels={labelOverrides as unknown as Partial<MediaEditorLabels>}\n        onValidationError={onValidationError}\n        onPermissionDenied={onPermissionDenied}\n        renderEmpty={renderEmpty}\n        renderPermissionDenied={renderPermissionDenied}\n        renderTopBar={(ctx) =>\n          renderTopBar ? (\n            renderTopBar(toComposerCtx(ctx))\n          ) : (\n            <ComposerPublishBar\n              isPublishing={\n                publishStatus === \"compositing\" ||\n                publishStatus === \"uploading\"\n              }\n              canPublish={ctx.isDirty}\n              // Hide Publish only during photo/video capture (nothing captured\n              // yet → a disabled CTA just crowds the mode pill). Text mode lives\n              // in the capture stage too but IS publishable, so keep it there.\n              showPublish={!ctx.isCapturing || ctx.mode === \"text\"}\n              // Hide the close X in the edit stage when the editor shows its own\n              // Back arrow in that corner (capture modes enabled). Keep it during\n              // capture, and for edit-only configs that have no Back.\n              showClose={\n                ctx.isCapturing ||\n                !(\n                  ctx.enabledModes.includes(\"photo\") ||\n                  ctx.enabledModes.includes(\"video\")\n                )\n              }\n              labels={labels}\n              onPublish={handlePublish}\n              onClose={onClose}\n            />\n          )\n        }\n        renderBottomToolbar={\n          renderBottomToolbar\n            ? (ctx) => renderBottomToolbar(toComposerCtx(ctx))\n            : undefined\n        }\n      />\n      {publishStatus === \"uploading\" ||\n      publishStatus === \"compositing\" ||\n      publishStatus === \"error\" ? (\n        renderPublishingOverlay ? (\n          renderPublishingOverlay({\n            progress: uploadProgress,\n            mode: (editorRef.current?.getMode() ??\n              \"photo\") as ComposerMode,\n          })\n        ) : (\n          <PublishingProgressOverlay\n            progress={publishStatus === \"uploading\" ? uploadProgress : null}\n            status={publishStatus === \"error\" ? \"error\" : \"uploading\"}\n            labels={labels}\n            onRetry={publishStatus === \"error\" ? handlePublish : undefined}\n            onCancel={() => {\n              publishCancelledRef.current = true;\n              uploader_.cancel();\n              setPublishStatus(\"idle\");\n              setUploadProgress(0);\n            }}\n          />\n        )\n      ) : null}\n    </>\n  );\n});\n",
      "type": "registry:component",
      "target": "components/story-composer/story-composer.tsx"
    },
    {
      "path": "src/registry/components/media/story-composer/index.ts",
      "content": "// ─── Main component ────────────────────────────────────────────────────\n\nexport { StoryComposer } from \"./story-composer\";\n\n// ─── Types ─────────────────────────────────────────────────────────────\n\nexport type {\n  StoryComposerProps,\n  StoryComposerHandle,\n  StoryComposerLabels,\n  ComposerMode,\n  ComposerStage,\n  ComposerCtx,\n  ValidationError,\n  StickerOption,\n  StickerSet,\n  FontOption,\n  FilterPreset,\n  KonvaFilterSpec,\n  ImageAdjustments,\n  TextOverlay,\n  PlacedSticker,\n  DrawingStroke,\n  AspectRatio,\n  EditTool,\n  PublishMetadata,\n  PublishResult,\n  PublishedStory,\n  PublishedStoryItem,\n  Uploader,\n} from \"./types\";\n\nexport {\n  DEFAULT_STORY_COMPOSER_LABELS,\n  DEFAULT_ADJUSTMENTS,\n} from \"./types\";\n\n// ─── Exported sealed-folder parts ──────────────────────────────────────\n\n// ComposerPublishBar STAYS in story-composer (story-shaped publish UI).\nexport { ComposerPublishBar } from \"./parts/composer-publish-bar\";\nexport type { ComposerPublishBarProps } from \"./parts/composer-publish-bar\";\n\n// v0.1.5 ComposerCamera/ComposerEditor/ComposerToolbar/ColorSwatchPicker\n// backward-compat aliases (+ their Props types). Unlike the useMediaCapture\n// band (explicitly scheduled and removed at v0.3.0+), THIS band carried a bare\n// @deprecated with no removal version, and the v0.1.5 export contract snapshot\n// says these names must still resolve — so they stay, now with a real\n// schedule. R4 restoration (2026-08-11): removal announced for v0.5.0.\n// `EditorCamera` lives in the capture feature slice since the P3 split.\n/** @deprecated since v0.2.0 — import from `@ilinxa/media-editor` / `@ilinxa/media-editor-capture` instead. REMOVED in v0.5.0. */\nexport { EditorCamera as ComposerCamera } from \"../media-editor/features/capture\";\n/** @deprecated since v0.2.0 — import `EditorCanvas` from `@ilinxa/media-editor` instead. REMOVED in v0.5.0. */\nexport {\n  EditorCanvas as ComposerEditor,\n  EditorToolbar as ComposerToolbar,\n  ColorSwatchPicker,\n} from \"../media-editor\";\n/** @deprecated since v0.2.0 — import the `Editor*Props` types from `@ilinxa/media-editor` / `@ilinxa/media-editor-capture` instead. REMOVED in v0.5.0. */\nexport type {\n  EditorCanvasProps as ComposerEditorProps,\n  EditorToolbarProps as ComposerToolbarProps,\n  ColorSwatchPickerProps,\n} from \"../media-editor\";\nexport type { EditorCameraProps as ComposerCameraProps } from \"../media-editor/features/capture\";\n\n// ─── Exported hooks (plan §10) ─────────────────────────────────────────\n\nexport {\n  useStoryComposerState,\n  type UseStoryComposerStateOptions,\n  type UseStoryComposerStateResult,\n} from \"./hooks/use-story-composer-state\";\n\n// useMediaCapture/CapturedPhoto/CapturedVideo/etc. v0.1.5 backward-compat\n// re-exports — REMOVED at v0.4.0 per the v0.2.0 @deprecated schedule.\n// `useMediaCapture` moved again in P3 S3: import from\n// `@ilinxa/media-editor-capture`. `validateGalleryFile` stayed on\n// `@ilinxa/media-editor` (generic file-intake).\n\nexport {\n  useImageUploader,\n  type UseImageUploaderOptions,\n  type UseImageUploaderResult,\n  type UploadStatus,\n} from \"./hooks/use-image-uploader\";\n\n// ─── Lib helpers ───────────────────────────────────────────────────────\n// All lib helpers moved to media-editor in v0.2.0.\n// @deprecated import from \"@ilinxa/media-editor\" — removed in v0.3.0.\n\nexport {\n  resolveFilterPresets,\n  BUILT_IN_FILTER_PRESETS,\n  resolveStickerSets,\n  BUILT_IN_STICKER_SETS,\n  exportPhotoBlob,\n  exportTextOnlyBlob,\n  type ExportPhotoOptions,\n  compositeVideo,\n  type CompositeVideoOptions,\n  type CompositeVideoResult,\n  selectRecorderMime,\n  containerFor,\n  PREFERRED_RECORDER_MIME_TYPES,\n  type RecorderMimeType,\n} from \"../media-editor\";\n\n// ─── Default tokens ────────────────────────────────────────────────────\n// Moved to media-editor/lib/defaults.ts in v0.2.0.\n// @deprecated import from \"@ilinxa/media-editor\" — removed in v0.3.0.\n\nexport {\n  DEFAULT_TEXT_GRADIENTS,\n  DEFAULT_FONTS,\n  DEFAULT_COLOR_PRESETS,\n  type GradientPreset,\n} from \"../media-editor\";\n\n// ─── Crop helpers (CropRect type lives here; helper functions too) ─────\n\n// Crop helpers moved with tool-crop-overlay to media-editor in v0.2.0.\n// @deprecated import from \"@ilinxa/media-editor\" — removed in v0.3.0.\nexport {\n  fitCropToStage,\n  ASPECT_RATIO_VALUES,\n  type CropRect,\n} from \"../media-editor\";\n",
      "type": "registry:component",
      "target": "components/story-composer/index.ts"
    },
    {
      "path": "src/registry/components/media/story-composer/types.ts",
      "content": "import type { ReactNode } from \"react\";\nimport type { AspectRatio } from \"../media-editor\";\nexport type { AspectRatio };\n\n// ─── Modes + stage ──────────────────────────────────────────────────────\n\nexport type ComposerMode = \"photo\" | \"video\" | \"text\";\n\nexport type ComposerStage =\n  | \"capture\"\n  | \"edit\"\n  | \"publishing\"\n  | \"done\"\n  | \"error\";\n\n// ─── Validation ─────────────────────────────────────────────────────────\n\nexport interface ValidationError {\n  kind:\n    | \"file-too-large\"\n    | \"unsupported-type\"\n    | \"unsupported-codec\"\n    | \"duration-exceeded\"\n    | \"no-camera\"\n    | \"permission-denied\";\n  message: string;\n  file?: File;\n}\n\n// ─── Stickers ───────────────────────────────────────────────────────────\n\nexport interface StickerOption {\n  id: string;\n  src: string;\n  alt: string;\n  width?: number;\n  height?: number;\n}\n\nexport interface StickerSet {\n  id: string;\n  label: string;\n  stickers: StickerOption[];\n}\n\n// ─── Filters + adjustments ──────────────────────────────────────────────\n\nexport interface KonvaFilterSpec {\n  name: string;\n  params?: Record<string, number>;\n}\n\nexport interface FilterPreset {\n  id: string;\n  label: string;\n  konvaFilters: KonvaFilterSpec[];\n}\n\nexport interface ImageAdjustments {\n  brightness: number;\n  contrast: number;\n  saturation: number;\n  blur: number;\n}\n\nexport const DEFAULT_ADJUSTMENTS: ImageAdjustments = {\n  brightness: 0,\n  contrast: 0,\n  saturation: 0,\n  blur: 0,\n};\n\n// ─── Overlay items ──────────────────────────────────────────────────────\n\nexport interface TextOverlay {\n  id: string;\n  text: string;\n  x: number;\n  y: number;\n  rotation: number;\n  scale: number;\n  fontFamily: string;\n  fontSize: number;\n  fill: string;\n  align: \"left\" | \"center\" | \"right\";\n}\n\nexport interface PlacedSticker {\n  id: string;\n  stickerId: string;\n  x: number;\n  y: number;\n  rotation: number;\n  scale: number;\n}\n\nexport interface DrawingStroke {\n  id: string;\n  points: number[];\n  color: string;\n  brushSize: number;\n  mode: \"draw\" | \"erase\";\n}\n\n// ─── Fonts ──────────────────────────────────────────────────────────────\n\nexport interface FontOption {\n  id: string;\n  label: string;\n  family: string;\n}\n\n// ─── Aspect ratios + tools ──────────────────────────────────────────────\n\n// AspectRatio moved to media-editor in v0.2.0 (added \"16:9\" as additive\n// non-breaking extension — narrowing-style consumers unaffected).\n// Imported here so this types.ts can reference AspectRatio internally + re-exports it.\n\nexport type EditTool =\n  | \"text\"\n  | \"draw\"\n  | \"stickers\"\n  | \"filters\"\n  | \"adjust\"\n  | \"crop\";\n\n// ─── Publish ────────────────────────────────────────────────────────────\n\nexport interface PublishMetadata {\n  mode: ComposerMode;\n  width: number;\n  height: number;\n  durationMs?: number;\n  mimeType: string;\n  textOverlays: TextOverlay[];\n  stickers: PlacedSticker[];\n  drawingStrokes: number;\n  appliedFilter?: string;\n  adjustments: ImageAdjustments;\n}\n\nexport interface PublishResult {\n  url: string;\n  thumbnailUrl?: string;\n  [key: string]: unknown;\n}\n\nexport interface PublishedStoryItem {\n  id: string;\n  type: \"image\" | \"video\";\n  src: string;\n  duration?: number;\n  thumbnailUrl?: string;\n}\n\nexport interface PublishedStory {\n  id: string;\n  createdAt: string;\n  items: PublishedStoryItem[];\n}\n\n// ─── Slot context ───────────────────────────────────────────────────────\n\nexport interface ComposerCtx {\n  mode: ComposerMode;\n  stage: ComposerStage;\n  isDirty: boolean;\n  publishing: { active: boolean; progress: number };\n  setMode: (m: ComposerMode) => void;\n  cancel: () => void;\n  publish: () => Promise<void>;\n}\n\n// ─── Uploader ───────────────────────────────────────────────────────────\n\nexport type Uploader = (\n  blob: Blob,\n  metadata: PublishMetadata,\n) => Promise<PublishResult>;\n\n// ─── i18n labels ────────────────────────────────────────────────────────\n\nexport interface StoryComposerLabels {\n  composerLabel?: string;\n  composerDescription?: string;\n\n  modePhoto?: string;\n  modeVideo?: string;\n  modeText?: string;\n\n  shutterPhoto?: string;\n  shutterVideoStart?: string;\n  shutterVideoStop?: string;\n\n  galleryPicker?: string;\n  switchCamera?: string;\n\n  permissionDeniedTitle?: string;\n  permissionDeniedBody?: string;\n  permissionRetry?: string;\n  permissionUsePicker?: string;\n  permissionRequesting?: string;\n\n  toolText?: string;\n  toolDraw?: string;\n  toolStickers?: string;\n  toolFilters?: string;\n  toolAdjust?: string;\n  toolCrop?: string;\n\n  adjustBrightness?: string;\n  adjustContrast?: string;\n  adjustSaturation?: string;\n  adjustBlur?: string;\n\n  drawColor?: string;\n  drawBrush?: string;\n  drawEraser?: string;\n  drawUndo?: string;\n  drawRedo?: string;\n\n  textPlaceholder?: string;\n  textFontFamily?: string;\n  textFontSize?: string;\n  textAlign?: string;\n\n  publish?: string;\n  publishing?: string;\n  published?: string;\n  close?: string;\n\n  discardConfirmTitle?: string;\n  discardConfirmBody?: string;\n  discardConfirm?: string;\n  discardCancel?: string;\n\n  uploadFailedTitle?: string;\n  uploadRetry?: string;\n\n  recordingLabel?: string;\n  trimStart?: string;\n  trimEnd?: string;\n}\n\nexport const DEFAULT_STORY_COMPOSER_LABELS: Required<StoryComposerLabels> = {\n  composerLabel: \"Create story\",\n  composerDescription:\n    \"Create a new story by capturing a photo, recording a video, or composing a text-only message. Tap the close button to exit.\",\n\n  modePhoto: \"Photo\",\n  modeVideo: \"Video\",\n  modeText: \"Text\",\n\n  shutterPhoto: \"Take photo\",\n  shutterVideoStart: \"Start recording\",\n  shutterVideoStop: \"Stop recording\",\n\n  galleryPicker: \"Choose from gallery\",\n  switchCamera: \"Switch camera\",\n\n  permissionDeniedTitle: \"Camera access blocked\",\n  permissionDeniedBody:\n    \"We need camera permission to capture your story. Enable it in your browser settings, or choose a file from your gallery.\",\n  permissionRetry: \"Try again\",\n  permissionUsePicker: \"Use gallery instead\",\n  permissionRequesting: \"Requesting camera…\",\n\n  toolText: \"Text\",\n  toolDraw: \"Draw\",\n  toolStickers: \"Stickers\",\n  toolFilters: \"Filters\",\n  toolAdjust: \"Adjust\",\n  toolCrop: \"Crop\",\n\n  adjustBrightness: \"Brightness\",\n  adjustContrast: \"Contrast\",\n  adjustSaturation: \"Saturation\",\n  adjustBlur: \"Blur\",\n\n  drawColor: \"Color\",\n  drawBrush: \"Brush size\",\n  drawEraser: \"Eraser\",\n  drawUndo: \"Undo\",\n  drawRedo: \"Redo\",\n\n  textPlaceholder: \"Type something…\",\n  textFontFamily: \"Font\",\n  textFontSize: \"Size\",\n  textAlign: \"Align\",\n\n  publish: \"Publish\",\n  publishing: \"Publishing…\",\n  published: \"Published\",\n  close: \"Close\",\n\n  discardConfirmTitle: \"Discard story?\",\n  discardConfirmBody:\n    \"You'll lose your captured media and edits. This can't be undone.\",\n  discardConfirm: \"Discard\",\n  discardCancel: \"Keep editing\",\n\n  uploadFailedTitle: \"Upload failed\",\n  uploadRetry: \"Try again\",\n\n  recordingLabel: \"Recording\",\n  trimStart: \"Start\",\n  trimEnd: \"End\",\n};\n\n// ─── Public component props ─────────────────────────────────────────────\n\nexport interface StoryComposerProps {\n  // Display\n  isOpen: boolean;\n  onClose: () => void;\n\n  // Publish destination\n  uploadUrl?: string;\n  uploader?: Uploader;\n  uploadFields?: Record<string, string>;\n  onPublished: (story: PublishedStory) => void;\n  onPublishError?: (error: Error) => void;\n\n  // Capture\n  defaultMode?: ComposerMode;\n  hideModes?: ComposerMode[];\n  defaultFacing?: \"user\" | \"environment\";\n  maxVideoDuration?: number;\n  recordAudio?: boolean;\n  maxFileSizeMb?: number;\n  onValidationError?: (error: ValidationError) => void;\n\n  // Edit tools\n  stickers?: StickerSet[];\n  replaceBuiltinStickers?: boolean;\n  fonts?: FontOption[];\n  colorPresets?: string[];\n  filterPresets?: FilterPreset[];\n  replaceBuiltinFilters?: boolean;\n  enabledTools?: EditTool[];\n\n  // Layout + behavior\n  cropAspects?: AspectRatio[];\n  presentation?: \"auto\" | \"fullscreen\" | \"modal\";\n  /**\n   * @notImplemented Declared since v0.1 and never applied — StoryComposer\n   * renders `MediaEditor` directly, so it has no surface of its own to paint,\n   * and MediaEditor exposes no background prop to forward to. Passing it warns\n   * in development. Style the editor from your own layout instead.\n   */\n  editorBackground?: string;\n  confirmOnDiscard?: boolean;\n\n  // Localization\n  labels?: Partial<StoryComposerLabels>;\n\n  // Permissions\n  onPermissionDenied?: () => void;\n\n  // Slots\n  renderTopBar?: (ctx: ComposerCtx) => ReactNode;\n  renderBottomToolbar?: (ctx: ComposerCtx) => ReactNode;\n  renderPermissionDenied?: (ctx: {\n    retry: () => void;\n    usePicker: () => void;\n  }) => ReactNode;\n  renderEmpty?: () => ReactNode;\n  renderPublishingOverlay?: (ctx: {\n    progress: number;\n    mode: ComposerMode;\n  }) => ReactNode;\n}\n\n// ─── Imperative handle ──────────────────────────────────────────────────\n\nexport interface StoryComposerHandle {\n  // Lifecycle\n  open: () => void;\n  close: () => void;\n  reset: () => void;\n  // Capture\n  switchCamera: () => Promise<void>;\n  takePhoto: () => Promise<void>;\n  startRecording: () => Promise<void>;\n  stopRecording: () => Promise<void>;\n  importFromGallery: () => void;\n  // Edit\n  addText: (text?: string) => void;\n  addSticker: (sticker: StickerOption) => void;\n  setAdjustments: (adj: Partial<ImageAdjustments>) => void;\n  applyFilter: (name: string | null) => void;\n  // Publish\n  publish: () => Promise<void>;\n  exportBlob: () => Promise<{ blob: Blob; metadata: PublishMetadata }>;\n}\n",
      "type": "registry:component",
      "target": "components/story-composer/types.ts"
    },
    {
      "path": "src/registry/components/media/story-composer/hooks/use-image-uploader.ts",
      "content": "\"use client\";\n\nimport { useCallback, useRef, useState } from \"react\";\nimport type {\n  PublishMetadata,\n  PublishResult,\n  Uploader,\n} from \"../types\";\n\nexport type UploadStatus =\n  | \"idle\"\n  | \"uploading\"\n  | \"done\"\n  | \"error\"\n  | \"cancelled\";\n\nexport interface UseImageUploaderOptions {\n  /** Server endpoint for default POST FormData upload (Q-P6a). */\n  uploadUrl?: string;\n  /** Extra FormData fields appended to every request. */\n  uploadFields?: Record<string, string>;\n  /** Custom uploader — wins over uploadUrl when both are set. */\n  uploader?: Uploader;\n}\n\nexport interface UseImageUploaderResult {\n  status: UploadStatus;\n  /** 0..1 — determinate when XMLHttpRequest reports lengthComputable. */\n  progress: number;\n  error: Error | null;\n  upload: (blob: Blob, metadata: PublishMetadata) => Promise<PublishResult>;\n  cancel: () => void;\n  reset: () => void;\n}\n\n/**\n * Default POST FormData uploader (Q-P6a) + escape hatch for signed-URL flows.\n *\n * XMLHttpRequest, not fetch — fetch lacks upload-progress events in browsers,\n * which the publishing overlay needs for a determinate progress bar.\n */\nexport function useImageUploader(\n  options: UseImageUploaderOptions,\n): UseImageUploaderResult {\n  const { uploadUrl, uploadFields, uploader } = options;\n  const [status, setStatus] = useState<UploadStatus>(\"idle\");\n  const [progress, setProgress] = useState(0);\n  const [error, setError] = useState<Error | null>(null);\n  const xhrRef = useRef<XMLHttpRequest | null>(null);\n\n  const reset = useCallback(() => {\n    setStatus(\"idle\");\n    setProgress(0);\n    setError(null);\n  }, []);\n\n  const cancel = useCallback(() => {\n    xhrRef.current?.abort();\n    xhrRef.current = null;\n    setStatus(\"cancelled\");\n  }, []);\n\n  const upload = useCallback(\n    async (blob: Blob, metadata: PublishMetadata): Promise<PublishResult> => {\n      setStatus(\"uploading\");\n      setProgress(0);\n      setError(null);\n\n      // Custom uploader path (S3 pre-signed PUT / Cloudinary / Mux).\n      if (uploader) {\n        try {\n          const result = await uploader(blob, metadata);\n          setStatus(\"done\");\n          setProgress(1);\n          return result;\n        } catch (err) {\n          const e = err as Error;\n          setStatus(\"error\");\n          setError(e);\n          throw e;\n        }\n      }\n\n      // Default POST FormData path.\n      if (!uploadUrl) {\n        const e = new Error(\n          \"story-composer: pass `uploadUrl` or `uploader` to publish.\",\n        );\n        setStatus(\"error\");\n        setError(e);\n        throw e;\n      }\n\n      return new Promise<PublishResult>((resolve, reject) => {\n        const xhr = new XMLHttpRequest();\n        xhrRef.current = xhr;\n        xhr.open(\"POST\", uploadUrl, true);\n\n        xhr.upload.onprogress = (ev) => {\n          if (ev.lengthComputable) {\n            setProgress(ev.loaded / ev.total);\n          }\n        };\n\n        xhr.onload = () => {\n          xhrRef.current = null;\n          if (xhr.status >= 200 && xhr.status < 300) {\n            try {\n              const text = xhr.responseText;\n              const parsed: PublishResult = text\n                ? JSON.parse(text)\n                : { url: \"\" };\n              if (!parsed.url) {\n                // Allow servers that return 200 without a body, but caller\n                // probably wants the URL — leave it blank and proceed.\n                parsed.url = \"\";\n              }\n              setStatus(\"done\");\n              setProgress(1);\n              resolve(parsed);\n            } catch (parseErr) {\n              const e =\n                parseErr instanceof Error\n                  ? parseErr\n                  : new Error(\"Upload response was not JSON\");\n              setStatus(\"error\");\n              setError(e);\n              reject(e);\n            }\n          } else {\n            const e = new Error(`Upload failed (HTTP ${xhr.status})`);\n            setStatus(\"error\");\n            setError(e);\n            reject(e);\n          }\n        };\n\n        xhr.onerror = () => {\n          xhrRef.current = null;\n          const e = new Error(\"Upload network error\");\n          setStatus(\"error\");\n          setError(e);\n          reject(e);\n        };\n\n        xhr.onabort = () => {\n          xhrRef.current = null;\n          // status already set to \"cancelled\" by cancel(). Reject so awaiting\n          // callers don't hang forever — DOMException/AbortError mirrors the\n          // fetch abort contract, so callers can filter it from real errors.\n          reject(new DOMException(\"Upload cancelled\", \"AbortError\"));\n        };\n\n        const form = new FormData();\n        const ext = blob.type.includes(\"webm\")\n          ? \"webm\"\n          : blob.type.includes(\"mp4\")\n            ? \"mp4\"\n            : blob.type.includes(\"png\")\n              ? \"png\"\n              : \"jpg\";\n        form.append(\n          \"file\",\n          blob,\n          `story-${Date.now()}.${ext}`,\n        );\n        // Append metadata as a JSON string so backends can pick keys they want.\n        form.append(\"metadata\", JSON.stringify(metadata));\n        if (uploadFields) {\n          for (const [k, v] of Object.entries(uploadFields)) form.append(k, v);\n        }\n        xhr.send(form);\n      });\n    },\n    [uploadFields, uploadUrl, uploader],\n  );\n\n  return { status, progress, error, upload, cancel, reset };\n}\n",
      "type": "registry:component",
      "target": "components/story-composer/hooks/use-image-uploader.ts"
    },
    {
      "path": "src/registry/components/media/story-composer/hooks/use-story-composer-state.ts",
      "content": "\"use client\";\n\nimport { useCallback, useMemo, useRef, useState } from \"react\";\nimport {\n  useMediaEditorState,\n  type UseMediaEditorStateResult,\n} from \"../../media-editor\";\nimport type { ComposerMode, ComposerStage } from \"../types\";\n\n/**\n * useStoryComposerState — v0.2.0.\n *\n * Composes `useMediaEditorState` (the editor-shaped state machine\n * extracted to media-editor v0.1.0) and augments with story-shaped\n * publish state (publishStatus / uploadProgress / publishError).\n *\n * **Return-shape invariant.** Every v0.1.5 field is preserved with the\n * same identifier and type. New fields are additive:\n *   - All useMediaEditorState fields spread first (state / activeTool /\n *     draft / trim / overlay setters / etc.).\n *   - v0.1.5 fields override / augment: mode (non-null), stage,\n *     visibleModes, setMode (wrapped to preserve switch-during-edit-\n *     resets-stage semantics), markDirty (no-op + dev-warn — isDirty\n *     is now derived).\n *   - Publish-shaped additions tail-spread: publishStatus,\n *     uploadProgress, publishError, and their setters.\n *\n * Consumers who only read v0.1.5 fields keep working unchanged. Consumers\n * who want the new editor-shaped surface (draft, drawingTool, etc.) can\n * access them directly.\n */\n\nconst ALL_MODES: ComposerMode[] = [\"photo\", \"video\", \"text\"];\n\nexport type PublishStatus =\n  | \"idle\"\n  | \"compositing\"\n  | \"uploading\"\n  | \"done\"\n  | \"error\";\n\nexport interface UseStoryComposerStateOptions {\n  defaultMode?: ComposerMode;\n  hideModes?: ComposerMode[];\n  /** Forwarded to useMediaEditorState onDirtyChange. */\n  onDirtyChange?: (isDirty: boolean) => void;\n}\n\nexport interface UseStoryComposerStateResult\n  extends Omit<UseMediaEditorStateResult, \"setMode\"> {\n  // === v0.1.5 surface preserved ===\n  /** Non-null mode — defaults to first visible mode if editor state is null. */\n  mode: ComposerMode;\n  stage: ComposerStage;\n  visibleModes: ComposerMode[];\n  /** v0.1.5 setMode preserved (non-null arg). Resets stage to \"capture\" + drops draft on a switch-during-edit. */\n  setMode: (mode: ComposerMode) => void;\n  /**\n   * @deprecated v0.2.0 — isDirty is now derived from useMediaEditorState.\n   * markDirty(true) is a no-op + dev console.warn. Override the derivation\n   * by writing to the editor state directly.\n   */\n  markDirty: (dirty?: boolean) => void;\n\n  // === v0.2.0 publish-shaped additions ===\n  publishStatus: PublishStatus;\n  uploadProgress: number;\n  publishError: Error | null;\n  setPublishStatus: (status: PublishStatus) => void;\n  setUploadProgress: (progress: number) => void;\n  setPublishError: (error: Error | null) => void;\n}\n\nexport function useStoryComposerState(\n  options: UseStoryComposerStateOptions = {},\n): UseStoryComposerStateResult {\n  const { defaultMode = \"photo\", hideModes = [], onDirtyChange } = options;\n\n  const visibleModes = useMemo(\n    () => ALL_MODES.filter((m) => !hideModes.includes(m)),\n    [hideModes],\n  );\n\n  // If the default mode is hidden, fall back to the first visible mode.\n  const initialMode = visibleModes.includes(defaultMode)\n    ? defaultMode\n    : (visibleModes[0] ?? \"photo\");\n\n  const editor = useMediaEditorState({\n    defaultMode: initialMode,\n    onDirtyChange,\n  });\n\n  // v0.1.5 semantics: switching mode while editing drops back to capture +\n  // discards the draft. useMediaEditorState's bare setMode doesn't do that.\n  const setMode = useCallback(\n    (mode: ComposerMode) => {\n      if (mode === editor.state.mode) return;\n      if (editor.state.stage === \"edit\") {\n        editor.setStage(\"capture\");\n        editor.setDraft(null);\n      }\n      editor.setMode(mode);\n    },\n    [editor],\n  );\n\n  // markDirty: v0.1.5 manually toggled an isDirty flag. v0.2.0 derives\n  // isDirty from useMediaEditorState. Preserved as a no-op + dev warn so\n  // existing callers compile and run; visual behavior is governed by the\n  // derived value.\n  const warnedRef = useRef(false);\n  const markDirty = useCallback((dirty: boolean = true) => {\n    if (process.env.NODE_ENV === \"production\") return;\n    if (warnedRef.current) return;\n    warnedRef.current = true;\n    console.warn(\n      \"useStoryComposerState.markDirty(\" +\n        dirty +\n        \") is a no-op in v0.2.0 — isDirty is derived from useMediaEditorState. \" +\n        \"Drive dirty via setDraft / addTextOverlay / etc. instead. \" +\n        \"Removed in v0.3.0.\",\n    );\n  }, []);\n\n  const [publishStatus, setPublishStatus] = useState<PublishStatus>(\"idle\");\n  const [uploadProgress, setUploadProgress] = useState(0);\n  const [publishError, setPublishError] = useState<Error | null>(null);\n\n  // Reset clears publish state alongside the editor state.\n  const baseReset = editor.reset;\n  const reset = useCallback(() => {\n    baseReset();\n    setPublishStatus(\"idle\");\n    setUploadProgress(0);\n    setPublishError(null);\n  }, [baseReset]);\n\n  return {\n    ...editor,\n    // v0.1.5 overrides — non-null mode, custom setMode, markDirty shim\n    mode: editor.state.mode ?? initialMode,\n    stage: editor.state.stage,\n    visibleModes,\n    setMode,\n    markDirty,\n    reset,\n    // Publish-shaped additions\n    publishStatus,\n    uploadProgress,\n    publishError,\n    setPublishStatus,\n    setUploadProgress,\n    setPublishError,\n  };\n}\n",
      "type": "registry:component",
      "target": "components/story-composer/hooks/use-story-composer-state.ts"
    },
    {
      "path": "src/registry/components/media/story-composer/parts/composer-publish-bar.tsx",
      "content": "\"use client\";\n\nimport { Loader2, Send, X } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport type { StoryComposerLabels } from \"../types\";\n\nexport interface ComposerPublishBarProps {\n  isPublishing: boolean;\n  canPublish: boolean;\n  /**\n   * Show the publish CTA. Defaults to true. The parent passes false during the\n   * capture stage — nothing has been captured yet, so a disabled Publish button\n   * just crowds the mode pill on narrow (9:16) widths.\n   */\n  showPublish?: boolean;\n  /**\n   * Show the close (X) button. Defaults to true. The parent passes false in the\n   * edit stage when the editor renders its own Back arrow in the same corner —\n   * Back replaces close (Instagram convention). Edit-only consumers with no\n   * capture mode keep close, since there's no Back to fall back to.\n   */\n  showClose?: boolean;\n  labels: Required<StoryComposerLabels>;\n  onPublish: () => void;\n  onClose: () => void;\n  className?: string;\n}\n\n/**\n * Top bar — close (left) + publish CTA (right).\n *\n * Replaces the C3-era right-slot placeholder. Mounted by the parent only\n * when stage === \"edit\"; the capture stage keeps its own simple top bar.\n */\nexport function ComposerPublishBar({\n  isPublishing,\n  canPublish,\n  showPublish = true,\n  showClose = true,\n  labels,\n  onPublish,\n  onClose,\n  className,\n}: ComposerPublishBarProps) {\n  return (\n    <div\n      className={cn(\n        \"absolute top-[max(0.75rem,env(safe-area-inset-top))] left-3 right-3 z-30 flex items-center justify-between\",\n        className,\n      )}\n    >\n      {showClose ? (\n        <Button\n          variant=\"ghost\"\n          size=\"icon\"\n          onClick={onClose}\n          aria-label={labels.close}\n          disabled={isPublishing}\n          // Matches the editor's Back arrow (dark scrim circle) so the\n          // top-left action looks identical across capture + edit stages.\n          className=\"rounded-full bg-black/30 text-white backdrop-blur hover:bg-black/45 hover:text-white\"\n        >\n          <X className=\"size-5\" />\n        </Button>\n      ) : null}\n\n      {showPublish ? (\n        <Button\n          type=\"button\"\n          onClick={onPublish}\n          disabled={!canPublish || isPublishing}\n          aria-busy={isPublishing}\n          className={cn(\n            // ml-auto keeps Publish pinned right even when the close button is\n            // hidden (otherwise justify-between would pull a lone child left).\n            \"ml-auto rounded-full bg-white text-black hover:bg-white/90 gap-1.5 px-4\",\n            \"disabled:opacity-50\",\n          )}\n        >\n          {isPublishing ? (\n            <>\n              <Loader2 className=\"size-4 animate-spin\" />\n              <span>{labels.publishing}</span>\n            </>\n          ) : (\n            <>\n              <Send className=\"size-4\" />\n              <span>{labels.publish}</span>\n            </>\n          )}\n        </Button>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/story-composer/parts/composer-publish-bar.tsx"
    },
    {
      "path": "src/registry/components/media/story-composer/parts/publishing-progress-overlay.tsx",
      "content": "\"use client\";\n\nimport { AlertTriangle, Loader2 } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport type { StoryComposerLabels } from \"../types\";\n\nexport interface PublishingProgressOverlayProps {\n  /** 0..1 — null when indeterminate (no length-computable from server). */\n  progress: number | null;\n  /**\n   * \"done\" is intentionally NOT a state here — the composer dismisses the\n   * overlay on success (onPublished), so only the in-flight + error states\n   * render. Kept narrow so the dead \"published\" arm can't drift back in.\n   */\n  status: \"uploading\" | \"error\";\n  errorMessage?: string;\n  labels: Required<StoryComposerLabels>;\n  onRetry?: () => void;\n  onCancel?: () => void;\n  className?: string;\n}\n\nexport function PublishingProgressOverlay({\n  progress,\n  status,\n  errorMessage,\n  labels,\n  onRetry,\n  onCancel,\n  className,\n}: PublishingProgressOverlayProps) {\n  return (\n    <div\n      role=\"status\"\n      aria-live=\"polite\"\n      className={cn(\n        \"absolute inset-0 z-40 grid place-items-center bg-black/85 backdrop-blur-sm text-white px-6\",\n        className,\n      )}\n    >\n      <div className=\"flex flex-col items-center gap-4 text-center max-w-xs\">\n        {status === \"uploading\" ? (\n          <>\n            <Loader2 className=\"size-10 animate-spin text-white/80\" />\n            <p className=\"text-base font-medium\">{labels.publishing}</p>\n            {progress !== null ? (\n              <div className=\"w-full\">\n                <div className=\"h-1.5 w-full rounded-full bg-white/15 overflow-hidden\">\n                  <div\n                    className=\"h-full bg-white transition-[width] duration-150\"\n                    style={{ width: `${Math.round(progress * 100)}%` }}\n                  />\n                </div>\n                <p className=\"mt-2 text-xs font-mono tabular-nums text-white/60\">\n                  {Math.round(progress * 100)}%\n                </p>\n              </div>\n            ) : null}\n            {onCancel ? (\n              <Button\n                variant=\"ghost\"\n                onClick={onCancel}\n                className=\"text-white/70 hover:bg-white/10 hover:text-white\"\n              >\n                {labels.discardCancel /* reuses \"Keep editing\" copy */}\n              </Button>\n            ) : null}\n          </>\n        ) : (\n          <>\n            <AlertTriangle className=\"size-10 text-amber-400\" />\n            <p className=\"text-base font-medium\">{labels.uploadFailedTitle}</p>\n            {errorMessage ? (\n              <p className=\"text-xs text-white/60 leading-relaxed\">\n                {errorMessage}\n              </p>\n            ) : null}\n            {onRetry ? (\n              <Button\n                onClick={onRetry}\n                className=\"bg-white text-black hover:bg-white/90\"\n              >\n                {labels.uploadRetry}\n              </Button>\n            ) : null}\n          </>\n        )}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/story-composer/parts/publishing-progress-overlay.tsx"
    }
  ],
  "categories": [
    "media"
  ],
  "type": "registry:block"
}