{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "media-editor",
  "title": "Media Editor",
  "author": "ilinxa",
  "description": "Media capture and edit surface for photo, video, and text — capability dials and an Instagram-style chrome model.",
  "dependencies": [
    "konva",
    "react-konva",
    "lucide-react"
  ],
  "registryDependencies": [
    "dialog",
    "alert-dialog",
    "popover",
    "button",
    "slider"
  ],
  "files": [
    {
      "path": "src/registry/components/media/media-editor/media-editor.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport type Konva from \"konva\";\nimport { ArrowLeft, Loader2 } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { useMediaEditorState } from \"./hooks/use-media-editor-state\";\nimport { useCaptureExtensionOptional } from \"./hooks/use-capture-extension\";\nimport { resolvePresentation } from \"./lib/presentation-resolver\";\nimport { dialogDimsForAspect } from \"./lib/dialog-size-for-aspect\";\nimport { resolveCropAspects } from \"./lib/resolve-crop-aspects\";\nimport { loadInitialSource } from \"./lib/initial-source-loader\";\nimport { exportPhotoBlob, exportTextOnlyBlob } from \"./lib/export-blob\";\nimport { compositeVideo } from \"./lib/composite-video\";\nimport { validateGalleryFile } from \"./lib/validate-media-file\";\nimport { DiscardConfirmDialog } from \"./parts/discard-confirm-dialog\";\nimport { TextOnlyCanvas } from \"./parts/text-only-canvas\";\nimport { ToolAdjustSliders } from \"./parts/tool-adjust-sliders\";\nimport { ToolDrawControls } from \"./parts/tool-draw-controls\";\nimport { ToolFilterStrip } from \"./parts/tool-filter-strip\";\nimport { ToolStickerPicker } from \"./parts/tool-sticker-picker\";\nimport { ToolTextInput } from \"./parts/tool-text-input\";\nimport { resolveFilterPresets } from \"./lib/konva-filters\";\nimport { resolveStickerSets } from \"./lib/built-in-stickers\";\nimport {\n  DEFAULT_COLOR_PRESETS,\n  DEFAULT_FONTS,\n  DEFAULT_TEXT_GRADIENTS,\n} from \"./lib/defaults\";\nimport { useHistory } from \"./hooks/use-history\";\nimport type {\n  AspectRatio,\n  ComposerMode,\n  EditAction,\n  EditTool,\n  ExportImageOpts,\n  ExportMetadata,\n  ExportOpts,\n  ExportVideoOpts,\n  ImageAdjustments,\n  LoadStateOpts,\n  MediaEditorHandle,\n  MediaEditorProps,\n  MediaEditorState,\n  SourceError,\n  StickerOption,\n  StoryComposerLabels,\n  ValidationError,\n} from \"./types\";\nimport type {\n  CapturedPhotoLike as CapturedPhoto,\n  CapturedVideoLike as CapturedVideo,\n} from \"./hooks/use-capture-extension\";\n\n// Lazy-load the konva canvas (compound-component-structure rule — heavy dep\n// boundary). Module-scoped (not inside the component) so the lazy factory's\n// identity is stable across renders. `parts/lazy-editor-canvas` is a\n// same-folder default-export wrapper — EditorCanvas itself is a named\n// export and React.lazy needs a default one.\nconst LazyEditorCanvas = React.lazy(() => import(\"./parts/lazy-editor-canvas\"));\n\n/**\n * media-editor — black-box orchestrator.\n *\n * Owns the editor state machine, capability gating\n * (enabledModes/Tools/Sources/aspect), presentation (inline / dialog / auto),\n * initial-source intake, the Konva editor surface (camera surface / EditorCanvas /\n * tool panels), export pipeline, and the imperative ref handle.\n *\n * P3 S3 (feature-slicing): the camera capture surface is no longer statically\n * imported here — it's injected via the optional `capture` prop\n * (`MediaCaptureExtension`, see `hooks/use-capture-extension.ts`). Install\n * `@ilinxa/media-editor-capture` and pass `capture={mediaCapture}` to enable\n * a live camera; without it, \"camera\" in `mediaSources` falls back to the\n * base file/gallery intake surface (one dev console.warn) — base-alone stays\n * a real pick-file → edit → export flow. `MediaEditor` itself is a thin\n * wrapper that mounts `props.capture.Provider` (when present) around\n * `MediaEditorImpl`, which holds all the logic below and reads the\n * extension back out via `useCaptureExtensionOptional()`.\n *\n * Known v0.1.x gap: the imperative *capture* handle methods (takePhoto,\n * startRecording, stopRecording, switchCamera, importFromGallery) are deferred\n * to v0.2 — they dev-warn if called. The in-UI camera controls are the\n * supported capture path in v0.1.x; everything else on the handle (inspect /\n * state / export / edit-overlay mutation) is fully wired.\n */\n\nconst NOT_IMPLEMENTED_MARKER =\n  \"media-editor: imperative capture (takePhoto / startRecording / stopRecording / switchCamera / importFromGallery) is not implemented — drive capture via the in-UI controls instead.\";\n\nconst NOT_WIRED_HISTORY_MARKER =\n  \"media-editor: undo/redo is not wired in v0.1.x (no mutations are recorded as commands yet) — no-op. The per-mutation command layer lands in v0.2.\";\n\n// English fallback for the v0.1.5-shaped StoryComposerLabels that several\n// part files (EditorCamera, EditorToolbar, DiscardConfirmDialog, etc.) still\n// require until the C17 refactor moves them onto MediaEditorLabels.\n// Module-scoped; not exported via the barrel. Consumers override individual\n// strings via the `labels` prop (mapped through DEFAULT_LABELS).\nconst INTERNAL_LABELS_FALLBACK: Required<StoryComposerLabels> = {\n  composerLabel: \"Media editor\",\n  composerDescription: \"Capture a photo, record a video, or compose text-only content.\",\n  modePhoto: \"Photo\",\n  modeVideo: \"Video\",\n  modeText: \"Text\",\n  shutterPhoto: \"Take photo\",\n  shutterVideoStart: \"Start recording\",\n  shutterVideoStop: \"Stop recording\",\n  galleryPicker: \"Choose from gallery\",\n  switchCamera: \"Switch camera\",\n  permissionDeniedTitle: \"Camera access blocked\",\n  permissionDeniedBody:\n    \"We need camera permission to capture media. Enable it in your browser settings, or pick a file from your gallery.\",\n  permissionRetry: \"Try again\",\n  permissionUsePicker: \"Use gallery instead\",\n  permissionRequesting: \"Requesting camera…\",\n  toolText: \"Text\",\n  toolDraw: \"Draw\",\n  toolStickers: \"Stickers\",\n  toolFilters: \"Filters\",\n  toolAdjust: \"Adjust\",\n  toolCrop: \"Crop\",\n  adjustBrightness: \"Brightness\",\n  adjustContrast: \"Contrast\",\n  adjustSaturation: \"Saturation\",\n  adjustBlur: \"Blur\",\n  drawColor: \"Color\",\n  drawBrush: \"Brush size\",\n  drawEraser: \"Eraser\",\n  drawUndo: \"Undo\",\n  drawRedo: \"Redo\",\n  textPlaceholder: \"Type something…\",\n  textFontFamily: \"Font\",\n  textFontSize: \"Size\",\n  textAlign: \"Align\",\n  publish: \"Publish\",\n  publishing: \"Publishing…\",\n  published: \"Published\",\n  close: \"Close\",\n  discardConfirmTitle: \"Discard?\",\n  discardConfirmBody: \"You'll lose your captured media and edits.\",\n  discardConfirm: \"Discard\",\n  discardCancel: \"Keep editing\",\n  uploadFailedTitle: \"Upload failed\",\n  uploadRetry: \"Retry\",\n  recordingLabel: \"Recording\",\n  trimStart: \"Trim start\",\n  trimEnd: \"Trim end\",\n};\n\nfunction devWarnOnce(memo: Set<string>, key: string, message: string) {\n  if (process.env.NODE_ENV === \"production\") return;\n  if (memo.has(key)) return;\n  memo.add(key);\n  console.warn(message);\n}\n\n// Thin outer wrapper — its ONLY job is mounting `props.capture.Provider`\n// (when a capture extension is wired) as an ANCESTOR of the real\n// implementation, so `MediaEditorImpl` can read it back via\n// `useCaptureExtensionOptional()` (a component can't consume a context\n// Provider it renders itself — the Provider must be an ancestor). No other\n// logic lives here; everything else is unchanged in `MediaEditorImpl` below.\nexport const MediaEditor = React.forwardRef<MediaEditorHandle, MediaEditorProps>(\n  function MediaEditor(props, ref) {\n    if (props.capture) {\n      const { Provider } = props.capture;\n      return (\n        <Provider>\n          <MediaEditorImpl {...props} ref={ref} />\n        </Provider>\n      );\n    }\n    return <MediaEditorImpl {...props} ref={ref} />;\n  },\n);\n\nconst MediaEditorImpl = React.forwardRef<\n  MediaEditorHandle,\n  MediaEditorProps\n>(function MediaEditorImpl(props, ref) {\n  const captureExt = useCaptureExtensionOptional();\n  const { defaultMode } = props;\n\n  // Seed the editor's cropAspect with the first allowed option (the hook's\n  // \"free\" fallback may not be in the consumer-derived list). Computed\n  // inline here — the memoized version below covers the render path.\n  const initialCropAspect =\n    resolveCropAspects(props.aspect ?? \"free\", props.cropAspects)[0] ?? \"free\";\n\n  const editor = useMediaEditorState({\n    defaultMode,\n    defaultCropAspect: initialCropAspect,\n    onDirtyChange: props.onDirtyChange,\n  });\n\n  const warnedRef = React.useRef<Set<string>>(new Set());\n\n  // ─── Observability emitters (onModeChange / onEditAction) ──────────\n  // Latest-callback refs so the emit effects below depend only on the state\n  // they watch — never on parent-supplied callback identity (which churns on\n  // every parent render and would refire the effects spuriously).\n  const onModeChangeRef = React.useRef(props.onModeChange);\n  const onEditActionRef = React.useRef(props.onEditAction);\n  React.useEffect(() => {\n    onModeChangeRef.current = props.onModeChange;\n    onEditActionRef.current = props.onEditAction;\n  });\n  const emitEditAction = React.useCallback((action: EditAction) => {\n    onEditActionRef.current?.(action);\n  }, []);\n\n  // Emit mode changes to onModeChange + onEditAction. Effect-driven so it\n  // catches EVERY transition source (mode tab, gallery pick, initialSource\n  // load, programmatic) rather than threading the callback through each call\n  // site. Skips the initial mount so a seeded defaultMode doesn't fire.\n  const prevEmitModeRef = React.useRef<ComposerMode | null | undefined>(\n    undefined,\n  );\n  React.useEffect(() => {\n    const mode = editor.state.mode;\n    const prev = prevEmitModeRef.current;\n    if (prev !== undefined && prev !== mode) {\n      onModeChangeRef.current?.(mode);\n      emitEditAction({ kind: \"mode-change\", from: prev, to: mode });\n    }\n    prevEmitModeRef.current = mode;\n  }, [editor.state.mode, emitEditAction]);\n\n  // Emit tool-open / tool-close as the active tool changes. A tool→tool switch\n  // emits a close for the old then an open for the new. Skips the initial mount.\n  const prevEmitToolRef = React.useRef<EditTool | null | undefined>(undefined);\n  React.useEffect(() => {\n    const curr = editor.activeTool;\n    const prev = prevEmitToolRef.current;\n    if (prev !== undefined && prev !== curr) {\n      if (prev !== null) emitEditAction({ kind: \"tool-close\", tool: prev });\n      if (curr !== null) emitEditAction({ kind: \"tool-open\", tool: curr });\n    }\n    prevEmitToolRef.current = curr;\n  }, [editor.activeTool, emitEditAction]);\n\n  // ─── Capability defaults (description §8 — Q-P-locked) ─────────────\n\n  // Memoized so the `??`-fallback arrays keep a stable identity across\n  // renders (both feed hook dependency arrays further down).\n  const enabledModes = React.useMemo(\n    () => props.enabledModes ?? ([\"photo\", \"video\", \"text\"] as const),\n    [props.enabledModes],\n  );\n  const enabledTools = React.useMemo(\n    () =>\n      props.enabledTools ??\n      ([\"text\", \"draw\", \"stickers\", \"filters\", \"adjust\", \"crop\"] as const),\n    [props.enabledTools],\n  );\n  const mediaSources = props.mediaSources ?? ([\"camera\", \"upload\"] as const);\n  const aspect = props.aspect ?? \"free\";\n\n  // Crop aspects derivation per description §4.\n  const resolvedCropAspects = React.useMemo(\n    () => resolveCropAspects(aspect, props.cropAspects),\n    [aspect, props.cropAspects],\n  );\n\n  // Mode pill is hidden when ≤1 mode enabled (per description §1).\n  const showModePill = enabledModes.length >= 2;\n\n  // ─── Initial source intake (description §5) ─────────────────────────\n  // When `initialSource` is set, fetch + validate, then land directly in\n  // the edit stage with mode + image/video pre-loaded. On error, surface\n  // via onInitialSourceError + sit in an empty state (no auto-fallback to\n  // capture — the consumer asked for a specific source and got an error).\n\n  const [sourceError, setSourceError] =\n    React.useState<SourceError | null>(null);\n\n  const onInitialSourceErrorRef = React.useRef(props.onInitialSourceError);\n  React.useEffect(() => {\n    onInitialSourceErrorRef.current = props.onInitialSourceError;\n  });\n\n  // Snapshot enabledModes into a ref so the load effect doesn't re-fire on\n  // array identity churn. enabledModes is read by the loader at the moment\n  // the source changes; subsequent toggles of enabledModes don't retrigger\n  // a re-fetch (which would be surprising — and slow on URL sources).\n  const enabledModesRef = React.useRef<readonly ComposerMode[]>(enabledModes);\n  React.useEffect(() => {\n    enabledModesRef.current = enabledModes;\n  });\n\n  // Value-based effect key: url-kind sources are idiomatically passed as\n  // inline object literals (fresh identity every parent render) — keying the\n  // load effect on the object identity would cancel + revoke + refetch on\n  // every render, killing the loaded image mid-edit. url-kind keys by\n  // content; blob/file-kind keys by the payload's identity (the wrapper\n  // object may churn, the Blob/File itself doesn't).\n  const initialSourceDep: string | Blob | File | null = !props.initialSource\n    ? null\n    : props.initialSource.kind === \"url\"\n      ? `url|${props.initialSource.mode}|${props.initialSource.url}`\n      : props.initialSource.kind === \"blob\"\n        ? props.initialSource.blob\n        : props.initialSource.file;\n\n  React.useEffect(() => {\n    const source = props.initialSource;\n    if (!source) {\n      // eslint-disable-next-line react-hooks/set-state-in-effect\n      setSourceError(null);\n      return;\n    }\n\n    let cancelled = false;\n    let allocatedUrl: string | null = null;\n\n    (async () => {\n      const result = await loadInitialSource(source, enabledModesRef.current);\n      if (cancelled) {\n        if (result.ok) URL.revokeObjectURL(result.loaded.objectUrl);\n        return;\n      }\n      if (!result.ok) {\n        setSourceError(result.error);\n        onInitialSourceErrorRef.current?.(result.error);\n        return;\n      }\n      allocatedUrl = result.loaded.objectUrl;\n      setSourceError(null);\n      editor.setMode(result.loaded.mode);\n      if (result.loaded.mode === \"photo\") {\n        editor.setImageSrc(result.loaded.objectUrl);\n      } else {\n        editor.setVideoBlob(result.loaded.blob);\n      }\n      // Also register as a draft so the canvas mounts via the unified\n      // draft path. Lifetime of the URL is governed by the cleanup below\n      // (which revokes on cancel/unmount), so we don't double-revoke.\n      editor.setDraft({\n        source: \"initial\",\n        kind: result.loaded.mode === \"photo\" ? \"image\" : \"video\",\n        blob: result.loaded.blob,\n        url: result.loaded.objectUrl,\n        mimeType: result.loaded.blob.type,\n      });\n      editor.setStage(\"edit\");\n    })();\n\n    return () => {\n      cancelled = true;\n      if (allocatedUrl) URL.revokeObjectURL(allocatedUrl);\n    };\n    // editor methods are stable refs from the hook; only the VALUE-keyed\n    // `initialSourceDep` drives this effect (see derivation above). The\n    // effect body reads props.initialSource from the render that changed the\n    // key. enabledModes is read via ref above so a tools-dial toggle doesn't\n    // refetch a URL source mid-edit.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [initialSourceDep]);\n\n  // Camera surface: show if camera intake allowed AND at least one capture mode\n  // is enabled; otherwise fall back to the base file/gallery intake surface.\n  const hasCaptureMode = enabledModes.some(\n    (m) => m === \"photo\" || m === \"video\",\n  );\n  // `cameraIntakeAvailable` is CONFIG-only (does the consumer want camera at\n  // all). `captureSurfaceReady` additionally requires the capture extension\n  // to actually be wired (`props.capture` → `captureExt`) — camera requested\n  // but unwired degrades to the file-intake surface below (one dev-warn),\n  // rather than a dead \"Connect to camera\" button.\n  const cameraIntakeAvailable =\n    mediaSources.includes(\"camera\") && hasCaptureMode;\n  const CameraSurface = captureExt?.CameraSurface ?? null;\n  const captureSurfaceReady = cameraIntakeAvailable && CameraSurface !== null;\n\n  React.useEffect(() => {\n    if (!cameraIntakeAvailable) return;\n    if (captureExt?.CameraSurface) return;\n    devWarnOnce(\n      warnedRef.current,\n      \"capture-extension-missing\",\n      'media-editor: mediaSources includes \"camera\" but no capture extension is wired — install @ilinxa/media-editor-capture and pass capture={mediaCapture} (MediaEditorProps.capture) to enable the camera. Falling back to file/gallery intake.',\n    );\n  }, [cameraIntakeAvailable, captureExt]);\n\n  // ─── R4: Undo / redo history + discard guard ─────────────────────────\n\n  // Command-stack scaffolding — nothing calls history.execute() in v0.1.x, so\n  // the stack is always empty. bindKeyboard stays OFF until the per-mutation\n  // command layer lands (v0.2): a live window Ctrl/Cmd+Z listener that\n  // preventDefault()s page-wide while doing nothing is strictly worse than no\n  // listener. handle.undo/redo dev-warn accordingly (see the handle below).\n  const history = useHistory({ capacity: 50, bindKeyboard: false });\n\n  const [showDiscardConfirm, setShowDiscardConfirm] = React.useState(false);\n  const textOnlyRef = React.useRef<HTMLDivElement | null>(null);\n\n  // Run a clean reset that also drops the history stack. Emits the `reset`\n  // edit-action centrally so every reset path (handle.reset, back-to-capture,\n  // discard-confirm) reports it once.\n  const performReset = React.useCallback(() => {\n    editor.reset();\n    history.reset();\n    emitEditAction({ kind: \"reset\" });\n  }, [editor, history, emitEditAction]);\n\n  // Close path with confirm-on-discard. Mirrors v0.1.5 Q-P10a:\n  //   - In dialog mode, fires onClose unless dirty (then shows confirm).\n  //   - In inline mode, just lets the consumer know via onClose (no UI\n  //     close to perform).\n  const requestClose = React.useCallback(() => {\n    if (props.confirmOnDiscard !== false && editor.isDirty) {\n      setShowDiscardConfirm(true);\n      return;\n    }\n    props.onClose?.();\n  }, [editor.isDirty, props]);\n\n  // ─── Konva stage handle (C10 export wiring) ─────────────────────────\n  // EditorCanvas surfaces its Konva.Stage via onStageReady. The handle's\n  // export methods read this ref to snapshot the canvas at publish time.\n  const stageRef = React.useRef<Konva.Stage | null>(null);\n\n  // Derive a stable object URL from the loaded video blob so EditorCanvas\n  // can play it. Allocated lazily and revoked when the blob identity\n  // changes or the component unmounts.\n  const videoBlob = editor.state.videoBlob;\n  const [videoUrl, setVideoUrl] = React.useState<string | null>(null);\n  React.useEffect(() => {\n    if (!videoBlob) {\n      // eslint-disable-next-line react-hooks/set-state-in-effect\n      setVideoUrl(null);\n      return;\n    }\n    const url = URL.createObjectURL(videoBlob);\n    setVideoUrl(url);\n    return () => URL.revokeObjectURL(url);\n  }, [videoBlob]);\n\n  // Edit canvas is mounted whenever we have a loaded source. Source can land\n  // via initialSource (sets imageSrc/videoBlob + draft) OR camera capture\n  // (sets draft only) OR gallery pick (sets draft only). The unified read\n  // is `editor.draft`.\n  const hasLoadedSource =\n    editor.draft !== null ||\n    editor.state.imageSrc !== null ||\n    editor.state.videoBlob !== null;\n  const showEditCanvas =\n    !sourceError && editor.state.stage === \"edit\" && hasLoadedSource;\n\n  // ─── R2: Capture flow handlers ─────────────────────────────────────\n  // Camera + gallery callbacks promote captured media to a draft and\n  // transition to the edit stage. EditorCanvas takes over from there.\n\n  const handlePhoto = React.useCallback(\n    (photo: CapturedPhoto) => {\n      const url = URL.createObjectURL(photo.blob);\n      editor.setDraft({\n        source: \"camera\",\n        kind: \"image\",\n        blob: photo.blob,\n        url,\n        width: photo.width,\n        height: photo.height,\n        mimeType: photo.mimeType,\n      });\n      editor.setMode(\"photo\");\n      editor.setImageSrc(url);\n      editor.setStage(\"edit\");\n    },\n    [editor],\n  );\n\n  const handleVideo = React.useCallback(\n    (video: CapturedVideo) => {\n      const url = URL.createObjectURL(video.blob);\n      editor.setDraft({\n        source: \"camera\",\n        kind: \"video\",\n        blob: video.blob,\n        url,\n        durationMs: video.durationMs,\n        mimeType: video.mimeType,\n      });\n      editor.setMode(\"video\");\n      editor.setVideoBlob(video.blob);\n      editor.setStage(\"edit\");\n    },\n    [editor],\n  );\n\n  const handleGalleryFile = React.useCallback(\n    (file: File) => {\n      const kind: \"image\" | \"video\" = file.type.startsWith(\"video/\")\n        ? \"video\"\n        : \"image\";\n      const url = URL.createObjectURL(file);\n      editor.setDraft({\n        source: \"gallery\",\n        kind,\n        blob: file,\n        url,\n        mimeType: file.type,\n      });\n      editor.setMode(kind === \"image\" ? \"photo\" : \"video\");\n      if (kind === \"image\") {\n        editor.setImageSrc(url);\n      } else {\n        editor.setVideoBlob(file);\n      }\n      editor.setStage(\"edit\");\n    },\n    [editor],\n  );\n\n  // ─── R3: Tool-panel resolved data ─────────────────────────────────\n  const resolvedStickerSets = React.useMemo(\n    () =>\n      resolveStickerSets(props.stickers, !!props.replaceBuiltinStickers),\n    [props.stickers, props.replaceBuiltinStickers],\n  );\n  const resolvedFilterPresets = React.useMemo(\n    () =>\n      resolveFilterPresets(\n        props.filterPresets,\n        !!props.replaceBuiltinFilters,\n      ),\n    [props.filterPresets, props.replaceBuiltinFilters],\n  );\n  const resolvedFonts = React.useMemo(\n    () => props.fonts ?? DEFAULT_FONTS,\n    [props.fonts],\n  );\n  const resolvedColorPresets = React.useMemo(\n    () => props.colorPresets ?? DEFAULT_COLOR_PRESETS,\n    [props.colorPresets],\n  );\n\n  // Flatten resolved sticker sets into a Map<id, StickerOption> for the\n  // canvas-side resolveSticker(stickerId) lookup. Memoized — only rebuilds\n  // when the sets change.\n  const stickerIndex = React.useMemo(() => {\n    const m = new Map<string, StickerOption>();\n    for (const set of resolvedStickerSets) {\n      for (const sticker of set.stickers) m.set(sticker.id, sticker);\n    }\n    return m;\n  }, [resolvedStickerSets]);\n  const resolveSticker = React.useCallback(\n    (stickerId: string) => stickerIndex.get(stickerId),\n    [stickerIndex],\n  );\n\n  // In-progress drawing stroke kept locally — committed to editor state on\n  // pointer-up via addDrawingStroke. Lives outside MediaEditorState because\n  // it's transient render-only data.\n  const [currentDrawingStroke, setCurrentDrawingStroke] = React.useState<\n    import(\"./types\").DrawingStroke | null\n  >(null);\n  const handleDrawBegin = React.useCallback(\n    (x: number, y: number) => {\n      setCurrentDrawingStroke({\n        id: `stroke-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,\n        points: [x, y],\n        color: editor.drawingTool.color,\n        brushSize: editor.drawingTool.brushSize,\n        mode: editor.drawingTool.mode,\n      });\n    },\n    [editor.drawingTool],\n  );\n  const handleDrawExtend = React.useCallback((x: number, y: number) => {\n    setCurrentDrawingStroke((prev) =>\n      prev ? { ...prev, points: [...prev.points, x, y] } : prev,\n    );\n  }, []);\n  const handleDrawEnd = React.useCallback(() => {\n    // Commit OUTSIDE the setState updater — React 19 strict mode invokes the\n    // updater twice in dev, which would call addDrawingStroke twice with the\n    // same id and produce duplicate-key warnings. setState updaters must be\n    // pure. Closure-read of the current stroke is fine here.\n    if (currentDrawingStroke && currentDrawingStroke.points.length >= 4) {\n      editor.addDrawingStroke(currentDrawingStroke);\n    }\n    setCurrentDrawingStroke(null);\n  }, [currentDrawingStroke, editor]);\n\n  // Tool-panel state snapshot: captures editor.state at the moment the user\n  // enters a tool (activeTool flips from null → tool) so a \"Back\" button can\n  // revert any in-tool changes. Cleared on tool exit so re-entering captures\n  // a fresh baseline.\n  const toolEntrySnapshot = React.useRef<MediaEditorState | null>(null);\n  const prevActiveToolRef = React.useRef<EditTool | null>(null);\n  React.useEffect(() => {\n    const prev = prevActiveToolRef.current;\n    const curr = editor.activeTool;\n    if (prev === null && curr !== null) {\n      toolEntrySnapshot.current = editor.state;\n    } else if (curr === null) {\n      toolEntrySnapshot.current = null;\n    }\n    prevActiveToolRef.current = curr;\n    // editor.state is read intentionally at entry; deps only on activeTool.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [editor.activeTool]);\n\n  const handleApplyTool = React.useCallback(() => {\n    editor.setSelectedTextId(null);\n    editor.setSelectedStickerId(null);\n    editor.setActiveTool(null);\n  }, [editor]);\n  const handleBackTool = React.useCallback(() => {\n    const snap = toolEntrySnapshot.current;\n    if (snap) editor.loadState(snap);\n    editor.setSelectedTextId(null);\n    editor.setSelectedStickerId(null);\n    editor.setActiveTool(null);\n  }, [editor]);\n\n  const selectedText = React.useMemo(\n    () =>\n      editor.state.textOverlays.find((o) => o.id === editor.selectedTextId) ??\n      null,\n    [editor.state.textOverlays, editor.selectedTextId],\n  );\n\n  // Auto-add a text overlay when the text tool activates with nothing\n  // selected. Mirrors v0.1.5 behavior — tapping \"Text\" gives you something\n  // to edit immediately rather than presenting an empty inspector.\n  const lastSeededToolRef = React.useRef<EditTool | null>(null);\n  React.useEffect(() => {\n    if (editor.activeTool !== \"text\") {\n      lastSeededToolRef.current = null;\n      return;\n    }\n    if (selectedText) return;\n    if (lastSeededToolRef.current === \"text\") return;\n    lastSeededToolRef.current = \"text\";\n    const id = `text-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;\n    editor.addTextOverlay({\n      id,\n      text: INTERNAL_LABELS_FALLBACK.textPlaceholder,\n      x: 100,\n      y: 200,\n      rotation: 0,\n      scale: 1,\n      fontFamily: resolvedFonts[0]?.family ?? \"sans-serif\",\n      fontSize: 40,\n      fill: \"#ffffff\",\n      align: \"center\",\n    });\n    editor.setSelectedTextId(id);\n    // editor methods are stable; only activeTool/selectedText/fonts drive seed.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [editor.activeTool, selectedText, resolvedFonts]);\n\n  // Sticker pick → add at canvas center + select for the transformer.\n  const handleStickerPick = React.useCallback(\n    (sticker: StickerOption) => {\n      const id = `sticker-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;\n      editor.addSticker({\n        id,\n        stickerId: sticker.id,\n        x: 200,\n        y: 200,\n        rotation: 0,\n        scale: 1,\n      });\n      editor.setSelectedStickerId(id);\n    },\n    [editor],\n  );\n\n  const mergedLabels: Required<StoryComposerLabels> = React.useMemo(() => {\n    // The MediaEditorLabels → StoryComposerLabels mapping is C17 work.\n    // Until then, only string overrides at the keys that overlap with the\n    // flat shim flow through (e.g., `composerLabel`); everything else\n    // falls back to the English defaults.\n    const overrides = (props.labels ?? {}) as Partial<StoryComposerLabels>;\n    return { ...INTERNAL_LABELS_FALLBACK, ...overrides };\n  }, [props.labels]);\n\n  // Capture surface is mounted when the editor is in capture stage AND the\n  // current mode is photo/video AND a camera extension is actually wired.\n  const showCameraSurface =\n    editor.state.stage === \"capture\" &&\n    captureSurfaceReady &&\n    (editor.state.mode === \"photo\" || editor.state.mode === \"video\");\n\n  // Mode auto-seed removed — initial state is intentionally mode:null so the\n  // canvas can render a clean \"Connect to camera\" entry-point button instead\n  // of jumping straight into a permission prompt. The button + mode tabs are\n  // the user gestures that move the editor out of the empty state. Only the\n  // text-only fallback (no capture modes available) does NOT need a gesture.\n  React.useEffect(() => {\n    if (editor.state.mode !== null) return;\n    if (editor.state.stage !== \"capture\") return;\n    const captureModesEnabled = enabledModes.some(\n      (m) => m === \"photo\" || m === \"video\",\n    );\n    if (!captureModesEnabled && enabledModes.includes(\"text\")) {\n      editor.setMode(\"text\");\n    }\n    // editor.setMode is stable per hook contract; mode/stage/modes drive it.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [editor.state.mode, editor.state.stage, enabledModes]);\n\n  // Tracks whether the user has explicitly clicked a button that should kick\n  // off the camera-acquire flow. When true, EditorCamera receives autoAcquire\n  // and the browser permission prompt fires inside the same user-gesture\n  // tick — avoiding spontaneous prompts on first paint.\n  const [userInitiatedCamera, setUserInitiatedCamera] = React.useState(false);\n\n  // \"Back\" from the edit stage → return to the capture surface so the user can\n  // re-take. Clears the captured draft + edits + history (via performReset),\n  // then restores the capture mode so the camera reopens in the same mode —\n  // gesture-credited through userInitiatedCamera so there's no fresh permission\n  // friction. Only offered when a capture mode exists (see hasCaptureMode).\n  const backToCapture = React.useCallback(() => {\n    const mode = editor.state.mode;\n    performReset();\n    if (mode === \"photo\" || mode === \"video\") {\n      editor.setMode(mode);\n      setUserInitiatedCamera(true);\n    }\n  }, [editor, performReset]);\n\n  // ─── Multi-instance guard (C11 — Q-P5 (b)) ──────────────────────────\n  // P3 S3: the guard's call site moved into the capture feature's\n  // EditorCamera (features/capture/parts/editor-camera.tsx) — it's a pure\n  // capture concern (live-camera contention), and the base can no longer\n  // statically import the hook that implements it. It now fires while the\n  // camera surface is actually mounted rather than merely \"configured for\n  // camera\" — a tighter, still-accurate trigger window. Base-alone (no\n  // capture extension) never acquires a camera, so it needs no guard at all.\n\n  // ─── Empty-state footgun guard (C11 — description §1) ────────────────\n  // `enabledModes: []` AND no `initialSource` AND no current source loaded\n  // → editor has nothing to capture and nothing to edit. Render the\n  // renderEmpty slot output (or a default \"No source provided\" surface)\n  // and dev-warn the misconfiguration.\n  const isEmptyConfig =\n    enabledModes.length === 0 &&\n    props.initialSource === undefined &&\n    !hasLoadedSource;\n\n  React.useEffect(() => {\n    if (!isEmptyConfig) return;\n    if (process.env.NODE_ENV === \"production\") return;\n    devWarnOnce(\n      warnedRef.current,\n      \"empty-config\",\n      \"media-editor: enabledModes=[] AND no initialSource — editor has nothing to capture or edit. Provide an initialSource or enable at least one capture mode.\",\n    );\n  }, [isEmptyConfig]);\n\n  // ─── Presentation resolution (description §6) ───────────────────────\n\n  const resolved = resolvePresentation(props.presentation, enabledModes);\n\n  // Dev-only required-prop guard: dialog mode needs isOpen + onClose.\n  React.useEffect(() => {\n    if (process.env.NODE_ENV === \"production\") return;\n    if (resolved !== \"dialog\") return;\n    if (props.isOpen === undefined || props.onClose === undefined) {\n      devWarnOnce(\n        warnedRef.current,\n        \"dialog-requires-isOpen-onClose\",\n        \"media-editor: presentation='dialog' requires isOpen + onClose props. Falling back to uncontrolled-open behaviour for now.\",\n      );\n    }\n  }, [resolved, props.isOpen, props.onClose]);\n\n  // ─── Export implementations (C10) ───────────────────────────────────\n  // Factored out of the handle factory so the polymorphic export() can\n  // dispatch by mode without a `this`-binding gotcha.\n\n  const exportImage = React.useCallback(\n    async (opts?: ExportImageOpts) => {\n      const stage = stageRef.current;\n      if (!stage) throw new Error(\"media-editor: editor canvas not ready\");\n      const format = opts?.format ?? \"image/jpeg\";\n      const quality = opts?.quality ?? 0.9;\n      const pixelRatio = opts?.pixelRatio ?? 2;\n      const cropRect = editor.state.crop;\n      // Identity-transform guard: exporting while pan/zoomed would bake the\n      // viewport transform into the output (stage.toCanvas captures the\n      // rendered stage). Normalize, snapshot, restore.\n      const blob = await withStageIdentityTransform(stage, () =>\n        exportPhotoBlob({\n          stage,\n          cropRect,\n          mimeType: format,\n          quality,\n          pixelRatio,\n          onProgress: opts?.onProgress,\n        }),\n      );\n      const width = cropRect?.width ?? stage.width();\n      const height = cropRect?.height ?? stage.height();\n      return {\n        blob,\n        metadata: buildExportMetadata(\n          editor.state,\n          \"photo\",\n          width,\n          height,\n          format,\n        ),\n      };\n    },\n    [editor.state],\n  );\n\n  // Text-only mode rasterizes the styled DOM surface (no Konva stage exists in\n  // text mode) — review 1.2: without this branch, publishing a text story\n  // threw \"editor canvas not ready\".\n  const exportText = React.useCallback(\n    async (opts?: ExportImageOpts) => {\n      const el = textOnlyRef.current;\n      if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) {\n        throw new Error(\"media-editor: text canvas not ready\");\n      }\n      const width = el.offsetWidth;\n      const height = el.offsetHeight;\n      opts?.onProgress?.(0);\n      const blob = await exportTextOnlyBlob(el, width, height);\n      opts?.onProgress?.(1);\n      return {\n        blob,\n        metadata: buildExportMetadata(\n          editor.state,\n          \"text\",\n          width,\n          height,\n          \"image/png\",\n        ),\n      };\n    },\n    [editor.state],\n  );\n\n  const exportVideo = React.useCallback(\n    async (opts?: ExportVideoOpts) => {\n      const videoBlob = editor.state.videoBlob;\n      if (!videoBlob) {\n        throw new Error(\"media-editor: no video blob loaded\");\n      }\n      // Perf-shortcut: no overlays AND no crop → return the raw blob.\n      // Skips the expensive MediaRecorder re-encode for the\n      // \"captured then immediately published\" path (chat / DM flows).\n      const shortcut = !hasAnyOverlay(editor.state) && !editor.state.crop;\n      if (shortcut) {\n        opts?.onProgress?.(0);\n        opts?.onProgress?.(1);\n        return {\n          blob: videoBlob,\n          metadata: buildExportMetadata(\n            editor.state,\n            \"video\",\n            editor.state.crop?.width ?? 720,\n            editor.state.crop?.height ?? 1280,\n            videoBlob.type || \"video/webm\",\n          ),\n        };\n      }\n      // Full re-encode path: bake Konva overlays into each video frame.\n      const stage = stageRef.current;\n      const cropRect = editor.state.crop;\n      const ow = cropRect?.width ?? stage?.width() ?? 720;\n      const oh = cropRect?.height ?? stage?.height() ?? 1280;\n      // Identity-transform guard for the per-frame overlay snapshot — same\n      // viewport-baking issue as exportImage, held for the whole re-encode.\n      const runComposite = () => compositeVideo({\n        sourceBlob: videoBlob,\n        outputWidth: Math.round(ow),\n        outputHeight: Math.round(oh),\n        mimeType: opts?.mimeType,\n        bitsPerSecond: opts?.bitsPerSecond,\n        onProgress: opts?.onProgress,\n        renderFrame: (ctx, video) => {\n          ctx.clearRect(0, 0, ow, oh);\n          const vw = video.videoWidth || ow;\n          const vh = video.videoHeight || oh;\n          const scale = Math.max(ow / vw, oh / vh);\n          const dw = vw * scale;\n          const dh = vh * scale;\n          ctx.drawImage(video, (ow - dw) / 2, (oh - dh) / 2, dw, dh);\n          if (stage) {\n            const overlay = stage.toCanvas({\n              x: cropRect?.x ?? 0,\n              y: cropRect?.y ?? 0,\n              width: cropRect?.width ?? stage.width(),\n              height: cropRect?.height ?? stage.height(),\n              pixelRatio: 1,\n            });\n            ctx.drawImage(overlay, 0, 0, ow, oh);\n          }\n        },\n      });\n      const result = stage\n        ? await withStageIdentityTransform(stage, runComposite)\n        : await runComposite();\n      return {\n        blob: result.blob,\n        metadata: buildExportMetadata(\n          editor.state,\n          \"video\",\n          Math.round(ow),\n          Math.round(oh),\n          result.mimeType,\n          result.durationMs,\n        ),\n      };\n    },\n    [editor.state],\n  );\n\n  const exportPolymorphic = React.useCallback(\n    async (opts?: ExportOpts) => {\n      const mode = editor.state.mode;\n      if (mode === \"video\") {\n        return exportVideo(opts as ExportVideoOpts | undefined);\n      }\n      if (mode === \"text\") {\n        return exportText(opts as ExportImageOpts | undefined);\n      }\n      return exportImage(opts as ExportImageOpts | undefined);\n    },\n    [editor.state.mode, exportImage, exportVideo, exportText],\n  );\n\n  // ─── Imperative handle ──────────────────────────────────────────────\n  // Inspect / state / export / edit-overlay methods are fully wired. The\n  // imperative CAPTURE methods (takePhoto / startRecording / stopRecording /\n  // switchCamera / importFromGallery) are not implemented — they dev-warn\n  // until then; the in-UI camera controls are the supported capture path.\n  React.useImperativeHandle(\n    ref,\n    (): MediaEditorHandle => ({\n      // === Inspect ===\n      getIsDirty: () => editor.isDirty,\n      getMode: () => editor.state.mode,\n      getState: () => editor.state,\n      loadState: (state: MediaEditorState, opts?: LoadStateOpts) => {\n        const sourceBlob = opts?.sourceBlob ?? null;\n        if (sourceBlob && state.mode !== \"video\") {\n          // Re-materialize (review 1.3): a persisted snapshot's imageSrc is a\n          // dead object URL once the capturing editor unmounted. Mint a fresh\n          // URL from the caller-persisted source blob and rebuild the draft so\n          // the canvas AND the draft-gated tool panels run as they did\n          // pre-unmount. setDraft's lifecycle owns revoking the minted URL.\n          const url = URL.createObjectURL(sourceBlob);\n          editor.loadState({ ...state, imageSrc: url });\n          editor.setDraft({\n            source: \"initial\",\n            kind: \"image\",\n            blob: sourceBlob,\n            url,\n            mimeType: sourceBlob.type || \"image/jpeg\",\n          });\n        } else {\n          editor.loadState(state);\n        }\n      },\n      getSourceBlob: () =>\n        editor.draft && editor.draft.kind === \"image\"\n          ? editor.draft.blob\n          : null,\n\n      // === Capture (imperative path not implemented — dev-warns; see note above) ===\n      switchCamera: async () => {\n        devWarnOnce(warnedRef.current, \"switchCamera\", NOT_IMPLEMENTED_MARKER);\n      },\n      takePhoto: async () => {\n        devWarnOnce(warnedRef.current, \"takePhoto\", NOT_IMPLEMENTED_MARKER);\n      },\n      startRecording: async () => {\n        devWarnOnce(warnedRef.current, \"startRecording\", NOT_IMPLEMENTED_MARKER);\n      },\n      stopRecording: async () => {\n        devWarnOnce(warnedRef.current, \"stopRecording\", NOT_IMPLEMENTED_MARKER);\n      },\n      importFromGallery: () => {\n        devWarnOnce(warnedRef.current, \"importFromGallery\", NOT_IMPLEMENTED_MARKER);\n      },\n\n      // === Edit ===\n      addText: (text?: string) => {\n        if (text !== undefined) {\n          editor.setTextContent(text);\n        } else {\n          devWarnOnce(warnedRef.current, \"addText\", NOT_IMPLEMENTED_MARKER);\n        }\n      },\n      addSticker: (sticker: StickerOption) => {\n        editor.addSticker({\n          id: `sticker-${Date.now()}`,\n          stickerId: sticker.id,\n          x: 0,\n          y: 0,\n          rotation: 0,\n          scale: 1,\n        });\n      },\n      setAdjustments: (adj: Partial<ImageAdjustments>) =>\n        editor.setAdjustments(adj),\n      applyFilter: (name: string | null) => editor.setFilter(name),\n      clearLayer: (layer: \"drawing\" | \"stickers\" | \"text\") =>\n        editor.clearLayer(layer),\n      // No execute() callers exist yet (per-mutation commands land in v0.2) —\n      // the stack is always empty, so these are honest no-ops, not silent ones.\n      undo: () => {\n        devWarnOnce(warnedRef.current, \"undo\", NOT_WIRED_HISTORY_MARKER);\n      },\n      redo: () => {\n        devWarnOnce(warnedRef.current, \"redo\", NOT_WIRED_HISTORY_MARKER);\n      },\n\n      // === Export (C10) ===\n      exportImage,\n      exportVideo,\n      export: exportPolymorphic,\n\n      // === Lifecycle ===\n      reset: () => performReset(),\n      /**\n       * Dialog mode is controlled: the consumer owns `isOpen`, so the handle\n       * genuinely cannot open the editor, and inline mode has nothing to open.\n       * That made this a legitimate no-op — but a SILENT one, which is\n       * indistinguishable from a broken method at the call site.\n       *\n       * It now says so, exactly as this component's capture methods already\n       * do. Being unable to act is fine; being unable to act quietly is not.\n       *\n       * @notImplemented Set `isOpen` on the component instead.\n       */\n      open: () => {\n        devWarnOnce(\n          warnedRef.current,\n          \"handle-open-noop\",\n          \"media-editor: handle.open() cannot open the editor — dialog mode is controlled, so set isOpen={true} on the component (inline mode has nothing to open).\",\n        );\n      },\n      close: () => {\n        // Routes through requestClose so confirm-on-discard guard fires\n        // if the editor is dirty. The dialog-mode branch in the wrapper\n        // also wires onOpenChange→requestClose for backdrop/Escape.\n        if (resolved === \"dialog\") {\n          requestClose();\n        }\n      },\n    }),\n    [\n      editor,\n      exportImage,\n      exportVideo,\n      exportPolymorphic,\n      resolved,\n      performReset,\n      requestClose,\n    ],\n  );\n\n  // ─── Slot context for render* props ─────────────────────────────────\n\n  const slotCtx = React.useMemo(\n    () => ({\n      mode: editor.state.mode,\n      stage: editor.state.stage,\n      isDirty: editor.isDirty,\n      isCapturing: editor.state.stage === \"capture\",\n      isExporting: editor.state.stage === \"publishing\",\n      activeTool: editor.activeTool,\n      enabledTools: [...enabledTools] as EditTool[],\n      enabledModes: [...enabledModes] as ComposerMode[],\n      aspect,\n    }),\n    [editor, enabledTools, enabledModes, aspect],\n  );\n\n  // ─── Render — inner surface (shared by inline + dialog branches) ──\n\n  const inner = (\n    <div\n      className={cn(\n        // `relative` anchors the floating top-center control (mode pill / Back)\n        // and the consumer's renderTopBar (X / Publish) to the SAME box so they\n        // share one row instead of stacking.\n        \"relative flex h-full w-full flex-col gap-3 p-4\",\n        // Inline-only: gets its own card chrome. In dialog mode, DialogContent\n        // provides the chrome and we go edge-to-edge inside it.\n        resolved === \"inline\" &&\n          \"rounded-2xl border border-border bg-card text-card-foreground shadow-sm min-h-[400px]\",\n      )}\n      data-slot=\"media-editor\"\n      data-mode={editor.state.mode ?? \"none\"}\n      data-stage={editor.state.stage}\n      data-presentation={resolved}\n      data-aspect={aspect}\n    >\n      {props.renderTopBar?.(slotCtx)}\n\n      {/* Top-center control — absolutely centered so it shares the consumer\n          top-bar's row (X left / Publish right) instead of stacking under it.\n          Capture stage → mode pill (choose what to capture). Hidden once a\n          draft exists: switching modes mid-edit is meaningless. */}\n      {editor.state.stage === \"capture\" && showModePill ? (\n        <div className=\"absolute top-[max(0.75rem,env(safe-area-inset-top))] left-1/2 z-30 -translate-x-1/2\">\n          <div className=\"inline-flex gap-1 rounded-full border border-border bg-muted/50 p-1 text-xs backdrop-blur\">\n            {enabledModes.map((m) => (\n              <button\n                key={m}\n                type=\"button\"\n                onClick={() => {\n                  editor.setMode(m);\n                  if (m === \"photo\" || m === \"video\") {\n                    setUserInitiatedCamera(true);\n                  }\n                }}\n                className={cn(\n                  \"rounded-full px-3 py-1 font-medium transition-colors\",\n                  editor.state.mode === m\n                    ? \"bg-primary text-primary-foreground\"\n                    : \"text-muted-foreground hover:text-foreground\",\n                )}\n                data-mode={m}\n              >\n                {m === \"photo\"\n                  ? mergedLabels.modePhoto\n                  : m === \"video\"\n                    ? mergedLabels.modeVideo\n                    : mergedLabels.modeText}\n              </button>\n            ))}\n          </div>\n        </div>\n      ) : null}\n\n      {/* Edit stage → Back-to-capture (icon-only, top-left), taking the close\n          button's slot. The consumer's renderTopBar hides its own close here so\n          Back is the single left-corner action (Instagram convention). Only\n          when a capture mode exists to return to — edit-only consumers\n          (initialSource, no capture modes) keep their close button instead. */}\n      {editor.state.stage === \"edit\" && hasCaptureMode ? (\n        <div className=\"absolute top-[max(0.75rem,env(safe-area-inset-top))] left-3 z-30\">\n          <button\n            type=\"button\"\n            onClick={backToCapture}\n            aria-label=\"Back to capture\"\n            className=\"inline-flex size-9 items-center justify-center rounded-full bg-black/30 text-white backdrop-blur transition-colors hover:bg-black/45\"\n          >\n            <ArrowLeft className=\"size-5\" />\n          </button>\n        </div>\n      ) : null}\n\n      {/* Canvas — aspect-locked placeholder (real Konva stage lands in C10).\n          min-h-0 lets this flex-1 region shrink below the aspect-locked card's\n          intrinsic height so the bottom tool row keeps its space instead of\n          being pushed past the dialog's clipped (overflow-hidden) bottom edge. */}\n      <div className=\"relative flex flex-1 min-h-0 items-center justify-center\">\n        <div\n          className={cn(\n            \"flex flex-col items-stretch justify-center gap-2 overflow-hidden rounded-xl border border-dashed border-border/60 bg-muted/30 text-center text-xs text-muted-foreground\",\n            // Fixed aspect ratio, sized to fill the frame (no side letterbox).\n            resolved === \"dialog\"\n              ? // Dialog: fill the region edge-to-edge. The dialog itself is\n                // aspect-locked, so the canvas fills full width + height and the\n                // bottom controls OVERLAY the canvas (like the camera shutter)\n                // rather than taking flow space — so nothing shifts or crops.\n                \"h-full w-full max-h-full max-w-full\"\n              : // Inline has no guaranteed definite height, so drive by WIDTH\n                // with min/max bounds — responsive between a chat-embed (small)\n                // and a full surface (large) without breaking the ratio.\n                aspect === \"free\"\n                ? \"w-full min-h-64 min-w-50 max-w-150 max-h-[70vh]\"\n                : \"w-full min-h-64 min-w-50 max-w-120 max-h-[70vh]\",\n          )}\n          style={{\n            aspectRatio:\n              aspect === \"free\" ? \"16 / 9\" : aspect.replace(\":\", \" / \"),\n          }}\n          data-canvas-placeholder=\"\"\n          data-stage={editor.state.stage}\n        >\n          {sourceError ? (\n            <div className=\"flex flex-col items-center gap-1 p-4\">\n              <p className=\"font-medium text-destructive\">\n                Couldn&apos;t load source\n              </p>\n              <p className=\"text-[11px] text-muted-foreground\">\n                <code>{sourceError.kind}</code>\n                {\"url\" in sourceError ? (\n                  <>\n                    {\" · \"}\n                    <code className=\"break-all\">{sourceError.url}</code>\n                  </>\n                ) : null}\n                {sourceError.kind === \"mode-not-enabled\" ? (\n                  <>\n                    {\" · attempted \"}\n                    <code>{sourceError.attempted}</code>\n                    {\" · enabled \"}\n                    <code>{sourceError.enabled.join(\",\") || \"(none)\"}</code>\n                  </>\n                ) : null}\n                {sourceError.kind === \"unsupported-file-type\" ? (\n                  <>\n                    {\" · type \"}\n                    <code>{sourceError.fileType || \"(blank)\"}</code>\n                  </>\n                ) : null}\n              </p>\n              {props.renderEmpty ? props.renderEmpty() : null}\n            </div>\n          ) : isEmptyConfig ? (\n            <div\n              role=\"status\"\n              aria-label=\"Media editor: no source provided. Pass initialSource or enable a capture mode.\"\n              className=\"flex flex-col items-center gap-2 p-6 text-muted-foreground\"\n              data-empty-config=\"\"\n            >\n              {props.renderEmpty ? (\n                props.renderEmpty()\n              ) : (\n                <>\n                  <p className=\"font-medium text-foreground\">\n                    No source provided\n                  </p>\n                  <p className=\"text-[11px]\">\n                    Pass <code>initialSource</code> or enable a capture mode\n                    in <code>enabledModes</code>.\n                  </p>\n                </>\n              )}\n            </div>\n          ) : showEditCanvas ? (\n            <React.Suspense\n              fallback={\n                <div className=\"flex h-full w-full flex-col items-center justify-center gap-2 text-muted-foreground\">\n                  <Loader2\n                    className=\"size-6 animate-spin text-primary\"\n                    aria-hidden=\"true\"\n                  />\n                </div>\n              }\n            >\n              <LazyEditorCanvas\n                imageUrl={\n                  editor.draft?.kind === \"image\"\n                    ? editor.draft.url\n                    : editor.state.imageSrc\n                }\n                videoUrl={\n                  editor.draft?.kind === \"video\" ? editor.draft.url : videoUrl\n                }\n                textOverlays={editor.state.textOverlays}\n                selectedTextId={editor.selectedTextId}\n                onTextChange={(next) => editor.updateTextOverlay(next.id, next)}\n                onTextSelect={editor.setSelectedTextId}\n                stickers={editor.state.stickers}\n                resolveSticker={resolveSticker}\n                selectedStickerId={editor.selectedStickerId}\n                onStickerChange={(next) => editor.updateSticker(next.id, next)}\n                onStickerSelect={editor.setSelectedStickerId}\n                drawingStrokes={editor.state.drawingStrokes}\n                currentDrawingStroke={currentDrawingStroke}\n                isDrawing={editor.activeTool === \"draw\"}\n                onDrawBegin={handleDrawBegin}\n                onDrawExtend={handleDrawExtend}\n                onDrawEnd={handleDrawEnd}\n                cropRect={editor.state.crop}\n                cropActive={editor.activeTool === \"crop\"}\n                cropAspectRatio={aspectAsRatio(editor.cropAspect)}\n                onCropChange={editor.setCrop}\n                adjustments={editor.state.adjustments}\n                activeFilter={\n                  resolvedFilterPresets.find(\n                    (f) => f.id === editor.state.filter,\n                  ) ?? null\n                }\n                onStageReady={(s) => {\n                  stageRef.current = s;\n                }}\n                className=\"h-full w-full\"\n              />\n            </React.Suspense>\n          ) : editor.state.stage === \"capture\" &&\n            editor.state.mode === \"text\" &&\n            enabledModes.includes(\"text\") ? (\n            <TextOnlyCanvas\n              ref={textOnlyRef}\n              gradients={DEFAULT_TEXT_GRADIENTS}\n              fonts={resolvedFonts}\n              colorPresets={resolvedColorPresets}\n              labels={mergedLabels}\n              value={editor.textOnly}\n              onChange={(next) => editor.setTextOnly(next)}\n            />\n          ) : showCameraSurface && CameraSurface ? (\n            <CameraSurface\n              enabled={true}\n              mode={editor.state.mode === \"video\" ? \"video\" : \"photo\"}\n              defaultFacing={props.defaultFacing}\n              recordAudio={props.recordAudio}\n              maxFileSizeMb={props.maxFileSizeMb}\n              maxVideoDurationSec={props.maxVideoDuration}\n              captureAspectRatio={aspectAsRatio(aspect)}\n              autoAcquireOverride={userInitiatedCamera}\n              labels={mergedLabels}\n              onPhoto={handlePhoto}\n              onVideo={handleVideo}\n              onGalleryFile={handleGalleryFile}\n              onValidationError={props.onValidationError}\n              onPermissionDenied={props.onPermissionDenied}\n              renderPermissionDenied={props.renderPermissionDenied}\n            />\n          ) : captureSurfaceReady ? (\n            <div className=\"flex h-full w-full flex-col items-center justify-center gap-4 p-6 text-center\">\n              <p className=\"text-sm text-muted-foreground\">\n                Pick a mode above, or jump straight to the camera.\n              </p>\n              <button\n                type=\"button\"\n                onClick={() => {\n                  const firstCapture = enabledModes.find(\n                    (m) => m === \"photo\" || m === \"video\",\n                  );\n                  if (firstCapture) {\n                    editor.setMode(firstCapture);\n                    setUserInitiatedCamera(true);\n                  }\n                }}\n                className=\"rounded-full bg-primary px-5 py-2 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90\"\n              >\n                Connect to camera\n              </button>\n            </div>\n          ) : mediaSources.includes(\"upload\") ||\n            mediaSources.includes(\"camera\") ? (\n            // Base file/gallery intake — the ONLY intake surface base-alone\n            // ships (no capture extension installed), and the fallback when\n            // \"camera\" was requested but nothing answered it (dev-warn fired\n            // above). Real, not a placeholder: picking a file promotes it\n            // through the SAME draft path as a camera capture would\n            // (handleGalleryFile → editor.setDraft → stage \"edit\").\n            <FileIntakeSurface\n              galleryLabel={mergedLabels.galleryPicker}\n              maxFileSizeMb={props.maxFileSizeMb ?? 50}\n              onFile={handleGalleryFile}\n              onValidationError={props.onValidationError}\n            />\n          ) : (\n            <>\n              <p className=\"font-medium text-foreground\">\n                No capture source available\n              </p>\n              <p>\n                aspect: <code>{aspect}</code> · sources:{\" \"}\n                <code>{mediaSources.join(\",\") || \"(none)\"}</code>\n              </p>\n            </>\n          )}\n        </div>\n      </div>\n\n      {/* Bottom control overlay — edit stage only. Floats over the bottom of\n          the full-bleed canvas (mirrors the camera's overlaid shutter) with a\n          scrim, so the tool panel + chip row never steal the canvas's space or\n          get clipped by the dialog's bottom edge.\n\n          EXCEPTION — crop: the crop frame's bottom drag-handles live at the\n          image's bottom edge, so a floating overlay would cover them (you\n          couldn't grab the bottom handles). When the crop tool is active the\n          controls drop INTO FLOW below the canvas instead — the canvas region\n          (flex-1) shrinks to reserve the space, keeping every handle reachable.\n          Other tools are sliders/inputs (no canvas-edge handles), so they keep\n          the space-saving overlay. */}\n      {showEditCanvas ? (\n        <div\n          className={cn(\n            \"flex flex-col items-stretch gap-2 px-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] *:pointer-events-auto\",\n            editor.activeTool === \"crop\"\n              ? \"relative pt-2\"\n              : \"pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-linear-to-t from-black/70 via-black/30 to-transparent pt-10\",\n          )}\n        >\n          {/* Active tool panel — keyed by editor.activeTool. Renders above the\n              chip-row toolbar. Image-draft tools only show on image drafts;\n              video-draft trim/etc. land in R4. */}\n          {editor.draft?.kind === \"image\" ? (\n            <>\n              {editor.activeTool === \"text\" && selectedText ? (\n                <ToolPanelFrame onBack={handleBackTool} onApply={handleApplyTool}>\n                  <ToolTextInput\n                    overlay={selectedText}\n                    fonts={resolvedFonts}\n                    colorPresets={resolvedColorPresets}\n                    labels={mergedLabels}\n                    onChange={(next) =>\n                      editor.updateTextOverlay(next.id, next)\n                    }\n                    onDelete={() => {\n                      editor.removeTextOverlay(selectedText.id);\n                      editor.setSelectedTextId(null);\n                    }}\n                  />\n                </ToolPanelFrame>\n              ) : null}\n              {editor.activeTool === \"stickers\" ? (\n                <ToolPanelFrame onBack={handleBackTool} onApply={handleApplyTool}>\n                  <ToolStickerPicker\n                    sets={resolvedStickerSets}\n                    onPick={handleStickerPick}\n                  />\n                </ToolPanelFrame>\n              ) : null}\n              {editor.activeTool === \"filters\" && editor.draft.url ? (\n                <ToolPanelFrame onBack={handleBackTool} onApply={handleApplyTool}>\n                  <ToolFilterStrip\n                    presets={resolvedFilterPresets}\n                    sourceUrl={editor.draft.url}\n                    activeId={editor.state.filter}\n                    onSelect={(id) => editor.setFilter(id)}\n                  />\n                </ToolPanelFrame>\n              ) : null}\n              {editor.activeTool === \"adjust\" ? (\n                <ToolPanelFrame onBack={handleBackTool} onApply={handleApplyTool}>\n                  <ToolAdjustSliders\n                    value={editor.state.adjustments}\n                    onChange={(next) => editor.setAdjustments(next)}\n                    labels={mergedLabels}\n                  />\n                </ToolPanelFrame>\n              ) : null}\n              {editor.activeTool === \"draw\" ? (\n                <ToolPanelFrame onBack={handleBackTool} onApply={handleApplyTool}>\n                  <ToolDrawControls\n                    color={editor.drawingTool.color}\n                    brushSize={editor.drawingTool.brushSize}\n                    mode={editor.drawingTool.mode}\n                    colorPresets={resolvedColorPresets}\n                    labels={mergedLabels}\n                    onColorChange={(color) => editor.setDrawingTool({ color })}\n                    onBrushSizeChange={(brushSize) =>\n                      editor.setDrawingTool({ brushSize })\n                    }\n                    onModeChange={(mode) => editor.setDrawingTool({ mode })}\n                  />\n                </ToolPanelFrame>\n              ) : null}\n              {editor.activeTool === \"crop\" ? (\n                <div className=\"flex flex-wrap items-center justify-center gap-2 rounded-md border border-border bg-muted/30 p-2\">\n                  {resolvedCropAspects.length > 1 ? (\n                    <div className=\"flex flex-wrap items-center gap-1 border-r border-border pr-2\">\n                      {resolvedCropAspects.map((a) => (\n                        <button\n                          key={a}\n                          type=\"button\"\n                          onClick={() => {\n                            editor.setCropAspect(a);\n                            editor.setCrop(null);\n                          }}\n                          className={cn(\n                            \"rounded px-2 py-0.5 text-xs font-medium transition-colors\",\n                            editor.cropAspect === a\n                              ? \"bg-primary text-primary-foreground\"\n                              : \"border border-border bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground\",\n                          )}\n                        >\n                          {a}\n                        </button>\n                      ))}\n                    </div>\n                  ) : null}\n                  <button\n                    type=\"button\"\n                    onClick={async () => {\n                      // Apply crop = burn the cropRect into the source image at\n                      // NATIVE resolution. We can't use stage.toCanvas() — that\n                      // captures the displayed stage size (typically smaller than\n                      // source), so output would be down-sampled. Instead: load\n                      // the source image as <img>, map cropRect from stage coords\n                      // → image natural coords via the object-contain fit region,\n                      // then drawImage into a new canvas at natural resolution.\n                      const stage = stageRef.current;\n                      const crop = editor.state.crop;\n                      const sourceUrl =\n                        editor.draft?.url ?? editor.state.imageSrc;\n                      if (!stage || !crop || !sourceUrl) {\n                        editor.setActiveTool(null);\n                        return;\n                      }\n                      try {\n                        const img = new Image();\n                        img.crossOrigin = \"anonymous\";\n                        await new Promise<void>((resolve, reject) => {\n                          img.onload = () => resolve();\n                          img.onerror = () =>\n                            reject(new Error(\"image load failed\"));\n                          img.src = sourceUrl;\n                        });\n                        // Compute the image's object-contain fit region in stage\n                        // coords (mirrors the Konva canvas's fitInto helper).\n                        const stageW = stage.width();\n                        const stageH = stage.height();\n                        const fitScale = Math.min(\n                          stageW / img.naturalWidth,\n                          stageH / img.naturalHeight,\n                        );\n                        const fitW = img.naturalWidth * fitScale;\n                        const fitH = img.naturalHeight * fitScale;\n                        const fitX = (stageW - fitW) / 2;\n                        const fitY = (stageH - fitH) / 2;\n                        // Intersect crop with fit (clip letterbox area out).\n                        const ix = Math.max(crop.x, fitX);\n                        const iy = Math.max(crop.y, fitY);\n                        const iw =\n                          Math.min(crop.x + crop.width, fitX + fitW) - ix;\n                        const ih =\n                          Math.min(crop.y + crop.height, fitY + fitH) - iy;\n                        if (iw <= 0 || ih <= 0) {\n                          editor.setActiveTool(null);\n                          return;\n                        }\n                        // Map intersection to natural image coords.\n                        const natScale = img.naturalWidth / fitW;\n                        const sx = (ix - fitX) * natScale;\n                        const sy = (iy - fitY) * natScale;\n                        const sw = iw * natScale;\n                        const sh = ih * natScale;\n                        const canvas = document.createElement(\"canvas\");\n                        canvas.width = Math.round(sw);\n                        canvas.height = Math.round(sh);\n                        const ctx = canvas.getContext(\"2d\");\n                        if (!ctx) {\n                          editor.setActiveTool(null);\n                          return;\n                        }\n                        ctx.drawImage(\n                          img,\n                          sx,\n                          sy,\n                          sw,\n                          sh,\n                          0,\n                          0,\n                          canvas.width,\n                          canvas.height,\n                        );\n                        const blob = await new Promise<Blob | null>((res) => {\n                          canvas.toBlob(res, \"image/jpeg\", 0.92);\n                        });\n                        if (!blob) {\n                          editor.setActiveTool(null);\n                          return;\n                        }\n                        const newUrl = URL.createObjectURL(blob);\n                        // Replace draft + imageSrc with the cropped image so the\n                        // tool sub-panel gate (editor.draft?.kind === \"image\")\n                        // stays true and re-crop works on the new image.\n                        editor.setDraft({\n                          source: editor.draft?.source ?? \"initial\",\n                          kind: \"image\",\n                          blob,\n                          url: newUrl,\n                          width: canvas.width,\n                          height: canvas.height,\n                          mimeType: \"image/jpeg\",\n                        });\n                        editor.setImageSrc(newUrl);\n                        editor.setCrop(null);\n                        editor.setActiveTool(null);\n                      } catch {\n                        editor.setActiveTool(null);\n                      }\n                    }}\n                    className=\"rounded bg-primary px-3 py-1 text-xs font-medium text-primary-foreground hover:opacity-90\"\n                  >\n                    Apply\n                  </button>\n                  <button\n                    type=\"button\"\n                    onClick={() => {\n                      editor.setCrop(null);\n                      editor.setActiveTool(null);\n                    }}\n                    className=\"rounded border border-border bg-muted/40 px-3 py-1 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground\"\n                  >\n                    Cancel\n                  </button>\n                </div>\n              ) : null}\n            </>\n          ) : null}\n\n      {/* Edit-tool chip row — filtered by enabledTools (description §4). Only\n          in the edit stage: the tools act on a captured draft, so they have\n          nothing to do during capture / text-only / empty-config. Centered when\n          the chips fit, horizontally scrollable when they don't, so they never\n          crop on narrow (9:16) widths. */}\n          {enabledTools.length > 0 && !isEmptyConfig ? (\n            <div className=\"max-w-full self-center overflow-x-auto rounded-full border border-border bg-background/80 backdrop-blur [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\">\n              <div className=\"mx-auto flex w-max items-center gap-1 p-1\">\n                {enabledTools.map((tool) => (\n                  <button\n                    key={tool}\n                    type=\"button\"\n                    onClick={() => editor.setActiveTool(tool)}\n                    className={cn(\n                      \"shrink-0 rounded-full px-3 py-1 text-xs font-medium transition-colors\",\n                      editor.activeTool === tool\n                        ? \"bg-primary text-primary-foreground\"\n                        : \"text-muted-foreground hover:bg-muted hover:text-foreground\",\n                    )}\n                    data-tool={tool}\n                  >\n                    {toolChipLabel(tool, mergedLabels)}\n                  </button>\n                ))}\n                {enabledTools.includes(\"crop\") && resolvedCropAspects.length > 1 ? (\n                  <span className=\"ml-2 shrink-0 self-center text-[10px] text-muted-foreground/70\">\n                    ({resolvedCropAspects.length} crop aspects)\n                  </span>\n                ) : null}\n              </div>\n            </div>\n          ) : null}\n        </div>\n      ) : null}\n\n      {props.renderBottomToolbar?.(slotCtx)}\n\n      {/* Discard confirm — fires from requestClose() when isDirty + close\n          attempt (backdrop / Escape / handle.close()). Q-P10a. */}\n      <DiscardConfirmDialog\n        open={showDiscardConfirm}\n        labels={mergedLabels}\n        onCancel={() => setShowDiscardConfirm(false)}\n        onConfirm={() => {\n          setShowDiscardConfirm(false);\n          performReset();\n          props.onClose?.();\n        }}\n      />\n    </div>\n  );\n\n  // ─── Render — presentation branch ──\n\n  if (resolved === \"inline\") {\n    return inner;\n  }\n\n  // Dialog branch — sizing derived from aspect (description §6). On desktop\n  // the dialog is a viewport-relative box constrained by aspect-ratio:\n  // height-driven for portrait aspects, width-driven for landscape. The\n  // unspecified dimension is computed from CSS aspect-ratio; max-* clamps\n  // the secondary axis when the driver would force overflow.\n  const { aspectRatio, orientation } = dialogDimsForAspect(aspect);\n\n  return (\n    <Dialog\n      open={props.isOpen ?? false}\n      onOpenChange={(open) => {\n        if (!open) requestClose();\n      }}\n    >\n      <DialogContent\n        showCloseButton={false}\n        className={cn(\n          // Mobile: edge-to-edge fullscreen. Defeat shadcn base centering\n          // (top-1/2 left-1/2 + -translate-x/y-1/2) so the dialog anchors at 0,0.\n          \"!fixed !inset-0 !top-0 !left-0 !translate-x-0 !translate-y-0\",\n          \"!max-w-none gap-0 overflow-hidden p-0\",\n          \"h-[100dvh] w-screen !rounded-none\",\n          // Desktop: restore viewport-centered positioning. !inset-auto cancels\n          // the mobile !inset-0 so top/left:50% can land.\n          \"md:!inset-auto md:!top-1/2 md:!left-1/2\",\n          \"md:!-translate-x-1/2 md:!-translate-y-1/2\",\n          \"md:!rounded-2xl\",\n          // Aspect-ratio-driven sizing. Only the DRIVER dimension is sized; the\n          // other follows from the inline aspectRatio. Using clamp() on the\n          // driver gives a min + max floor/ceiling WITHOUT breaking the ratio\n          // (the derived dimension still tracks it) — so the dialog can't\n          // collapse to an unusable thumbnail on a short window. portrait drives\n          // height (clamped 24rem–44rem around 85dvh); landscape drives width\n          // (clamped 28rem–60rem around 85vw). max-w/max-h clamp the derived\n          // axis if it would exceed the viewport.\n          orientation === \"portrait\"\n            ? \"md:h-[clamp(24rem,85dvh,44rem)] md:w-auto md:max-w-[90vw]\"\n            : \"md:w-[clamp(28rem,85vw,60rem)] md:h-auto md:max-h-[85dvh]\",\n        )}\n        style={{\n          // aspect-ratio is driven inline (Tailwind v4's md:aspect-[…] doesn't\n          // support runtime values without a JIT-friendly literal).\n          aspectRatio,\n        }}\n      >\n        <DialogTitle className=\"sr-only\">{mergedLabels.composerLabel}</DialogTitle>\n        <DialogDescription className=\"sr-only\">\n          {mergedLabels.composerDescription}\n        </DialogDescription>\n        {inner}\n      </DialogContent>\n    </Dialog>\n  );\n});\n\n// ─── Export-time helpers ──────────────────────────────────────────────\n\n/**\n * Returns true if the edit state has any overlay / mutation that would\n * change the output vs the raw source. Drives the video perf-shortcut:\n * if nothing's been changed, skip the MediaRecorder re-encode.\n *\n * Adjustments are checked by ALL three default values — any deviation\n * counts as \"modified.\"\n */\n/** Default display ratio used for `aspect=\"free\"` — chosen to match the\n * typical webcam frame so preview ↔ capture stay consistent and the canvas\n * doesn't render as a too-wide / too-short box. */\nconst FREE_ASPECT_FALLBACK = 16 / 9;\n\n/**\n * Convert an `AspectRatio` string (\"9:16\", \"1:1\", etc.) to a numeric ratio\n * (width / height) for capture-time cropping. For `\"free\"`, returns\n * `FREE_ASPECT_FALLBACK` so the captured frame is cropped to the same shape\n * the canvas is displayed at (avoiding the \"captured photo doesn't match\n * preview\" mismatch the canvas-placeholder uses the same ratio).\n */\nfunction aspectAsRatio(aspect: AspectRatio): number {\n  if (aspect === \"free\") return FREE_ASPECT_FALLBACK;\n  const [w, h] = aspect.split(\":\").map(Number);\n  if (!w || !h) return FREE_ASPECT_FALLBACK;\n  return w / h;\n}\n\n/**\n * Base file/gallery intake surface (P3 S3 — part B). The ONLY intake path\n * base-alone ships once the capture feature slice is a separate install:\n * a button + a hidden `<input type=\"file\">`, honoring `maxFileSizeMb` via\n * `validateGalleryFile` (same validation the capture feature's gallery\n * button used pre-split). Matches the existing \"Connect to camera\" chrome —\n * no new visual language.\n */\nfunction FileIntakeSurface({\n  galleryLabel,\n  maxFileSizeMb,\n  onFile,\n  onValidationError,\n}: {\n  galleryLabel: string;\n  maxFileSizeMb: number;\n  onFile: (file: File) => void;\n  onValidationError?: (error: ValidationError) => void;\n}) {\n  const inputRef = React.useRef<HTMLInputElement | null>(null);\n  return (\n    <div\n      className=\"flex h-full w-full flex-col items-center justify-center gap-4 p-6 text-center\"\n      data-intake-surface=\"file\"\n    >\n      <p className=\"text-sm text-muted-foreground\">\n        Pick a photo or video from your device.\n      </p>\n      <button\n        type=\"button\"\n        onClick={() => inputRef.current?.click()}\n        className=\"rounded-full bg-primary px-5 py-2 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90\"\n      >\n        {galleryLabel}\n      </button>\n      <input\n        ref={inputRef}\n        type=\"file\"\n        accept=\"image/*,video/*\"\n        onChange={(e) => {\n          const file = e.target.files?.[0];\n          e.target.value = \"\"; // reset so picking the same file twice re-fires\n          if (!file) return;\n          const err = validateGalleryFile(file, maxFileSizeMb);\n          if (err) {\n            onValidationError?.(err);\n            return;\n          }\n          onFile(file);\n        }}\n        className=\"hidden\"\n      />\n    </div>\n  );\n}\n\n/**\n * Shared chrome for tool sub-panels: wraps the tool's own UI with a Back +\n * Apply button row so every tool follows the same Apply/revert contract.\n * Back calls `onBack` which restores the state snapshot captured at tool\n * entry. Apply just closes the panel, keeping all in-tool changes.\n * Crop has its own custom panel chrome because Apply burns the cropped\n * image into a new blob — that's outside the generic snapshot/restore model.\n */\nfunction ToolPanelFrame({\n  onBack,\n  onApply,\n  children,\n}: {\n  onBack: () => void;\n  onApply: () => void;\n  children: React.ReactNode;\n}) {\n  return (\n    <div className=\"flex flex-col gap-2\">\n      <div className=\"flex items-center justify-end gap-2 px-1\">\n        <button\n          type=\"button\"\n          onClick={onBack}\n          className=\"rounded border border-border bg-muted/40 px-3 py-1 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground\"\n        >\n          Back\n        </button>\n        <button\n          type=\"button\"\n          onClick={onApply}\n          className=\"rounded bg-primary px-3 py-1 text-xs font-medium text-primary-foreground hover:opacity-90\"\n        >\n          Apply\n        </button>\n      </div>\n      {children}\n    </div>\n  );\n}\n\n/** Chip label for an edit tool — routed through the labels prop (i18n). */\nfunction toolChipLabel(\n  tool: EditTool,\n  labels: Required<StoryComposerLabels>,\n): string {\n  switch (tool) {\n    case \"text\":\n      return labels.toolText;\n    case \"draw\":\n      return labels.toolDraw;\n    case \"stickers\":\n      return labels.toolStickers;\n    case \"filters\":\n      return labels.toolFilters;\n    case \"adjust\":\n      return labels.toolAdjust;\n    case \"crop\":\n      return labels.toolCrop;\n  }\n}\n\n/**\n * Run `fn` with the stage temporarily at the identity transform (scale 1,\n * position 0,0), restoring the prior pan/zoom afterwards. Export snapshots\n * (stage.toCanvas) capture the RENDERED stage — exporting while zoomed/panned\n * would bake the viewport into the output.\n */\nasync function withStageIdentityTransform<T>(\n  stage: Konva.Stage,\n  fn: () => Promise<T>,\n): Promise<T> {\n  const prior = {\n    x: stage.x(),\n    y: stage.y(),\n    scaleX: stage.scaleX(),\n    scaleY: stage.scaleY(),\n  };\n  if (\n    prior.x === 0 &&\n    prior.y === 0 &&\n    prior.scaleX === 1 &&\n    prior.scaleY === 1\n  ) {\n    return fn();\n  }\n  stage.scale({ x: 1, y: 1 });\n  stage.position({ x: 0, y: 0 });\n  stage.batchDraw();\n  try {\n    return await fn();\n  } finally {\n    stage.scale({ x: prior.scaleX, y: prior.scaleY });\n    stage.position({ x: prior.x, y: prior.y });\n    stage.batchDraw();\n  }\n}\n\nfunction hasAnyOverlay(state: MediaEditorState): boolean {\n  if (state.textOverlays.length > 0) return true;\n  if (state.stickers.length > 0) return true;\n  if (state.drawingStrokes.length > 0) return true;\n  if (state.filter !== null) return true;\n  const a = state.adjustments;\n  if (a.brightness !== 0 || a.contrast !== 0 || a.saturation !== 0 || a.blur !== 0) {\n    return true;\n  }\n  return false;\n}\n\nfunction buildExportMetadata(\n  state: MediaEditorState,\n  mode: ComposerMode,\n  width: number,\n  height: number,\n  mimeType: string,\n  durationMs?: number,\n): ExportMetadata {\n  return {\n    mode,\n    width,\n    height,\n    durationMs,\n    mimeType,\n    textOverlays: state.textOverlays,\n    stickers: state.stickers,\n    drawingStrokes: state.drawingStrokes.length,\n    appliedFilter: state.filter ?? undefined,\n    adjustments: state.adjustments,\n    crop: state.crop ?? undefined,\n  };\n}\n\n// ─── Tail type re-exports (cross-procomp consumers) ──────────────────────────\n// Procomps that compose media-editor must import these types from THIS\n// component-file path, not `./types`: the shadcn path rewriter preserves the\n// component-file path but mangles `/types` cross-procomp imports (F-S1).\nexport type {\n  MediaEditorProps,\n  MediaEditorHandle,\n  MediaEditorState,\n  ExportMetadata,\n  InitialSource,\n  ComposerMode,\n  EditTool,\n  MediaSource,\n  AspectRatio,\n  LoadStateOpts,\n} from \"./types\";\n",
      "type": "registry:component",
      "target": "components/media-editor/media-editor.tsx"
    },
    {
      "path": "src/registry/components/media/media-editor/index.ts",
      "content": "// media-editor — public barrel (BASE item; capture feature slice is\n// `features/capture/` — its own barrel, own registry item).\n//\n// Exports grow progressively as each commit lands its surface:\n//   C2  (this commit)         — main component + all editor-shaped types\n//   C3                        — moved hooks (useMediaCapture + 6 internal)\n//   C4                        — moved lib helpers (filters, stickers, export, composite, mime)\n//   C5                        — moved parts (EditorCamera, EditorCanvas, EditorToolbar,\n//                                ColorSwatchPicker, DiscardConfirmDialog)\n//   C6                        — useMediaEditorState (NEW)\n//   C9-C11                    — initial-source / multi-instance internal helpers (NOT re-exported)\n//   P3 S3 (feature-slicing)   — capture split OUT to features/capture/: useMediaCapture,\n//                                useCameraPermissions, useMultiInstanceGuard, EditorCamera,\n//                                CameraPermissionPrompt, ShutterButton (+ their types) no\n//                                longer export from here — import from `@ilinxa/media-editor-capture`\n//                                (features/capture/index.ts) instead. `validateGalleryFile` STAYS\n//                                here (generic file-intake, not a capture concern) — moved to\n//                                `./lib/validate-media-file`. The capture injection seam\n//                                (`MediaCaptureExtension` + friends) is NEW here.\n//\n// Backward-compat for story-composer v0.1.5 consumers is handled in\n// story-composer v0.2.0's barrel via re-exports from THIS barrel\n// (see plan §\"Type re-export band\").\n\n// ─── Main component ────────────────────────────────────────────────────\n\nexport { MediaEditor } from \"./media-editor\";\n\n// ─── Types — editor-shaped (story-composer v0.2.0 re-exports these) ───\n\nexport type {\n  // Modes + stage\n  ComposerMode,\n  ComposerStage,\n  // Validation\n  ValidationError,\n  // Stickers\n  StickerOption,\n  StickerSet,\n  // Filters + adjustments\n  KonvaFilterSpec,\n  FilterPreset,\n  ImageAdjustments,\n  // Overlay items\n  TextOverlay,\n  PlacedSticker,\n  DrawingStroke,\n  // Fonts\n  FontOption,\n  // Aspect + tools\n  AspectRatio,\n  EditTool,\n  // Source intake\n  MediaSource,\n  // Crop\n  CropRect,\n  // Initial source\n  InitialSource,\n  SourceError,\n  // Draft + edit-working state\n  DraftMedia,\n  TrimRange,\n  DrawingToolConfig,\n  TextOnlyState,\n  // Editor state + actions\n  GradientPreset,\n  MediaEditorState,\n  EditAction,\n  // Export\n  ExportImageOpts,\n  ExportVideoOpts,\n  ExportOpts,\n  ExportMetadata,\n  // Slots\n  EditorCtx,\n  // Labels\n  MediaEditorLabels,\n  // Public component contracts\n  MediaEditorProps,\n  MediaEditorHandle,\n  LoadStateOpts,\n} from \"./types\";\n\n// ─── Constants ─────────────────────────────────────────────────────────\n\nexport { DEFAULT_ADJUSTMENTS, DEFAULT_LABELS } from \"./types\";\n\n// ─── Capture injection seam (P3 S3 — see hooks/use-capture-extension.ts) ─\n// For consumers building a CUSTOM capture provider (not the shipped\n// `@ilinxa/media-editor-capture`). Ordinary consumers just import\n// `mediaCapture` from the capture feature and pass `capture={mediaCapture}`.\n\nexport {\n  MediaCaptureExtensionContext,\n  type MediaCaptureExtension,\n  type MediaCaptureExtensionContextValue,\n  type MediaCaptureSurfaceProps,\n  type CapturedPhotoLike,\n  type CapturedVideoLike,\n} from \"./hooks/use-capture-extension\";\n\n// ─── Hooks (C3 — moved from story-composer v0.1.5 via git mv) ───────\n\nexport {\n  useDrawingStroke,\n  type UseDrawingStrokeOptions,\n  type UseDrawingStrokeResult,\n} from \"./hooks/use-drawing-stroke\";\n\nexport {\n  useHistory,\n  type Command,\n  type UseHistoryOptions,\n  type UseHistoryResult,\n} from \"./hooks/use-history\";\n\nexport {\n  useKonvaSelection,\n  type SelectableKind,\n  type SelectedItem,\n  type UseKonvaSelectionResult,\n} from \"./hooks/use-konva-selection\";\n\nexport {\n  useKonvaStageSize,\n  type StageSize,\n} from \"./hooks/use-konva-stage-size\";\n\nexport {\n  usePanZoom,\n  type PanZoomTransform,\n  type UsePanZoomOptions,\n  type UsePanZoomResult,\n} from \"./hooks/use-pan-zoom\";\n\n// useMediaEditorState (C6) — written NEW; replaces story-composer's\n// use-story-composer-state for editor-shaped concerns.\nexport {\n  useMediaEditorState,\n  type UseMediaEditorStateOptions,\n  type UseMediaEditorStateResult,\n} from \"./hooks/use-media-editor-state\";\n\n// useMultiInstanceGuard moved to features/capture/ in P3 S3 — its call site\n// is now inside the feature's EditorCamera (live-camera contention is a\n// capture-only concern). Advanced consumers: import from\n// `@ilinxa/media-editor-capture` instead.\n\n// ─── Lib helpers (C3 + C4 — moved from story-composer v0.1.5 via git mv) ──\n\n// mime-fallback (C3, SHARED — capture recording + base export re-encode\n// both use it):\nexport {\n  selectRecorderMime,\n  containerFor,\n  PREFERRED_RECORDER_MIME_TYPES,\n  type RecorderMimeType,\n} from \"./lib/mime-fallback\";\n\n// validate-media-file (P3 S3 — extracted from the old use-media-capture.ts;\n// generic file-intake, not a capture concern, so it stayed in base):\nexport { validateGalleryFile } from \"./lib/validate-media-file\";\n\n// konva-filters (C4):\nexport {\n  resolveFilterPresets,\n  BUILT_IN_FILTER_PRESETS,\n} from \"./lib/konva-filters\";\n\n// built-in-stickers (C4):\nexport {\n  resolveStickerSets,\n  BUILT_IN_STICKER_SETS,\n} from \"./lib/built-in-stickers\";\n\n// export-blob (C4):\nexport {\n  exportPhotoBlob,\n  exportTextOnlyBlob,\n  type ExportPhotoOptions,\n} from \"./lib/export-blob\";\n\n// composite-video (C4):\nexport {\n  compositeVideo,\n  type CompositeVideoOptions,\n  type CompositeVideoResult,\n} from \"./lib/composite-video\";\n\n// defaults — editor tokens (C4):\nexport {\n  DEFAULT_TEXT_GRADIENTS,\n  DEFAULT_FONTS,\n  DEFAULT_COLOR_PRESETS,\n} from \"./lib/defaults\";\n\n// ─── Parts (C5 — moved from story-composer v0.1.5 via git mv) ──────\n// EditorCamera / CameraPermissionPrompt / ShutterButton moved to\n// features/capture/ in P3 S3 — import from `@ilinxa/media-editor-capture`.\n\nexport { EditorCanvas } from \"./parts/editor-canvas\";\nexport type { EditorCanvasProps } from \"./parts/editor-canvas\";\n\n// lazy-editor-canvas is an internal same-folder default-export shim for\n// React.lazy() (see parts/lazy-editor-canvas.tsx) — not re-exported; use\n// `EditorCanvas` above for direct/non-lazy composition.\n\nexport { EditorToolbar } from \"./parts/editor-toolbar\";\nexport type { EditorToolbarProps } from \"./parts/editor-toolbar\";\n\n// Other public parts (per description §10):\nexport { ColorSwatchPicker } from \"./parts/color-swatch-picker\";\nexport type { ColorSwatchPickerProps } from \"./parts/color-swatch-picker\";\n\nexport { DiscardConfirmDialog } from \"./parts/discard-confirm-dialog\";\n\n// Crop helpers (v0.1.5 public surface preserved):\nexport {\n  ToolCropOverlay,\n  ASPECT_RATIO_VALUES,\n  fitCropToStage,\n} from \"./parts/tool-crop-overlay\";\nexport type { ToolCropOverlayProps } from \"./parts/tool-crop-overlay\";\n\n// Internal-by-convention parts — re-exported for sealed-folder consumers.\nexport { ModeTogglePill } from \"./parts/mode-toggle-pill\";\nexport { VideoTrimBar } from \"./parts/video-trim-bar\";\nexport { TextOnlyCanvas } from \"./parts/text-only-canvas\";\nexport type {\n  TextOnlyCanvasProps,\n  TextOnlyCanvasState,\n} from \"./parts/text-only-canvas\";\nexport { ToolAdjustSliders } from \"./parts/tool-adjust-sliders\";\nexport { ToolDrawControls } from \"./parts/tool-draw-controls\";\nexport { ToolFilterStrip } from \"./parts/tool-filter-strip\";\nexport { ToolStickerPicker } from \"./parts/tool-sticker-picker\";\nexport { ToolTextInput } from \"./parts/tool-text-input\";\n",
      "type": "registry:component",
      "target": "components/media-editor/index.ts"
    },
    {
      "path": "src/registry/components/media/media-editor/types.ts",
      "content": "import type { ReactNode } from \"react\";\nimport type { MediaCaptureExtension } from \"./hooks/use-capture-extension\";\n\n// ─── Modes + stage ──────────────────────────────────────────────────────\n// (Locked: NO renames from story-composer v0.1.5 — see plan §\"Type system\".\n// story-composer v0.2.0 re-exports these for backward-compat.)\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// v0.1.0 of media-editor adds \"16:9\" to AspectRatio (additive — non-breaking\n// for v0.1.5 consumers; they used at most \"9:16\" | \"1:1\" | \"4:5\" | \"free\").\n\nexport type AspectRatio = \"9:16\" | \"1:1\" | \"16:9\" | \"4:5\" | \"free\";\n\nexport type EditTool =\n  | \"text\"\n  | \"draw\"\n  | \"stickers\"\n  | \"filters\"\n  | \"adjust\"\n  | \"crop\";\n\n// ─── Media source intake (NEW in media-editor — for capability gating) ──\n\nexport type MediaSource = \"camera\" | \"upload\";\n// \"library\" is not implemented — see description §\"Out of scope\"\n\n// ─── Crop ───────────────────────────────────────────────────────────────\n// Lifted from story-composer/parts/tool-crop-overlay.tsx (where v0.1.5\n// defined it). Shape preserved verbatim — 4 fields, no `aspect`. Adding fields\n// would break v0.1.5 consumers via type-narrowing.\n\nexport interface CropRect {\n  x: number;\n  y: number;\n  width: number;\n  height: number;\n}\n\n// ─── Initial source (NEW — CMS re-edit / draft restore path) ────────────\n\nexport type InitialSource =\n  | { kind: \"url\"; url: string; mode: \"photo\" | \"video\" }\n  | { kind: \"blob\"; blob: Blob; mode: \"photo\" | \"video\" }\n  | { kind: \"file\"; file: File };\n\n// ─── Draft media (NEW — capture output before edit transition) ──────────\n\n/**\n * Captured (or imported) media awaiting edit. Set by camera flow + gallery\n * pick. Held in editor state so getState/loadState can round-trip draft\n * restores (e.g., draft persisted to localStorage between sessions).\n */\nexport interface DraftMedia {\n  source: \"camera\" | \"gallery\" | \"initial\";\n  kind: \"image\" | \"video\";\n  blob: Blob;\n  /** Object URL for preview — caller owns revocation when draft is replaced. */\n  url: string;\n  width?: number;\n  height?: number;\n  durationMs?: number;\n  mimeType: string;\n}\n\n/** Video trim range, in seconds. */\nexport interface TrimRange {\n  startSec: number;\n  endSec: number;\n  durationSec: number;\n}\n\n/** Drawing-tool UI configuration. Not content; not in MediaEditorState. */\nexport interface DrawingToolConfig {\n  color: string;\n  brushSize: number;\n  mode: \"draw\" | \"erase\";\n}\n\n/** Text-only mode capture state. */\nexport interface TextOnlyState {\n  text: string;\n  fontFamily: string;\n  textColor: string;\n  gradientId: string;\n}\n\n// ─── Source / load errors (NEW) ─────────────────────────────────────────\n\nexport type SourceError =\n  | { kind: \"cors\"; url: string; underlying: Error }\n  | { kind: \"fetch-failed\"; url: string; underlying: Error }\n  | {\n      kind: \"mode-not-enabled\";\n      attempted: ComposerMode;\n      enabled: ComposerMode[];\n    }\n  | { kind: \"unsupported-file-type\"; fileType: string; file: File }\n  | { kind: \"invalid-blob\"; reason: string };\n\n// ─── Editor state (NEW — serializable snapshot for getState / loadState) ──\n\nexport interface GradientPreset {\n  id: string;\n  label: string;\n  /** CSS background value — used directly on the text-only canvas. (Preserves v0.1.5 contract.) */\n  background: string;\n}\n\nexport interface MediaEditorState {\n  mode: ComposerMode | null;\n  stage: ComposerStage;\n  imageSrc: string | null;\n  videoBlob: Blob | null;\n  textBackground: GradientPreset | null;\n  textContent: string | null;\n  textOverlays: TextOverlay[];\n  stickers: PlacedSticker[];\n  drawingStrokes: DrawingStroke[];\n  filter: string | null;\n  adjustments: ImageAdjustments;\n  crop: CropRect | null;\n}\n\n// ─── Edit action (NEW — analytics + undo sync) ──────────────────────────\n\nexport type EditAction =\n  | { kind: \"mode-change\"; from: ComposerMode | null; to: ComposerMode | null }\n  | { kind: \"tool-open\"; tool: EditTool }\n  | { kind: \"tool-close\"; tool: EditTool }\n  | { kind: \"text-add\"; overlay: TextOverlay }\n  | { kind: \"text-update\"; id: string; patch: Partial<TextOverlay> }\n  | { kind: \"text-remove\"; id: string }\n  | { kind: \"sticker-add\"; placed: PlacedSticker }\n  | { kind: \"sticker-remove\"; id: string }\n  | { kind: \"draw-stroke\"; stroke: DrawingStroke }\n  | { kind: \"filter-apply\"; name: string | null }\n  | { kind: \"adjust-change\"; patch: Partial<ImageAdjustments> }\n  | { kind: \"crop-set\"; crop: CropRect | null }\n  | { kind: \"undo\" }\n  | { kind: \"redo\" }\n  | { kind: \"reset\" };\n\n// ─── Export contracts (NEW — locked at Q-P3 sign-off) ───────────────────\n\nexport interface ExportImageOpts {\n  /** Default \"image/jpeg\" (Q-P3 lock). */\n  format?: \"image/jpeg\" | \"image/png\" | \"image/webp\";\n  /** 0..1; default 0.9; only meaningful for jpeg/webp. */\n  quality?: number;\n  /** Default 2 (retina). */\n  pixelRatio?: number;\n  /** 0..1; fires twice (start, end) for jpeg/png; webp may fire interim. */\n  onProgress?: (progress: number) => void;\n}\n\nexport interface ExportVideoOpts {\n  /** Default auto-pick via mime-fallback chain (vp9 → vp8 → mp4). */\n  mimeType?: string;\n  /** Default browser-chosen. */\n  bitsPerSecond?: number;\n  /** 0..1; fires ~10× during re-encode (or once if perf-shortcut applies). */\n  onProgress?: (progress: number) => void;\n}\n\nexport type ExportOpts = ExportImageOpts | ExportVideoOpts;\n\nexport interface ExportMetadata {\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  crop?: CropRect;\n}\n\n// ─── Slot context (NEW — replaces ComposerCtx for editor-only consumers) ─\n\nexport interface EditorCtx {\n  mode: ComposerMode | null;\n  stage: ComposerStage;\n  isDirty: boolean;\n  isCapturing: boolean;\n  isExporting: boolean;\n  activeTool: EditTool | null;\n  enabledTools: EditTool[];\n  enabledModes: ComposerMode[];\n  aspect: AspectRatio;\n}\n\n// ─── Compile-time shim for v0.1.5 part label deps (REMOVED IN C17) ──────\n// Several parts (camera-permission-prompt, discard-confirm-dialog, editor-camera,\n// editor-toolbar, mode-toggle-pill, text-only-canvas, tool-*) were authored\n// against story-composer's flat StoryComposerLabels shape. Refactoring\n// each part to use MediaEditorLabels is C17-territory (wrapper refactor).\n// This temporary alias mirrors the v0.1.5 shape verbatim so the parts compile\n// post-move. NOT exported via barrel — purely an internal compile shim.\n\n/** @internal — temporary shim; refactored to MediaEditorLabels in C17. */\nexport interface StoryComposerLabels {\n  composerLabel?: string;\n  composerDescription?: string;\n  modePhoto?: string;\n  modeVideo?: string;\n  modeText?: string;\n  shutterPhoto?: string;\n  shutterVideoStart?: string;\n  shutterVideoStop?: string;\n  galleryPicker?: string;\n  switchCamera?: string;\n  permissionDeniedTitle?: string;\n  permissionDeniedBody?: string;\n  permissionRetry?: string;\n  permissionUsePicker?: string;\n  permissionRequesting?: string;\n  toolText?: string;\n  toolDraw?: string;\n  toolStickers?: string;\n  toolFilters?: string;\n  toolAdjust?: string;\n  toolCrop?: string;\n  adjustBrightness?: string;\n  adjustContrast?: string;\n  adjustSaturation?: string;\n  adjustBlur?: string;\n  drawColor?: string;\n  drawBrush?: string;\n  drawEraser?: string;\n  drawUndo?: string;\n  drawRedo?: string;\n  textPlaceholder?: string;\n  textFontFamily?: string;\n  textFontSize?: string;\n  textAlign?: string;\n  publish?: string;\n  publishing?: string;\n  published?: string;\n  close?: string;\n  discardConfirmTitle?: string;\n  discardConfirmBody?: string;\n  discardConfirm?: string;\n  discardCancel?: string;\n  uploadFailedTitle?: string;\n  uploadRetry?: string;\n  recordingLabel?: string;\n  trimStart?: string;\n  trimEnd?: string;\n}\n\n// ─── i18n labels (NEW — full key tree per description §11) ──────────────\n\nexport interface MediaEditorLabels {\n  capture: {\n    requestingCamera?: string;\n    cameraDenied: {\n      title?: string;\n      body?: string;\n      retryCta?: string;\n      useGalleryCta?: string;\n    };\n    galleryButton?: string;\n    switchCamera?: string;\n    modes: { photo?: string; video?: string; text?: string };\n  };\n  toolbar: {\n    text?: string;\n    draw?: string;\n    stickers?: string;\n    filters?: string;\n    adjust?: string;\n    crop?: string;\n  };\n  adjust: {\n    brightness?: string;\n    contrast?: string;\n    saturation?: string;\n    blur?: string;\n  };\n  publish: { exporting?: string; cancel?: string; ready?: string };\n  discard: {\n    title?: string;\n    body?: string;\n    cancel?: string;\n    confirm?: string;\n  };\n  empty: { noSource?: string };\n}\n\n// ─── Public component props ─────────────────────────────────────────────\n\nexport interface MediaEditorProps {\n  // === Capability surface (the \"partial use\" dials, plan-locked defaults) ===\n  /** Which capture modes are available. Default [\"photo\",\"video\",\"text\"] (Q-P2). Empty array + initialSource = edit-only mode. */\n  enabledModes?: ComposerMode[];\n  /** Which edit tools appear in the toolbar. Default all 6. */\n  enabledTools?: EditTool[];\n  /** Which source intake methods are offered. Default [\"camera\",\"upload\"]. \"library\" deferred. */\n  mediaSources?: MediaSource[];\n  /** Aspect lock for the editor canvas. Default \"free\" (Q-P1). */\n  aspect?: AspectRatio;\n\n  // === Initial source (skip capture surface) ===\n  initialSource?: InitialSource;\n  onInitialSourceError?: (error: SourceError) => void;\n\n  // === Presentation ===\n  /** \"inline\" | \"dialog\" | \"auto\". Default \"auto\" — picks dialog if enabledModes non-empty, else inline. */\n  presentation?: \"inline\" | \"dialog\" | \"auto\";\n  /** Required when presentation resolves to \"dialog\". Dev-error if missing. */\n  isOpen?: boolean;\n  /** Required when presentation resolves to \"dialog\". */\n  onClose?: () => void;\n\n  // === Capture extension (P3 S3 — install @ilinxa/media-editor-capture) ===\n  /**\n   * Enables camera capture. Install `@ilinxa/media-editor-capture` and pass\n   * its `mediaCapture` export straight through:\n   * `<MediaEditor capture={mediaCapture} />`. Without it, `\"camera\"` in\n   * `mediaSources` falls back to the base file/gallery intake surface (one\n   * dev console.warn) — base-alone stays fully usable (pick a file → edit →\n   * export), it just can't drive a live camera.\n   */\n  capture?: MediaCaptureExtension;\n\n  // === Capture config (only meaningful if enabledModes overlaps capture modes) ===\n  defaultMode?: ComposerMode;\n  defaultFacing?: \"user\" | \"environment\";\n  /** Default 30s. */\n  maxVideoDuration?: number;\n  /** Default true. */\n  recordAudio?: boolean;\n  /** Default 50MB. */\n  maxFileSizeMb?: number;\n  onValidationError?: (error: ValidationError) => void;\n  onPermissionDenied?: () => void;\n\n  // === Edit-tool config ===\n  stickers?: StickerSet[];\n  replaceBuiltinStickers?: boolean;\n  fonts?: FontOption[];\n  colorPresets?: string[];\n  filterPresets?: FilterPreset[];\n  replaceBuiltinFilters?: boolean;\n  /** Overrides default crop choices. Default derivation: if aspect !== \"free\" → [aspect]; else all 5. */\n  cropAspects?: AspectRatio[];\n\n  // === Discard guard ===\n  /** Default true. Dialog mode only — fires on Escape/backdrop click/programmatic close() when dirty. Inline mode ignores (consumer owns navigation guarding via getIsDirty() handle). */\n  confirmOnDiscard?: boolean;\n\n  // === Localization ===\n  labels?: Partial<MediaEditorLabels>;\n\n  // === State change observability ===\n  /** Fires whenever the active capture mode changes (tab switch, gallery pick,\n   *  initialSource load, programmatic). Skips the initial mount. */\n  onModeChange?: (mode: ComposerMode | null) => void;\n  onDirtyChange?: (isDirty: boolean) => void;\n  /**\n   * Analytics/undo-sync hook. v0.1.x emits the navigation + lifecycle actions:\n   * `mode-change`, `tool-open`, `tool-close`, and `reset`. The fine-grained\n   * content-mutation actions (`text-*`, `sticker-*`, `draw-stroke`,\n   * `filter-apply`, `adjust-change`, `crop-set`, `undo`, `redo`) land in v0.2\n   * when the per-mutation instrumentation layer ships — the `EditAction` union\n   * is already complete so consumers can switch exhaustively today.\n   */\n  onEditAction?: (action: EditAction) => void;\n\n  // === Slots (escape hatches) ===\n  renderTopBar?: (ctx: EditorCtx) => ReactNode;\n  renderBottomToolbar?: (ctx: EditorCtx) => ReactNode;\n  renderPermissionDenied?: (ctx: {\n    retry: () => void;\n    usePicker: () => void;\n  }) => ReactNode;\n  renderEmpty?: () => ReactNode;\n}\n\n// ─── Imperative handle (LOCKED in description §7) ──────────────────────\n\nexport interface LoadStateOpts {\n  /**\n   * The blob backing `state.imageSrc` (photo path). A persisted snapshot's\n   * `imageSrc` is typically a dead object URL (revoked when the capturing\n   * editor instance unmounted) — when `sourceBlob` is provided, `loadState`\n   * re-mints a fresh object URL from it instead of trusting `imageSrc`, and\n   * rebuilds the internal draft so the edit tools work as they did pre-unmount.\n   * Obtain it from `getSourceBlob()` at snapshot time. (Review 1.3.)\n   */\n  sourceBlob?: Blob | null;\n}\n\nexport interface MediaEditorHandle {\n  // === Inspect ===\n  /** True iff the user has captured OR loaded OR edited something. Capture without edit IS dirty. */\n  getIsDirty: () => boolean;\n  getMode: () => ComposerMode | null;\n  /** Serializable snapshot — for draft persistence. */\n  getState: () => MediaEditorState;\n  loadState: (state: MediaEditorState, opts?: LoadStateOpts) => void;\n  /**\n   * Blob backing the current photo draft / canvas image — persist it alongside\n   * `getState()` so a later `loadState(state, { sourceBlob })` can\n   * re-materialize the (revoked) object URL. Null when nothing is loaded, or\n   * in video mode (persist `getState().videoBlob` instead).\n   */\n  getSourceBlob: () => Blob | null;\n\n  // === Capture (only if enabledModes includes capture modes; else dev-warn + no-op) ===\n  switchCamera: () => Promise<void>;\n  takePhoto: () => Promise<void>;\n  startRecording: () => Promise<void>;\n  stopRecording: () => Promise<void>;\n  importFromGallery: () => void;\n\n  // === Edit (gated by enabledTools — disabled tool methods dev-warn + no-op) ===\n  addText: (text?: string) => void;\n  addSticker: (sticker: StickerOption) => void;\n  setAdjustments: (adj: Partial<ImageAdjustments>) => void;\n  applyFilter: (name: string | null) => void;\n  clearLayer: (layer: \"drawing\" | \"stickers\" | \"text\") => void;\n  undo: () => void;\n  redo: () => void;\n\n  // === Export ===\n  exportImage: (\n    opts?: ExportImageOpts,\n  ) => Promise<{ blob: Blob; metadata: ExportMetadata }>;\n  exportVideo: (\n    opts?: ExportVideoOpts,\n  ) => Promise<{ blob: Blob; metadata: ExportMetadata }>;\n  /** Polymorphic — picks exportImage or exportVideo based on current mode. */\n  export: (\n    opts?: ExportOpts,\n  ) => Promise<{ blob: Blob; metadata: ExportMetadata }>;\n\n  // === Lifecycle ===\n  reset: () => void;\n  /** Dialog mode only — no-op in inline. */\n  open: () => void;\n  /** Dialog mode only — no-op in inline. */\n  close: () => void;\n}\n\n// ─── Default labels ─────────────────────────────────────────────────────\n\nexport const DEFAULT_LABELS: MediaEditorLabels = {\n  capture: {\n    requestingCamera: \"Requesting camera…\",\n    cameraDenied: {\n      title: \"Camera access blocked\",\n      body: \"We need camera permission to continue. Enable it in your browser settings, or choose a file from your gallery.\",\n      retryCta: \"Try again\",\n      useGalleryCta: \"Use gallery instead\",\n    },\n    galleryButton: \"Choose from gallery\",\n    switchCamera: \"Switch camera\",\n    modes: { photo: \"Photo\", video: \"Video\", text: \"Text\" },\n  },\n  toolbar: {\n    text: \"Text\",\n    draw: \"Draw\",\n    stickers: \"Stickers\",\n    filters: \"Filters\",\n    adjust: \"Adjust\",\n    crop: \"Crop\",\n  },\n  adjust: {\n    brightness: \"Brightness\",\n    contrast: \"Contrast\",\n    saturation: \"Saturation\",\n    blur: \"Blur\",\n  },\n  publish: {\n    exporting: \"Exporting…\",\n    cancel: \"Cancel\",\n    ready: \"Ready\",\n  },\n  discard: {\n    title: \"Discard changes?\",\n    body: \"You'll lose your captured media and edits. This can't be undone.\",\n    cancel: \"Keep editing\",\n    confirm: \"Discard\",\n  },\n  empty: {\n    noSource: \"No source provided.\",\n  },\n};\n",
      "type": "registry:component",
      "target": "components/media-editor/types.ts"
    },
    {
      "path": "src/registry/components/media/media-editor/hooks/use-drawing-stroke.ts",
      "content": "\"use client\";\n\nimport { useCallback, useRef, useState } from \"react\";\nimport type { DrawingStroke } from \"../types\";\n\nexport interface UseDrawingStrokeOptions {\n  color: string;\n  brushSize: number;\n  mode: \"draw\" | \"erase\";\n  /** Fires once when the user lifts the pointer — receives the completed stroke. */\n  onStrokeComplete: (stroke: DrawingStroke) => void;\n}\n\nexport interface UseDrawingStrokeResult {\n  /** In-progress stroke (rendered live until pointer-up). */\n  currentStroke: DrawingStroke | null;\n  /** Bind onto Stage pointer events when draw tool is active. */\n  beginAt: (x: number, y: number) => void;\n  extendTo: (x: number, y: number) => void;\n  end: () => void;\n  cancel: () => void;\n}\n\n/**\n * Pointer-frame collector for a single freehand stroke.\n *\n * The composer-editor wires Stage pointer events into beginAt/extendTo/end\n * when the draw tool is active. Each completed stroke is dispatched once via\n * `onStrokeComplete` — the parent records that as a single undo command (one\n * command per stroke, not one per pointer frame).\n */\nexport function useDrawingStroke(\n  options: UseDrawingStrokeOptions,\n): UseDrawingStrokeResult {\n  const { color, brushSize, mode, onStrokeComplete } = options;\n  const [currentStroke, setCurrentStroke] = useState<DrawingStroke | null>(\n    null,\n  );\n  const strokeRef = useRef<DrawingStroke | null>(null);\n\n  const beginAt = useCallback(\n    (x: number, y: number) => {\n      const id = `stroke-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;\n      const next: DrawingStroke = {\n        id,\n        points: [x, y],\n        color,\n        brushSize,\n        mode,\n      };\n      strokeRef.current = next;\n      setCurrentStroke(next);\n    },\n    [color, brushSize, mode],\n  );\n\n  const extendTo = useCallback((x: number, y: number) => {\n    const s = strokeRef.current;\n    if (!s) return;\n    s.points.push(x, y);\n    // Re-render — clone the points array so React notices.\n    setCurrentStroke({ ...s, points: [...s.points] });\n  }, []);\n\n  const end = useCallback(() => {\n    const s = strokeRef.current;\n    strokeRef.current = null;\n    setCurrentStroke(null);\n    if (!s || s.points.length < 4) return; // ignore single-point taps\n    onStrokeComplete(s);\n  }, [onStrokeComplete]);\n\n  const cancel = useCallback(() => {\n    strokeRef.current = null;\n    setCurrentStroke(null);\n  }, []);\n\n  return { currentStroke, beginAt, extendTo, end, cancel };\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/hooks/use-drawing-stroke.ts"
    },
    {
      "path": "src/registry/components/media/media-editor/hooks/use-history.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nexport interface Command {\n  do: () => void;\n  undo: () => void;\n  label?: string;\n}\n\nexport interface UseHistoryOptions {\n  /** Hard cap on undo depth. Oldest entries FIFO-evicted past this. Default 50. */\n  capacity?: number;\n  /** When true, Ctrl/Cmd+Z and Ctrl/Cmd+Shift+Z bind to undo/redo. */\n  bindKeyboard?: boolean;\n}\n\nexport interface UseHistoryResult {\n  /** Run a command + push it onto the undo stack. Clears redo stack. */\n  execute: (cmd: Command) => void;\n  undo: () => void;\n  redo: () => void;\n  canUndo: boolean;\n  canRedo: boolean;\n  /** Drops both stacks (e.g., when the draft is replaced). */\n  reset: () => void;\n}\n\n/**\n * Generic undo/redo command stack.\n *\n * Each command's `do()` is run on execute() and again on redo(); `undo()` is\n * run on undo. The hook does NOT mediate state — callers wrap their state\n * mutations into Command pairs.\n *\n * Drawing strokes record one command per completed stroke (not per pointer\n * frame). Text/sticker add/delete/move/transform record one command per\n * terminal action.\n */\nexport function useHistory(\n  options: UseHistoryOptions = {},\n): UseHistoryResult {\n  const { capacity = 50, bindKeyboard = true } = options;\n  const pastRef = useRef<Command[]>([]);\n  const futureRef = useRef<Command[]>([]);\n  // Stack-depth flags mirrored into state by `tick()` after every mutation\n  // (execute / undo / redo / reset all end with tick()), so render reads\n  // state — never the refs — while the fresh object identity still forces a\n  // re-render exactly like the old force-counter did.\n  const [stackFlags, setStackFlags] = useState({\n    canUndo: false,\n    canRedo: false,\n  });\n  const tick = useCallback(() => {\n    setStackFlags({\n      canUndo: pastRef.current.length > 0,\n      canRedo: futureRef.current.length > 0,\n    });\n  }, []);\n\n  const execute = useCallback(\n    (cmd: Command) => {\n      cmd.do();\n      pastRef.current.push(cmd);\n      if (pastRef.current.length > capacity) {\n        pastRef.current.shift();\n      }\n      futureRef.current = [];\n      tick();\n    },\n    [capacity, tick],\n  );\n\n  const undo = useCallback(() => {\n    const cmd = pastRef.current.pop();\n    if (!cmd) return;\n    cmd.undo();\n    futureRef.current.push(cmd);\n    tick();\n  }, [tick]);\n\n  const redo = useCallback(() => {\n    const cmd = futureRef.current.pop();\n    if (!cmd) return;\n    cmd.do();\n    pastRef.current.push(cmd);\n    tick();\n  }, [tick]);\n\n  const reset = useCallback(() => {\n    pastRef.current = [];\n    futureRef.current = [];\n    tick();\n  }, [tick]);\n\n  // Ctrl/Cmd+Z / Ctrl/Cmd+Shift+Z keyboard shortcuts.\n  useEffect(() => {\n    if (!bindKeyboard || typeof window === \"undefined\") return;\n    const handler = (e: KeyboardEvent) => {\n      const mod = e.ctrlKey || e.metaKey;\n      if (!mod) return;\n      if (e.key.toLowerCase() !== \"z\") return;\n      // Don't hijack while typing in an input/textarea.\n      const tgt = e.target as HTMLElement | null;\n      if (\n        tgt?.tagName === \"INPUT\" ||\n        tgt?.tagName === \"TEXTAREA\" ||\n        tgt?.isContentEditable\n      ) {\n        return;\n      }\n      e.preventDefault();\n      if (e.shiftKey) redo();\n      else undo();\n    };\n    window.addEventListener(\"keydown\", handler);\n    return () => window.removeEventListener(\"keydown\", handler);\n  }, [bindKeyboard, undo, redo]);\n\n  return {\n    execute,\n    undo,\n    redo,\n    canUndo: stackFlags.canUndo,\n    canRedo: stackFlags.canRedo,\n    reset,\n  };\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/hooks/use-history.ts"
    },
    {
      "path": "src/registry/components/media/media-editor/hooks/use-konva-selection.ts",
      "content": "\"use client\";\n\nimport { useCallback, useRef, useState } from \"react\";\nimport type Konva from \"konva\";\n\nexport type SelectableKind = \"text\" | \"sticker\" | \"drawing\";\n\nexport interface SelectedItem {\n  kind: SelectableKind;\n  id: string;\n}\n\nexport interface UseKonvaSelectionResult {\n  selected: SelectedItem | null;\n  transformerRef: React.MutableRefObject<Konva.Transformer | null>;\n  /** Select an overlay item and attach the transformer to its node. */\n  select: (item: SelectedItem | null, node: Konva.Node | null) => void;\n  /** Clear selection (Esc / click on background / draft replaced). */\n  clear: () => void;\n}\n\n/**\n * Konva.Transformer attach/detach helper.\n *\n * C6 lands the hook surface only — there are no selectable items yet\n * (text/stickers/drawing land C8/C9/C10). Each tool wires its node into\n * `select(item, node)`; the hook calls Transformer.nodes([node]).\n */\nexport function useKonvaSelection(): UseKonvaSelectionResult {\n  const [selected, setSelected] = useState<SelectedItem | null>(null);\n  const transformerRef = useRef<Konva.Transformer | null>(null);\n\n  const select = useCallback(\n    (item: SelectedItem | null, node: Konva.Node | null) => {\n      setSelected(item);\n      const t = transformerRef.current;\n      if (!t) return;\n      if (item && node) {\n        t.nodes([node]);\n        t.getLayer()?.batchDraw();\n      } else {\n        t.nodes([]);\n        t.getLayer()?.batchDraw();\n      }\n    },\n    [],\n  );\n\n  const clear = useCallback(() => {\n    select(null, null);\n  }, [select]);\n\n  return { selected, transformerRef, select, clear };\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/hooks/use-konva-selection.ts"
    },
    {
      "path": "src/registry/components/media/media-editor/hooks/use-konva-stage-size.ts",
      "content": "\"use client\";\n\nimport { useEffect, useState, type RefObject } from \"react\";\n\nexport interface StageSize {\n  width: number;\n  height: number;\n}\n\n/**\n * Observe the container element's content box and return its size in px.\n *\n * Konva.Stage needs explicit `width` + `height` props in pixels — it can't\n * track CSS sizing on its own. This hook bridges that gap via ResizeObserver.\n */\nexport function useKonvaStageSize(\n  ref: RefObject<HTMLElement | null>,\n): StageSize {\n  const [size, setSize] = useState<StageSize>({ width: 0, height: 0 });\n\n  useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n\n    // Seed from the initial bounding rect so first paint isn't 0×0.\n    const rect = el.getBoundingClientRect();\n    setSize({ width: Math.round(rect.width), height: Math.round(rect.height) });\n\n    if (typeof ResizeObserver === \"undefined\") return;\n\n    const observer = new ResizeObserver((entries) => {\n      const entry = entries[0];\n      if (!entry) return;\n      // Prefer contentBoxSize when available (Safari 13.4+, Chrome 84+).\n      const box = Array.isArray(entry.contentBoxSize)\n        ? entry.contentBoxSize[0]\n        : entry.contentBoxSize;\n      const width = box\n        ? box.inlineSize\n        : entry.contentRect.width;\n      const height = box\n        ? box.blockSize\n        : entry.contentRect.height;\n      setSize({ width: Math.round(width), height: Math.round(height) });\n    });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [ref]);\n\n  return size;\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/hooks/use-konva-stage-size.ts"
    },
    {
      "path": "src/registry/components/media/media-editor/hooks/use-media-editor-state.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useReducer, useRef, useState } from \"react\";\nimport type {\n  AspectRatio,\n  ComposerMode,\n  ComposerStage,\n  CropRect,\n  DraftMedia,\n  DrawingToolConfig,\n  EditTool,\n  ImageAdjustments,\n  MediaEditorState,\n  PlacedSticker,\n  TextOnlyState,\n  TextOverlay,\n  TrimRange,\n} from \"../types\";\nimport { DEFAULT_ADJUSTMENTS } from \"../types\";\nimport { DEFAULT_FONTS } from \"../lib/defaults\";\nimport { DEFAULT_TEXT_GRADIENTS } from \"../lib/defaults\";\n\n/**\n * Editor-shaped state machine for media-editor.\n *\n * Pure editor concerns only — no publish/upload state (those are consumer-owned,\n * see plan §\"What media-editor does NOT own\"). story-composer v0.2.0's\n * useStoryComposerState COMPOSES this hook and augments with publish state.\n *\n * Two state surfaces are managed here:\n *   - **`state` (MediaEditorState)** — the serializable snapshot returned by\n *     `getState()` for draft persistence. Holds mode / stage / mediaSrc /\n *     overlays / filter / adjustments / crop.\n *   - **Edit working state** (alongside `state`) — captured draft, video\n *     trim, selection ids, drawing tool config, text-only state, crop\n *     aspect. These shape ongoing edits but aren't part of the persistent\n *     snapshot.\n *\n * The split keeps the persistent snapshot clean (12 fields, all content)\n * while letting the working state grow without breaking `loadState`\n * call sites.\n */\n\n// ─── Action surface (internal) ──────────────────────────────────────────\n\ntype Action =\n  | { type: \"set-mode\"; mode: ComposerMode | null }\n  | { type: \"set-stage\"; stage: ComposerStage }\n  | { type: \"set-image-src\"; src: string | null }\n  | { type: \"set-video-blob\"; blob: Blob | null }\n  | { type: \"set-text-content\"; content: string | null }\n  | { type: \"add-text-overlay\"; overlay: TextOverlay }\n  | { type: \"update-text-overlay\"; id: string; patch: Partial<TextOverlay> }\n  | { type: \"remove-text-overlay\"; id: string }\n  | { type: \"add-sticker\"; placed: PlacedSticker }\n  | { type: \"update-sticker\"; id: string; patch: Partial<PlacedSticker> }\n  | { type: \"remove-sticker\"; id: string }\n  | { type: \"add-drawing-stroke\"; stroke: import(\"../types\").DrawingStroke }\n  | { type: \"set-filter\"; name: string | null }\n  | { type: \"set-adjustments\"; adjustments: Partial<ImageAdjustments> }\n  | { type: \"set-crop\"; crop: CropRect | null }\n  | { type: \"clear-layer\"; layer: \"drawing\" | \"stickers\" | \"text\" }\n  | { type: \"load-state\"; state: MediaEditorState }\n  | { type: \"reset\" };\n\n// ─── Initial state ──────────────────────────────────────────────────────\n\nconst INITIAL_STATE: MediaEditorState = {\n  mode: null,\n  stage: \"capture\",\n  imageSrc: null,\n  videoBlob: null,\n  textBackground: null,\n  textContent: null,\n  textOverlays: [],\n  stickers: [],\n  drawingStrokes: [],\n  filter: null,\n  adjustments: { ...DEFAULT_ADJUSTMENTS },\n  crop: null,\n};\n\nconst INITIAL_DRAWING_TOOL: DrawingToolConfig = {\n  color: \"#ffffff\",\n  brushSize: 8,\n  mode: \"draw\",\n};\n\nconst INITIAL_TEXT_ONLY: TextOnlyState = {\n  text: \"\",\n  fontFamily: DEFAULT_FONTS[0]?.family ?? \"Onest\",\n  textColor: \"#ffffff\",\n  gradientId: DEFAULT_TEXT_GRADIENTS[0]?.id ?? \"midnight\",\n};\n\n// ─── Reducer ────────────────────────────────────────────────────────────\n\nfunction reducer(state: MediaEditorState, action: Action): MediaEditorState {\n  switch (action.type) {\n    case \"set-mode\":\n      return { ...state, mode: action.mode };\n    case \"set-stage\":\n      return { ...state, stage: action.stage };\n    case \"set-image-src\":\n      return { ...state, imageSrc: action.src };\n    case \"set-video-blob\":\n      return { ...state, videoBlob: action.blob };\n    case \"set-text-content\":\n      return { ...state, textContent: action.content };\n    case \"add-text-overlay\":\n      return { ...state, textOverlays: [...state.textOverlays, action.overlay] };\n    case \"update-text-overlay\":\n      return {\n        ...state,\n        textOverlays: state.textOverlays.map((o) =>\n          o.id === action.id ? { ...o, ...action.patch } : o,\n        ),\n      };\n    case \"remove-text-overlay\":\n      return {\n        ...state,\n        textOverlays: state.textOverlays.filter((o) => o.id !== action.id),\n      };\n    case \"add-sticker\":\n      return { ...state, stickers: [...state.stickers, action.placed] };\n    case \"update-sticker\":\n      return {\n        ...state,\n        stickers: state.stickers.map((s) =>\n          s.id === action.id ? { ...s, ...action.patch } : s,\n        ),\n      };\n    case \"remove-sticker\":\n      return {\n        ...state,\n        stickers: state.stickers.filter((s) => s.id !== action.id),\n      };\n    case \"add-drawing-stroke\":\n      return {\n        ...state,\n        drawingStrokes: [...state.drawingStrokes, action.stroke],\n      };\n    case \"set-filter\":\n      return { ...state, filter: action.name };\n    case \"set-adjustments\":\n      return {\n        ...state,\n        adjustments: { ...state.adjustments, ...action.adjustments },\n      };\n    case \"set-crop\":\n      return { ...state, crop: action.crop };\n    case \"clear-layer\":\n      if (action.layer === \"drawing\") {\n        return { ...state, drawingStrokes: [] };\n      }\n      if (action.layer === \"stickers\") {\n        return { ...state, stickers: [] };\n      }\n      return { ...state, textOverlays: [], textContent: null };\n    case \"load-state\":\n      return action.state;\n    case \"reset\":\n      return INITIAL_STATE;\n    default:\n      return state;\n  }\n}\n\n// ─── Public hook ────────────────────────────────────────────────────────\n\nexport interface UseMediaEditorStateOptions {\n  defaultMode?: ComposerMode;\n  defaultCropAspect?: AspectRatio;\n  onDirtyChange?: (isDirty: boolean) => void;\n}\n\nexport interface UseMediaEditorStateResult {\n  // === Persistent snapshot ===\n  state: MediaEditorState;\n  isDirty: boolean;\n\n  // === Working / UI state (not in MediaEditorState snapshot) ===\n  activeTool: EditTool | null;\n  draft: DraftMedia | null;\n  trim: TrimRange | null;\n  selectedTextId: string | null;\n  selectedStickerId: string | null;\n  drawingTool: DrawingToolConfig;\n  cropAspect: AspectRatio;\n  textOnly: TextOnlyState;\n\n  // === Persistent-snapshot actions ===\n  setMode: (mode: ComposerMode | null) => void;\n  setStage: (stage: ComposerStage) => void;\n  setImageSrc: (src: string | null) => void;\n  setVideoBlob: (blob: Blob | null) => void;\n  setTextContent: (content: string | null) => void;\n  addTextOverlay: (overlay: TextOverlay) => void;\n  updateTextOverlay: (id: string, patch: Partial<TextOverlay>) => void;\n  removeTextOverlay: (id: string) => void;\n  addSticker: (placed: PlacedSticker) => void;\n  updateSticker: (id: string, patch: Partial<PlacedSticker>) => void;\n  removeSticker: (id: string) => void;\n  addDrawingStroke: (stroke: import(\"../types\").DrawingStroke) => void;\n  setFilter: (name: string | null) => void;\n  setAdjustments: (adjustments: Partial<ImageAdjustments>) => void;\n  setCrop: (crop: CropRect | null) => void;\n  clearLayer: (layer: \"drawing\" | \"stickers\" | \"text\") => void;\n  loadState: (state: MediaEditorState) => void;\n  reset: () => void;\n\n  // === Working-state actions ===\n  setActiveTool: (tool: EditTool | null) => void;\n  setDraft: (draft: DraftMedia | null) => void;\n  setTrim: (trim: TrimRange | null) => void;\n  setSelectedTextId: (id: string | null) => void;\n  setSelectedStickerId: (id: string | null) => void;\n  setDrawingTool: (patch: Partial<DrawingToolConfig>) => void;\n  setCropAspect: (aspect: AspectRatio) => void;\n  setTextOnly: (patch: Partial<TextOnlyState>) => void;\n}\n\nexport function useMediaEditorState(\n  opts: UseMediaEditorStateOptions = {},\n): UseMediaEditorStateResult {\n  const [state, dispatch] = useReducer(reducer, INITIAL_STATE, (init) => ({\n    ...init,\n    mode: opts.defaultMode ?? null,\n  }));\n\n  // Working state — not part of the persistent snapshot.\n  const [activeTool, setActiveToolState] = useState<EditTool | null>(null);\n  const [draft, setDraftState] = useState<DraftMedia | null>(null);\n  const [trim, setTrimState] = useState<TrimRange | null>(null);\n  const [selectedTextId, setSelectedTextIdState] = useState<string | null>(\n    null,\n  );\n  const [selectedStickerId, setSelectedStickerIdState] = useState<\n    string | null\n  >(null);\n  const [drawingTool, setDrawingToolState] = useState<DrawingToolConfig>(\n    INITIAL_DRAWING_TOOL,\n  );\n  const [cropAspect, setCropAspectState] = useState<AspectRatio>(\n    opts.defaultCropAspect ?? \"9:16\",\n  );\n  const [textOnly, setTextOnlyState] = useState<TextOnlyState>(INITIAL_TEXT_ONLY);\n\n  // Revoke draft object URL on replacement / unmount. The revoke runs OUTSIDE\n  // the setState updater — updaters must stay pure (React 19 StrictMode\n  // double-invokes them). draftUrlRef mirrors the last committed URL, and\n  // every draft write goes through setDraft, so it's always current.\n  const draftUrlRef = useRef<string | null>(null);\n  const setDraft = useCallback((next: DraftMedia | null) => {\n    const prevUrl = draftUrlRef.current;\n    if (prevUrl && prevUrl !== next?.url) {\n      URL.revokeObjectURL(prevUrl);\n    }\n    draftUrlRef.current = next?.url ?? null;\n    setDraftState(next);\n  }, []);\n  useEffect(() => {\n    return () => {\n      if (draftUrlRef.current) URL.revokeObjectURL(draftUrlRef.current);\n    };\n  }, []);\n\n  const setActiveTool = useCallback((tool: EditTool | null) => {\n    setActiveToolState(tool);\n  }, []);\n  const setTrim = useCallback((next: TrimRange | null) => {\n    setTrimState(next);\n  }, []);\n  const setSelectedTextId = useCallback((id: string | null) => {\n    setSelectedTextIdState(id);\n  }, []);\n  const setSelectedStickerId = useCallback((id: string | null) => {\n    setSelectedStickerIdState(id);\n  }, []);\n  const setDrawingTool = useCallback((patch: Partial<DrawingToolConfig>) => {\n    setDrawingToolState((prev) => ({ ...prev, ...patch }));\n  }, []);\n  const setCropAspect = useCallback((aspect: AspectRatio) => {\n    setCropAspectState(aspect);\n  }, []);\n  const setTextOnly = useCallback((patch: Partial<TextOnlyState>) => {\n    setTextOnlyState((prev) => ({ ...prev, ...patch }));\n  }, []);\n\n  // Dirty derivation: any non-initial state counts as dirty. Includes draft\n  // (capture-without-edit IS dirty per description §7) and text-only edits.\n  const isDirty =\n    state.mode !== null ||\n    state.imageSrc !== null ||\n    state.videoBlob !== null ||\n    state.textContent !== null ||\n    state.textOverlays.length > 0 ||\n    state.stickers.length > 0 ||\n    state.drawingStrokes.length > 0 ||\n    state.filter !== null ||\n    state.crop !== null ||\n    draft !== null ||\n    textOnly.text.length > 0;\n\n  // Fire onDirtyChange when isDirty flips. Effect skips the initial mount.\n  const prevDirtyRef = useRef<boolean | null>(null);\n  const onDirtyChangeRef = useRef(opts.onDirtyChange);\n  useEffect(() => {\n    onDirtyChangeRef.current = opts.onDirtyChange;\n  });\n  useEffect(() => {\n    if (prevDirtyRef.current !== null && prevDirtyRef.current !== isDirty) {\n      onDirtyChangeRef.current?.(isDirty);\n    }\n    prevDirtyRef.current = isDirty;\n  }, [isDirty]);\n\n  // Reset clears BOTH the persistent snapshot AND the working state.\n  const reset = useCallback(() => {\n    dispatch({ type: \"reset\" });\n    setDraft(null);\n    setTrimState(null);\n    setActiveToolState(null);\n    setSelectedTextIdState(null);\n    setSelectedStickerIdState(null);\n    setTextOnlyState(INITIAL_TEXT_ONLY);\n    setDrawingToolState(INITIAL_DRAWING_TOOL);\n  }, [setDraft]);\n\n  return {\n    state,\n    isDirty,\n    activeTool,\n    draft,\n    trim,\n    selectedTextId,\n    selectedStickerId,\n    drawingTool,\n    cropAspect,\n    textOnly,\n\n    setMode: (mode) => dispatch({ type: \"set-mode\", mode }),\n    setStage: (stage) => dispatch({ type: \"set-stage\", stage }),\n    setImageSrc: (src) => dispatch({ type: \"set-image-src\", src }),\n    setVideoBlob: (blob) => dispatch({ type: \"set-video-blob\", blob }),\n    setTextContent: (content) =>\n      dispatch({ type: \"set-text-content\", content }),\n    addTextOverlay: (overlay) =>\n      dispatch({ type: \"add-text-overlay\", overlay }),\n    updateTextOverlay: (id, patch) =>\n      dispatch({ type: \"update-text-overlay\", id, patch }),\n    removeTextOverlay: (id) =>\n      dispatch({ type: \"remove-text-overlay\", id }),\n    addSticker: (placed) => dispatch({ type: \"add-sticker\", placed }),\n    updateSticker: (id, patch) =>\n      dispatch({ type: \"update-sticker\", id, patch }),\n    removeSticker: (id) => dispatch({ type: \"remove-sticker\", id }),\n    addDrawingStroke: (stroke) =>\n      dispatch({ type: \"add-drawing-stroke\", stroke }),\n    setFilter: (name) => dispatch({ type: \"set-filter\", name }),\n    setAdjustments: (adjustments) =>\n      dispatch({ type: \"set-adjustments\", adjustments }),\n    setCrop: (crop) => dispatch({ type: \"set-crop\", crop }),\n    clearLayer: (layer) => dispatch({ type: \"clear-layer\", layer }),\n    loadState: (s) => dispatch({ type: \"load-state\", state: s }),\n    reset,\n\n    setActiveTool,\n    setDraft,\n    setTrim,\n    setSelectedTextId,\n    setSelectedStickerId,\n    setDrawingTool,\n    setCropAspect,\n    setTextOnly,\n  };\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/hooks/use-media-editor-state.ts"
    },
    {
      "path": "src/registry/components/media/media-editor/hooks/use-pan-zoom.ts",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n  type PointerEvent,\n  type RefObject,\n} from \"react\";\n\nexport interface PanZoomTransform {\n  scale: number;\n  x: number;\n  y: number;\n}\n\nexport interface UsePanZoomOptions {\n  /**\n   * Container element receiving the gestures. The wheel listener is attached\n   * here as a NATIVE non-passive listener so `e.preventDefault()` actually\n   * blocks the browser's default Ctrl+wheel page-zoom. React's `onWheel` is\n   * registered as passive in modern browsers and can't preventDefault.\n   */\n  targetRef: RefObject<HTMLElement | null>;\n  /** When false, all gestures + keys are no-ops. Default true. */\n  enabled?: boolean;\n  /**\n   * When true, the hook listens for keydown (arrows / +/- / 0) ON THE\n   * `targetRef` element — NOT on `window` — so the keys only act while the\n   * canvas (or a child) holds focus and never hijack arrow keys elsewhere on\n   * the page. The target must be focusable (e.g. `tabIndex={0}`) to receive\n   * them. Default true.\n   */\n  bindKeyboard?: boolean;\n  /** Min zoom. Default 1 (full-screen fit baseline). */\n  minScale?: number;\n  /** Max zoom. Default 4. */\n  maxScale?: number;\n  /** Keyboard pan step in px. Default 32. */\n  panStep?: number;\n  /** Keyboard zoom step (multiplicative). Default 1.15. */\n  zoomStep?: number;\n  /**\n   * Called on single-pointer pointer-down to decide whether this gesture should\n   * drag-pan the canvas. Return false to yield the pointer to whatever is\n   * underneath (e.g. a draggable text/sticker overlay). Receives only the\n   * pointer's viewport coordinates — enough to hit-test — so callers needn't\n   * accept a full React event. Default: always pan.\n   */\n  shouldStartPan?: (point: { clientX: number; clientY: number }) => boolean;\n  /** Movement in px before a single-pointer drag counts as a pan (tap tolerance). Default 4. */\n  panThreshold?: number;\n}\n\nexport interface UsePanZoomResult {\n  transform: PanZoomTransform;\n  /** Whether the transform is at the identity (1× / 0,0) — drives the \"reset\" CTA. */\n  isIdentity: boolean;\n  /** Attach to a wrapper element receiving the gestures. */\n  handlers: {\n    onPointerDown: (e: PointerEvent) => void;\n    onPointerMove: (e: PointerEvent) => void;\n    onPointerUp: (e: PointerEvent) => void;\n    onPointerCancel: (e: PointerEvent) => void;\n  };\n  reset: () => void;\n  /** Programmatic zoom. `center` is in container coordinates. */\n  zoomBy: (factor: number, center?: { x: number; y: number }) => void;\n}\n\ninterface PointerInfo {\n  id: number;\n  x: number;\n  y: number;\n}\n\n/**\n * Pan + pinch-zoom hook for the Konva editor.\n *\n * Touch:\n *   - 2-finger pinch → zoom anchored to the midpoint\n *   - 2-finger pan → translate by the midpoint delta\n *   - Single finger → drag-pan the canvas, UNLESS `shouldStartPan` vetoes the\n *     gesture (the consumer hit-tests so a finger landing on a draggable\n *     text/sticker overlay drags that overlay instead). A tap (movement under\n *     `panThreshold`) never pans, so selection/placement still works.\n *\n * Desktop:\n *   - Click-drag over the canvas → pan (same `shouldStartPan` gate).\n *   - Plain wheel over the canvas → zoom anchored to the cursor. The wheel\n *     listener is attached natively with `passive: false` + always\n *     preventDefaults, so the browser's Ctrl+wheel page-zoom doesn't ALSO\n *     zoom the page when the cursor is over the canvas. Trackpad pinch\n *     (which browsers report as wheel.ctrlKey=true) is covered too.\n *   - Vertical scroll over non-canvas areas works normally — the listener\n *     is scoped to the targetRef element.\n *\n * Keyboard (when bindKeyboard=true, the targetRef element is focused, and no\n * text input is focused). Bound to the target element (not window) so the keys\n * are focus-scoped to the canvas:\n *   - Arrow keys → pan (step = panStep)\n *   - + / =  → zoom in\n *   - - / _  → zoom out\n *   - 0      → reset\n *\n * Transform is returned for the consumer to apply (e.g. as Konva.Stage\n * scale + position props).\n */\nexport function usePanZoom(\n  options: UsePanZoomOptions,\n): UsePanZoomResult {\n  const {\n    targetRef,\n    enabled = true,\n    bindKeyboard = true,\n    minScale = 1,\n    maxScale = 4,\n    panStep = 32,\n    zoomStep = 1.15,\n    shouldStartPan,\n    panThreshold = 4,\n  } = options;\n\n  const [transform, setTransform] = useState<PanZoomTransform>({\n    scale: 1,\n    x: 0,\n    y: 0,\n  });\n  const pointersRef = useRef<PointerInfo[]>([]);\n  const pinchStartRef = useRef<{\n    dist: number;\n    midX: number;\n    midY: number;\n    transform: PanZoomTransform;\n  } | null>(null);\n  // Single-pointer drag-pan bookkeeping. `active` flips once movement crosses\n  // panThreshold (so a tap stays a tap); `allowed` is the shouldStartPan verdict.\n  const panStateRef = useRef<{\n    id: number;\n    lastX: number;\n    lastY: number;\n    startX: number;\n    startY: number;\n    active: boolean;\n    allowed: boolean;\n  } | null>(null);\n\n  const clamp = useCallback(\n    (s: number) => Math.max(minScale, Math.min(maxScale, s)),\n    [minScale, maxScale],\n  );\n\n  const reset = useCallback(() => {\n    setTransform({ scale: 1, x: 0, y: 0 });\n  }, []);\n\n  // Zoom by `factor` around `center` (in container coordinates).\n  const zoomBy = useCallback(\n    (factor: number, center?: { x: number; y: number }) => {\n      setTransform((prev) => {\n        const nextScale = clamp(prev.scale * factor);\n        if (nextScale === prev.scale) return prev;\n        const cx = center?.x ?? 0;\n        const cy = center?.y ?? 0;\n        // Keep the world point under the cursor stationary:\n        //   world = (screen - pos) / scale\n        //   newPos = screen - world * newScale\n        const worldX = (cx - prev.x) / prev.scale;\n        const worldY = (cy - prev.y) / prev.scale;\n        return {\n          scale: nextScale,\n          x: cx - worldX * nextScale,\n          y: cy - worldY * nextScale,\n        };\n      });\n    },\n    [clamp],\n  );\n\n  const panBy = useCallback((dx: number, dy: number) => {\n    setTransform((prev) => ({ ...prev, x: prev.x + dx, y: prev.y + dy }));\n  }, []);\n\n  // ─── Pointer / pinch gestures ────────────────────────────────────────\n\n  const onPointerDown = useCallback(\n    (e: PointerEvent) => {\n      if (!enabled) return;\n      pointersRef.current = [\n        ...pointersRef.current.filter((p) => p.id !== e.pointerId),\n        { id: e.pointerId, x: e.clientX, y: e.clientY },\n      ];\n      if (pointersRef.current.length === 2) {\n        const [a, b] = pointersRef.current;\n        const dx = b.x - a.x;\n        const dy = b.y - a.y;\n        pinchStartRef.current = {\n          dist: Math.hypot(dx, dy),\n          midX: (a.x + b.x) / 2,\n          midY: (a.y + b.y) / 2,\n          transform,\n        };\n        // A second pointer joined → pinch owns the gesture; cancel any\n        // single-pointer pan in progress.\n        panStateRef.current = null;\n      } else if (pointersRef.current.length === 1) {\n        // Arm a single-pointer drag-pan. Whether it engages is decided by\n        // shouldStartPan (so overlay drags can claim the pointer) and by\n        // crossing panThreshold (so taps still register as taps).\n        panStateRef.current = {\n          id: e.pointerId,\n          lastX: e.clientX,\n          lastY: e.clientY,\n          startX: e.clientX,\n          startY: e.clientY,\n          active: false,\n          allowed: shouldStartPan ? shouldStartPan(e) : true,\n        };\n      }\n    },\n    [enabled, transform, shouldStartPan],\n  );\n\n  const onPointerMove = useCallback(\n    (e: PointerEvent) => {\n      if (!enabled) return;\n      const ptrs = pointersRef.current;\n      const idx = ptrs.findIndex((p) => p.id === e.pointerId);\n      if (idx === -1) return;\n      ptrs[idx] = { id: e.pointerId, x: e.clientX, y: e.clientY };\n\n      // Single-pointer drag-pan (desktop click-drag + touch one-finger).\n      const ps = panStateRef.current;\n      if (ptrs.length === 1 && ps && ps.id === e.pointerId && ps.allowed) {\n        if (!ps.active) {\n          if (\n            Math.hypot(e.clientX - ps.startX, e.clientY - ps.startY) <\n            panThreshold\n          ) {\n            return;\n          }\n          // Crossing the threshold: anchor the pan to HERE so the first frame\n          // doesn't jump by the accumulated pre-threshold distance (up to\n          // panThreshold px). Subsequent frames compute incremental deltas.\n          ps.active = true;\n          ps.lastX = e.clientX;\n          ps.lastY = e.clientY;\n          return;\n        }\n        const dx = e.clientX - ps.lastX;\n        const dy = e.clientY - ps.lastY;\n        ps.lastX = e.clientX;\n        ps.lastY = e.clientY;\n        panBy(dx, dy);\n        return;\n      }\n\n      if (ptrs.length === 2 && pinchStartRef.current) {\n        const [a, b] = ptrs;\n        const dx = b.x - a.x;\n        const dy = b.y - a.y;\n        const dist = Math.hypot(dx, dy);\n        const midX = (a.x + b.x) / 2;\n        const midY = (a.y + b.y) / 2;\n        const start = pinchStartRef.current;\n\n        const rawScale = (start.transform.scale * dist) / start.dist;\n        const nextScale = clamp(rawScale);\n\n        // Anchor the start midpoint's world coords under the current midpoint.\n        const worldX = (start.midX - start.transform.x) / start.transform.scale;\n        const worldY = (start.midY - start.transform.y) / start.transform.scale;\n        setTransform({\n          scale: nextScale,\n          x: midX - worldX * nextScale,\n          y: midY - worldY * nextScale,\n        });\n      }\n    },\n    [clamp, enabled, panBy, panThreshold],\n  );\n\n  const onPointerUp = useCallback((e: PointerEvent) => {\n    pointersRef.current = pointersRef.current.filter(\n      (p) => p.id !== e.pointerId,\n    );\n    if (pointersRef.current.length < 2) pinchStartRef.current = null;\n    if (panStateRef.current?.id === e.pointerId) panStateRef.current = null;\n  }, []);\n\n  const onPointerCancel = onPointerUp;\n\n  // ─── Wheel (native non-passive — beats browser page-zoom) ────────────\n  // Plain wheel (no modifier) zooms when over the canvas. Always\n  // preventDefaults so the browser doesn't also zoom the page.\n\n  useEffect(() => {\n    if (!enabled) return;\n    const el = targetRef.current;\n    if (!el) return;\n    const handler = (e: WheelEvent) => {\n      e.preventDefault();\n      const rect = el.getBoundingClientRect();\n      const center = {\n        x: e.clientX - rect.left,\n        y: e.clientY - rect.top,\n      };\n      const factor = e.deltaY < 0 ? zoomStep : 1 / zoomStep;\n      zoomBy(factor, center);\n    };\n    el.addEventListener(\"wheel\", handler, { passive: false });\n    return () => el.removeEventListener(\"wheel\", handler);\n  }, [enabled, targetRef, zoomBy, zoomStep]);\n\n  // ─── Keyboard ────────────────────────────────────────────────────────\n\n  useEffect(() => {\n    if (!enabled || !bindKeyboard) return;\n    // Focus-scoped: listen on the target element, not window, so the keys only\n    // act while the canvas holds focus and never hijack arrow keys/zoom on the\n    // rest of the page. Requires the target to be focusable (tabIndex).\n    const el = targetRef.current;\n    if (!el) return;\n    const handler = (e: KeyboardEvent) => {\n      // Don't hijack while typing into a nested input.\n      const tgt = e.target as HTMLElement | null;\n      if (\n        tgt?.tagName === \"INPUT\" ||\n        tgt?.tagName === \"TEXTAREA\" ||\n        tgt?.isContentEditable\n      ) {\n        return;\n      }\n      // Arrow keys move the IMAGE in the arrow's direction (the natural\n      // mental model when you're looking at media you want to reposition),\n      // not the viewport. ArrowRight ⇒ image shifts right ⇒ transform.x +.\n      switch (e.key) {\n        case \"ArrowLeft\":\n          panBy(-panStep, 0);\n          e.preventDefault();\n          break;\n        case \"ArrowRight\":\n          panBy(panStep, 0);\n          e.preventDefault();\n          break;\n        case \"ArrowUp\":\n          panBy(0, -panStep);\n          e.preventDefault();\n          break;\n        case \"ArrowDown\":\n          panBy(0, panStep);\n          e.preventDefault();\n          break;\n        case \"+\":\n        case \"=\":\n          zoomBy(zoomStep);\n          e.preventDefault();\n          break;\n        case \"-\":\n        case \"_\":\n          zoomBy(1 / zoomStep);\n          e.preventDefault();\n          break;\n        case \"0\":\n          reset();\n          e.preventDefault();\n          break;\n      }\n    };\n    el.addEventListener(\"keydown\", handler);\n    return () => el.removeEventListener(\"keydown\", handler);\n  }, [bindKeyboard, enabled, targetRef, panBy, panStep, reset, zoomBy, zoomStep]);\n\n  return {\n    transform,\n    isIdentity:\n      transform.scale === 1 && transform.x === 0 && transform.y === 0,\n    handlers: {\n      onPointerDown,\n      onPointerMove,\n      onPointerUp,\n      onPointerCancel,\n    },\n    reset,\n    zoomBy,\n  };\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/hooks/use-pan-zoom.ts"
    },
    {
      "path": "src/registry/components/media/media-editor/lib/built-in-stickers.ts",
      "content": "import type { StickerOption, StickerSet } from \"../types\";\n\n/**\n * Built-in emoji stickers (Q-P9a — inline data URLs in TS).\n *\n * Implemented as SVG-text data URLs rather than base64 PNGs:\n *   - Each sticker is ~180 bytes vs ~3-5KB for an equivalent PNG.\n *   - Vector — scales infinitely with Transformer.\n *   - Renders consistently regardless of host emoji-font (the SVG inlines\n *     the unicode codepoint and the browser renders with its system emoji).\n *\n * Total payload for the 36 stickers below: ~6KB → no measurable bundle\n * bloat (well under the ~80KB ceiling that would trigger a switch to\n * the separate-PNG-files pattern from description §15 Q-P9b).\n */\n\nfunction emojiSticker(id: string, emoji: string, alt: string): StickerOption {\n  const svg = `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 256 256\"><text x=\"128\" y=\"170\" font-size=\"200\" text-anchor=\"middle\">${emoji}</text></svg>`;\n  return {\n    id,\n    src: `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`,\n    alt,\n    width: 256,\n    height: 256,\n  };\n}\n\nconst FACES: StickerOption[] = [\n  emojiSticker(\"smile\", \"😀\", \"Smile\"),\n  emojiSticker(\"laugh\", \"😂\", \"Laugh\"),\n  emojiSticker(\"heart-eyes\", \"😍\", \"Heart eyes\"),\n  emojiSticker(\"kiss\", \"😘\", \"Kiss\"),\n  emojiSticker(\"cool\", \"😎\", \"Cool\"),\n  emojiSticker(\"wink\", \"😉\", \"Wink\"),\n  emojiSticker(\"thinking\", \"🤔\", \"Thinking\"),\n  emojiSticker(\"party\", \"🥳\", \"Party\"),\n  emojiSticker(\"sleepy\", \"😴\", \"Sleepy\"),\n];\n\nconst HEARTS: StickerOption[] = [\n  emojiSticker(\"heart-red\", \"❤️\", \"Red heart\"),\n  emojiSticker(\"heart-orange\", \"🧡\", \"Orange heart\"),\n  emojiSticker(\"heart-yellow\", \"💛\", \"Yellow heart\"),\n  emojiSticker(\"heart-green\", \"💚\", \"Green heart\"),\n  emojiSticker(\"heart-blue\", \"💙\", \"Blue heart\"),\n  emojiSticker(\"heart-purple\", \"💜\", \"Purple heart\"),\n  emojiSticker(\"heart-black\", \"🖤\", \"Black heart\"),\n  emojiSticker(\"heart-broken\", \"💔\", \"Broken heart\"),\n  emojiSticker(\"heart-sparkle\", \"💖\", \"Sparkle heart\"),\n];\n\nconst SYMBOLS: StickerOption[] = [\n  emojiSticker(\"fire\", \"🔥\", \"Fire\"),\n  emojiSticker(\"star\", \"⭐\", \"Star\"),\n  emojiSticker(\"sparkles\", \"✨\", \"Sparkles\"),\n  emojiSticker(\"crown\", \"👑\", \"Crown\"),\n  emojiSticker(\"rocket\", \"🚀\", \"Rocket\"),\n  emojiSticker(\"rainbow\", \"🌈\", \"Rainbow\"),\n  emojiSticker(\"sun\", \"☀️\", \"Sun\"),\n  emojiSticker(\"moon\", \"🌙\", \"Moon\"),\n  emojiSticker(\"lightning\", \"⚡\", \"Lightning\"),\n];\n\nconst HANDS: StickerOption[] = [\n  emojiSticker(\"thumbs-up\", \"👍\", \"Thumbs up\"),\n  emojiSticker(\"clap\", \"👏\", \"Clap\"),\n  emojiSticker(\"raised-hands\", \"🙌\", \"Raised hands\"),\n  emojiSticker(\"peace\", \"✌️\", \"Peace\"),\n  emojiSticker(\"ok-hand\", \"👌\", \"OK hand\"),\n  emojiSticker(\"muscle\", \"💪\", \"Muscle\"),\n  emojiSticker(\"pray\", \"🙏\", \"Pray\"),\n  emojiSticker(\"wave\", \"👋\", \"Wave\"),\n  emojiSticker(\"point-up\", \"☝️\", \"Point up\"),\n];\n\nexport const BUILT_IN_STICKER_SETS: StickerSet[] = [\n  { id: \"faces\", label: \"Faces\", stickers: FACES },\n  { id: \"hearts\", label: \"Hearts\", stickers: HEARTS },\n  { id: \"symbols\", label: \"Symbols\", stickers: SYMBOLS },\n  { id: \"hands\", label: \"Hands\", stickers: HANDS },\n];\n\n/**\n * Merge consumer-supplied sticker sets with the built-in catalog.\n * `replace=true` → consumer set wins entirely.\n */\nexport function resolveStickerSets(\n  consumer: StickerSet[] | undefined,\n  replace: boolean,\n): StickerSet[] {\n  if (replace) return consumer ?? [];\n  if (!consumer || consumer.length === 0) return BUILT_IN_STICKER_SETS;\n  return [...BUILT_IN_STICKER_SETS, ...consumer];\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/lib/built-in-stickers.ts"
    },
    {
      "path": "src/registry/components/media/media-editor/lib/composite-video.ts",
      "content": "import { selectRecorderMime } from \"./mime-fallback\";\n\nexport interface CompositeVideoOptions {\n  sourceBlob: Blob;\n  /** Seconds — trim range. Optional; defaults to full duration. */\n  startSec?: number;\n  endSec?: number;\n  /** Renders one frame onto the canvas. Called per RAF tick. */\n  renderFrame: (\n    ctx: CanvasRenderingContext2D,\n    sourceVideo: HTMLVideoElement,\n  ) => void;\n  /** Output dimensions — usually crop size. */\n  outputWidth: number;\n  outputHeight: number;\n  /** Capture frame rate. Default 30. */\n  fps?: number;\n  /** Override mime selection; falls back to selectRecorderMime() chain. */\n  mimeType?: string;\n  /** MediaRecorder bitsPerSecond (browser default if omitted). */\n  bitsPerSecond?: number;\n  /**\n   * 0..1; fires ~10× during re-encode keyed off video.currentTime /\n   * (endSec - startSec). Capped at 1 to avoid overshoot on slow ticks.\n   */\n  onProgress?: (progress: number) => void;\n}\n\nexport interface CompositeVideoResult {\n  blob: Blob;\n  durationMs: number;\n  mimeType: string;\n}\n\n/**\n * Bake text/sticker/drawing overlays into a video file (Q-P1a — full\n * Instagram parity).\n *\n * Pipeline:\n *   1. Play the source video in a hidden <video> from startSec → endSec\n *   2. Each requestVideoFrameCallback (or RAF) tick, renderFrame() draws\n *      both the video frame AND the overlay snapshot onto an offscreen\n *      <canvas> at outputWidth × outputHeight\n *   3. canvas.captureStream(fps) feeds a new MediaRecorder\n *   4. Audio tracks from the source video are mixed into the output stream\n *   5. On video-end (or endSec), stop the recorder; resolve with the blob\n *\n * The caller owns the overlay rendering (`renderFrame`) — typically that\n * function snapshots the current Konva stage with stage.toCanvas() and\n * draws it on top of the video frame. The composer wires that hook up at\n * publish time.\n */\nexport async function compositeVideo(\n  opts: CompositeVideoOptions,\n): Promise<CompositeVideoResult> {\n  const {\n    sourceBlob,\n    startSec,\n    endSec,\n    renderFrame,\n    outputWidth,\n    outputHeight,\n    fps = 30,\n    mimeType,\n    bitsPerSecond,\n    onProgress,\n  } = opts;\n\n  const mime = mimeType ?? selectRecorderMime();\n  if (!mime) {\n    throw new Error(\n      \"This browser does not support MediaRecorder — video publish unavailable.\",\n    );\n  }\n\n  const sourceUrl = URL.createObjectURL(sourceBlob);\n  const video = document.createElement(\"video\");\n  video.src = sourceUrl;\n  video.muted = false;\n  video.playsInline = true;\n  video.crossOrigin = \"anonymous\";\n\n  const canvas = document.createElement(\"canvas\");\n  canvas.width = outputWidth;\n  canvas.height = outputHeight;\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) throw new Error(\"2D canvas context unavailable\");\n\n  try {\n    await new Promise<void>((resolve, reject) => {\n      video.onloadedmetadata = () => resolve();\n      video.onerror = () => reject(new Error(\"Source video failed to load\"));\n    });\n\n    const begin = startSec ?? 0;\n    const end = endSec ?? video.duration;\n    video.currentTime = begin;\n\n    // Build the output stream: canvas video + source audio (if any).\n    const canvasStream = canvas.captureStream(fps);\n    // Mix in original audio so the export isn't silent.\n    try {\n      // captureStream on the video element only works in some browsers;\n      // fall back to a MediaStreamAudioSourceNode chain otherwise.\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      const vAny = video as any;\n      if (typeof vAny.captureStream === \"function\") {\n        const srcStream: MediaStream = vAny.captureStream();\n        for (const track of srcStream.getAudioTracks()) {\n          canvasStream.addTrack(track);\n        }\n      } else if (typeof vAny.mozCaptureStream === \"function\") {\n        const srcStream: MediaStream = vAny.mozCaptureStream();\n        for (const track of srcStream.getAudioTracks()) {\n          canvasStream.addTrack(track);\n        }\n      }\n    } catch {\n      /* audio mixing best-effort; some browsers lock down crossorigin */\n    }\n\n    const recorder = new MediaRecorder(canvasStream, {\n      mimeType: mime,\n      ...(bitsPerSecond !== undefined ? { bitsPerSecond } : {}),\n    });\n    const chunks: Blob[] = [];\n    recorder.ondataavailable = (e) => {\n      if (e.data && e.data.size > 0) chunks.push(e.data);\n    };\n\n    const startedAt = Date.now();\n\n    // Progress is bucketed into 10% steps to satisfy the ExportVideoOpts\n    // \"fires ~10× during re-encode\" contract without flooding the callback.\n    const duration = Math.max(end - begin, 0.001);\n    let lastBucket = -1;\n    onProgress?.(0);\n\n    // RAF loop drives the canvas frames. We stop when the video crosses\n    // `end`, or earlier if the video ends naturally.\n    let raf = 0;\n\n    // Shared teardown for EVERY exit path (stop / recorder error / play\n    // rejection). Without it an error path leaves the hidden source video\n    // playing and the captureStream tracks (canvas video + mixed-in source\n    // audio) live until GC.\n    let cleanedUp = false;\n    const cleanup = () => {\n      if (cleanedUp) return;\n      cleanedUp = true;\n      cancelAnimationFrame(raf);\n      video.pause();\n      for (const track of canvasStream.getTracks()) track.stop();\n    };\n    let failed = false;\n\n    const tick = () => {\n      if (video.currentTime >= end || video.ended) {\n        if (recorder.state === \"recording\") recorder.stop();\n        return;\n      }\n      renderFrame(ctx, video);\n      if (onProgress) {\n        const elapsed = Math.max(video.currentTime - begin, 0);\n        const bucket = Math.min(9, Math.floor((elapsed / duration) * 10));\n        if (bucket > lastBucket) {\n          lastBucket = bucket;\n          onProgress((bucket + 1) / 10);\n        }\n      }\n      raf = requestAnimationFrame(tick);\n    };\n\n    return await new Promise<CompositeVideoResult>((resolve, reject) => {\n      recorder.onstop = () => {\n        cleanup();\n        if (failed) return; // stop forced by a failure path — already rejected\n        onProgress?.(1);\n        const blob = new Blob(chunks, { type: mime });\n        resolve({ blob, durationMs: Date.now() - startedAt, mimeType: mime });\n      };\n      recorder.onerror = (ev) => {\n        failed = true;\n        cleanup();\n        const e =\n          (ev as unknown as { error?: Error }).error ??\n          new Error(\"MediaRecorder failed during composite\");\n        reject(e);\n      };\n\n      recorder.start(250);\n      video\n        .play()\n        .then(() => {\n          tick();\n        })\n        .catch((err) => {\n          failed = true;\n          try {\n            if (recorder.state !== \"inactive\") recorder.stop();\n          } catch {\n            /* already stopped */\n          }\n          cleanup();\n          reject(err instanceof Error ? err : new Error(String(err)));\n        });\n    });\n  } finally {\n    URL.revokeObjectURL(sourceUrl);\n  }\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/lib/composite-video.ts"
    },
    {
      "path": "src/registry/components/media/media-editor/lib/defaults.ts",
      "content": "import type { FontOption, GradientPreset } from \"../types\";\n\n// ─── Gradient presets for text-only mode ────────────────────────────────\n// GradientPreset type moved to ../types.ts in v0.1.0 of media-editor.\n\nexport const DEFAULT_TEXT_GRADIENTS: GradientPreset[] = [\n  {\n    id: \"lime\",\n    label: \"Lime\",\n    background:\n      \"linear-gradient(135deg, oklch(0.86 0.18 132) 0%, oklch(0.62 0.16 145) 100%)\",\n  },\n  {\n    id: \"sunset\",\n    label: \"Sunset\",\n    background:\n      \"linear-gradient(135deg, oklch(0.78 0.18 35) 0%, oklch(0.58 0.22 15) 100%)\",\n  },\n  {\n    id: \"lavender\",\n    label: \"Lavender\",\n    background:\n      \"linear-gradient(135deg, oklch(0.78 0.14 290) 0%, oklch(0.56 0.18 305) 100%)\",\n  },\n  {\n    id: \"ocean\",\n    label: \"Ocean\",\n    background:\n      \"linear-gradient(135deg, oklch(0.72 0.14 220) 0%, oklch(0.45 0.18 245) 100%)\",\n  },\n  {\n    id: \"mono\",\n    label: \"Mono\",\n    background:\n      \"linear-gradient(135deg, oklch(0.30 0.005 250) 0%, oklch(0.13 0.006 250) 100%)\",\n  },\n  {\n    id: \"fire\",\n    label: \"Fire\",\n    background:\n      \"linear-gradient(135deg, oklch(0.82 0.20 60) 0%, oklch(0.55 0.22 25) 100%)\",\n  },\n  {\n    id: \"rose\",\n    label: \"Rose\",\n    background:\n      \"linear-gradient(135deg, oklch(0.80 0.16 10) 0%, oklch(0.58 0.20 350) 100%)\",\n  },\n  {\n    id: \"midnight\",\n    label: \"Midnight\",\n    background:\n      \"linear-gradient(135deg, oklch(0.32 0.10 270) 0%, oklch(0.18 0.04 250) 100%)\",\n  },\n];\n\n// ─── Default fonts (Onest + JetBrains Mono first per design system) ─────\n\nexport const DEFAULT_FONTS: FontOption[] = [\n  { id: \"onest\", label: \"Onest\", family: \"var(--font-onest, Onest, sans-serif)\" },\n  {\n    id: \"mono\",\n    label: \"Mono\",\n    family: \"var(--font-mono, 'JetBrains Mono', monospace)\",\n  },\n  { id: \"serif\", label: \"Serif\", family: \"Georgia, 'Times New Roman', serif\" },\n  { id: \"display\", label: \"Display\", family: \"'Playfair Display', Georgia, serif\" },\n  { id: \"rounded\", label: \"Rounded\", family: \"system-ui, -apple-system, sans-serif\" },\n  { id: \"handwriting\", label: \"Handwriting\", family: \"'Comic Sans MS', cursive\" },\n  { id: \"impact\", label: \"Impact\", family: \"Impact, 'Arial Black', sans-serif\" },\n  { id: \"courier\", label: \"Courier\", family: \"'Courier New', monospace\" },\n];\n\n// ─── Default color palette ──────────────────────────────────────────────\n\nexport const DEFAULT_COLOR_PRESETS: string[] = [\n  \"#ffffff\",\n  \"#000000\",\n  \"#ef4444\",\n  \"#f97316\",\n  \"#eab308\",\n  \"#84cc16\",\n  \"#22c55e\",\n  \"#14b8a6\",\n  \"#0ea5e9\",\n  \"#6366f1\",\n  \"#a855f7\",\n  \"#ec4899\",\n];\n",
      "type": "registry:component",
      "target": "components/media-editor/lib/defaults.ts"
    },
    {
      "path": "src/registry/components/media/media-editor/lib/dialog-size-for-aspect.ts",
      "content": "import type { AspectRatio } from \"../types\";\n\nexport interface DialogDims {\n  /** CSS `aspect-ratio` value, e.g. `\"9 / 16\"`. */\n  aspectRatio: string;\n  /** Drives which dimension is the viewport-relative driver. */\n  orientation: \"portrait\" | \"landscape\";\n}\n\n/**\n * Aspect → desktop dialog sizing hints per description §6.\n *\n * Mobile (< md breakpoint) always falls back to fullscreen; these hints only\n * take effect at the `md:` breakpoint, where the dialog is constructed as a\n * viewport-relative box constrained by `aspect-ratio` — height-driven for\n * portrait aspects, width-driven for landscape. The CSS in media-editor.tsx\n * sets the driver dimension to a viewport-relative size (e.g. `85dvh`) and\n * lets `aspect-ratio` compute the other.\n *\n * v0.1 does NOT expose a width-override prop — consumers needing custom sizing\n * pass `presentation=\"inline\"` and own the wrapper.\n */\nconst DIALOG_DIMS_FOR_ASPECT: Record<AspectRatio, DialogDims> = {\n  \"9:16\": { aspectRatio: \"9 / 16\", orientation: \"portrait\" },\n  \"1:1\": { aspectRatio: \"1 / 1\", orientation: \"portrait\" },\n  \"16:9\": { aspectRatio: \"16 / 9\", orientation: \"landscape\" },\n  \"4:5\": { aspectRatio: \"4 / 5\", orientation: \"portrait\" },\n  free: { aspectRatio: \"4 / 3\", orientation: \"landscape\" },\n};\n\nexport function dialogDimsForAspect(aspect: AspectRatio): DialogDims {\n  return DIALOG_DIMS_FOR_ASPECT[aspect];\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/lib/dialog-size-for-aspect.ts"
    },
    {
      "path": "src/registry/components/media/media-editor/lib/export-blob.ts",
      "content": "import type Konva from \"konva\";\nimport type { CropRect } from \"../types\";\n\nexport interface ExportPhotoOptions {\n  stage: Konva.Stage;\n  cropRect?: CropRect | null;\n  /** Pixel ratio multiplier. Default 2 for retina sharpness. */\n  pixelRatio?: number;\n  /** Default \"image/jpeg\" per Q-P3 lock. PNG and WebP also supported. */\n  mimeType?: \"image/jpeg\" | \"image/png\" | \"image/webp\";\n  /** 0..1; default 0.9 per Q-P3. Only meaningful for jpeg / webp. */\n  quality?: number;\n  /** Fires (0) at start and (1) at end. Two-tick contract for image export. */\n  onProgress?: (progress: number) => void;\n}\n\n/**\n * Export the Konva stage to a Blob at the crop rect (or full stage if none).\n *\n * Uses stage.toCanvas() with x/y/width/height to write only the crop region.\n * stage.toBlob() is not available in all Konva versions; the canvas →\n * toBlob() path is more portable.\n *\n * Progress emits twice for jpeg/png/webp (start=0, end=1) per the\n * ExportImageOpts.onProgress contract. WebP encoders in some browsers may\n * yield mid-encode but we don't expose those intermediate ticks.\n */\nexport function exportPhotoBlob(opts: ExportPhotoOptions): Promise<Blob> {\n  const {\n    stage,\n    cropRect,\n    pixelRatio = 2,\n    mimeType = \"image/jpeg\",\n    quality = 0.9,\n    onProgress,\n  } = opts;\n\n  const region = cropRect ?? {\n    x: 0,\n    y: 0,\n    width: stage.width(),\n    height: stage.height(),\n  };\n\n  onProgress?.(0);\n\n  const canvas = stage.toCanvas({\n    x: region.x,\n    y: region.y,\n    width: region.width,\n    height: region.height,\n    pixelRatio,\n  });\n\n  return new Promise<Blob>((resolve, reject) => {\n    canvas.toBlob(\n      (b) => {\n        if (b) {\n          onProgress?.(1);\n          resolve(b);\n        } else {\n          reject(new Error(\"Canvas.toBlob returned null\"));\n        }\n      },\n      mimeType,\n      quality,\n    );\n  });\n}\n\n/** Copy every computed style property from `src` onto `dst` inline. The\n * serialized SVG has no stylesheet, so class-driven styles (Tailwind) would\n * otherwise be lost in the rasterized output. The text-only surface is tiny\n * (a handful of nodes), so the full property walk is cheap. */\nfunction inlineComputedStyles(src: Element, dst: HTMLElement): void {\n  const cs = window.getComputedStyle(src);\n  for (let i = 0; i < cs.length; i++) {\n    const prop = cs.item(i);\n    dst.style.setProperty(prop, cs.getPropertyValue(prop));\n  }\n}\n\n/** Clone `source` for serialization: inline computed styles on every element\n * and swap textareas for divs carrying their live `.value` (a controlled\n * textarea's value lives on the DOM property, which XMLSerializer drops). */\nfunction prepareExportClone(\n  source: HTMLElement,\n  width: number,\n  height: number,\n): HTMLElement {\n  const clone = source.cloneNode(true) as HTMLElement;\n  const srcNodes: Element[] = [source, ...source.querySelectorAll(\"*\")];\n  const dstNodes: Element[] = [clone, ...clone.querySelectorAll(\"*\")];\n  for (let i = 0; i < srcNodes.length; i++) {\n    const src = srcNodes[i];\n    const dst = dstNodes[i] as HTMLElement;\n    if (src instanceof HTMLTextAreaElement) {\n      const div = clone.ownerDocument.createElement(\"div\");\n      div.textContent = src.value;\n      inlineComputedStyles(src, div);\n      div.style.whiteSpace = \"pre-wrap\";\n      div.style.overflow = \"hidden\";\n      dst.replaceWith(div);\n    } else {\n      inlineComputedStyles(src, dst);\n    }\n  }\n  // Pin the root to the export dimensions (the live element is positioned by\n  // its parent, which isn't serialized).\n  clone.style.position = \"static\";\n  clone.style.width = `${width}px`;\n  clone.style.height = `${height}px`;\n  return clone;\n}\n\n/**\n * Export a text-only mode capture from the styled DOM surface.\n *\n * SVG-foreignObject technique: clone the element (computed styles inlined,\n * textarea values materialized), serialise it into an SVG, then draw the SVG\n * onto a 2× canvas. Avoids a third-party html-to-canvas dependency.\n */\nexport async function exportTextOnlyBlob(\n  source: HTMLElement,\n  width: number,\n  height: number,\n): Promise<Blob> {\n  const xml = new XMLSerializer().serializeToString(\n    prepareExportClone(source, width, height),\n  );\n  const svg = `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"${width}\" height=\"${height}\"><foreignObject width=\"100%\" height=\"100%\">${xml}</foreignObject></svg>`;\n  const blob = new Blob([svg], { type: \"image/svg+xml;charset=utf-8\" });\n  const url = URL.createObjectURL(blob);\n  try {\n    const img = new Image();\n    await new Promise<void>((resolve, reject) => {\n      img.onload = () => resolve();\n      img.onerror = () => reject(new Error(\"Text-mode SVG render failed\"));\n      img.src = url;\n    });\n    const canvas = document.createElement(\"canvas\");\n    canvas.width = width * 2;\n    canvas.height = height * 2;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) throw new Error(\"2D context unavailable\");\n    ctx.scale(2, 2);\n    ctx.drawImage(img, 0, 0, width, height);\n    return await new Promise<Blob>((resolve, reject) => {\n      canvas.toBlob(\n        (b) => (b ? resolve(b) : reject(new Error(\"toBlob null\"))),\n        \"image/png\",\n      );\n    });\n  } finally {\n    URL.revokeObjectURL(url);\n  }\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/lib/export-blob.ts"
    },
    {
      "path": "src/registry/components/media/media-editor/lib/initial-source-loader.ts",
      "content": "// initial-source-loader — InitialSource intake + validation (C9).\n//\n// Pure helpers (no React). Resolves `InitialSource` → either a loaded\n// payload (object URL + derived mode + blob) or a `SourceError` per\n// description §5 validation rules:\n//\n//   - For `kind: \"file\"`, mode is derived from `File.type` prefix.\n//   - For `kind: \"url\"`, `fetch()` is attempted; CORS / network failures\n//     surface as `kind: \"cors\"` or `kind: \"fetch-failed\"`.\n//   - For `kind: \"blob\"`, mode is the consumer-supplied value.\n//   - In all paths, the resolved mode MUST be a member of `enabledModes`.\n//     Mismatch → `kind: \"mode-not-enabled\"`.\n//   - Empty / blank blobs → `kind: \"invalid-blob\"`.\n//\n// The orchestrator owns objectURL lifetime — the loader returns the URL\n// and the caller is expected to revoke it on unmount / source change.\n\nimport type { ComposerMode, InitialSource, SourceError } from \"../types\";\n\nexport type LoadedInitialSource = {\n  mode: \"photo\" | \"video\";\n  blob: Blob;\n  objectUrl: string;\n};\n\nexport type LoadInitialSourceResult =\n  | { ok: true; loaded: LoadedInitialSource }\n  | { ok: false; error: SourceError };\n\n/**\n * Derive mode from a `File.type` MIME prefix. Returns `null` for anything\n * other than `image/*` or `video/*` (consumer should treat as unsupported).\n */\nexport function inferModeFromFile(\n  file: File,\n): \"photo\" | \"video\" | null {\n  const type = file.type ?? \"\";\n  if (type.startsWith(\"image/\")) return \"photo\";\n  if (type.startsWith(\"video/\")) return \"video\";\n  return null;\n}\n\n/**\n * Resolve `InitialSource` to either a loaded payload or a `SourceError`.\n *\n * The returned object URL is allocated via `URL.createObjectURL` — caller\n * MUST revoke it (typically on unmount / next source change) to avoid\n * leaking blob references.\n *\n * `enabledModes` gates the resolved mode. If the consumer disabled the\n * mode required by the source, returns `mode-not-enabled` without\n * fetching (URL path) — so we don't waste a request on a source we\n * can't display anyway.\n */\nexport async function loadInitialSource(\n  source: InitialSource,\n  enabledModes: readonly ComposerMode[],\n): Promise<LoadInitialSourceResult> {\n  // Step 1 — resolve { mode, blob }. file/blob paths are sync; url path fetches.\n  let mode: \"photo\" | \"video\";\n  let blob: Blob;\n\n  if (source.kind === \"file\") {\n    const inferred = inferModeFromFile(source.file);\n    if (inferred === null) {\n      return {\n        ok: false,\n        error: {\n          kind: \"unsupported-file-type\",\n          fileType: source.file.type ?? \"\",\n          file: source.file,\n        },\n      };\n    }\n    mode = inferred;\n    blob = source.file;\n  } else if (source.kind === \"blob\") {\n    mode = source.mode;\n    blob = source.blob;\n  } else {\n    // kind === \"url\"\n    mode = source.mode;\n    // Pre-check enabledModes before paying the network cost.\n    // Empty enabledModes signals the edit-only path (initialSource without\n    // capture); the mode gate only applies when the consumer has explicitly\n    // enabled at least one capture mode.\n    if (enabledModes.length > 0 && !enabledModes.includes(mode)) {\n      return {\n        ok: false,\n        error: {\n          kind: \"mode-not-enabled\",\n          attempted: mode,\n          enabled: [...enabledModes],\n        },\n      };\n    }\n    try {\n      const res = await fetch(source.url, { mode: \"cors\" });\n      if (!res.ok) {\n        return {\n          ok: false,\n          error: {\n            kind: \"fetch-failed\",\n            url: source.url,\n            underlying: new Error(`HTTP ${res.status} ${res.statusText}`),\n          },\n        };\n      }\n      blob = await res.blob();\n    } catch (err) {\n      // CORS rejection surfaces as TypeError per spec; non-CORS network\n      // failures usually do too. The distinction matters for the consumer's\n      // recovery path (pre-fetch + blob vs. retry url), so we attempt to\n      // disambiguate via the error name + message.\n      const underlying = err instanceof Error ? err : new Error(String(err));\n      const looksLikeCors =\n        underlying.name === \"TypeError\" &&\n        /cors|cross.origin|failed to fetch|networkerror/i.test(\n          underlying.message,\n        );\n      return {\n        ok: false,\n        error: looksLikeCors\n          ? { kind: \"cors\", url: source.url, underlying }\n          : { kind: \"fetch-failed\", url: source.url, underlying },\n      };\n    }\n  }\n\n  // Step 2 — validate against enabledModes (file/blob paths only; url path\n  // pre-checked above to avoid a wasted fetch). Empty enabledModes = edit-only\n  // path, same exemption as the url pre-check.\n  if (enabledModes.length > 0 && !enabledModes.includes(mode)) {\n    return {\n      ok: false,\n      error: {\n        kind: \"mode-not-enabled\",\n        attempted: mode,\n        enabled: [...enabledModes],\n      },\n    };\n  }\n\n  // Step 3 — sanity-check the blob isn't empty.\n  if (blob.size === 0) {\n    return {\n      ok: false,\n      error: {\n        kind: \"invalid-blob\",\n        reason: \"Blob is empty (size: 0).\",\n      },\n    };\n  }\n\n  // Step 4 — allocate the object URL. Caller owns revocation.\n  const objectUrl = URL.createObjectURL(blob);\n  return { ok: true, loaded: { mode, blob, objectUrl } };\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/lib/initial-source-loader.ts"
    },
    {
      "path": "src/registry/components/media/media-editor/lib/konva-filters.ts",
      "content": "import type { FilterPreset, KonvaFilterSpec } from \"../types\";\n\n/**\n * Built-in filter presets (Q-P9-style Instagram-inspired chains, recreated\n * with Konva's filter primitives + reusable param overrides).\n *\n * Each preset is applied by:\n *   node.cache();\n *   node.filters(buildKonvaFilterChain(preset.konvaFilters));\n *   node.brightness(params.brightness ?? 0);\n *   // ...etc. per filter\n *\n * Real wiring lives in `composer-editor.tsx` — this file ships the data.\n */\n\nconst f = (name: string, params?: Record<string, number>): KonvaFilterSpec =>\n  params ? { name, params } : { name };\n\nexport const BUILT_IN_FILTER_PRESETS: FilterPreset[] = [\n  {\n    id: \"original\",\n    label: \"Original\",\n    konvaFilters: [],\n  },\n  {\n    id: \"clarendon\",\n    label: \"Clarendon\",\n    konvaFilters: [\n      f(\"Brighten\", { brightness: 0.08 }),\n      f(\"Contrast\", { contrast: 18 }),\n      f(\"HSL\", { saturation: 0.45, hue: 4 }),\n    ],\n  },\n  {\n    id: \"gingham\",\n    label: \"Gingham\",\n    konvaFilters: [\n      f(\"Brighten\", { brightness: 0.05 }),\n      f(\"Contrast\", { contrast: -12 }),\n      f(\"HSL\", { saturation: -0.25 }),\n      f(\"Sepia\"),\n    ],\n  },\n  {\n    id: \"moon\",\n    label: \"Moon\",\n    konvaFilters: [\n      f(\"Grayscale\"),\n      f(\"Brighten\", { brightness: 0.04 }),\n      f(\"Contrast\", { contrast: 14 }),\n    ],\n  },\n  {\n    id: \"lark\",\n    label: \"Lark\",\n    konvaFilters: [\n      f(\"Brighten\", { brightness: 0.12 }),\n      f(\"HSL\", { saturation: 0.2, hue: -4 }),\n    ],\n  },\n  {\n    id: \"reyes\",\n    label: \"Reyes\",\n    konvaFilters: [\n      f(\"Brighten\", { brightness: 0.1 }),\n      f(\"Contrast\", { contrast: -18 }),\n      f(\"HSL\", { saturation: -0.3 }),\n      f(\"Sepia\"),\n    ],\n  },\n  {\n    id: \"juno\",\n    label: \"Juno\",\n    konvaFilters: [\n      f(\"Contrast\", { contrast: 22 }),\n      f(\"HSL\", { saturation: 0.4, hue: 6 }),\n    ],\n  },\n  {\n    id: \"slumber\",\n    label: \"Slumber\",\n    konvaFilters: [\n      f(\"Brighten\", { brightness: -0.04 }),\n      f(\"HSL\", { saturation: -0.2, hue: 8 }),\n      f(\"Sepia\"),\n    ],\n  },\n  {\n    id: \"crema\",\n    label: \"Crema\",\n    konvaFilters: [\n      f(\"Brighten\", { brightness: 0.06 }),\n      f(\"HSL\", { saturation: -0.15 }),\n      f(\"Sepia\"),\n    ],\n  },\n  {\n    id: \"ludwig\",\n    label: \"Ludwig\",\n    konvaFilters: [\n      f(\"Brighten\", { brightness: 0.08 }),\n      f(\"Contrast\", { contrast: 16 }),\n      f(\"HSL\", { saturation: 0.1 }),\n    ],\n  },\n];\n\n/**\n * Merge consumer-supplied presets with built-ins.\n * `replace` true → consumer set wins entirely (no built-ins).\n */\nexport function resolveFilterPresets(\n  consumer: FilterPreset[] | undefined,\n  replace: boolean,\n): FilterPreset[] {\n  if (replace) return consumer ?? [];\n  if (!consumer || consumer.length === 0) return BUILT_IN_FILTER_PRESETS;\n  // Consumer presets appended after built-ins; ids are not de-duped (consumer's\n  // responsibility — id collisions can't happen by accident with built-in names).\n  return [...BUILT_IN_FILTER_PRESETS, ...consumer];\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/lib/konva-filters.ts"
    },
    {
      "path": "src/registry/components/media/media-editor/lib/mime-fallback.ts",
      "content": "/**\n * MediaRecorder codec preference chain.\n *\n * Chrome/Edge/Firefox prefer WebM (VP9 → VP8); Safari only supports MP4.\n * Each entry includes the audio codec so the resulting MIME string is\n * directly usable in `new MediaRecorder(stream, { mimeType })`.\n */\nexport const PREFERRED_RECORDER_MIME_TYPES = [\n  \"video/webm;codecs=vp9,opus\",\n  \"video/webm;codecs=vp8,opus\",\n  \"video/webm\",\n  \"video/mp4;codecs=avc1.42E01E,mp4a.40.2\",\n  \"video/mp4;codecs=avc1,mp4a\",\n  \"video/mp4\",\n] as const;\n\nexport type RecorderMimeType = (typeof PREFERRED_RECORDER_MIME_TYPES)[number];\n\n/**\n * Picks the highest-preference MIME the current browser supports for\n * MediaRecorder. Returns null when nothing matches (very old browsers).\n */\nexport function selectRecorderMime(): RecorderMimeType | null {\n  if (typeof MediaRecorder === \"undefined\") return null;\n  for (const mime of PREFERRED_RECORDER_MIME_TYPES) {\n    try {\n      if (MediaRecorder.isTypeSupported(mime)) return mime;\n    } catch {\n      // Some browsers throw on unknown MIMEs — keep looking.\n    }\n  }\n  return null;\n}\n\n/** Extracts the bare container (\"webm\" or \"mp4\") for filename hints. */\nexport function containerFor(mime: string): \"webm\" | \"mp4\" | \"unknown\" {\n  if (mime.startsWith(\"video/webm\")) return \"webm\";\n  if (mime.startsWith(\"video/mp4\")) return \"mp4\";\n  return \"unknown\";\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/lib/mime-fallback.ts"
    },
    {
      "path": "src/registry/components/media/media-editor/lib/presentation-resolver.ts",
      "content": "import type { ComposerMode } from \"../types\";\n\nexport type ResolvedPresentation = \"inline\" | \"dialog\";\n\n/**\n * Resolves the runtime `presentation` mode per description §6.\n *\n * Rules:\n *  - \"inline\" — render bare in parent layout. No portal/modal/focus-trap.\n *  - \"dialog\" — wrap in shadcn dialog.\n *  - \"auto\"   — if `enabledModes` is empty (pure-edit context, no capture\n *               surface), pick \"inline\". Otherwise pick \"dialog\". The presence\n *               of `initialSource` does NOT change the rule.\n */\nexport function resolvePresentation(\n  presentation: \"inline\" | \"dialog\" | \"auto\" | undefined,\n  enabledModes: readonly ComposerMode[],\n): ResolvedPresentation {\n  if (presentation === \"inline\") return \"inline\";\n  if (presentation === \"dialog\") return \"dialog\";\n  // auto (or undefined, defaults to auto)\n  return enabledModes.length === 0 ? \"inline\" : \"dialog\";\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/lib/presentation-resolver.ts"
    },
    {
      "path": "src/registry/components/media/media-editor/lib/resolve-crop-aspects.ts",
      "content": "import type { AspectRatio } from \"../types\";\n\nconst ALL_ASPECTS: readonly AspectRatio[] = [\n  \"9:16\",\n  \"1:1\",\n  \"16:9\",\n  \"4:5\",\n  \"free\",\n];\n\n/**\n * Crop tool's `cropAspects` default-derivation per description §4 \"crop\" row:\n *\n *   - If parent `aspect !== \"free\"` (canvas IS locked):\n *       default cropAspects = [aspect] — crop tool offers sub-region selection\n *       WITHIN the locked aspect only.\n *\n *   - If parent `aspect === \"free\"` (no canvas lock):\n *       default cropAspects = [\"9:16\",\"1:1\",\"16:9\",\"4:5\",\"free\"] — full menu.\n *\n *   - If consumer passes `cropAspects` explicitly, that overrides the default.\n */\nexport function resolveCropAspects(\n  aspect: AspectRatio,\n  cropAspectsOverride: AspectRatio[] | undefined,\n): AspectRatio[] {\n  if (cropAspectsOverride !== undefined) return cropAspectsOverride;\n  if (aspect === \"free\") return [...ALL_ASPECTS];\n  return [aspect];\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/lib/resolve-crop-aspects.ts"
    },
    {
      "path": "src/registry/components/media/media-editor/parts/color-swatch-picker.tsx",
      "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface ColorSwatchPickerProps {\n  value: string;\n  presets: string[];\n  onChange: (color: string) => void;\n  ariaLabel: string;\n  triggerClassName?: string;\n}\n\n/**\n * Popover color picker — preset swatch grid + free-form hex input.\n *\n * The trigger renders a 28px circle showing the current color.\n * No native <input type=\"color\"> (intentionally — it breaks design-system\n * consistency across OS pickers).\n *\n * F-cross-13 defense: PopoverTrigger renders as its own button without\n * `asChild` — Base UI's PopoverTrigger doesn't accept Slot mergeProps,\n * and the namespaced `radix-ui` package isn't portable across consumers\n * (some have only @radix-ui/react-popover individually). Composing\n * directly through PopoverTrigger works against either shape.\n */\nexport function ColorSwatchPicker({\n  value,\n  presets,\n  onChange,\n  ariaLabel,\n  triggerClassName,\n}: ColorSwatchPickerProps) {\n  const [hexDraft, setHexDraft] = useState(value);\n  const [open, setOpen] = useState(false);\n\n  const commitHex = (raw: string) => {\n    const trimmed = raw.trim();\n    if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(trimmed)) {\n      onChange(trimmed);\n    }\n  };\n\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      <PopoverTrigger\n        aria-label={ariaLabel}\n        className={cn(\n          \"inline-grid place-items-center size-7 rounded-full ring-2 ring-white/40 hover:ring-white transition-shadow\",\n          \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white\",\n          triggerClassName,\n        )}\n        style={{ background: value }}\n      >\n        <span className=\"sr-only\">{ariaLabel}</span>\n      </PopoverTrigger>\n      <PopoverContent\n        sideOffset={8}\n        className=\"w-56 p-3 bg-popover text-popover-foreground\"\n      >\n        <div className=\"grid grid-cols-6 gap-2\">\n          {presets.map((color) => {\n            const isActive = color.toLowerCase() === value.toLowerCase();\n            return (\n              <button\n                key={color}\n                type=\"button\"\n                onClick={() => {\n                  onChange(color);\n                  setHexDraft(color);\n                }}\n                aria-label={`Color ${color}`}\n                aria-pressed={isActive}\n                className={cn(\n                  \"size-7 rounded-full ring-2 ring-offset-1 ring-offset-popover\",\n                  isActive ? \"ring-foreground\" : \"ring-transparent\",\n                )}\n                style={{ background: color }}\n              />\n            );\n          })}\n        </div>\n        <div className=\"mt-3 flex items-center gap-2\">\n          <label className=\"text-xs text-muted-foreground\" htmlFor=\"color-hex\">\n            Hex\n          </label>\n          <input\n            id=\"color-hex\"\n            type=\"text\"\n            value={hexDraft}\n            onChange={(e) => setHexDraft(e.target.value)}\n            onBlur={(e) => commitHex(e.target.value)}\n            onKeyDown={(e) => {\n              if (e.key === \"Enter\") {\n                commitHex((e.target as HTMLInputElement).value);\n                setOpen(false);\n              }\n            }}\n            placeholder=\"#ffffff\"\n            className=\"flex-1 min-w-0 h-7 rounded-md border border-input bg-background px-2 text-xs font-mono\"\n          />\n        </div>\n      </PopoverContent>\n    </Popover>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/parts/color-swatch-picker.tsx"
    },
    {
      "path": "src/registry/components/media/media-editor/parts/discard-confirm-dialog.tsx",
      "content": "\"use client\";\n\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport type { StoryComposerLabels } from \"../types\";\n\nexport interface DiscardConfirmDialogProps {\n  open: boolean;\n  labels: Required<StoryComposerLabels>;\n  onCancel: () => void;\n  onConfirm: () => void;\n}\n\n/**\n * Q-P10a guard — confirms before discarding unsaved edits.\n *\n * Mounted unconditionally; the parent owns `open`. Consumers opt out\n * entirely via `confirmOnDiscard: false`.\n */\nexport function DiscardConfirmDialog({\n  open,\n  labels,\n  onCancel,\n  onConfirm,\n}: DiscardConfirmDialogProps) {\n  return (\n    <AlertDialog\n      open={open}\n      onOpenChange={(next) => {\n        if (!next) onCancel();\n      }}\n    >\n      <AlertDialogContent>\n        <AlertDialogHeader>\n          <AlertDialogTitle>{labels.discardConfirmTitle}</AlertDialogTitle>\n          <AlertDialogDescription>\n            {labels.discardConfirmBody}\n          </AlertDialogDescription>\n        </AlertDialogHeader>\n        <AlertDialogFooter>\n          <AlertDialogCancel onClick={onCancel}>\n            {labels.discardCancel}\n          </AlertDialogCancel>\n          <AlertDialogAction\n            onClick={onConfirm}\n            className=\"bg-destructive text-destructive-foreground hover:bg-destructive/90\"\n          >\n            {labels.discardConfirm}\n          </AlertDialogAction>\n        </AlertDialogFooter>\n      </AlertDialogContent>\n    </AlertDialog>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/parts/discard-confirm-dialog.tsx"
    },
    {
      "path": "src/registry/components/media/media-editor/parts/editor-canvas.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport Konva from \"konva\";\nimport type { Filter as KonvaFilter } from \"konva/lib/Node\";\nimport {\n  Stage,\n  Layer,\n  Image as KonvaImage,\n  Line as KonvaLine,\n  Text as KonvaText,\n  Transformer,\n} from \"react-konva\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  useKonvaStageSize,\n  useKonvaSelection,\n  usePanZoom,\n  type StageSize,\n} from \"../../media-editor\";\nimport { Button } from \"@/components/ui/button\";\nimport { Maximize2 } from \"lucide-react\";\nimport type { CropRect } from \"./tool-crop-overlay\";\nimport type {\n  DrawingStroke,\n  FilterPreset,\n  ImageAdjustments,\n  PlacedSticker,\n  StickerOption,\n  TextOverlay,\n} from \"../types\";\n\nexport interface EditorCanvasProps {\n  /** Image draft preview URL. For video drafts, the editor renders a <video> overlay instead (C7+). */\n  imageUrl: string | null;\n  /** Video draft preview URL (overlay edits land C7+; for now displays as background). */\n  videoUrl?: string | null;\n  /** Canvas surface background — usually composer's editorBackground. */\n  background?: string;\n  /** Brightness/contrast/saturation/blur sliders (C7). */\n  adjustments?: ImageAdjustments;\n  /** Active filter preset (C7). null = no preset. */\n  activeFilter?: FilterPreset | null;\n  /** Text overlays (C8). */\n  textOverlays?: TextOverlay[];\n  /** Currently selected text overlay id (C8). */\n  selectedTextId?: string | null;\n  /** Fired on drag-end or transform-end of a text overlay. */\n  onTextChange?: (next: TextOverlay) => void;\n  /** Fired when a text overlay is tapped (selects it). */\n  onTextSelect?: (id: string | null) => void;\n  /** Sticker overlays (C9). */\n  stickers?: PlacedSticker[];\n  /** Sticker-id → StickerOption resolver (lets the editor look up src/dimensions). */\n  resolveSticker?: (stickerId: string) => StickerOption | undefined;\n  /** Currently selected sticker id (C9). */\n  selectedStickerId?: string | null;\n  onStickerChange?: (next: PlacedSticker) => void;\n  onStickerSelect?: (id: string | null) => void;\n  /** Completed drawing strokes (C10). */\n  drawingStrokes?: DrawingStroke[];\n  /** In-progress stroke (rendered live while pointer is down). */\n  currentDrawingStroke?: DrawingStroke | null;\n  /** When true, Stage pointer events feed the drawing pipeline. */\n  isDrawing?: boolean;\n  onDrawBegin?: (x: number, y: number) => void;\n  onDrawExtend?: (x: number, y: number) => void;\n  onDrawEnd?: () => void;\n  /** Crop rect in stage coordinates (C11). Editor renders a dim+frame overlay when active. */\n  cropRect?: CropRect | null;\n  /** When true, the crop overlay is interactive (drag handles + reposition). */\n  cropActive?: boolean;\n  /** Aspect ratio of crop — locks resize behavior. */\n  cropAspectRatio?: number | null;\n  onCropChange?: (next: CropRect) => void;\n  /** Surfaces stage dimensions to the parent (used to recompute crop rects on aspect change). */\n  onStageSize?: (size: { width: number; height: number }) => void;\n  /** Surfaces the underlying Konva.Stage instance for export (C12). */\n  onStageReady?: (stage: Konva.Stage | null) => void;\n  className?: string;\n}\n\n/**\n * Multi-layer Konva canvas with:\n *   <Layer id=\"image\">     — captured media as Konva.Image (filters land C7)\n *   <Layer id=\"drawing\">   — freehand strokes (C10)\n *   <Layer id=\"stickers\">  — sticker instances (C9)\n *   <Layer id=\"text\">      — text overlays (C8)\n *   <Layer id=\"ui\">        — Konva.Transformer (selection handles)\n *\n * C6: only the image layer renders. Other layers are wired empty so\n * subsequent commits land their items into the right z-order without\n * a substrate refactor.\n */\nexport function EditorCanvas({\n  imageUrl,\n  videoUrl,\n  background = \"#000\",\n  adjustments,\n  activeFilter,\n  textOverlays,\n  selectedTextId,\n  onTextChange,\n  onTextSelect,\n  stickers,\n  resolveSticker,\n  selectedStickerId,\n  onStickerChange,\n  onStickerSelect,\n  drawingStrokes,\n  currentDrawingStroke,\n  isDrawing = false,\n  onDrawBegin,\n  onDrawExtend,\n  onDrawEnd,\n  cropRect,\n  cropActive = false,\n  cropAspectRatio,\n  onCropChange,\n  onStageSize,\n  onStageReady,\n  className,\n}: EditorCanvasProps) {\n  const containerRef = useRef<HTMLDivElement | null>(null);\n  const stageRef = useRef<Konva.Stage | null>(null);\n  const stageSize = useKonvaStageSize(containerRef);\n  // Destructure the selection hook result to locals up front. `transformerRef`\n  // is a ref-typed field; reading it as `selection.transformerRef` makes the\n  // React Compiler's aliasing analysis treat the whole result object as a ref\n  // container and flag every access (known false-positive class — same fix as\n  // story-viewer/reply-composer).\n  const { transformerRef, clear: clearSelection } = useKonvaSelection();\n  const imageNodeRef = useRef<Konva.Image | null>(null);\n\n  // Single-pointer drag-pan must yield to draggable overlays. Hit-test the\n  // stage on pointer-down: if a listening node (text / sticker / transformer\n  // handle) is under the pointer, don't pan — let Konva drag that node. The\n  // image layer is listening={false}, so background/image hits return null →\n  // pan engages.\n  const shouldStartPan = useCallback((e: { clientX: number; clientY: number }) => {\n    const stage = stageRef.current;\n    if (!stage) return true;\n    const rect = stage.container().getBoundingClientRect();\n    return !stage.getIntersection({\n      x: e.clientX - rect.left,\n      y: e.clientY - rect.top,\n    });\n  }, []);\n\n  // Pan + pinch-zoom (single-pointer drag / touch 2-finger / wheel / keyboard).\n  // Disabled when:\n  //   - drawing is active (Stage owns the pointer pipeline for strokes)\n  //   - cropping is active (crop overlay is DOM; can't follow stage transform)\n  const panZoom = usePanZoom({\n    targetRef: containerRef,\n    enabled: !isDrawing && !cropActive,\n    shouldStartPan,\n  });\n\n  // Crop maps stage-coordinate rects onto the source image assuming an\n  // identity stage transform, and the DOM crop overlay can't follow stage\n  // zoom/pan — normalize the view the moment the crop tool takes over.\n  // (Pan/zoom is merely DISABLED while cropping; without this reset a\n  // zoom-then-crop crops the wrong region.)\n  const panZoomReset = panZoom.reset;\n  useEffect(() => {\n    if (cropActive) panZoomReset();\n  }, [cropActive, panZoomReset]);\n\n  const [image, imageSize] = useImage(imageUrl);\n  const fit = fitInto(imageSize, stageSize);\n\n  // Plain-<img> underlay: visible while Konva decodes the blob URL into its\n  // own HTMLImageElement and waits for the Stage's ResizeObserver to fire.\n  // Without this the user sees a black background between Suspense resolving\n  // and the KonvaImage actually rendering. Once `image` lands, hide the\n  // underlay so applied filters/adjustments aren't doubled by the unfiltered\n  // copy bleeding through Konva's transparent canvas.\n  const showUnderlay = !!imageUrl && !image;\n\n  useEffect(() => {\n    if (stageSize.width > 0 && stageSize.height > 0) {\n      onStageSize?.(stageSize);\n    }\n  }, [stageSize, onStageSize]);\n\n  // Auto-initialize cropRect when the crop tool activates without an existing\n  // rect. Default = centered, 80% of stage dimensions, respecting cropAspectRatio\n  // if set (rect aspect = min of stage-fit + crop-lock). Without this, clicking\n  // the crop button activates the tool but leaves the overlay invisible because\n  // it gates on `cropRect != null`.\n  useEffect(() => {\n    if (!cropActive) return;\n    if (cropRect) return;\n    if (stageSize.width <= 0 || stageSize.height <= 0) return;\n    if (!onCropChange) return;\n    const sw = stageSize.width;\n    const sh = stageSize.height;\n    let rw = sw * 0.8;\n    let rh = sh * 0.8;\n    if (cropAspectRatio && cropAspectRatio > 0) {\n      // Fit the largest centered rect with cropAspectRatio inside 80%-of-stage.\n      const boxAspect = rw / rh;\n      if (boxAspect > cropAspectRatio) {\n        rw = rh * cropAspectRatio;\n      } else {\n        rh = rw / cropAspectRatio;\n      }\n    }\n    onCropChange({\n      x: (sw - rw) / 2,\n      y: (sh - rh) / 2,\n      width: rw,\n      height: rh,\n    });\n  }, [cropActive, cropRect, stageSize, cropAspectRatio, onCropChange]);\n\n  // Apply filter chain + adjustments to the image node.\n  // Konva requires .cache() before filters take effect. Re-cache when the\n  // image source changes; re-apply filter params on every change.\n  useEffect(() => {\n    const node = imageNodeRef.current;\n    if (!node || !image) return;\n    const filterChain = buildKonvaFilterChain(\n      activeFilter ?? null,\n      adjustments,\n    );\n    node.cache();\n    // The Konva.Filters array type is loose; chain is a list of filter fns.\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    node.filters(filterChain as any);\n    applyFilterParams(node, activeFilter ?? null, adjustments);\n    node.getLayer()?.batchDraw();\n  }, [image, activeFilter, adjustments]);\n\n  const textNodeRefs = useRef<Map<string, Konva.Text>>(new Map());\n  const stickerNodeRefs = useRef<Map<string, Konva.Image>>(new Map());\n\n  // Attach the Transformer to the currently-selected node (text or sticker).\n  useEffect(() => {\n    const t = transformerRef.current;\n    if (!t) return;\n    let node: Konva.Node | undefined;\n    if (selectedTextId) {\n      node = textNodeRefs.current.get(selectedTextId);\n    } else if (selectedStickerId) {\n      node = stickerNodeRefs.current.get(selectedStickerId);\n    }\n    if (node) {\n      t.nodes([node]);\n    } else {\n      t.nodes([]);\n    }\n    t.getLayer()?.batchDraw();\n  }, [\n    selectedTextId,\n    selectedStickerId,\n    textOverlays,\n    stickers,\n    transformerRef,\n  ]);\n\n  // Click/tap on bare Stage background → clear all selection. Accepts mouse OR\n  // touch events so it can wire to both onMouseDown + onTouchStart without a cast.\n  const handleStageMouseDown = (\n    e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,\n  ) => {\n    if (isDrawing) {\n      // Drawing intercepts everything — see handleStagePointerDown below.\n      return;\n    }\n    if (e.target === e.target.getStage()) {\n      onTextSelect?.(null);\n      onStickerSelect?.(null);\n      clearSelection();\n    }\n  };\n\n  // Drawing-mode pointer pipeline. We read the cursor position from the\n  // Stage so it's already in stage coordinates (not page coordinates).\n  const handleStagePointerDown = (\n    e: Konva.KonvaEventObject<PointerEvent>,\n  ) => {\n    if (!isDrawing) return;\n    const stage = e.target.getStage();\n    // getRelativePointerPosition returns coords AFTER the Stage's own scale +\n    // position transform, which is what we want — strokes stick to the image\n    // regardless of zoom/pan state.\n    const pos = stage?.getRelativePointerPosition();\n    if (pos) onDrawBegin?.(pos.x, pos.y);\n  };\n\n  const handleStagePointerMove = (\n    e: Konva.KonvaEventObject<PointerEvent>,\n  ) => {\n    if (!isDrawing || !currentDrawingStroke) return;\n    const stage = e.target.getStage();\n    // getRelativePointerPosition returns coords AFTER the Stage's own scale +\n    // position transform, which is what we want — strokes stick to the image\n    // regardless of zoom/pan state.\n    const pos = stage?.getRelativePointerPosition();\n    if (pos) onDrawExtend?.(pos.x, pos.y);\n  };\n\n  const handleStagePointerUp = () => {\n    if (!isDrawing) return;\n    onDrawEnd?.();\n  };\n\n  return (\n    <div\n      ref={containerRef}\n      // Focusable so usePanZoom's keyboard listener (bound to this element, not\n      // window) is focus-scoped — arrow/zoom keys only act when the canvas holds\n      // focus. role/aria-label name the region for AT.\n      tabIndex={0}\n      role=\"group\"\n      aria-label=\"Media editor canvas — arrow keys pan, +/- zoom, 0 resets\"\n      className={cn(\n        \"relative w-full h-full overflow-hidden touch-none\",\n        \"outline-hidden focus-visible:ring-2 focus-visible:ring-ring/70 focus-visible:ring-inset\",\n        className,\n      )}\n      style={{ background }}\n      onPointerDown={panZoom.handlers.onPointerDown}\n      onPointerMove={panZoom.handlers.onPointerMove}\n      onPointerUp={panZoom.handlers.onPointerUp}\n      onPointerCancel={panZoom.handlers.onPointerCancel}\n    >\n      {/* Instant-photo underlay — see `showUnderlay` above. */}\n      {showUnderlay ? (\n        <img\n          src={imageUrl ?? undefined}\n          alt=\"\"\n          draggable={false}\n          className=\"absolute inset-0 w-full h-full object-contain pointer-events-none\"\n        />\n      ) : null}\n\n      {/* Video drafts render as plain <video> until C7+ wires overlays. */}\n      {videoUrl ? (\n        <video\n          src={videoUrl}\n          autoPlay\n          loop\n          muted\n          playsInline\n          className=\"absolute inset-0 w-full h-full object-contain\"\n        />\n      ) : null}\n\n      {/* Stage requires non-zero dimensions; render only after size is measured. */}\n      {stageSize.width > 0 && stageSize.height > 0 ? (\n        <Stage\n          ref={(s) => {\n            stageRef.current = s;\n            onStageReady?.(s);\n          }}\n          width={stageSize.width}\n          height={stageSize.height}\n          scaleX={panZoom.transform.scale}\n          scaleY={panZoom.transform.scale}\n          x={panZoom.transform.x}\n          y={panZoom.transform.y}\n          onMouseDown={handleStageMouseDown}\n          onTouchStart={handleStageMouseDown}\n          onPointerDown={handleStagePointerDown}\n          onPointerMove={handleStagePointerMove}\n          onPointerUp={handleStagePointerUp}\n          onPointerCancel={handleStagePointerUp}\n          className=\"absolute inset-0\"\n          style={isDrawing ? { cursor: \"crosshair\" } : undefined}\n        >\n          {/* image layer */}\n          <Layer listening={false}>\n            {image && imageUrl ? (\n              <KonvaImage\n                ref={imageNodeRef}\n                image={image}\n                x={fit.x}\n                y={fit.y}\n                width={fit.width}\n                height={fit.height}\n              />\n            ) : null}\n          </Layer>\n          {/* drawing layer (C10) */}\n          <Layer listening={false}>\n            {drawingStrokes?.map((stroke) => (\n              <KonvaLine\n                key={stroke.id}\n                points={stroke.points}\n                stroke={stroke.color}\n                strokeWidth={stroke.brushSize}\n                lineCap=\"round\"\n                lineJoin=\"round\"\n                tension={0.4}\n                globalCompositeOperation={\n                  stroke.mode === \"erase\"\n                    ? \"destination-out\"\n                    : \"source-over\"\n                }\n              />\n            ))}\n            {currentDrawingStroke ? (\n              <KonvaLine\n                key=\"current\"\n                points={currentDrawingStroke.points}\n                stroke={currentDrawingStroke.color}\n                strokeWidth={currentDrawingStroke.brushSize}\n                lineCap=\"round\"\n                lineJoin=\"round\"\n                tension={0.4}\n                globalCompositeOperation={\n                  currentDrawingStroke.mode === \"erase\"\n                    ? \"destination-out\"\n                    : \"source-over\"\n                }\n              />\n            ) : null}\n          </Layer>\n          {/* sticker layer (C9) */}\n          <Layer>\n            {stickers?.map((placed) => {\n              const option = resolveSticker?.(placed.stickerId);\n              if (!option) return null;\n              return (\n                <PlacedStickerImage\n                  key={placed.id}\n                  placed={placed}\n                  option={option}\n                  onMount={(node) => {\n                    if (node) stickerNodeRefs.current.set(placed.id, node);\n                    else stickerNodeRefs.current.delete(placed.id);\n                  }}\n                  onSelect={() => onStickerSelect?.(placed.id)}\n                  onChange={onStickerChange}\n                />\n              );\n            })}\n          </Layer>\n          {/* text layer (C8) */}\n          <Layer>\n            {textOverlays?.map((overlay) => (\n              <KonvaText\n                key={overlay.id}\n                ref={(node) => {\n                  if (node) textNodeRefs.current.set(overlay.id, node);\n                  else textNodeRefs.current.delete(overlay.id);\n                }}\n                x={overlay.x}\n                y={overlay.y}\n                text={overlay.text}\n                fontFamily={overlay.fontFamily}\n                fontSize={overlay.fontSize}\n                fill={overlay.fill}\n                align={overlay.align}\n                rotation={overlay.rotation}\n                scaleX={overlay.scale}\n                scaleY={overlay.scale}\n                draggable\n                onMouseDown={(e) => {\n                  e.cancelBubble = true;\n                  onTextSelect?.(overlay.id);\n                }}\n                onTouchStart={(e) => {\n                  e.cancelBubble = true;\n                  onTextSelect?.(overlay.id);\n                }}\n                onDragEnd={(e) => {\n                  onTextChange?.({\n                    ...overlay,\n                    x: e.target.x(),\n                    y: e.target.y(),\n                  });\n                }}\n                onTransformEnd={(e) => {\n                  const node = e.target as Konva.Text;\n                  const nextScale = node.scaleX();\n                  onTextChange?.({\n                    ...overlay,\n                    x: node.x(),\n                    y: node.y(),\n                    rotation: node.rotation(),\n                    scale: nextScale,\n                  });\n                }}\n              />\n            ))}\n          </Layer>\n          {/* ui layer — Transformer attached on selection */}\n          <Layer>\n            <Transformer\n              ref={transformerRef}\n              rotateEnabled\n              keepRatio={false}\n              boundBoxFunc={(_oldBox, newBox) => {\n                // Prevent degenerate negative-size handles.\n                if (newBox.width < 8 || newBox.height < 8) return _oldBox;\n                return newBox;\n              }}\n            />\n          </Layer>\n        </Stage>\n      ) : null}\n\n      {/* Reset-zoom CTA — only visible when zoomed/panned, hidden during crop */}\n      {!panZoom.isIdentity && !cropActive ? (\n        <Button\n          type=\"button\"\n          variant=\"secondary\"\n          size=\"sm\"\n          onClick={panZoom.reset}\n          aria-label=\"Reset zoom\"\n          className=\"absolute top-3 left-3 z-20 gap-1.5 bg-black/60 backdrop-blur-md text-white hover:bg-black/80 hover:text-white\"\n        >\n          <Maximize2 className=\"size-3.5\" />\n          <span className=\"text-xs font-mono tabular-nums\">\n            {Math.round(panZoom.transform.scale * 100)}%\n          </span>\n        </Button>\n      ) : null}\n\n      {/* Crop overlay (DOM over the Konva canvas). C11. */}\n      {cropActive && cropRect && stageSize.width > 0 ? (\n        <CropOverlay\n          rect={cropRect}\n          stageWidth={stageSize.width}\n          stageHeight={stageSize.height}\n          aspectRatio={cropAspectRatio ?? null}\n          onChange={onCropChange}\n        />\n      ) : null}\n    </div>\n  );\n}\n\n// ─── Crop overlay (DOM) ───────────────────────────────────────────────\n\ninterface CropOverlayProps {\n  rect: CropRect;\n  stageWidth: number;\n  stageHeight: number;\n  aspectRatio: number | null;\n  onChange?: (next: CropRect) => void;\n}\n\nconst HANDLE_SIZE = 14;\nconst MIN_CROP = 40;\n\nfunction CropOverlay({\n  rect,\n  stageWidth,\n  stageHeight,\n  aspectRatio,\n  onChange,\n}: CropOverlayProps) {\n  const dragStateRef = useRef<{\n    mode: \"move\" | \"ne\" | \"nw\" | \"se\" | \"sw\";\n    startX: number;\n    startY: number;\n    startRect: CropRect;\n  } | null>(null);\n\n  // Plain two-arg handler invoked from inline arrows in the JSX props below.\n  // (The previous curried `beginDrag(mode)` builder ran during render, which\n  // made the compiler flag its ref write; the handler form only runs on the\n  // pointer event.)\n  const beginDrag = (\n    mode: \"move\" | \"ne\" | \"nw\" | \"se\" | \"sw\",\n    e: React.PointerEvent,\n  ) => {\n    e.stopPropagation();\n    e.currentTarget.setPointerCapture?.(e.pointerId);\n    dragStateRef.current = {\n      mode,\n      startX: e.clientX,\n      startY: e.clientY,\n      startRect: { ...rect },\n    };\n  };\n\n  const onPointerMove = (e: React.PointerEvent) => {\n    const ds = dragStateRef.current;\n    if (!ds || !onChange) return;\n    const dx = e.clientX - ds.startX;\n    const dy = e.clientY - ds.startY;\n\n    let { x, y, width, height } = ds.startRect;\n    const r = aspectRatio; // null = free\n\n    if (ds.mode === \"move\") {\n      x = clamp(ds.startRect.x + dx, 0, stageWidth - ds.startRect.width);\n      y = clamp(ds.startRect.y + dy, 0, stageHeight - ds.startRect.height);\n    } else {\n      // Resize from a corner. Compute signed \"growth\" for each axis (positive\n      // = corner drag enlarges the rect). When aspect-locked, the rect must\n      // stay locked end-to-end — including when it hits a stage edge: growth\n      // stops cleanly, never breaks ratio. We compute max-allowable next\n      // dimensions given the anchor position + stage bounds, then clamp the\n      // drive to the most-restrictive of (candidate-from-drag, max-from-X,\n      // max-from-Y-mapped-through-r). When free, axes are independent.\n      const growX =\n        ds.mode === \"se\" || ds.mode === \"ne\" ? dx : -dx;\n      const growY =\n        ds.mode === \"se\" || ds.mode === \"sw\" ? dy : -dy;\n      // Max additional growth allowed in each axis, given the anchor.\n      const maxGrowX =\n        ds.mode === \"ne\" || ds.mode === \"se\"\n          ? stageWidth - (ds.startRect.x + ds.startRect.width)\n          : ds.startRect.x;\n      const maxGrowY =\n        ds.mode === \"sw\" || ds.mode === \"se\"\n          ? stageHeight - (ds.startRect.y + ds.startRect.height)\n          : ds.startRect.y;\n      const maxNextW = ds.startRect.width + maxGrowX;\n      const maxNextH = ds.startRect.height + maxGrowY;\n\n      let nextWidth: number;\n      let nextHeight: number;\n      if (r) {\n        // User drives EITHER axis. Pick the axis with larger signed growth.\n        const xEquivFromY = growY * r;\n        const drive =\n          Math.abs(xEquivFromY) > Math.abs(growX) ? xEquivFromY : growX;\n        let candidateW = ds.startRect.width + drive;\n        // Aspect-respecting upper bound: width can't exceed maxNextW OR\n        // maxNextH * r (whichever binds first). Lower bound: MIN_CROP.\n        const aspectCappedMaxW = Math.min(maxNextW, maxNextH * r);\n        candidateW = Math.min(candidateW, aspectCappedMaxW);\n        candidateW = Math.max(candidateW, MIN_CROP);\n        // Same for shrink direction — width derived height must be >= MIN_CROP.\n        candidateW = Math.max(candidateW, MIN_CROP * r);\n        nextWidth = candidateW;\n        nextHeight = nextWidth / r;\n      } else {\n        // Free resize — each axis clamped independently.\n        nextWidth = Math.max(\n          MIN_CROP,\n          Math.min(ds.startRect.width + growX, maxNextW),\n        );\n        nextHeight = Math.max(\n          MIN_CROP,\n          Math.min(ds.startRect.height + growY, maxNextH),\n        );\n      }\n\n      // Anchor: the corner opposite to the dragged one stays fixed.\n      if (ds.mode === \"nw\" || ds.mode === \"sw\") {\n        x = ds.startRect.x + (ds.startRect.width - nextWidth);\n      }\n      if (ds.mode === \"nw\" || ds.mode === \"ne\") {\n        y = ds.startRect.y + (ds.startRect.height - nextHeight);\n      }\n\n      // Stage-bound clamp (safety; aspect-respecting cap above should make\n      // these no-ops in normal cases).\n      x = clamp(x, 0, stageWidth - nextWidth);\n      y = clamp(y, 0, stageHeight - nextHeight);\n\n      width = nextWidth;\n      height = nextHeight;\n    }\n\n    onChange({ x, y, width, height });\n  };\n\n  const endDrag = () => {\n    dragStateRef.current = null;\n  };\n\n  return (\n    <div\n      className=\"absolute inset-0 pointer-events-none\"\n      style={{ width: stageWidth, height: stageHeight }}\n      onPointerMove={onPointerMove}\n      onPointerUp={endDrag}\n      onPointerCancel={endDrag}\n    >\n      {/* Dim 4 quadrants outside the crop rect */}\n      <div\n        className=\"absolute inset-x-0 top-0 bg-black/60\"\n        style={{ height: rect.y }}\n      />\n      <div\n        className=\"absolute inset-x-0 bottom-0 bg-black/60\"\n        style={{ height: stageHeight - rect.y - rect.height }}\n      />\n      <div\n        className=\"absolute left-0 bg-black/60\"\n        style={{\n          top: rect.y,\n          height: rect.height,\n          width: rect.x,\n        }}\n      />\n      <div\n        className=\"absolute right-0 bg-black/60\"\n        style={{\n          top: rect.y,\n          height: rect.height,\n          width: stageWidth - rect.x - rect.width,\n        }}\n      />\n\n      {/* Crop frame (movable) */}\n      <div\n        className=\"absolute border-2 border-white pointer-events-auto cursor-move\"\n        style={{\n          left: rect.x,\n          top: rect.y,\n          width: rect.width,\n          height: rect.height,\n        }}\n        onPointerDown={(e) => beginDrag(\"move\", e)}\n      >\n        {/* Rule-of-thirds grid */}\n        <div className=\"absolute inset-0 grid grid-cols-3 grid-rows-3 pointer-events-none\">\n          {Array.from({ length: 9 }).map((_, i) => (\n            <div key={i} className=\"border border-white/20\" />\n          ))}\n        </div>\n\n        {/* Corner handles */}\n        {([\"nw\", \"ne\", \"sw\", \"se\"] as const).map((corner) => (\n          <button\n            type=\"button\"\n            key={corner}\n            onPointerDown={(e) => beginDrag(corner, e)}\n            className=\"absolute bg-white rounded-full shadow\"\n            style={{\n              width: HANDLE_SIZE,\n              height: HANDLE_SIZE,\n              left:\n                corner === \"nw\" || corner === \"sw\"\n                  ? -HANDLE_SIZE / 2\n                  : undefined,\n              right:\n                corner === \"ne\" || corner === \"se\"\n                  ? -HANDLE_SIZE / 2\n                  : undefined,\n              top:\n                corner === \"nw\" || corner === \"ne\"\n                  ? -HANDLE_SIZE / 2\n                  : undefined,\n              bottom:\n                corner === \"sw\" || corner === \"se\"\n                  ? -HANDLE_SIZE / 2\n                  : undefined,\n              cursor:\n                corner === \"nw\" || corner === \"se\"\n                  ? \"nwse-resize\"\n                  : \"nesw-resize\",\n            }}\n            aria-label={`Resize ${corner.toUpperCase()}`}\n          />\n        ))}\n      </div>\n    </div>\n  );\n}\n\nfunction clamp(v: number, min: number, max: number): number {\n  return Math.max(min, Math.min(max, v));\n}\n\n// ─── Sticker rendering ────────────────────────────────────────────────\n\ninterface PlacedStickerImageProps {\n  placed: PlacedSticker;\n  option: StickerOption;\n  onMount: (node: Konva.Image | null) => void;\n  onSelect: () => void;\n  onChange?: (next: PlacedSticker) => void;\n}\n\nfunction PlacedStickerImage({\n  placed,\n  option,\n  onMount,\n  onSelect,\n  onChange,\n}: PlacedStickerImageProps) {\n  const [image] = useImage(option.src);\n  if (!image) return null;\n  return (\n    <KonvaImage\n      ref={(n) => onMount(n)}\n      image={image}\n      x={placed.x}\n      y={placed.y}\n      width={option.width ?? 128}\n      height={option.height ?? 128}\n      offsetX={(option.width ?? 128) / 2}\n      offsetY={(option.height ?? 128) / 2}\n      rotation={placed.rotation}\n      scaleX={placed.scale}\n      scaleY={placed.scale}\n      draggable\n      onMouseDown={(e) => {\n        e.cancelBubble = true;\n        onSelect();\n      }}\n      onTouchStart={(e) => {\n        e.cancelBubble = true;\n        onSelect();\n      }}\n      onDragEnd={(e) => {\n        onChange?.({\n          ...placed,\n          x: e.target.x(),\n          y: e.target.y(),\n        });\n      }}\n      onTransformEnd={(e) => {\n        const node = e.target as Konva.Image;\n        onChange?.({\n          ...placed,\n          x: node.x(),\n          y: node.y(),\n          rotation: node.rotation(),\n          scale: node.scaleX(),\n        });\n      }}\n    />\n  );\n}\n\n// ─── Helpers ──────────────────────────────────────────────────────────\n\ninterface Size {\n  width: number;\n  height: number;\n}\n\n/** Load an image element + report its natural size. Returns [image, size]. */\nfunction useImage(\n  src: string | null,\n): [HTMLImageElement | null, Size] {\n  const [image, setImage] = useState<HTMLImageElement | null>(null);\n  const [size, setSize] = useState<Size>({ width: 0, height: 0 });\n\n  // Reset when the source is CLEARED — during render via the guarded\n  // prev-props pattern (React docs \"adjusting state when props change\"), so\n  // the effect below only does external-system work (image loading).\n  // Deliberately fires only on the →null transition: a src→src change keeps\n  // the old image visible until the replacement loads (existing behavior).\n  const [prevSrc, setPrevSrc] = useState(src);\n  if (prevSrc !== src) {\n    setPrevSrc(src);\n    if (!src) {\n      setImage(null);\n      setSize({ width: 0, height: 0 });\n    }\n  }\n\n  useEffect(() => {\n    if (!src) return;\n    const img = new window.Image();\n    img.crossOrigin = \"anonymous\";\n    let cancelled = false;\n    img.onload = () => {\n      if (cancelled) return;\n      setImage(img);\n      setSize({ width: img.naturalWidth, height: img.naturalHeight });\n    };\n    img.onerror = () => {\n      if (cancelled) return;\n      setImage(null);\n    };\n    img.src = src;\n    return () => {\n      cancelled = true;\n    };\n  }, [src]);\n\n  return [image, size];\n}\n\n// ─── Filter helpers ───────────────────────────────────────────────────\n\nconst KONVA_FILTER_MAP: Record<string, KonvaFilter | undefined> = {\n  Brighten: Konva.Filters.Brighten,\n  Contrast: Konva.Filters.Contrast,\n  HSL: Konva.Filters.HSL,\n  Sepia: Konva.Filters.Sepia,\n  Grayscale: Konva.Filters.Grayscale,\n  Blur: Konva.Filters.Blur,\n  Noise: Konva.Filters.Noise,\n};\n\n/** Build the KonvaFilter array for the active preset + adjust sliders. */\nfunction buildKonvaFilterChain(\n  preset: FilterPreset | null,\n  adjustments?: ImageAdjustments,\n): KonvaFilter[] {\n  const seen = new Set<string>();\n  const out: KonvaFilter[] = [];\n  const add = (name: string) => {\n    if (seen.has(name)) return;\n    const fn = KONVA_FILTER_MAP[name];\n    if (!fn) return;\n    seen.add(name);\n    out.push(fn);\n  };\n  if (preset) {\n    for (const spec of preset.konvaFilters) add(spec.name);\n  }\n  // Always include the adjustment filters so the sliders work over any preset.\n  if (adjustments) {\n    if (adjustments.brightness !== 0) add(\"Brighten\");\n    if (adjustments.contrast !== 0) add(\"Contrast\");\n    if (adjustments.saturation !== 0) add(\"HSL\");\n    if (adjustments.blur > 0) add(\"Blur\");\n  }\n  return out;\n}\n\n/** Push numeric filter params onto the image node. */\nfunction applyFilterParams(\n  node: Konva.Image,\n  preset: FilterPreset | null,\n  adjustments?: ImageAdjustments,\n) {\n  // Reset to neutral before applying.\n  node.brightness(0);\n  node.contrast(0);\n  node.saturation(0);\n  node.hue(0);\n  node.blurRadius(0);\n\n  // Apply preset values first.\n  if (preset) {\n    for (const spec of preset.konvaFilters) {\n      const p = spec.params ?? {};\n      if (spec.name === \"Brighten\" && p.brightness !== undefined) {\n        node.brightness(p.brightness);\n      } else if (spec.name === \"Contrast\" && p.contrast !== undefined) {\n        node.contrast(p.contrast);\n      } else if (spec.name === \"HSL\") {\n        if (p.saturation !== undefined) node.saturation(p.saturation);\n        if (p.hue !== undefined) node.hue(p.hue);\n      } else if (spec.name === \"Blur\" && p.blurRadius !== undefined) {\n        node.blurRadius(p.blurRadius);\n      }\n    }\n  }\n  // Adjustments add on top of preset values.\n  if (adjustments) {\n    node.brightness(node.brightness() + adjustments.brightness);\n    node.contrast(node.contrast() + adjustments.contrast);\n    node.saturation(node.saturation() + adjustments.saturation);\n    if (adjustments.blur > 0) node.blurRadius(adjustments.blur);\n  }\n}\n\n/** Object-fit: contain inside the stage. */\nfunction fitInto(\n  source: Size,\n  target: StageSize,\n): { x: number; y: number; width: number; height: number } {\n  if (\n    source.width === 0 ||\n    source.height === 0 ||\n    target.width === 0 ||\n    target.height === 0\n  ) {\n    return { x: 0, y: 0, width: 0, height: 0 };\n  }\n  const scale = Math.min(\n    target.width / source.width,\n    target.height / source.height,\n  );\n  const width = source.width * scale;\n  const height = source.height * scale;\n  const x = (target.width - width) / 2;\n  const y = (target.height - height) / 2;\n  return { x, y, width, height };\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/parts/editor-canvas.tsx"
    },
    {
      "path": "src/registry/components/media/media-editor/parts/editor-toolbar.tsx",
      "content": "\"use client\";\n\nimport {\n  Crop,\n  Palette,\n  Sliders,\n  Smile,\n  Type,\n  Wand,\n} from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport type { EditTool, StoryComposerLabels } from \"../types\";\n\nexport interface EditorToolbarProps {\n  activeTool: EditTool | null;\n  enabledTools: EditTool[];\n  /** Tools that aren't shipped yet — render disabled. */\n  pendingTools?: EditTool[];\n  labels: Required<StoryComposerLabels>;\n  onSelect: (tool: EditTool | null) => void;\n  className?: string;\n}\n\ninterface ToolConfig {\n  key: EditTool;\n  icon: React.ComponentType<{ className?: string }>;\n  label: (labels: Required<StoryComposerLabels>) => string;\n}\n\nconst TOOL_ORDER: ToolConfig[] = [\n  { key: \"text\", icon: Type, label: (l) => l.toolText },\n  { key: \"draw\", icon: Wand, label: (l) => l.toolDraw },\n  { key: \"stickers\", icon: Smile, label: (l) => l.toolStickers },\n  { key: \"filters\", icon: Palette, label: (l) => l.toolFilters },\n  { key: \"adjust\", icon: Sliders, label: (l) => l.toolAdjust },\n  { key: \"crop\", icon: Crop, label: (l) => l.toolCrop },\n];\n\nexport function EditorToolbar({\n  activeTool,\n  enabledTools,\n  pendingTools = [],\n  labels,\n  onSelect,\n  className,\n}: EditorToolbarProps) {\n  const visible = TOOL_ORDER.filter((t) => enabledTools.includes(t.key));\n\n  return (\n    <div\n      role=\"toolbar\"\n      aria-label=\"Edit tools\"\n      className={cn(\n        \"flex items-center justify-around gap-1 rounded-2xl\",\n        \"bg-black/70 backdrop-blur-md px-1 py-1.5 text-white\",\n        className,\n      )}\n    >\n      {visible.map((tool) => {\n        const Icon = tool.icon;\n        const isActive = activeTool === tool.key;\n        const isPending = pendingTools.includes(tool.key);\n        return (\n          <Button\n            key={tool.key}\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"sm\"\n            disabled={isPending}\n            aria-pressed={isActive}\n            aria-label={tool.label(labels)}\n            onClick={() => onSelect(isActive ? null : tool.key)}\n            className={cn(\n              \"flex flex-col items-center gap-0.5 h-auto px-2 py-1.5 text-[10px] text-white/80 hover:bg-white/10 hover:text-white\",\n              isActive && \"text-white bg-white/15\",\n              isPending && \"opacity-40\",\n            )}\n          >\n            <Icon className=\"size-5\" />\n            <span>{tool.label(labels)}</span>\n          </Button>\n        );\n      })}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/parts/editor-toolbar.tsx"
    },
    {
      "path": "src/registry/components/media/media-editor/parts/mode-toggle-pill.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport type { ComposerMode, StoryComposerLabels } from \"../types\";\n\nexport interface ModeTogglePillProps {\n  mode: ComposerMode;\n  visibleModes: ComposerMode[];\n  labels: Required<StoryComposerLabels>;\n  onModeChange: (mode: ComposerMode) => void;\n  className?: string;\n}\n\nconst ORDER: ComposerMode[] = [\"photo\", \"video\", \"text\"];\n\n/**\n * Plain-button segmented control. Deliberately NOT shadcn ToggleGroup — its\n * value model diverges between Radix (single: string) and Base UI (string[]),\n * an F-cross-13 carrier that breaks Base-UI consumers (filter-panel 0.1.1 /\n * event-calendar v0.2.1 precedent: same swap). Re-tapping the active mode stays\n * a noop (ToggleGroup used to emit \"\" on re-tap; here we simply don't fire).\n */\nexport function ModeTogglePill({\n  mode,\n  visibleModes,\n  labels,\n  onModeChange,\n  className,\n}: ModeTogglePillProps) {\n  if (visibleModes.length < 2) return null;\n\n  const orderedVisible = ORDER.filter((m) => visibleModes.includes(m));\n\n  return (\n    <div\n      role=\"group\"\n      className={cn(\n        \"inline-flex rounded-full bg-black/40 backdrop-blur-md p-1 text-white\",\n        className,\n      )}\n    >\n      {orderedVisible.map((m) => {\n        const active = m === mode;\n        return (\n          <button\n            key={m}\n            type=\"button\"\n            aria-pressed={active}\n            aria-label={labelFor(m, labels)}\n            onClick={() => {\n              if (!active) onModeChange(m);\n            }}\n            className={cn(\n              \"rounded-full px-4 py-1.5 text-xs font-medium tracking-wide uppercase\",\n              \"transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n              active\n                ? \"bg-white text-black\"\n                : \"text-white/70 hover:bg-white/10 hover:text-white\",\n            )}\n          >\n            {labelFor(m, labels)}\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n\nfunction labelFor(\n  mode: ComposerMode,\n  labels: Required<StoryComposerLabels>,\n): string {\n  switch (mode) {\n    case \"photo\":\n      return labels.modePhoto;\n    case \"video\":\n      return labels.modeVideo;\n    case \"text\":\n      return labels.modeText;\n  }\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/parts/mode-toggle-pill.tsx"
    },
    {
      "path": "src/registry/components/media/media-editor/parts/text-only-canvas.tsx",
      "content": "\"use client\";\n\nimport { forwardRef, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport { ColorSwatchPicker } from \"./color-swatch-picker\";\nimport type { GradientPreset, FontOption } from \"../../media-editor\";\nimport type { StoryComposerLabels } from \"../types\";\n\nexport interface TextOnlyCanvasProps {\n  gradients: GradientPreset[];\n  fonts: FontOption[];\n  colorPresets: string[];\n  labels: Required<StoryComposerLabels>;\n  className?: string;\n}\n\nexport interface TextOnlyCanvasState {\n  text: string;\n  fontFamily: string;\n  textColor: string;\n  gradientId: string;\n}\n\n/**\n * Text-only mode capture surface.\n *\n * Rendered as a styled DOM element (NOT Konva) so `exportTextOnlyBlob()`\n * can rasterise it via the SVG-foreignObject technique on publish. The\n * forwardRef exposes the canvas <div> element so the publish path can\n * pass it to the exporter.\n */\nexport const TextOnlyCanvas = forwardRef<\n  HTMLDivElement,\n  TextOnlyCanvasProps & {\n    value: TextOnlyCanvasState;\n    onChange: (next: TextOnlyCanvasState) => void;\n  }\n>(function TextOnlyCanvas(\n  { gradients, fonts, colorPresets, labels, value, onChange, className },\n  ref,\n) {\n  const [pickerOpen, setPickerOpen] = useState<\"gradient\" | \"font\" | null>(\n    null,\n  );\n\n  const activeGradient =\n    gradients.find((g) => g.id === value.gradientId) ?? gradients[0];\n\n  const update = (patch: Partial<TextOnlyCanvasState>) =>\n    onChange({ ...value, ...patch });\n\n  return (\n    <div\n      className={cn(\"relative flex-1 flex flex-col overflow-hidden\", className)}\n    >\n      {/* Render surface — exported as PNG on publish */}\n      <div\n        ref={ref}\n        className=\"absolute inset-0 grid place-items-center px-8\"\n        style={{ background: activeGradient?.background ?? \"#000\" }}\n      >\n        <textarea\n          value={value.text}\n          onChange={(e) => update({ text: e.target.value })}\n          placeholder={labels.textPlaceholder}\n          rows={3}\n          aria-label={labels.textPlaceholder}\n          className=\"w-full max-w-[20rem] resize-none bg-transparent text-center placeholder:text-current/40 focus:outline-none\"\n          style={{\n            color: value.textColor,\n            fontFamily: value.fontFamily,\n            fontSize: \"clamp(1.5rem, 7vw, 3rem)\",\n            lineHeight: 1.2,\n            fontWeight: 600,\n            textShadow: \"0 2px 8px rgba(0,0,0,0.25)\",\n          }}\n        />\n      </div>\n\n      {/* Bottom control bar — gradient picker + font + color */}\n      <div\n        className=\"absolute left-0 right-0 z-20 flex flex-col gap-2 px-3\"\n        style={{\n          bottom: \"max(1.25rem, env(safe-area-inset-bottom))\",\n        }}\n      >\n        {pickerOpen === \"gradient\" ? (\n          <div className=\"flex items-center gap-2 overflow-x-auto rounded-2xl bg-black/70 backdrop-blur-md p-2\">\n            {gradients.map((g) => {\n              const isActive = g.id === value.gradientId;\n              return (\n                <button\n                  key={g.id}\n                  type=\"button\"\n                  onClick={() => update({ gradientId: g.id })}\n                  aria-label={g.label}\n                  aria-pressed={isActive}\n                  className={cn(\n                    \"flex-none size-10 rounded-full ring-2 transition-shadow\",\n                    isActive ? \"ring-white\" : \"ring-transparent\",\n                  )}\n                  style={{ background: g.background }}\n                />\n              );\n            })}\n          </div>\n        ) : null}\n\n        <div className=\"flex items-center justify-between gap-2 rounded-2xl bg-black/70 backdrop-blur-md p-2 text-white\">\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            onClick={() =>\n              setPickerOpen((p) => (p === \"gradient\" ? null : \"gradient\"))\n            }\n            aria-pressed={pickerOpen === \"gradient\"}\n            className=\"text-xs gap-2 text-white hover:bg-white/10 hover:text-white\"\n          >\n            <span\n              className=\"inline-block size-4 rounded-full ring-2 ring-white/40\"\n              style={{ background: activeGradient?.background ?? \"#000\" }}\n              aria-hidden\n            />\n            <span>Background</span>\n          </Button>\n\n          <div className=\"flex items-center gap-2\">\n            <select\n              value={value.fontFamily}\n              onChange={(e) => update({ fontFamily: e.target.value })}\n              aria-label={labels.textFontFamily}\n              className=\"h-7 rounded-md bg-black/40 border border-white/15 px-2 text-xs\"\n            >\n              {fonts.map((font) => (\n                <option\n                  key={font.id}\n                  value={font.family}\n                  style={{ fontFamily: font.family }}\n                >\n                  {font.label}\n                </option>\n              ))}\n            </select>\n            <ColorSwatchPicker\n              value={value.textColor}\n              presets={colorPresets}\n              onChange={(c) => update({ textColor: c })}\n              ariaLabel={labels.drawColor}\n            />\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n});\n",
      "type": "registry:component",
      "target": "components/media-editor/parts/text-only-canvas.tsx"
    },
    {
      "path": "src/registry/components/media/media-editor/parts/tool-adjust-sliders.tsx",
      "content": "\"use client\";\n\nimport { useCallback } from \"react\";\nimport { Slider } from \"@/components/ui/slider\";\nimport { cn } from \"@/lib/utils\";\nimport type { ImageAdjustments, StoryComposerLabels } from \"../types\";\n\nexport interface ToolAdjustSlidersProps {\n  value: ImageAdjustments;\n  onChange: (next: ImageAdjustments) => void;\n  labels: Required<StoryComposerLabels>;\n  className?: string;\n}\n\ninterface SliderConfig {\n  key: keyof ImageAdjustments;\n  label: string;\n  min: number;\n  max: number;\n  step: number;\n}\n\nexport function ToolAdjustSliders({\n  value,\n  onChange,\n  labels,\n  className,\n}: ToolAdjustSlidersProps) {\n  const configs: SliderConfig[] = [\n    {\n      key: \"brightness\",\n      label: labels.adjustBrightness,\n      min: -1,\n      max: 1,\n      step: 0.05,\n    },\n    {\n      key: \"contrast\",\n      label: labels.adjustContrast,\n      min: -100,\n      max: 100,\n      step: 5,\n    },\n    {\n      key: \"saturation\",\n      label: labels.adjustSaturation,\n      min: -2,\n      max: 5,\n      step: 0.1,\n    },\n    {\n      key: \"blur\",\n      label: labels.adjustBlur,\n      min: 0,\n      max: 40,\n      step: 1,\n    },\n  ];\n\n  const update = useCallback(\n    (key: keyof ImageAdjustments, next: number) => {\n      onChange({ ...value, [key]: next });\n    },\n    [onChange, value],\n  );\n\n  return (\n    <div\n      className={cn(\n        \"flex flex-col gap-4 p-4 rounded-2xl bg-black/70 backdrop-blur-md text-white\",\n        className,\n      )}\n    >\n      {configs.map((cfg) => (\n        <div key={cfg.key} className=\"flex flex-col gap-1.5\">\n          <div className=\"flex items-center justify-between text-xs\">\n            <span className=\"font-medium\">{cfg.label}</span>\n            <span className=\"font-mono tabular-nums text-white/60\">\n              {formatValue(value[cfg.key], cfg.key)}\n            </span>\n          </div>\n          <Slider\n            value={[value[cfg.key]]}\n            min={cfg.min}\n            max={cfg.max}\n            step={cfg.step}\n            // Radix emits number[]; Base UI number | readonly number[] (F-cross-13)\n            onValueChange={(v) => update(cfg.key, typeof v === \"number\" ? v : (v[0] ?? 0))}\n            aria-label={cfg.label}\n          />\n        </div>\n      ))}\n    </div>\n  );\n}\n\nfunction formatValue(v: number, key: keyof ImageAdjustments): string {\n  if (key === \"contrast\" || key === \"blur\") return String(Math.round(v));\n  return v.toFixed(2);\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/parts/tool-adjust-sliders.tsx"
    },
    {
      "path": "src/registry/components/media/media-editor/parts/tool-crop-overlay.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport type { AspectRatio, CropRect } from \"../types\";\n\n// Decoupled from story-shaped labels post-extraction. Story-composer-01 v0.2.0\n// wrapper passes `cropLabel={labels.toolCrop}`; other consumers pass their own\n// label string directly.\n\nexport interface ToolCropOverlayProps {\n  activeAspect: AspectRatio;\n  availableAspects: AspectRatio[];\n  /** Raw \"Crop\" label text. Defaults to \"Crop\". */\n  cropLabel?: string;\n  onAspectChange: (aspect: AspectRatio) => void;\n  className?: string;\n}\n\nconst ASPECT_LABEL: Record<AspectRatio, string> = {\n  \"9:16\": \"9:16\",\n  \"1:1\": \"1:1\",\n  \"16:9\": \"16:9\",\n  \"4:5\": \"4:5\",\n  free: \"Free\",\n};\n\nexport function ToolCropOverlay({\n  activeAspect,\n  availableAspects,\n  cropLabel = \"Crop\",\n  onAspectChange,\n  className,\n}: ToolCropOverlayProps) {\n  return (\n    <div\n      className={cn(\n        \"flex items-center justify-center gap-2 rounded-2xl bg-black/70 backdrop-blur-md p-2 text-white\",\n        className,\n      )}\n    >\n      <span className=\"text-[10px] uppercase tracking-wider text-white/60 px-2\">\n        {cropLabel}\n      </span>\n      {availableAspects.map((aspect) => {\n        const isActive = activeAspect === aspect;\n        return (\n          <button\n            key={aspect}\n            type=\"button\"\n            onClick={() => onAspectChange(aspect)}\n            aria-pressed={isActive}\n            className={cn(\n              \"rounded-full px-3 py-1 text-xs font-medium tracking-wide transition-colors\",\n              isActive\n                ? \"bg-white text-black\"\n                : \"text-white/70 hover:bg-white/10 hover:text-white\",\n            )}\n          >\n            {ASPECT_LABEL[aspect]}\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n\n// ─── Geometry helpers (consumed by editor-canvas) ─────────────────────\n\n// CropRect now lives in ../types — single source of truth across the procomp.\n// Re-exported here for any v0.1.5 consumer of story-composer that imported\n// `CropRect` from `parts/tool-crop-overlay` (story-composer v0.2.0 barrel\n// preserves the re-export for backward-compat).\nexport type { CropRect };\n\nexport const ASPECT_RATIO_VALUES: Record<AspectRatio, number | null> = {\n  \"9:16\": 9 / 16,\n  \"1:1\": 1,\n  \"16:9\": 16 / 9,\n  \"4:5\": 4 / 5,\n  free: null,\n};\n\n/**\n * Centered, max-fitting rect for the given aspect ratio inside the stage.\n * Returns the full stage rect for \"free\" (no constraint).\n */\nexport function fitCropToStage(\n  aspect: AspectRatio,\n  stageWidth: number,\n  stageHeight: number,\n): CropRect {\n  const ratio = ASPECT_RATIO_VALUES[aspect];\n  if (ratio === null) {\n    return { x: 0, y: 0, width: stageWidth, height: stageHeight };\n  }\n  // Find largest rect with the given aspect that fits inside stage.\n  let width = stageWidth;\n  let height = stageWidth / ratio;\n  if (height > stageHeight) {\n    height = stageHeight;\n    width = stageHeight * ratio;\n  }\n  return {\n    x: (stageWidth - width) / 2,\n    y: (stageHeight - height) / 2,\n    width,\n    height,\n  };\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/parts/tool-crop-overlay.tsx"
    },
    {
      "path": "src/registry/components/media/media-editor/parts/tool-draw-controls.tsx",
      "content": "\"use client\";\n\nimport { Eraser, Pen } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Slider } from \"@/components/ui/slider\";\nimport { cn } from \"@/lib/utils\";\nimport { ColorSwatchPicker } from \"./color-swatch-picker\";\nimport type { StoryComposerLabels } from \"../types\";\n\nexport interface ToolDrawControlsProps {\n  color: string;\n  brushSize: number;\n  mode: \"draw\" | \"erase\";\n  colorPresets: string[];\n  labels: Required<StoryComposerLabels>;\n  onColorChange: (color: string) => void;\n  onBrushSizeChange: (size: number) => void;\n  onModeChange: (mode: \"draw\" | \"erase\") => void;\n  className?: string;\n}\n\nconst MIN_BRUSH = 1;\nconst MAX_BRUSH = 60;\n\nexport function ToolDrawControls({\n  color,\n  brushSize,\n  mode,\n  colorPresets,\n  labels,\n  onColorChange,\n  onBrushSizeChange,\n  onModeChange,\n  className,\n}: ToolDrawControlsProps) {\n  return (\n    <div\n      className={cn(\n        \"flex flex-col gap-3 rounded-2xl bg-black/70 backdrop-blur-md p-3 text-white\",\n        className,\n      )}\n    >\n      {/* Top row: mode toggle + color */}\n      <div className=\"flex items-center justify-between gap-3\">\n        <div className=\"inline-flex rounded-md bg-black/40 border border-white/15 p-0.5\">\n          <Button\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"sm\"\n            onClick={() => onModeChange(\"draw\")}\n            aria-pressed={mode === \"draw\"}\n            aria-label={labels.toolDraw}\n            className={cn(\n              \"h-7 px-2 rounded-sm gap-1.5 text-xs\",\n              mode === \"draw\"\n                ? \"bg-white text-black hover:bg-white hover:text-black\"\n                : \"text-white/80 hover:bg-white/10 hover:text-white\",\n            )}\n          >\n            <Pen className=\"size-3.5\" />\n            <span>{labels.toolDraw}</span>\n          </Button>\n          <Button\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"sm\"\n            onClick={() => onModeChange(\"erase\")}\n            aria-pressed={mode === \"erase\"}\n            aria-label={labels.drawEraser}\n            className={cn(\n              \"h-7 px-2 rounded-sm gap-1.5 text-xs\",\n              mode === \"erase\"\n                ? \"bg-white text-black hover:bg-white hover:text-black\"\n                : \"text-white/80 hover:bg-white/10 hover:text-white\",\n            )}\n          >\n            <Eraser className=\"size-3.5\" />\n            <span>{labels.drawEraser}</span>\n          </Button>\n        </div>\n\n        <div className=\"flex items-center gap-2\">\n          <span className=\"text-[10px] uppercase tracking-wider text-white/60\">\n            {labels.drawColor}\n          </span>\n          <ColorSwatchPicker\n            value={color}\n            presets={colorPresets}\n            onChange={onColorChange}\n            ariaLabel={labels.drawColor}\n          />\n        </div>\n      </div>\n\n      {/* Brush size */}\n      <div className=\"flex items-center gap-3\">\n        <span className=\"text-[10px] uppercase tracking-wider text-white/60 w-12\">\n          {labels.drawBrush}\n        </span>\n        <Slider\n          value={[brushSize]}\n          min={MIN_BRUSH}\n          max={MAX_BRUSH}\n          step={1}\n          // Radix emits number[]; Base UI number | readonly number[] (F-cross-13)\n          onValueChange={(v) =>\n            onBrushSizeChange(typeof v === \"number\" ? v : (v[0] ?? brushSize))\n          }\n          aria-label={labels.drawBrush}\n          className=\"flex-1\"\n        />\n        <span className=\"text-xs font-mono tabular-nums w-7 text-right\">\n          {brushSize}\n        </span>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/parts/tool-draw-controls.tsx"
    },
    {
      "path": "src/registry/components/media/media-editor/parts/tool-filter-strip.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport type { FilterPreset } from \"../types\";\n\nexport interface ToolFilterStripProps {\n  presets: FilterPreset[];\n  /** Image URL the thumbnails sample (the captured photo). */\n  sourceUrl: string;\n  /** Currently-active preset id. null = \"original\" / no filter. */\n  activeId: string | null;\n  onSelect: (id: string | null) => void;\n  className?: string;\n}\n\ninterface ThumbnailEntry {\n  preset: FilterPreset;\n  url: string;\n}\n\nconst THUMB_SIZE = 56;\n\n/**\n * Horizontal scrollable strip of filter presets, each rendered as a\n * pre-rendered thumbnail of the captured photo with that filter applied.\n *\n * Q-P7a — thumbnails are computed once on edit-mode entry (~1s for ~10\n * filters on a typical 1080p photo). Subsequent taps are instant.\n *\n * For C7 we use CSS-filter approximations (cheap, no Konva), which gives\n * the user a near-WYSIWYG preview. The actual on-stage application\n * (which uses Konva.Filters) is wired in composer-editor.tsx and matches\n * the CSS recipe closely enough for browse-then-confirm UX.\n */\nexport function ToolFilterStrip({\n  presets,\n  sourceUrl,\n  activeId,\n  onSelect,\n  className,\n}: ToolFilterStripProps) {\n  const [thumbs, setThumbs] = useState<ThumbnailEntry[]>([]);\n  const cancelledRef = useRef(false);\n\n  // Clear stale thumbnails when the input empties — during render via the\n  // guarded prev-props pattern, so the effect below only does external-system\n  // work (image load + canvas rendering). Fires only on the →empty\n  // transition; a sourceUrl/presets change between non-empty values keeps the\n  // old thumbs visible until the re-render lands (existing behavior).\n  const inputEmpty = !sourceUrl || presets.length === 0;\n  const [prevInputEmpty, setPrevInputEmpty] = useState(inputEmpty);\n  if (inputEmpty !== prevInputEmpty) {\n    setPrevInputEmpty(inputEmpty);\n    if (inputEmpty) setThumbs([]);\n  }\n\n  useEffect(() => {\n    cancelledRef.current = false;\n    if (!sourceUrl || presets.length === 0) return;\n\n    const img = new window.Image();\n    img.crossOrigin = \"anonymous\";\n    img.onload = () => {\n      if (cancelledRef.current) return;\n      const canvas = document.createElement(\"canvas\");\n      canvas.width = THUMB_SIZE;\n      canvas.height = THUMB_SIZE;\n      const ctx = canvas.getContext(\"2d\");\n      if (!ctx) return;\n\n      const drawCovered = () => {\n        const ratio = Math.max(\n          THUMB_SIZE / img.naturalWidth,\n          THUMB_SIZE / img.naturalHeight,\n        );\n        const w = img.naturalWidth * ratio;\n        const h = img.naturalHeight * ratio;\n        const x = (THUMB_SIZE - w) / 2;\n        const y = (THUMB_SIZE - h) / 2;\n        ctx.clearRect(0, 0, THUMB_SIZE, THUMB_SIZE);\n        ctx.drawImage(img, x, y, w, h);\n      };\n\n      const out: ThumbnailEntry[] = [];\n      for (const preset of presets) {\n        ctx.filter = cssFilterForPreset(preset);\n        drawCovered();\n        out.push({ preset, url: canvas.toDataURL(\"image/jpeg\", 0.7) });\n      }\n      ctx.filter = \"none\";\n      if (!cancelledRef.current) setThumbs(out);\n    };\n    img.src = sourceUrl;\n\n    return () => {\n      cancelledRef.current = true;\n    };\n  }, [sourceUrl, presets]);\n\n  return (\n    <div\n      className={cn(\n        \"flex items-center gap-2 overflow-x-auto overscroll-x-contain\",\n        \"rounded-2xl bg-black/70 backdrop-blur-md p-2 text-white\",\n        \"scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent\",\n        className,\n      )}\n    >\n      {(thumbs.length > 0\n        ? thumbs\n        : presets.map((p) => ({ preset: p, url: \"\" }))\n      ).map(({ preset, url }) => {\n        const isActive = activeId === preset.id || (!activeId && preset.id === \"original\");\n        return (\n          <button\n            key={preset.id}\n            type=\"button\"\n            onClick={() =>\n              onSelect(preset.id === \"original\" ? null : preset.id)\n            }\n            className={cn(\n              \"flex-none flex flex-col items-center gap-1 outline-none\",\n              \"focus-visible:ring-2 focus-visible:ring-white rounded-lg\",\n            )}\n            aria-pressed={isActive}\n            aria-label={preset.label}\n          >\n            <div\n              className={cn(\n                \"size-14 rounded-lg overflow-hidden bg-black/60 ring-2 transition-shadow\",\n                isActive ? \"ring-white\" : \"ring-transparent\",\n              )}\n            >\n              {url ? (\n                <img\n                  src={url}\n                  alt=\"\"\n                  className=\"w-full h-full object-cover\"\n                />\n              ) : (\n                <div className=\"w-full h-full bg-white/5 animate-pulse\" />\n              )}\n            </div>\n            <span className=\"text-[10px] font-medium uppercase tracking-wider\">\n              {preset.label}\n            </span>\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n\n/**\n * Approximate each Konva filter chain with a CSS `filter:` string for the\n * preview thumbnail. CSS doesn't have a 1:1 mapping, but this approximation\n * is good enough for browse-and-pick UX.\n */\nfunction cssFilterForPreset(preset: FilterPreset): string {\n  if (preset.konvaFilters.length === 0) return \"none\";\n  const parts: string[] = [];\n  let saturate = 1;\n  let brightness = 1;\n  let contrast = 1;\n  let sepia = 0;\n  let hue = 0;\n  let grayscale = 0;\n  let blur = 0;\n  for (const f of preset.konvaFilters) {\n    switch (f.name) {\n      case \"Brighten\":\n        brightness *= 1 + (f.params?.brightness ?? 0);\n        break;\n      case \"Contrast\":\n        contrast *= 1 + (f.params?.contrast ?? 0) / 100;\n        break;\n      case \"HSL\":\n        saturate *= 1 + (f.params?.saturation ?? 0);\n        hue += f.params?.hue ?? 0;\n        break;\n      case \"Sepia\":\n        sepia = Math.min(1, sepia + 0.25);\n        break;\n      case \"Grayscale\":\n        grayscale = 1;\n        break;\n      case \"Blur\":\n        blur += f.params?.blurRadius ?? 0;\n        break;\n    }\n  }\n  parts.push(`brightness(${brightness.toFixed(2)})`);\n  parts.push(`contrast(${contrast.toFixed(2)})`);\n  parts.push(`saturate(${Math.max(0, saturate).toFixed(2)})`);\n  if (hue !== 0) parts.push(`hue-rotate(${hue}deg)`);\n  if (sepia > 0) parts.push(`sepia(${sepia.toFixed(2)})`);\n  if (grayscale > 0) parts.push(`grayscale(${grayscale})`);\n  if (blur > 0) parts.push(`blur(${blur}px)`);\n  return parts.join(\" \");\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/parts/tool-filter-strip.tsx"
    },
    {
      "path": "src/registry/components/media/media-editor/parts/tool-sticker-picker.tsx",
      "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport type { StickerOption, StickerSet } from \"../types\";\n\nexport interface ToolStickerPickerProps {\n  sets: StickerSet[];\n  onPick: (sticker: StickerOption) => void;\n  className?: string;\n}\n\n/**\n * Bottom-sheet sticker picker. Top row of category tabs + scrollable grid\n * of stickers from the active set. Tap a sticker → consumer adds it to\n * the stage (center) and selects it for transformer manipulation.\n */\nexport function ToolStickerPicker({\n  sets,\n  onPick,\n  className,\n}: ToolStickerPickerProps) {\n  const [activeSetId, setActiveSetId] = useState(sets[0]?.id ?? \"\");\n  const activeSet = sets.find((s) => s.id === activeSetId) ?? sets[0];\n\n  if (!activeSet) return null;\n\n  return (\n    <div\n      className={cn(\n        \"flex flex-col gap-2 rounded-2xl bg-black/70 backdrop-blur-md p-3 text-white\",\n        className,\n      )}\n    >\n      {/* Category tabs */}\n      {sets.length > 1 ? (\n        <div className=\"flex items-center gap-1 overflow-x-auto\">\n          {sets.map((set) => {\n            const isActive = set.id === activeSet.id;\n            return (\n              <button\n                key={set.id}\n                type=\"button\"\n                onClick={() => setActiveSetId(set.id)}\n                aria-pressed={isActive}\n                className={cn(\n                  \"flex-none rounded-full px-3 py-1 text-[10px] font-medium tracking-wider uppercase transition-colors\",\n                  isActive\n                    ? \"bg-white text-black\"\n                    : \"text-white/70 hover:text-white hover:bg-white/10\",\n                )}\n              >\n                {set.label}\n              </button>\n            );\n          })}\n        </div>\n      ) : null}\n\n      {/* Sticker grid — capped at ~3 rows so the panel doesn't take the whole screen */}\n      <div\n        className={cn(\n          \"grid grid-cols-6 gap-2 overflow-y-auto\",\n          \"max-h-[40dvh] sm:max-h-44\",\n        )}\n      >\n        {activeSet.stickers.map((sticker) => (\n          <button\n            key={sticker.id}\n            type=\"button\"\n            onClick={() => onPick(sticker)}\n            aria-label={sticker.alt}\n            className=\"aspect-square rounded-lg bg-white/5 hover:bg-white/15 active:scale-95 transition-transform p-1 grid place-items-center\"\n          >\n            <img\n              src={sticker.src}\n              alt={sticker.alt}\n              className=\"w-full h-full object-contain\"\n            />\n          </button>\n        ))}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/parts/tool-sticker-picker.tsx"
    },
    {
      "path": "src/registry/components/media/media-editor/parts/tool-text-input.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { AlignCenter, AlignLeft, AlignRight, Trash2 } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Slider } from \"@/components/ui/slider\";\nimport { cn } from \"@/lib/utils\";\nimport { ColorSwatchPicker } from \"./color-swatch-picker\";\nimport type {\n  FontOption,\n  StoryComposerLabels,\n  TextOverlay,\n} from \"../types\";\n\nexport interface ToolTextInputProps {\n  overlay: TextOverlay;\n  fonts: FontOption[];\n  colorPresets: string[];\n  labels: Required<StoryComposerLabels>;\n  onChange: (next: TextOverlay) => void;\n  onDelete: () => void;\n  className?: string;\n}\n\nconst MIN_FONT_SIZE = 16;\nconst MAX_FONT_SIZE = 96;\n\n/**\n * Active-text-overlay editor — text input, font, size, color, align.\n * Mounts when there's a selected text overlay.\n */\nexport function ToolTextInput({\n  overlay,\n  fonts,\n  colorPresets,\n  labels,\n  onChange,\n  onDelete,\n  className,\n}: ToolTextInputProps) {\n  const textareaRef = useRef<HTMLTextAreaElement | null>(null);\n\n  // Autofocus when a new overlay is selected.\n  useEffect(() => {\n    textareaRef.current?.focus();\n    textareaRef.current?.select();\n  }, [overlay.id]);\n\n  const update = (patch: Partial<TextOverlay>) =>\n    onChange({ ...overlay, ...patch });\n\n  return (\n    <div\n      className={cn(\n        \"flex flex-col gap-3 rounded-2xl bg-black/70 backdrop-blur-md p-3 text-white\",\n        className,\n      )}\n    >\n      <textarea\n        ref={textareaRef}\n        value={overlay.text}\n        onChange={(e) => update({ text: e.target.value })}\n        placeholder={labels.textPlaceholder}\n        rows={2}\n        className=\"w-full resize-none rounded-md bg-black/40 border border-white/15 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-white/40\"\n        style={{ fontFamily: overlay.fontFamily }}\n      />\n\n      {/* Font + color + delete */}\n      <div className=\"flex items-center gap-2\">\n        <label className=\"sr-only\" htmlFor=\"text-font\">\n          {labels.textFontFamily}\n        </label>\n        <select\n          id=\"text-font\"\n          value={overlay.fontFamily}\n          onChange={(e) => update({ fontFamily: e.target.value })}\n          className=\"flex-1 h-8 rounded-md bg-black/40 border border-white/15 px-2 text-xs\"\n        >\n          {fonts.map((font) => (\n            <option\n              key={font.id}\n              value={font.family}\n              style={{ fontFamily: font.family }}\n            >\n              {font.label}\n            </option>\n          ))}\n        </select>\n\n        <ColorSwatchPicker\n          value={overlay.fill}\n          presets={colorPresets}\n          onChange={(color) => update({ fill: color })}\n          ariaLabel={labels.drawColor}\n        />\n\n        {/* Align toggle */}\n        <div className=\"inline-flex rounded-md bg-black/40 border border-white/15 p-0.5\">\n          {([\"left\", \"center\", \"right\"] as const).map((align) => (\n            <button\n              key={align}\n              type=\"button\"\n              onClick={() => update({ align })}\n              aria-label={`${labels.textAlign} ${align}`}\n              aria-pressed={overlay.align === align}\n              className={cn(\n                \"p-1 rounded-sm\",\n                overlay.align === align\n                  ? \"bg-white text-black\"\n                  : \"text-white/80 hover:text-white\",\n              )}\n            >\n              {align === \"left\" ? (\n                <AlignLeft className=\"size-3.5\" />\n              ) : align === \"center\" ? (\n                <AlignCenter className=\"size-3.5\" />\n              ) : (\n                <AlignRight className=\"size-3.5\" />\n              )}\n            </button>\n          ))}\n        </div>\n\n        <Button\n          variant=\"ghost\"\n          size=\"icon\"\n          onClick={onDelete}\n          aria-label=\"Delete text\"\n          className=\"size-8 text-red-300 hover:bg-red-500/15 hover:text-red-200\"\n        >\n          <Trash2 className=\"size-4\" />\n        </Button>\n      </div>\n\n      {/* Font size slider */}\n      <div className=\"flex items-center gap-3\">\n        <span className=\"text-[10px] uppercase tracking-wider text-white/60 w-10\">\n          {labels.textFontSize}\n        </span>\n        <Slider\n          value={[overlay.fontSize]}\n          min={MIN_FONT_SIZE}\n          max={MAX_FONT_SIZE}\n          step={1}\n          // Radix emits number[]; Base UI number | readonly number[] (F-cross-13)\n          onValueChange={(v) =>\n            update({ fontSize: typeof v === \"number\" ? v : (v[0] ?? overlay.fontSize) })\n          }\n          aria-label={labels.textFontSize}\n          className=\"flex-1\"\n        />\n        <span className=\"text-xs font-mono tabular-nums w-8 text-right\">\n          {overlay.fontSize}\n        </span>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/parts/tool-text-input.tsx"
    },
    {
      "path": "src/registry/components/media/media-editor/parts/video-trim-bar.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n  type PointerEvent,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport type { StoryComposerLabels } from \"../types\";\n\nexport interface VideoTrimBarProps {\n  /** Source video URL (object URL from a Blob is fine). */\n  videoUrl: string;\n  /** Trim range in seconds — controlled. */\n  startSec: number;\n  endSec: number;\n  /** Authoritative duration from the source video. */\n  durationSec: number;\n  /** Number of preview thumbnails to generate (default 8). */\n  thumbnailCount?: number;\n  labels: Required<StoryComposerLabels>;\n  onChange: (next: { startSec: number; endSec: number }) => void;\n  className?: string;\n}\n\ninterface FrameThumb {\n  t: number;\n  url: string;\n}\n\nconst MIN_DURATION_SEC = 0.5;\n\n/**\n * Two-handle trim bar with auto-generated frame previews.\n *\n * Thumbnails are produced once on mount by seeking an off-DOM <video>\n * to N evenly-spaced timestamps and snapping each frame to a canvas.\n */\nexport function VideoTrimBar({\n  videoUrl,\n  startSec,\n  endSec,\n  durationSec,\n  thumbnailCount = 8,\n  labels,\n  onChange,\n  className,\n}: VideoTrimBarProps) {\n  const trackRef = useRef<HTMLDivElement | null>(null);\n  const [thumbs, setThumbs] = useState<FrameThumb[]>([]);\n  const draggingRef = useRef<\"start\" | \"end\" | null>(null);\n\n  // ─── Generate frame thumbnails ──────────────────────────────────────\n  useEffect(() => {\n    let cancelled = false;\n    if (!videoUrl || durationSec <= 0) return;\n\n    const video = document.createElement(\"video\");\n    video.src = videoUrl;\n    video.crossOrigin = \"anonymous\";\n    video.muted = true;\n    video.preload = \"auto\";\n    video.playsInline = true;\n\n    const cleanup = () => {\n      video.removeAttribute(\"src\");\n      video.load();\n    };\n\n    const onLoaded = async () => {\n      const canvas = document.createElement(\"canvas\");\n      const w = 64;\n      const h = 96;\n      canvas.width = w;\n      canvas.height = h;\n      const ctx = canvas.getContext(\"2d\");\n      if (!ctx) {\n        cleanup();\n        return;\n      }\n      const out: FrameThumb[] = [];\n      for (let i = 0; i < thumbnailCount; i++) {\n        const t = (i / (thumbnailCount - 1 || 1)) * durationSec;\n        await seekTo(video, t);\n        if (cancelled) return;\n        ctx.drawImage(video, 0, 0, w, h);\n        const url = canvas.toDataURL(\"image/jpeg\", 0.6);\n        out.push({ t, url });\n      }\n      if (!cancelled) setThumbs(out);\n      cleanup();\n    };\n\n    video.addEventListener(\"loadedmetadata\", onLoaded, { once: true });\n\n    return () => {\n      cancelled = true;\n      video.removeEventListener(\"loadedmetadata\", onLoaded);\n      cleanup();\n    };\n  }, [videoUrl, durationSec, thumbnailCount]);\n\n  // ─── Drag handlers ──────────────────────────────────────────────────\n  const positionForX = useCallback(\n    (clientX: number): number => {\n      const track = trackRef.current;\n      if (!track) return 0;\n      const rect = track.getBoundingClientRect();\n      const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));\n      return ratio * durationSec;\n    },\n    [durationSec],\n  );\n\n  const handlePointerDown = useCallback(\n    (which: \"start\" | \"end\") => (e: PointerEvent<HTMLButtonElement>) => {\n      e.currentTarget.setPointerCapture?.(e.pointerId);\n      draggingRef.current = which;\n    },\n    [],\n  );\n\n  const handlePointerMove = useCallback(\n    (e: PointerEvent<HTMLDivElement>) => {\n      if (!draggingRef.current) return;\n      const next = positionForX(e.clientX);\n      if (draggingRef.current === \"start\") {\n        const clamped = Math.min(next, endSec - MIN_DURATION_SEC);\n        onChange({ startSec: Math.max(0, clamped), endSec });\n      } else {\n        const clamped = Math.max(next, startSec + MIN_DURATION_SEC);\n        onChange({ startSec, endSec: Math.min(durationSec, clamped) });\n      }\n    },\n    [endSec, startSec, durationSec, onChange, positionForX],\n  );\n\n  const handlePointerUp = useCallback(() => {\n    draggingRef.current = null;\n  }, []);\n\n  // ─── Render ─────────────────────────────────────────────────────────\n  const startPct = durationSec > 0 ? (startSec / durationSec) * 100 : 0;\n  const endPct = durationSec > 0 ? (endSec / durationSec) * 100 : 100;\n\n  const placeholders = useMemo(\n    () => Array.from({ length: thumbnailCount }, (_, i) => i),\n    [thumbnailCount],\n  );\n\n  return (\n    <div className={cn(\"flex flex-col gap-2 select-none\", className)}>\n      <div className=\"flex items-center justify-between text-[10px] font-mono text-white/70\">\n        <span>{labels.trimStart}: {startSec.toFixed(1)}s</span>\n        <span>{labels.trimEnd}: {endSec.toFixed(1)}s</span>\n      </div>\n      <div\n        ref={trackRef}\n        onPointerMove={handlePointerMove}\n        onPointerUp={handlePointerUp}\n        className=\"relative h-16 rounded-md overflow-hidden bg-black/40 ring-1 ring-white/10\"\n      >\n        {/* Frame strip */}\n        <div className=\"absolute inset-0 flex\">\n          {(thumbs.length ? thumbs : placeholders).map((entry, i) =>\n            typeof entry === \"number\" ? (\n              <div\n                key={i}\n                className=\"flex-1 bg-gradient-to-b from-white/5 to-white/10 animate-pulse\"\n              />\n            ) : (\n              <div\n                key={i}\n                className=\"flex-1 bg-cover bg-center\"\n                style={{ backgroundImage: `url(${entry.url})` }}\n              />\n            ),\n          )}\n        </div>\n        {/* Dim out trimmed regions */}\n        <div\n          className=\"absolute inset-y-0 left-0 bg-black/60\"\n          style={{ width: `${startPct}%` }}\n          aria-hidden\n        />\n        <div\n          className=\"absolute inset-y-0 right-0 bg-black/60\"\n          style={{ width: `${100 - endPct}%` }}\n          aria-hidden\n        />\n        {/* Selection outline */}\n        <div\n          className=\"absolute inset-y-0 border-y-2 border-white pointer-events-none\"\n          style={{ left: `${startPct}%`, right: `${100 - endPct}%` }}\n          aria-hidden\n        />\n        {/* Start handle */}\n        <button\n          type=\"button\"\n          aria-label={labels.trimStart}\n          onPointerDown={handlePointerDown(\"start\")}\n          className=\"absolute top-0 bottom-0 w-3 -ml-1.5 grid place-items-center bg-white text-black cursor-ew-resize touch-none\"\n          style={{ left: `${startPct}%` }}\n        >\n          <span className=\"block h-6 w-0.5 bg-black/60\" aria-hidden />\n        </button>\n        {/* End handle */}\n        <button\n          type=\"button\"\n          aria-label={labels.trimEnd}\n          onPointerDown={handlePointerDown(\"end\")}\n          className=\"absolute top-0 bottom-0 w-3 -ml-1.5 grid place-items-center bg-white text-black cursor-ew-resize touch-none\"\n          style={{ left: `${endPct}%` }}\n        >\n          <span className=\"block h-6 w-0.5 bg-black/60\" aria-hidden />\n        </button>\n      </div>\n    </div>\n  );\n}\n\nfunction seekTo(video: HTMLVideoElement, t: number): Promise<void> {\n  return new Promise((resolve) => {\n    const onSeeked = () => {\n      video.removeEventListener(\"seeked\", onSeeked);\n      resolve();\n    };\n    video.addEventListener(\"seeked\", onSeeked, { once: true });\n    try {\n      video.currentTime = Math.min(t, Math.max(0, video.duration || t));\n    } catch {\n      video.removeEventListener(\"seeked\", onSeeked);\n      resolve();\n    }\n  });\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/parts/video-trim-bar.tsx"
    },
    {
      "path": "src/registry/components/media/media-editor/hooks/use-capture-extension.ts",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport type { ComposerMode, StoryComposerLabels, ValidationError } from \"../types\";\n\n/**\n * Capture injection seam (P3 S3 — feature-slicing, strategy b: injection\n * surface, R1 VERDICT). Base never statically imports `features/capture/**`\n * — a consumer wires camera capture in by passing `capture={mediaCapture}`\n * (MediaEditorProps.capture) from `@ilinxa/media-editor-capture`. This file\n * owns the context, the extension TYPE, and the surface-props contract the\n * base orchestrator (media-editor.tsx) mounts against — mirroring the\n * renderer-registry pattern used elsewhere in the library.\n */\n\n// ─── Structural mirrors of the capture feature's capture-result shapes ────\n// Duplicated here (NOT imported from features/capture/**) so this base file\n// carries zero static dependency on the feature folder — the F-S1 inline-copy\n// pattern (see cross-procomp-imports precedent). The feature's own\n// `CapturedPhoto` / `CapturedVideo` (features/capture/hooks/use-media-capture.ts)\n// are kept structurally identical to these so `EditorCamera` type-checks\n// as a `CameraSurface` without any cast.\n\nexport interface CapturedPhotoLike {\n  blob: Blob;\n  width: number;\n  height: number;\n  mimeType: string;\n}\n\nexport interface CapturedVideoLike {\n  blob: Blob;\n  durationMs: number;\n  mimeType: string;\n}\n\n/**\n * Props the base orchestrator passes to whatever camera surface answers the\n * capture extension. Mirrors `EditorCameraProps` (features/capture/parts/\n * editor-camera.tsx) field-for-field — the base mount site passes exactly\n * this shape regardless of which implementation is wired in.\n */\nexport interface MediaCaptureSurfaceProps {\n  /** Composer is open + we should hold a live camera stream. */\n  enabled: boolean;\n  /** Drives mirror behavior, default facing, and the shutter aria-label. */\n  mode: ComposerMode;\n  defaultFacing?: \"user\" | \"environment\";\n  recordAudio?: boolean;\n  maxFileSizeMb?: number;\n  maxVideoDurationSec?: number;\n  captureAspectRatio?: number | null;\n  autoAcquireOverride?: boolean;\n  labels: Required<StoryComposerLabels>;\n  onPhoto: (photo: CapturedPhotoLike) => void;\n  onVideo: (video: CapturedVideoLike) => void;\n  /** Loads an image / video file picked from the gallery into the editor. */\n  onGalleryFile: (file: File) => void;\n  onValidationError?: (error: ValidationError) => void;\n  onPermissionDenied?: () => void;\n  renderPermissionDenied?: (ctx: {\n    retry: () => void;\n    usePicker: () => void;\n  }) => React.ReactNode;\n}\n\nexport interface MediaCaptureExtensionContextValue {\n  /**\n   * The camera surface component to mount when the editor is in the capture\n   * stage with a photo/video mode active. No further capture-state\n   * accessors are exposed today — capture's own state (permissions, live\n   * stream, recording) lives inside the surface component itself, so there's\n   * nothing else for the orchestrator to read.\n   */\n  CameraSurface: React.ComponentType<MediaCaptureSurfaceProps>;\n}\n\nexport const MediaCaptureExtensionContext =\n  React.createContext<MediaCaptureExtensionContextValue | null>(null);\n\n/** Orchestrator-internal read. Null when no `capture` extension is wired. */\nexport function useCaptureExtensionOptional(): MediaCaptureExtensionContextValue | null {\n  return React.useContext(MediaCaptureExtensionContext);\n}\n\n/**\n * Public extension contract a consumer installs to enable camera capture.\n * `@ilinxa/media-editor-capture` ships `mediaCapture: MediaCaptureExtension`\n * — pass it straight through: `<MediaEditor capture={mediaCapture} />`.\n * Custom/alternative capture implementations can supply their own `Provider`\n * as long as it renders `MediaCaptureExtensionContext.Provider` with a\n * `CameraSurface` matching `MediaCaptureSurfaceProps`.\n */\nexport type MediaCaptureExtension = {\n  Provider: React.ComponentType<{ children: React.ReactNode }>;\n};\n",
      "type": "registry:component",
      "target": "components/media-editor/hooks/use-capture-extension.ts"
    },
    {
      "path": "src/registry/components/media/media-editor/lib/validate-media-file.ts",
      "content": "import type { ValidationError } from \"../types\";\n\n/**\n * Gallery/file-intake validation — type + size check. Generic (not\n * camera-specific): lives in BASE so the file/gallery intake surface works\n * with the capture feature slice absent (P3 S3 — the pure-CAPTURE files\n * moved to `features/capture/`, but picking a file off disk isn't a capture\n * concern). Used by the base file-intake surface (media-editor.tsx) AND by\n * `features/capture/parts/editor-camera.tsx`'s gallery button — the feature\n * imports this DIRECTLY (relative into base), never through the barrel.\n */\nexport function validateGalleryFile(\n  file: File,\n  maxFileSizeMb: number,\n): ValidationError | null {\n  if (!file.type.startsWith(\"image/\") && !file.type.startsWith(\"video/\")) {\n    return {\n      kind: \"unsupported-type\",\n      message: `Unsupported file type: ${file.type || \"unknown\"}`,\n      file,\n    };\n  }\n  const bytes = file.size;\n  const maxBytes = maxFileSizeMb * 1024 * 1024;\n  if (bytes > maxBytes) {\n    return {\n      kind: \"file-too-large\",\n      message: `File is ${(bytes / 1024 / 1024).toFixed(1)} MB — maximum is ${maxFileSizeMb} MB`,\n      file,\n    };\n  }\n  return null;\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/lib/validate-media-file.ts"
    },
    {
      "path": "src/registry/components/media/media-editor/parts/lazy-editor-canvas.tsx",
      "content": "// Local default-export wrapper so `React.lazy(() => import(\"./lazy-editor-canvas\"))`\n// has a target — `EditorCanvas` itself is a named export. Same-folder\n// (intra-component): this carries none of the cross-procomp rewriter hazards\n// the media-library `lazy-code-block.tsx` / `lazy-markdown-editor.tsx`\n// wrappers document (no category segment to mangle, no other registry item\n// involved) — it exists purely for the `React.lazy(...)` default-export\n// contract. Compound-component-structure rule: the konva canvas is the\n// heavy dep (konva + react-konva) every consumer pulls in, so it gets the\n// mandated lazy boundary even though (unlike media-library's viewers) it\n// isn't itself droppable — the win here is deferring the konva chunk off\n// the editor's initial paint (capture stage / text-only mode never need it).\nexport { EditorCanvas as default } from \"./editor-canvas\";\n",
      "type": "registry:component",
      "target": "components/media-editor/parts/lazy-editor-canvas.tsx"
    }
  ],
  "categories": [
    "media"
  ],
  "type": "registry:block"
}