---
phase: 42-wallpapers-social-format-conversion-voice
plan: 06
type: execute
wave: 3
depends_on: [42-03]
files_modified:
  - ui/src/pages/ConvertPage.tsx
  - ui/src/components/ConvertPanel.tsx
  - ui/src/api/convert.ts
  - ui/src/App.tsx
autonomous: true
requirements: [CONV-01, CONV-02, CONV-03, CONV-04, CONV-05, CONV-06, CONV-07, CONV-08, CONV-09]
must_haves:
  truths:
    - "User can drag-drop a file, select a target format, and download the converted file"
    - "Format chips are grouped by Images, Audio/Video, Documents, Data"
    - "All format pairs are selectable — unavailable direct converters show AI fallback notice"
    - "Deep-link /convert/png/svg pre-selects PNG source and SVG target on mount"
    - "MIME validation error shows inline when magic bytes mismatch extension"
    - "Drag-drop zone shows correct states (idle, dragover, error)"
    - "Convert button is disabled until both source file and target format are selected"
  artifacts:
    - path: "ui/src/pages/ConvertPage.tsx"
      provides: "Standalone /convert page with deep-link routing"
      contains: "sourceFormat"
    - path: "ui/src/components/ConvertPanel.tsx"
      provides: "Drag-drop zone + format chips + convert action bar"
      contains: "ConvertSourceZone"
    - path: "ui/src/api/convert.ts"
      provides: "submitConvertJob (multipart), getConverterCapabilities"
      exports: ["submitConvertJob", "getConverterCapabilities"]
    - path: "ui/src/App.tsx"
      provides: "Routes for /convert, /convert/:sourceFormat, /convert/:sourceFormat/:targetFormat"
      contains: "ConvertPage"
  key_links:
    - from: "ui/src/pages/ConvertPage.tsx"
      to: "ui/src/components/ConvertPanel.tsx"
      via: "renders ConvertPanel with route params"
      pattern: "ConvertPanel"
    - from: "ui/src/components/ConvertPanel.tsx"
      to: "ui/src/api/convert.ts"
      via: "submitConvertJob for multipart upload"
      pattern: "submitConvertJob"
    - from: "ui/src/api/convert.ts"
      to: "/api/companies/:companyId/convert"
      via: "FormData multipart POST"
      pattern: "FormData"
    - from: "ui/src/App.tsx"
      to: "ui/src/pages/ConvertPage.tsx"
      via: "Route path convert"
      pattern: "convert"
---

<objective>
Build the format conversion UI page with drag-drop upload, grouped format chips, AI fallback indicator, deep-link routing, and inline MIME validation errors.

Purpose: This surfaces the conversion renderer (Plan 03) to users. It fulfills the UI side of all CONV requirements.
Output: ConvertPage, ConvertPanel, convert API client, routes in App.tsx.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/42-wallpapers-social-format-conversion-voice/42-RESEARCH.md
@.planning/phases/42-wallpapers-social-format-conversion-voice/42-UI-SPEC.md
@.planning/phases/42-wallpapers-social-format-conversion-voice/42-03-SUMMARY.md

@ui/src/App.tsx
@ui/src/api/contentJobs.ts
@ui/src/api/client.ts
@ui/src/hooks/useContentJob.ts
@ui/src/components/ChatFileDropZone.tsx
</context>

<interfaces>
From server/src/routes/convert.ts (Plan 03):
```
POST /api/companies/:companyId/convert — multipart/form-data with fields: file, targetFormat
  Returns: 202 { jobId, status: "queued" }
  Error: 422 { error, actualMime, claimedMime }

GET /api/system/converters
  Returns: { imageConverter: boolean, audioVideoConverter: boolean, docConverter: boolean, dataConverter: boolean }
```

From server/src/services/renderers/types.ts:
```typescript
interface ConvertBundle {
  type: "convert-bundle";
  outputFilename: string;
  outputMime: string;
  outputBase64: string;
  method: "direct" | "ai-bridge";
}
```
</interfaces>

<tasks>

<task type="auto">
  <name>Task 1: Create convert API client and ConvertPanel component</name>
  <files>ui/src/api/convert.ts, ui/src/components/ConvertPanel.tsx</files>
  <read_first>ui/src/api/contentJobs.ts, ui/src/api/client.ts, ui/src/components/ChatFileDropZone.tsx, ui/src/hooks/useContentJob.ts</read_first>
  <action>
1. Create ui/src/api/convert.ts:
   - Export async function submitConvertJob(companyId: string, file: File, targetFormat: string): Promise<{ jobId: string; status: string } | { error: string; actualMime: string; claimedMime: string }>
     - Build FormData with file and targetFormat
     - POST to /api/companies/{companyId}/convert with FormData (do NOT set Content-Type header — browser sets multipart boundary)
     - If response status === 422: return the MIME validation error body
     - If response status === 202: return { jobId, status }
     - Throw on other errors
   - Export async function getConverterCapabilities(): Promise<{ imageConverter: boolean; audioVideoConverter: boolean; docConverter: boolean; dataConverter: boolean }>
     - GET /api/system/converters
     - Return JSON body

2. Create ui/src/components/ConvertPanel.tsx — this is a large component containing three visual zones:

   **FORMAT_GROUPS constant** (define at top of file):
   ```typescript
   const FORMAT_GROUPS = {
     Images: ["png", "jpg", "svg", "webp", "gif"],
     "Audio/Video": ["mp3", "mp4", "wav", "ogg", "webm"],
     Documents: ["md", "html", "pdf", "docx"],
     Data: ["csv", "json", "xlsx"],
   };
   ```

   **Props:** { initialSourceFormat?: string; initialTargetFormat?: string; companyId: string }

   **State:**
   - file: File | null
   - targetFormat: string | null (selected chip)
   - mimeError: { actualMime: string; claimedMime: string } | null
   - dragOver: boolean
   - capabilities: from getConverterCapabilities() (fetch on mount)
   - Use useContentJob(companyId) for job tracking after submit

   **ConvertSourceZone (left column):**
   - Drag-drop zone: min-h-[120px], p-6, dashed border (2px dashed var(--border))
   - role="button", tabIndex={0}, aria-label="Upload file - drop here or press Enter to browse"
   - onKeyDown: Enter/Space triggers hidden file input click
   - onDragOver: set dragOver=true, prevent default
   - onDragLeave: set dragOver=false
   - onDrop: extract file, set dragOver=false
   - Idle state: bg-secondary border, copy "Drop a file here or click to browse" (or "Drop a {FORMAT} file here or click to browse" if initialSourceFormat provided)
   - Dragover state: bg-accent/30, border-primary, copy "Release to select"
   - After file selected: show filename (text-sm font-medium), file size (text-xs text-muted-foreground), detected MIME (text-xs font-mono text-muted-foreground)
   - MIME error state: bg-destructive/10, border-destructive, error copy: "File extension does not match content. Got {actualMime}, expected {claimedMime}."
   - Click anywhere in zone to re-select file

   **ConvertTargetSelector (right column):**
   - Render FORMAT_GROUPS as sections. Each group has a label (text-sm font-medium) and a row of chips
   - Each chip: rounded-full px-3 py-1 text-xs font-medium
   - Idle: bg-muted text-muted-foreground. Selected: bg-primary text-primary-foreground
   - All chips are always selectable — never disabled (CONV-08: unavailable direct paths fall to AI bridge)
   - role="radiogroup" on container, role="radio" + aria-checked on each chip
   - When selected pair has no direct converter (check capabilities: e.g. Documents group when docConverter===false), show AI fallback notice below chips:
     "No direct converter for this pair - AI bridge will be used." (text-xs text-muted-foreground, Info icon 14px, bg-secondary rounded-md p-3)

   **ConvertActionBar (full width below):**
   - "Convert File" Button (primary, disabled until file !== null AND targetFormat !== null)
   - On click: call submitConvertJob. If result has "error" key: set mimeError. If result has "jobId": track via useContentJob SSE.
   - Progress bar below button (same SSE pattern)
   - After job done with ConvertBundle: "Download {outputFilename}" Button (primary) — create blob from outputBase64, trigger download
   - Error state: "Conversion failed - {detail}. Try again."
   - Empty state: heading "No conversion yet", body "Upload a file and choose a target format to convert."

   **Layout:** Two-column on desktop (grid grid-cols-1 md:grid-cols-2 gap-6), single column on mobile.

   **prefers-reduced-motion:** Disable drag-drop zone color transition.

   **Initial format pre-selection:** If initialSourceFormat or initialTargetFormat props provided, pre-select the corresponding chip (case-insensitive normalize per Pitfall 6).
  </action>
  <verify>
    <automated>cd /opt/nexus/ui && npx tsc --noEmit 2>&1 | head -20</automated>
  </verify>
  <acceptance_criteria>
    - grep "submitConvertJob" ui/src/api/convert.ts
    - grep "getConverterCapabilities" ui/src/api/convert.ts
    - grep "FormData" ui/src/api/convert.ts
    - grep "FORMAT_GROUPS" ui/src/components/ConvertPanel.tsx
    - grep "ConvertSourceZone\|drag" ui/src/components/ConvertPanel.tsx
    - grep "role=\"radiogroup\"" ui/src/components/ConvertPanel.tsx
    - grep "AI bridge" ui/src/components/ConvertPanel.tsx
    - grep "Convert File" ui/src/components/ConvertPanel.tsx
    - grep "actualMime" ui/src/components/ConvertPanel.tsx
    - grep "initialSourceFormat" ui/src/components/ConvertPanel.tsx
  </acceptance_criteria>
  <done>Convert API client handles multipart upload and MIME errors. ConvertPanel has drag-drop zone, grouped format chips, AI fallback notice, and download.</done>
</task>

<task type="auto">
  <name>Task 2: Create ConvertPage and wire routes in App.tsx</name>
  <files>ui/src/pages/ConvertPage.tsx, ui/src/App.tsx</files>
  <read_first>ui/src/App.tsx, ui/src/pages/ContentStudio.tsx</read_first>
  <action>
1. Create ui/src/pages/ConvertPage.tsx:
   - Read sourceFormat and targetFormat from URL params using useParams()
   - Normalize params to lowercase (Pitfall 6: case-insensitive)
   - If params are invalid format strings not in FORMAT_GROUPS values, silently ignore (render default state)
   - Get companyId from context (same pattern as ContentStudio.tsx)
   - Render page heading "Convert File" (h1, heading style from UI spec)
   - Render ConvertPanel with { initialSourceFormat, initialTargetFormat, companyId }

2. Update ui/src/App.tsx:
   - Add lazy import for ConvertPage:
     ```typescript
     const ConvertPage = lazy(() => import("./pages/ConvertPage").then(m => ({ default: m.ConvertPage })));
     ```
   - Add three routes inside boardRoutes() function, after the content-studio route:
     ```tsx
     <Route path="convert" element={<ConvertPage />} />
     <Route path="convert/:sourceFormat" element={<ConvertPage />} />
     <Route path="convert/:sourceFormat/:targetFormat" element={<ConvertPage />} />
     ```

This gives deep-link support: /convert/png/svg pre-selects PNG as source filter and SVG as target chip.
  </action>
  <verify>
    <automated>cd /opt/nexus/ui && npx tsc --noEmit 2>&1 | head -20</automated>
  </verify>
  <acceptance_criteria>
    - grep "ConvertPage" ui/src/pages/ConvertPage.tsx
    - grep "sourceFormat" ui/src/pages/ConvertPage.tsx
    - grep "targetFormat" ui/src/pages/ConvertPage.tsx
    - grep "toLowerCase" ui/src/pages/ConvertPage.tsx
    - grep "ConvertPage" ui/src/App.tsx
    - grep "convert/:sourceFormat/:targetFormat" ui/src/App.tsx
    - grep "convert/:sourceFormat" ui/src/App.tsx
  </acceptance_criteria>
  <done>ConvertPage reads URL params for deep-link pre-selection. Three route variants wired in App.tsx. Format params normalized case-insensitively.</done>
</task>

</tasks>

<verification>
- `cd /opt/nexus/ui && npx tsc --noEmit` passes
- /convert route renders ConvertPage with ConvertPanel
- /convert/png/svg pre-selects PNG and SVG
- All format chips are always selectable (never disabled)
- AI fallback notice shows for pairs without direct converter
</verification>

<success_criteria>
- Drag-drop upload with correct idle/dragover/error states
- Format chips grouped by category, all selectable
- AI fallback notice for unavailable direct paths
- Deep-link routing with case-insensitive format params
- MIME validation error shown inline on magic-byte mismatch
- Download button after successful conversion
- tsc compiles cleanly
</success_criteria>

<output>
After completion, create `.planning/phases/42-wallpapers-social-format-conversion-voice/42-06-SUMMARY.md`
</output>
