# Technical Design: KB AI Librarian & Intake

> Feature ID: FEATURE-049-F | Version: v1.5 | Last Updated: 03-18-2026

---

## Part 1: Agent-Facing Summary

> **Purpose:** Quick reference for AI agents navigating large projects.
> **📌 AI Coders:** Focus on this section for implementation context.

### Key Components Implemented

| Component | Responsibility | Scope/Impact | Tags |
|-----------|----------------|--------------|------|
| `KBService.get_intake_files()` | List .intake/ items (files AND folders) as nested tree, merge with status from .intake-status.json, derive folder statuses from children | Intake item listing with status | #kb #intake #service #backend |
| `KBService.update_intake_status()` | Update a single file's status/destination in .intake-status.json; folder assign cascades to all children | Status persistence | #kb #intake #status #backend |
| `KBService._build_intake_tree()` | Recursively build nested tree of .intake/ items with derived folder status and deep pending count | Tree data model (CR-005) | #kb #intake #tree #backend |
| `KBService._derive_folder_status()` | Compute folder status from children: pending > processing > filed | Status derivation (CR-005) | #kb #intake #status #backend |
| `KBConfig.ai_librarian` extension | Add `skill` field to ai_librarian config | Config schema | #kb #config #backend |
| `GET /api/kb/intake` | Route returning intake items as nested tree with pre-loaded children and deep pending count | Intake API | #kb #intake #api #route |
| `PUT /api/kb/intake/status` | Route to update a file's intake status (folder assign cascades) | Status update API | #kb #intake #api #route |
| `.github/skills/x-ipe-tool-kb-librarian/SKILL.md` | Tool skill: AI-powered intake file organizer — analyzes content, assigns tags, generates frontmatter, moves files | Skill file | #kb #librarian #skill #tool |
| `KBBrowseModal._renderIntakeScene()` | Full intake view: tree table with expand/collapse, status badges, filters, statistics, per-item actions | Intake UI (mockup Scene 4) | #kb #intake #frontend #ui |
| `KBBrowseModal._renderIntakeRow()` | Render single intake row — file or folder with indentation, toggle, icons, actions | Tree row rendering (CR-005) | #kb #intake #frontend #ui |
| `KBBrowseModal._runAILibrarian()` fix | Remove --workflow-mode, use plain NL command | Command fix | #kb #intake #frontend |
| `KBBrowseModal._triggerNormalFileInput()` | Create hidden `<input type="file" multiple>` and trigger native file dialog for Normal Upload zone | Click-to-browse (CR-007) | #kb #upload #frontend |
| `KBBrowseModal._uploadFilesWithFeedback()` | Upload files via `/api/kb/upload` and display success/error feedback in upload zone | Upload feedback (CR-007) | #kb #upload #frontend |
| `KBBrowseModal._showUploadFeedback()` | Show temporary success/error message in upload zone, auto-clear after timeout | UI feedback (CR-007) | #kb #upload #frontend |
| `FilePreviewRenderer` | Shared file preview component: type detection by extension, format-specific rendering (markdown/images/PDF/DOCX/MSG/HTML/code), configurable API endpoint, blob URL lifecycle, loading/error states | Core shared component (CR-008) | #core #preview #renderer #shared |

### Dependencies

| Dependency | Source | Design Link | Usage Description |
|------------|--------|-------------|-------------------|
| `KBService` | FEATURE-049-A | [x-ipe-docs/requirements/EPIC-049/FEATURE-049-A/technical-design.md](x-ipe-docs/requirements/EPIC-049/FEATURE-049-A/technical-design.md) | Base service for file/folder CRUD, config, tree, path safety |
| `kb_routes` | FEATURE-049-A | [x-ipe-docs/requirements/EPIC-049/FEATURE-049-A/technical-design.md](x-ipe-docs/requirements/EPIC-049/FEATURE-049-A/technical-design.md) | REST API blueprint, error handling pattern |
| `KBBrowseModal` | FEATURE-049-C | N/A | Browse modal class with scene switching, upload modes, intake scaffolding |
| `POST /api/kb/upload` | FEATURE-049-E | N/A | File upload with folder parameter (used for .intake uploads) |
| `PUT /api/kb/files/move` | FEATURE-049-A | N/A | Move files between folders (used for undo-filed) |
| `ContentRenderer` | Core | N/A | Markdown+DSL rendering for .md file previews (used by FilePreviewRenderer) |
| `/api/ideas/file` | Ideas Routes | N/A | File serving endpoint with DOCX/MSG conversion (used by FilePreviewRenderer for ideas) |
| `/api/kb/files/{path}/raw` | FEATURE-049-F (CR-008) | N/A | KB file raw endpoint (used by FilePreviewRenderer for KB; will migrate to `/preview` when CR-004 completes) |

### Major Flow

1. User toggles to "AI Librarian" upload mode → files upload to `.intake/` via existing `POST /api/kb/upload` with `folder=.intake`
2. Intake view calls `GET /api/kb/intake` → service recursively reads `.intake/` items (files + folders) + `.intake-status.json` → returns pre-loaded nested tree with derived folder statuses and deep pending count
3. Frontend renders tree table — folders show chevron toggle (▶/▼), folder icon, item count; files show file icon, size. All children pre-loaded (client-side expand/collapse)
4. User clicks "✨ Run AI Librarian" → plain NL command sent to Copilot CLI → AI skill processes individual files, updates `.intake-status.json`, moves files
5. Intake view refreshes → shows updated statuses; folder statuses auto-derive from children
6. User can: Preview files, Assign destination (files or folders — folder cascades to children), Remove (files or folders — folder removes recursively), Undo filed (files or folders — folder undoes all children)
7. File preview in any location (KB browse article scene, deliverable viewer): consumer creates `FilePreviewRenderer` with endpoint config → calls `renderPreview(filePath, container)` → renderer detects extension → routes to type-specific handler (image/PDF/markdown/DOCX/HTML/code) → loading indicator shown → content rendered or error displayed → blob URLs tracked for cleanup

### Usage Example

```python
# Backend: get intake items as nested tree
svc = app.config['KB_SERVICE']
result = svc.get_intake_files()
# Returns: {
#   "items": [
#     {"name": "notes.md", "type": "file", "size_bytes": 1024, "status": "pending", ...},
#     {"name": "extracted-docs/", "type": "folder", "item_count": 3, "status": "pending",
#      "children": [
#        {"name": "readme.md", "type": "file", "size_bytes": 512, "status": "pending", ...},
#        {"name": "images/", "type": "folder", "item_count": 2, "status": "filed", "children": [...]}
#      ]}
#   ],
#   "stats": {"total": 2, "pending": 1, "processing": 0, "filed": 1},
#   "pending_deep_count": 4
# }

# Backend: update status (folder assign cascades to all children)
svc.update_intake_status("extracted-docs/readme.md", status="processing", destination="guides/")
```

```javascript
// Frontend: expand/collapse folder (client-side, no API call)
_toggleFolder(folderPath) {
    if (this._expandedFolders.has(folderPath)) {
        this._expandedFolders.delete(folderPath);
    } else {
        this._expandedFolders.add(folderPath);
    }
    this._renderIntakeScene();
}
```

```javascript
// CR-008: FilePreviewRenderer — shared preview from any consumer
import { FilePreviewRenderer } from './core/file-preview-renderer.js';

// KB browse modal consumer
const kbRenderer = new FilePreviewRenderer({
    apiEndpoint: '/api/kb/files/{path}/raw',
    endpointStyle: 'path'  // path segment interpolation
});
await kbRenderer.renderPreview('guides/setup.docx', containerEl);
kbRenderer.destroy();  // revokes blob URLs

// Deliverable viewer consumer
const dvRenderer = new FilePreviewRenderer({
    apiEndpoint: '/api/ideas/file?path={path}',
    endpointStyle: 'query'  // query parameter interpolation
});
await dvRenderer.renderPreview('output/report.pdf', previewDiv);
dvRenderer.destroy();
```

---

## Part 2: Implementation Guide

> **Purpose:** Human-readable details for developers.
> **📌 Emphasis on visual diagrams for comprehension.**

### Workflow Diagram

```mermaid
sequenceDiagram
    participant U as User
    participant UI as KBBrowseModal
    participant API as kb_routes
    participant SVC as KBService
    participant FS as Filesystem (.intake/)
    participant CLI as Copilot CLI

    Note over U, CLI: Upload Phase
    U->>UI: Drop files (AI Librarian mode)
    UI->>API: POST /api/kb/upload (folder=.intake)
    API->>SVC: create_file/create_binary_file
    SVC->>FS: Write to .intake/

    Note over U, CLI: View Phase
    UI->>API: GET /api/kb/intake
    API->>SVC: get_intake_files()
    SVC->>FS: List .intake/ + read .intake-status.json
    SVC-->>API: Merged file list with statuses
    API-->>UI: JSON response
    UI-->>U: Render intake table

    Note over U, CLI: AI Librarian Phase
    U->>UI: Click "Run AI Librarian"
    UI->>CLI: Send NL command (no flags)
    Note over CLI: Future: x-ipe-tool-kb-librarian skill processes files
    CLI->>FS: Read .intake/, classify, move, update status.json

    Note over U, CLI: Manual Actions
    U->>UI: Assign folder / Remove / Undo
    UI->>API: PUT /api/kb/intake/status or DELETE/MOVE
    API->>SVC: update_intake_status() or existing methods
    SVC->>FS: Update .intake-status.json or move files
```

### State Diagram: Intake File Lifecycle

```mermaid
stateDiagram-v2
    [*] --> Pending: File uploaded to .intake/
    Pending --> Processing: AI Librarian starts
    Pending --> Pending: User assigns destination (manual)
    Pending --> [*]: User removes file
    Processing --> Filed: AI moves file to destination
    Processing --> Pending: AI encounters error
    Filed --> Pending: User clicks Undo
    Filed --> [*]: File stays in KB folder
```

### Class Diagram

```mermaid
classDiagram
    class KBService {
        -kb_root: Path
        -config_path: Path
        +get_intake_files() Dict
        +update_intake_status(filename, status, destination) Dict
        -_read_intake_status() Dict
        -_write_intake_status(data) None
        -_build_intake_tree(dir_path, status_data, depth) List~Dict~
        -$_derive_folder_status(children) str
        -_count_pending_deep(items) int
        +get_tree() List~KBNode~
        +list_files(folder, sort, recursive) List~KBNode~
        +get_config() Dict
    }

    class KBConfig {
        +tags: Dict
        +agent_write_allowlist: List
        +allowed_extensions: List
        +ai_librarian: Dict
        +to_dict() Dict
    }

    class KBBrowseModal {
        -uploadMode: string
        -_intakeItems: Array
        -_intakeStats: Object
        -intakeFilter: string
        -_expandedFolders: Set
        -_intakePendingDeep: number
        -_filePreviewRenderer: FilePreviewRenderer
        +_renderIntakeScene() void
        +_renderIntakeRow(item, depth) string
        +_toggleFolder(path) void
        +_runAILibrarian() void
        +_loadIntakeFiles() Object
        +_filterIntakeItems(items, status) Array
        +_handleIntakeAction(action, item) void
    }

    class FilePreviewRenderer {
        -_options: Object
        -_currentBlobUrl: string
        -_requestCounter: number
        +renderPreview(filePath, container) Promise
        +destroy() void
        +detectType(filePath)$ string
    }

    class ContentRenderer {
        +renderMarkdown(content) void
    }

    KBService --> KBConfig: reads config
    KBBrowseModal --> KBService: via REST API
    KBBrowseModal --> FilePreviewRenderer: delegates preview
    FilePreviewRenderer --> ContentRenderer: renders .md
```

### Data Models

#### `.intake-status.json` Schema

```json
{
  "sprint-retro-notes-q1.md": {
    "status": "pending",
    "destination": null,
    "updated_at": "2026-03-16T10:30:00Z"
  },
  "architecture-decisions-adr.md": {
    "status": "processing",
    "destination": "api-guidelines/",
    "updated_at": "2026-03-16T10:35:00Z"
  },
  "logo-variants-2026.svg": {
    "status": "filed",
    "destination": "brand-assets/",
    "updated_at": "2026-03-16T10:40:00Z"
  }
}
```

**Rules:**
- File in `.intake/` with NO status.json entry → status = `"pending"`, destination = `null`
- Status.json entry for file NOT in `.intake/` → silently ignored (stale)
- Corrupted/missing status.json → all files = `"pending"`

#### KBConfig.ai_librarian Extension

```python
ai_librarian: Dict[str, Any] = field(default_factory=lambda: {
    'enabled': False,
    'intake_folder': '.intake',
    'skill': 'x-ipe-tool-kb-librarian',
})
```

#### GET /api/kb/intake Response

```json
{
  "items": [
    {
      "name": "sprint-retro-notes-q1.md",
      "path": "sprint-retro-notes-q1.md",
      "type": "file",
      "size_bytes": 24576,
      "modified_date": "2026-03-11T00:00:00",
      "file_type": "md",
      "status": "pending",
      "destination": null
    },
    {
      "name": "extracted-docs",
      "path": "extracted-docs",
      "type": "folder",
      "item_count": 3,
      "status": "pending",
      "children": [
        {
          "name": "readme.md",
          "path": "extracted-docs/readme.md",
          "type": "file",
          "size_bytes": 1024,
          "modified_date": "2026-03-11T00:00:00",
          "file_type": "md",
          "status": "pending",
          "destination": null
        },
        {
          "name": "images",
          "path": "extracted-docs/images",
          "type": "folder",
          "item_count": 2,
          "status": "filed",
          "children": []
        }
      ]
    }
  ],
  "stats": {
    "total": 2,
    "pending": 1,
    "processing": 0,
    "filed": 1
  },
  "pending_deep_count": 4
}
```

**Response field changes (CR-005):**
- `files` → `items` (renamed to reflect files + folders)
- Each item has `type: "file"|"folder"`
- Folders have `item_count`, `children` array, NO `size_bytes`/`file_type`
- Folder `status` is derived (not from `.intake-status.json`)
- `stats.total` = top-level item count only
- `pending_deep_count` = total pending files across all folders (for sidebar badge)
```

#### PUT /api/kb/intake/status Request/Response

```json
// Request
{ "filename": "notes.md", "status": "pending", "destination": "api-guidelines/" }

// Response (200)
{ "ok": true, "filename": "notes.md", "status": "pending", "destination": "api-guidelines/" }

// Error (404)
{ "error": "FILE_NOT_FOUND", "message": "File not in .intake/" }
```

### Implementation Steps

#### 1. Backend: KBConfig Extension (~5 lines)

In `kb_service.py`, update `KBConfig.ai_librarian` default to include `skill` field:

```python
ai_librarian: Dict[str, Any] = field(default_factory=lambda: {
    'enabled': False,
    'intake_folder': '.intake',
    'skill': 'x-ipe-tool-kb-librarian',
})
```

#### 2. Backend: Intake Service Methods (~120 lines in kb_service.py)

Add to `KBService`:

**`_read_intake_status()`** — Private helper. Reads `.intake-status.json` from `.intake/` folder. Returns empty dict on missing/corrupt file (logs warning).

**`_write_intake_status(data)`** — Private helper. Writes status dict to `.intake-status.json` using the existing `_write_json()` method (atomic temp+rename pattern, same as config writes).

**`_build_intake_tree(dir_path, status_data, depth=0)`** — Private helper (CR-005). Recursively builds nested tree:
```python
def _build_intake_tree(self, dir_path: Path, status_data: dict, depth: int = 0) -> list:
    items = []
    try:
        entries = sorted(dir_path.iterdir(), key=lambda p: (p.is_file(), p.name.lower()))
    except PermissionError:
        return items
    for entry in entries:
        if entry.name.startswith('.'):
            continue
        rel_path = str(entry.relative_to(self.kb_root / INTAKE_FOLDER))
        if entry.is_dir():
            children = self._build_intake_tree(entry, status_data, depth + 1)
            items.append({
                'name': entry.name,
                'path': rel_path,
                'type': 'folder',
                'item_count': len(children),
                'status': self._derive_folder_status(children),
                'children': children,
            })
        elif entry.is_file():
            stat = entry.stat()
            entry_status = status_data.get(rel_path, {})
            items.append({
                'name': entry.name,
                'path': rel_path,
                'type': 'file',
                'size_bytes': stat.st_size,
                'modified_date': date.fromtimestamp(stat.st_mtime).isoformat(),
                'file_type': entry.suffix.lstrip('.') if entry.suffix else '',
                'status': entry_status.get('status', 'pending'),
                'destination': entry_status.get('destination'),
            })
    return items
```

**`_derive_folder_status(children)`** — Static private helper (CR-005). Derives folder status from children:
```python
@staticmethod
def _derive_folder_status(children: list) -> str:
    statuses = set()
    for child in children:
        statuses.add(child.get('status', 'pending'))
    if 'pending' in statuses:
        return 'pending'
    if 'processing' in statuses:
        return 'processing'
    if 'filed' in statuses:
        return 'filed'
    return 'pending'  # empty folder defaults to pending
```

**`_count_pending_deep(items)`** — Private helper (CR-005). Counts all pending files across nested tree:
```python
def _count_pending_deep(self, items: list) -> int:
    count = 0
    for item in items:
        if item['type'] == 'file' and item['status'] == 'pending':
            count += 1
        elif item['type'] == 'folder':
            count += self._count_pending_deep(item.get('children', []))
    return count
```

**`get_intake_files()`** — Refactored (CR-005):
1. Build nested tree via `_build_intake_tree()` (replaces flat `iterdir()` loop)
2. Calculate top-level stats from tree root items
3. Calculate `pending_deep_count` via `_count_pending_deep()`
4. Return `{"items": [...], "stats": {...}, "pending_deep_count": N}`

**`update_intake_status(filename, status, destination=None)`** — Extended (CR-005):
1. `filename` is now a relative path (e.g., `subfolder/file.md`)
2. Verify file exists in `.intake/` at the relative path
3. For folder assign: detect if target is directory → recursively update ALL child files in `.intake-status.json`
4. Write back atomically, invalidate cache

#### 3. Backend: Intake Routes (~40 lines in kb_routes.py)

**`GET /api/kb/intake`:**
```python
@kb_bp.route('/api/kb/intake')
def get_intake():
    svc = _get_kb_service_or_abort()
    return jsonify(svc.get_intake_files())
```

**`PUT /api/kb/intake/status`:**
```python
@kb_bp.route('/api/kb/intake/status', methods=['PUT'])
def update_intake_status():
    svc = _get_kb_service_or_abort()
    data = request.get_json(force=True)
    filename = data.get('filename', '').strip()
    status = data.get('status', '').strip()
    destination = data.get('destination')
    # Validate
    if not filename or status not in ('pending', 'processing', 'filed'):
        return _error('INVALID_INPUT', 'filename and valid status required', 400)
    try:
        result = svc.update_intake_status(filename, status, destination)
        return jsonify(result)
    except ValueError as e:
        return _error('FILE_NOT_FOUND', str(e), 404)
```

#### 4. Frontend: Fix _runAILibrarian() (~3 lines in kb-browse-modal.js)

```javascript
_runAILibrarian() {
    const command = 'organize knowledge base intake files with AI Librarian';
    // ... rest unchanged
}
```

#### 5. Frontend: Enhanced Intake Scene (~250 lines in kb-browse-modal.js)

**New instance state (CR-005):**
```javascript
this._expandedFolders = new Set(); // tracks expanded folder paths
this._intakeItems = [];            // cached intake tree items
this._intakePendingDeep = 0;       // deep count of pending files (for sidebar badge)
```

Update `_renderIntakeScene()` to match mockup Scene 4 with folder tree:

**Header:** Title + "✨ Run AI Librarian" gradient purple button
**Statistics bar:** Total (purple), Pending (orange), Processing (blue), Filed (green) badges — stats use top-level counts; sidebar badge uses `pending_deep_count`
**Filter pills:** All | Pending | Processing | Filed — toggles `this.intakeFilter`
**Intake table:** Name | Size/Items | Uploaded | Status | Destination | Actions columns
**Empty state:** When `items.length === 0`, show drop zone with "Drop more files into Intake, or browse" message instead of table
**Row styling:** Pending = normal, Processing = blue bg, Filed = dimmed (opacity 0.7)
**Actions per status and type:**
- File Pending: Preview (eye), Assign (folder), Remove (X)
- File Processing: all disabled
- File Filed: View in KB (arrow), Undo (refresh)
- Folder Pending: Assign (folder), Remove (X) — NO Preview
- Folder Filed: Undo (refresh) — cascades to children
- Folder Processing: all disabled
**Drop zone:** Purple dashed border at bottom

**`_renderIntakeRow(item, depth=0)`** — New method (CR-005). Renders a single table row:
```javascript
_renderIntakeRow(item, depth = 0) {
    const indent = depth * 20; // 20px per nesting level
    const isFolder = item.type === 'folder';
    const isExpanded = this._expandedFolders.has(item.path);

    let nameCell = '';
    if (isFolder) {
        const chevron = isExpanded ? 'bi-chevron-down' : 'bi-chevron-right';
        const folderIcon = isExpanded ? 'bi-folder2-open' : 'bi-folder';
        nameCell = `
            <td style="padding-left:${indent + 8}px">
                <span class="kb-intake-toggle" data-intake-toggle="${this._escapeAttr(item.path)}"
                      style="cursor:pointer;margin-right:4px">
                    <i class="bi ${chevron}"></i>
                </span>
                <i class="bi ${folderIcon}" style="margin-right:6px;color:#8b5cf6"></i>
                ${this._escapeHtml(item.name)}
            </td>`;
    } else {
        nameCell = `
            <td style="padding-left:${indent + 24}px">
                <i class="bi bi-file-earmark-text" style="margin-right:6px;opacity:0.6"></i>
                ${this._escapeHtml(item.name)}
            </td>`;
    }

    const sizeCell = isFolder
        ? `<td>${item.item_count} item${item.item_count !== 1 ? 's' : ''}</td>`
        : `<td>${this._formatFileSize(item.size_bytes || 0)}</td>`;

    // ... status badge, destination, actions (per type) ...
    let rows = `<tr class="kb-intake-row" data-item-path="${this._escapeAttr(item.path)}">${nameCell}${sizeCell}...rest...</tr>`;

    // Render children if folder is expanded
    if (isFolder && isExpanded && item.children) {
        for (const child of item.children) {
            rows += this._renderIntakeRow(child, depth + 1);
        }
    }
    return rows;
}
```

**`_toggleFolder(path)`** — New method (CR-005):
```javascript
_toggleFolder(path) {
    if (this._expandedFolders.has(path)) {
        this._expandedFolders.delete(path);
    } else {
        this._expandedFolders.add(path);
    }
    this._renderIntakeScene(); // re-render with new expand state
}
```

**`_filterIntakeItems(items, status)`** — New method (CR-005). Recursive filter that preserves folder hierarchy:
```javascript
_filterIntakeItems(items, status) {
    if (!status || status === 'all') return items;
    return items.filter(item => {
        if (item.type === 'file') return item.status === status;
        // Folder: show if ANY descendant matches
        const filteredChildren = this._filterIntakeItems(item.children || [], status);
        return filteredChildren.length > 0;
    }).map(item => {
        if (item.type === 'folder') {
            return { ...item, children: this._filterIntakeItems(item.children || [], status) };
        }
        return item;
    });
}
```

**Event delegation (CR-005):** Add handler for `data-intake-toggle` clicks in the intake scene event listener:
```javascript
const toggle = e.target.closest('[data-intake-toggle]');
if (toggle) {
    this._toggleFolder(toggle.dataset.intakeToggle);
    return;
}
```

#### 6. Frontend: Intake Data Loading (~30 lines)

Update `_loadIntakeFiles()` to use `GET /api/kb/intake` and handle nested tree response:
```javascript
async _loadIntakeFiles() {
    try {
        const res = await fetch('/api/kb/intake');
        if (!res.ok) return { items: [], stats: { total: 0, pending: 0, processing: 0, filed: 0 }, pending_deep_count: 0 };
        return await res.json();
    } catch { return { items: [], stats: { total: 0, pending: 0, processing: 0, filed: 0 }, pending_deep_count: 0 }; }
}
```

Update `_refreshIntakeFiles()` to use `pending_deep_count` for sidebar badge:
```javascript
async _refreshIntakeFiles() {
    const data = await this._loadIntakeFiles();
    const intakeFiles = data.items || data.files || [];
    this._intakeItems = intakeFiles;
    this._intakeStats = data.stats || {};
    this._intakePendingDeep = data.pending_deep_count ?? data.stats?.pending ?? 0;
    this._updateIntakeBadges(this._intakePendingDeep);
    this._renderIntakeFileList(intakeFiles);
    if (this.currentScene === 'intake') {
        this._renderIntakeScene();
    }
}
```

#### 7. Frontend: Per-Item Action Handlers (~80 lines)

Implement a single dispatcher method `_handleIntakeAction(action, item)` that routes to action-specific logic:

**Preview (file only):** Reuse existing article preview (call `_showScene('article')` with file data)
**Assign folder (file or folder):** Show folder picker (reuse existing `_showFolderPicker()` from browse modal), then `PUT /api/kb/intake/status` with destination. For folders: backend cascades to all children.
**Remove (file or folder):** Confirm → `DELETE /api/kb/files/.intake/{path}` → refresh. For folders: confirmation warns "This will delete the folder and all its contents."
**View in KB (file only):** Navigate browse view to destination folder
**Undo (file or folder):** For file: `PUT /api/kb/files/move` (source: destination, target: `.intake/{filename}`) → `PUT /api/kb/intake/status` (status: pending, destination: null) → refresh. For folder: `PUT /api/kb/intake/status` with folder path → backend cascades undo to all children → refresh

**Action dispatch table (CR-005):**

| Action | File | Folder |
|--------|------|--------|
| Preview | ✅ `_showArticle()` | ❌ Not available |
| Assign | ✅ Single file update | ✅ Cascades to all children |
| Remove | ✅ Delete file | ✅ Delete folder + contents |
| View in KB | ✅ Navigate to dest | ❌ Not available |
| Undo | ✅ Move back + reset | ✅ Cascades undo to all children |

#### 8. Frontend: Sidebar Intake Badge (~10 lines)

In `_renderSidebarFolders()`, update the "📥 Intake" entry to show pending count badge using `this._intakePendingDeep` (sourced from `pending_deep_count` in the API response, with fallback to `stats.pending`). This ensures the sidebar badge reflects deep file-level pending count, not top-level item count.

### Edge Cases & Error Handling

| Scenario | Expected Behavior | Component |
|----------|-------------------|-----------|
| `.intake-status.json` corrupted | `_read_intake_status()` returns `{}`, logs warning | KBService |
| `.intake-status.json` missing | Returns `{}` (all files = pending) | KBService |
| File in status.json but deleted from .intake | Ignored in merge (stale entry) | KBService.get_intake_files() |
| `.intake/` folder doesn't exist | `get_intake_files()` returns empty, `_uploadIntakeFiles()` creates it | KBService / frontend |
| Concurrent status writes | `_write_intake_status()` uses atomic write (temp+rename) | KBService |
| Undo filed: destination folder gone | Move fails → show error toast | Frontend |
| Duplicate filename upload | Handled by existing upload route (numeric suffix) | kb_routes upload |
| ai_librarian.enabled = false | Frontend hides all intake UI | KBBrowseModal |
| Empty folder in .intake/ (CR-005) | Show folder row with "0 items"; expanding shows no children | _renderIntakeRow |
| Deeply nested folders 5+ levels (CR-005) | Recursive tree build + rendering with cumulative indentation | _build_intake_tree / _renderIntakeRow |
| Folder with mixed-status children (CR-005) | Folder shown in all matching filter views; derived status = highest priority | _filterIntakeItems / _derive_folder_status |
| Folder removal with filed children (CR-005) | Confirmation warns "This folder contains filed items" | _handleIntakeAction |
| Large tree 500+ items (CR-005) | Pre-loaded in single API call; no pagination needed (NFR-049-F.6) | get_intake_files |
| Status.json has path for deleted subfolder (CR-005) | Stale entries ignored — only files present on disk are included | _build_intake_tree |

### Step 9: Create `x-ipe-tool-kb-librarian` Skill (CR-001)

**Files:** `.github/skills/x-ipe-tool-kb-librarian/SKILL.md`
**program_type:** skills
**Created via:** `x-ipe-meta-skill-creator`

#### Skill Architecture

```mermaid
sequenceDiagram
    participant User
    participant CLI as Copilot CLI
    participant Agent as Agent (executing skill)
    participant KB as KB Service API
    participant FS as Filesystem

    User->>CLI: "✨ Run AI Librarian"
    CLI->>Agent: NL command: "organize knowledge base intake files with AI Librarian"
    Agent->>Agent: Load x-ipe-tool-kb-librarian SKILL.md
    Agent->>KB: GET /api/kb/config (read tag taxonomy)
    Agent->>KB: GET /api/kb/intake (get pending files)

    loop For each pending file
        Agent->>KB: PUT /api/kb/intake/status (status → "processing")
        Agent->>FS: Read file content
        Agent->>Agent: AI: analyze content → determine folder + tags
        alt Markdown file
            Agent->>Agent: Generate/merge YAML frontmatter
            Agent->>KB: PUT /api/kb/files/{path} (write updated content)
        end
        Agent->>KB: POST /api/kb/files/move (move to destination)
        Agent->>KB: PUT /api/kb/intake/status (status → "filed", destination)
    end

    Agent->>CLI: Print summary: "N files processed → folder1/, folder2/"
```

#### Skill State Machine

```mermaid
stateDiagram-v2
    [*] --> ReadConfig: Skill invoked
    ReadConfig --> GetIntakeFiles: Config loaded
    GetIntakeFiles --> CheckPending: Files retrieved
    CheckPending --> Exit_NoPending: No pending files
    CheckPending --> ProcessFile: Has pending files

    state ProcessFile {
        [*] --> SetProcessing
        SetProcessing --> AnalyzeContent: Status updated
        AnalyzeContent --> DetermineDestination: Content analyzed
        DetermineDestination --> AssignTags: Folder selected
        AssignTags --> GenerateFrontmatter: Tags assigned
        GenerateFrontmatter --> MoveFile: Frontmatter ready (md) or skipped (non-md)
        MoveFile --> SetFiled: File moved
        SetFiled --> [*]: Status → filed
    }

    ProcessFile --> NextFile: File done
    NextFile --> ProcessFile: More files
    NextFile --> PrintSummary: All done
    PrintSummary --> [*]
    Exit_NoPending --> [*]
```

#### Skill SKILL.md Structure

The skill follows the x-ipe tool skill template:

```yaml
# SKILL.md structure
name: x-ipe-tool-kb-librarian
description: "Organize knowledge base intake files — analyze content, assign tags, generate frontmatter, move to destination folders."
triggers:
  - "organize knowledge base intake files with AI Librarian"
  - "run AI Librarian"
  - "organize intake"

input:
  kb_root: "auto-detect from project"  # x-ipe-docs/knowledge-base/
  intake_folder: ".intake"              # from knowledgebase-config.json

operations:
  - organize_intake:
      1. Read KB config (tag taxonomy, folder structure)
      2. Get all intake files via GET /api/kb/intake
      3. Filter to status == "pending"
      4. For each file:
         a. Update status → "processing"
         b. Read file content
         c. AI: analyze → determine destination folder + lifecycle/domain tags
         d. If destination pre-assigned in status.json → use it (skip AI folder selection)
         e. If markdown: generate/merge frontmatter (title, tags, author, created, auto_generated=true)
         f. If destination folder doesn't exist → create it
         g. Move file from .intake/ to destination
         h. Update status → "filed" with destination path
      5. Print terminal summary

output:
  files_processed: int
  destinations: list[str]
  errors: list[str]  # files that failed (continued processing others)
```

#### Key Design Decisions (from DAO)

| Decision | Choice | Rationale |
|----------|--------|-----------|
| Processing mode | Batch all pending | KISS — user clicked button = intent to process all |
| Folder determination | UI-assigned > AI fallback | Respect explicit user choices, AI fills gaps |
| Non-markdown files | Move + status track, skip frontmatter | Can't inject YAML into PDFs/images |
| Summary output | Terminal print | Simple, visible, no file overhead |
| Folder creation | Auto-create if needed | Cheap, reversible, preserves AI accuracy |
| Existing frontmatter | Preserve + merge (no overwrite) | Non-destructive, respect original metadata |

#### AI Classification Approach

The agent executing this skill uses its LLM capabilities to:
1. **Read file content** — extract key topics, domain terminology, structure
2. **Scan KB folder structure** — understand existing organization (via tree API)
3. **Match content to folder** — semantic matching of content topics to folder names/purposes
4. **Assign tags** — select from the config-defined taxonomy:
   - Lifecycle: Ideation, Requirement, Design, Implementation, Testing, Deployment, Maintenance
   - Domain: API, Authentication, UI-UX, Database, Infrastructure, Security, Performance, Integration, Documentation, Analytics
5. **Generate title** — derive from filename or first heading

No external AI API calls needed — the executing agent IS the LLM.

### Step 10: `.kb-index.json` Metadata Registry (CR-002)

**Motivation:** Replace frontmatter-embedded metadata with a centralized, per-folder JSON registry. Works for ALL file types (images, videos, PDFs) and supports folder-level metadata.

#### Architecture Overview

```mermaid
graph TB
    subgraph "KB Folder: guides/"
        IDX1[".kb-index.json"]
        F1["setup.md"]
        F2["diagram.png"]
        SF1["tutorials/"]
    end

    subgraph "KB Folder: guides/tutorials/"
        IDX2[".kb-index.json"]
        F3["quickstart.md"]
        F4["demo.mp4"]
    end

    IDX1 -->|indexes| F1
    IDX1 -->|indexes| F2
    IDX1 -->|indexes| SF1
    IDX2 -->|indexes| F3
    IDX2 -->|indexes| F4

    style IDX1 fill:#f9f,stroke:#333
    style IDX2 fill:#f9f,stroke:#333
```

**Key Design Principle:** Each `.kb-index.json` is locally-scoped — it indexes ONLY the files and immediate subfolders in its own directory. Nested subdirectories have their own `.kb-index.json`.

#### `.kb-index.json` Schema

```json
{
  "version": "1.0",
  "entries": {
    "setup.md": {
      "title": "Environment Setup Guide",
      "description": "Step-by-step guide for setting up the local development environment with required tools and dependencies.",
      "tags": {
        "domain": ["infrastructure"],
        "lifecycle": ["implementation"]
      },
      "author": "yzhang",
      "created": "2026-03-16",
      "type": "markdown",
      "auto_generated": false
    },
    "diagram.png": {
      "title": "Architecture Diagram",
      "description": "High-level system architecture showing component interactions and data flow paths.",
      "tags": {
        "domain": ["infrastructure"],
        "lifecycle": ["design"]
      },
      "author": "unknown",
      "created": "2026-03-16",
      "type": "image",
      "auto_generated": false
    },
    "tutorials/": {
      "title": "Tutorial Materials",
      "description": "Collection of quickstart guides and demo videos for onboarding new team members.",
      "tags": {
        "domain": ["documentation"],
        "lifecycle": ["implementation"]
      }
    }
  }
}
```

**Schema Rules:**
- `version`: Always `"1.0"` (for future schema migrations)
- Keys: filename for files, foldername + `/` for subfolders
- `type` field (files only): `markdown`, `image`, `video`, `pdf`, `document`, `other`
- `description`: Max 100 words, plain text
- `auto_generated`: Whether metadata was AI-generated (default `false`)
- Missing entry = file has no metadata (defaults from filename/extension)
- Folder entries do NOT have `type`, `author`, `created`, or `auto_generated` fields

#### Type Detection

```python
EXTENSION_TYPE_MAP = {
    '.md': 'markdown',
    '.png': 'image', '.jpg': 'image', '.jpeg': 'image',
    '.gif': 'image', '.svg': 'image', '.webp': 'image',
    '.mp4': 'video', '.mov': 'video', '.webm': 'video', '.avi': 'video',
    '.pdf': 'pdf',
    '.doc': 'document', '.docx': 'document', '.xls': 'document',
    '.xlsx': 'document', '.ppt': 'document', '.pptx': 'document',
}
# Default: 'other'
```

#### New/Refactored Methods in `kb_service.py`

| Method | Type | Purpose |
|--------|------|---------|
| `_read_kb_index(folder_path)` | New | Read `.kb-index.json` from folder; return `{}` entries if missing |
| `_write_kb_index(folder_path, index_data)` | New | Atomic write `.kb-index.json` to folder (temp+rename) |
| `_get_index_entry(folder_path, name)` | New | Get single entry from folder's index; return `None` if missing |
| `_set_index_entry(folder_path, name, entry)` | New | Set/update single entry in folder's index |
| `_remove_index_entry(folder_path, name)` | New | Remove entry from folder's index |
| `_auto_populate_index_entry(filename)` | Refactor | Default metadata from filename/extension (was `_auto_populate_frontmatter`) |
| `_detect_file_type(filename)` | New | Map file extension to type string |
| `_migrate_frontmatter_to_index(folder_path)` | New | One-time: read YAML frontmatter from .md files, write to `.kb-index.json` |

#### Migration Strategy (Frontmatter → Index)

```
For each KB folder:
  1. Scan for .md files with YAML frontmatter
  2. Parse frontmatter (title, tags, author, created)
  3. Write entries to .kb-index.json (preserve all existing values)
  4. Do NOT remove frontmatter from .md files (non-destructive)
  5. Mark index entries with auto_generated: false (human-authored frontmatter)
```

After migration, `_ensure_file_index()` reads from `.kb-index.json` instead of parsing frontmatter.

#### API Compatibility

The API response shape is preserved — no frontend changes needed:

```
Before (frontmatter):  file.frontmatter.title → from YAML in .md file
After (index):         file.frontmatter.title → from .kb-index.json entry
```

The `frontmatter` key in API responses is kept for backwards compatibility but now sourced from the index. Future API versions may rename it to `metadata`.

#### Sequence: File Creation with Index Entry

```mermaid
sequenceDiagram
    participant Client
    participant Routes
    participant KBService
    participant FS as Filesystem

    Client->>Routes: POST /api/kb/files (content, metadata)
    Routes->>KBService: create_file(path, content, metadata)
    KBService->>FS: Write file content (no frontmatter injection)
    KBService->>KBService: _auto_populate_index_entry(filename)
    KBService->>KBService: merge provided metadata with defaults
    KBService->>KBService: _set_index_entry(folder, filename, entry)
    KBService->>FS: Atomic write .kb-index.json
    KBService-->>Routes: {name, path, metadata}
    Routes-->>Client: 201 Created
```

### File Change Summary

| File | Changes | Est. Lines |
|------|---------|------------|
| `src/x_ipe/services/kb_service.py` | Refactor `get_intake_files()` to build nested tree. Add `_build_intake_tree()`, `_derive_folder_status()`, `_count_pending_deep()`. Extend `update_intake_status()` for relative paths and folder-cascade assign. Add `_read_kb_index()`, `_write_kb_index()`, `_get/set/remove_index_entry()`, `_auto_populate_index_entry()`, `_detect_file_type()`, `_migrate_frontmatter_to_index()`. | ~220 |
| `src/x_ipe/routes/kb_routes.py` | Update `GET /api/kb/intake` response (items→nested tree). Update `PUT /api/kb/intake/status` for relative paths and folder targets. | ~20 |
| `src/x_ipe/static/js/features/kb-browse-modal.js` | Refactor `_renderIntakeScene()` for tree rendering. Add `_renderIntakeRow()`, `_toggleFolder()`, `_filterIntakeItems()`. Add `_expandedFolders` state. Update `_handleIntakeAction()` for folder actions. Update `_refreshIntakeFiles()` for `pending_deep_count`. | ~120 |
| `.github/skills/x-ipe-tool-kb-librarian/SKILL.md` | Update procedure: generate index entries instead of frontmatter. Remove markdown-only gate. All file types get metadata. | ~30 |
| `tests/test_kb_service.py` | Add tests for `_build_intake_tree()`, `_derive_folder_status()`, `_count_pending_deep()`, folder-cascade assign, nested tree response. Update existing intake tests for `items` key (was `files`). | ~100 |
| `tests/frontend-js/kb-intake-049f.test.js` | Add tests for `_renderIntakeRow()`, `_toggleFolder()`, `_filterIntakeItems()`, folder actions, expand/collapse state, indentation. Update mocks for nested tree API response. | ~80 |

---

### Step 11: Full-Width Article Content Layout (CR-006)

**Scope:** [Frontend — CSS only]
**Program type:** frontend
**Tech stack:** CSS

#### Problem

The `.kb-article-main` container has `max-width: 780px` (line 837 of `kb-browse-modal.css`), which constrains all content — images, documents, markdown, code — to a narrow column. This wastes significant horizontal space, especially on wide screens, and makes image/document previews appear small.

#### Solution Design

Remove the fixed `max-width` constraint and let the content area fill available space via flexbox. The existing `flex: 1` on `.kb-article-main` already handles dynamic sizing — the `max-width: 780px` artificially caps it.

**CSS Changes (1 file, ~3 lines):**

```css
/* kb-browse-modal.css — .kb-article-main (line 834-839) */

/* BEFORE: */
.kb-article-main {
    flex: 1;
    padding: 32px 48px;
    max-width: 780px;      /* ← REMOVE this line */
    min-height: 0;
}

/* AFTER: */
.kb-article-main {
    flex: 1;
    padding: 32px 48px;    /* 48px L/R padding provides readability margins */
    min-height: 0;
}
```

**Why this works:**
- `flex: 1` already makes `.kb-article-main` expand to fill available space
- `padding: 32px 48px` provides comfortable left/right margins (48px each side)
- `.kb-article-sidebar` at `width: 260px; min-width: 260px` remains fixed — flexbox gives it priority, main content takes the remainder
- `.kb-image-preview` with `max-width: 100%` scales to the wider container automatically
- `@media (max-width: 900px)` hides the sidebar → content gets full width minus padding
- No JavaScript changes — layout is purely CSS-driven

**No changes needed for:**
- `.kb-article-sidebar` — fixed width, unaffected
- `.kb-image-preview` — `max-width: 100%` works at any container width
- `.kb-article-content` — no width constraints, inherits from parent
- `.kb-article-header-block` — no width constraints
- Iframes (PDF/docx/HTML) — already use `width: 100%` in rendered markup
- Media queries — existing `@media (max-width: 900px)` sidebar hiding is unaffected

#### Affected Files & LOC Estimate

| File | Change | LOC |
|------|--------|-----|
| `src/x_ipe/static/css/kb-browse-modal.css` | Remove `max-width: 780px` from `.kb-article-main` | ~1 |

#### AC Coverage

| AC | Design Coverage |
|----|-----------------|
| AC-049-F-17a | Removing `max-width` lets `flex: 1` expand main area; `padding: 48px` provides margins |
| AC-049-F-17b | `.kb-article-sidebar` `width: 260px; min-width: 260px` stays fixed; main takes remainder |
| AC-049-F-17c | `.kb-image-preview` `max-width: 100%` and iframes `width: 100%` scale to wider container |
| AC-049-F-17d | `@media (max-width: 900px)` already hides sidebar; no `max-width` means full width usage |

---

### Step 12: Normal Upload Click-to-Browse & Success Indication (CR-007)

**Scope:** [Frontend — JS only]
**Program type:** frontend
**Tech stack:** JavaScript

#### Problem

The Normal Upload zone in `kb-browse-modal.js` has two UX gaps:
1. **Click-to-browse:** Clicking the zone triggers `trigger-upload` action (line 1092-1097) which closes the browse modal and opens the full `KBFileUpload` modal — not a native file dialog.
2. **No feedback:** `_uploadFiles()` (line 1460-1470) posts files silently — no success/error indication to the user.

The Intake zone already solves both: `_triggerIntakeFileInput()` opens a native file dialog, and `_uploadIntakeFiles()` refreshes the file list after upload.

#### Solution Design

**1. Click-to-browse — `_triggerNormalFileInput()`:**

Replace the `trigger-upload` action handler with a new method that follows the Intake pattern:

```javascript
// kb-browse-modal.js — new method
_triggerNormalFileInput() {
    const input = document.createElement('input');
    input.type = 'file';
    input.multiple = true;
    input.addEventListener('change', () => {
        if (input.files?.length) {
            const folder = this.uploadFolder === '/' ? '' : this.uploadFolder;
            this._uploadFilesWithFeedback(input.files, folder);
        }
    });
    input.click();
}
```

Update `trigger-upload` action handler:
```javascript
// BEFORE (line 1092-1097):
case 'trigger-upload':
    if (typeof window.kbFileUpload !== 'undefined') {
        this.close();
        window.kbFileUpload.open();
    }
    break;

// AFTER:
case 'trigger-upload':
    this._triggerNormalFileInput();
    break;
```

**2. Upload feedback — `_showUploadFeedback()` + `_uploadFilesWithFeedback()`:**

Wrap `_uploadFiles()` to show result in the upload zone:

```javascript
// kb-browse-modal.js — new methods
async _uploadFilesWithFeedback(files, folder) {
    const zone = this.overlay?.querySelector('.kb-upload-zone');
    if (!zone) return;
    // Disable zone during upload
    zone.style.pointerEvents = 'none';
    zone.style.opacity = '0.6';
    try {
        const formData = new FormData();
        for (const file of files) formData.append('files', file);
        if (folder) formData.append('folder', folder);
        const res = await fetch('/api/kb/upload', { method: 'POST', body: formData });
        if (res.ok) {
            const data = await res.json();
            const count = data.uploaded?.length ?? files.length;
            document.dispatchEvent(new CustomEvent('kb:changed'));
            this._showUploadFeedback(zone, `✅ ${count} file(s) uploaded`, 'success', 3000);
        } else {
            this._showUploadFeedback(zone, '❌ Upload failed', 'error', 5000);
        }
    } catch {
        this._showUploadFeedback(zone, '❌ Upload failed', 'error', 5000);
    } finally {
        zone.style.pointerEvents = '';
        zone.style.opacity = '';
    }
}

_showUploadFeedback(zone, message, type, duration) {
    const originalHTML = zone.innerHTML;
    const color = type === 'success' ? '#10b981' : '#ef4444';
    zone.innerHTML = `<div style="color:${color};font-size:13px;font-weight:600;">${message}</div>`;
    setTimeout(() => { zone.innerHTML = originalHTML; }, duration);
}
```

**3. Update drag-and-drop handler:**

Update `_handleNormalDrop()` to use the feedback-enabled upload:

```javascript
// BEFORE (line 1250-1258):
_handleNormalDrop(files) {
    if (typeof window.kbFileUpload !== 'undefined' && window.kbFileUpload.uploadFiles) {
        const folder = this.uploadFolder === '/' ? '' : this.uploadFolder;
        window.kbFileUpload.uploadFiles(files, folder);
    } else {
        this._uploadFiles(files, this.uploadFolder === '/' ? '' : this.uploadFolder);
    }
}

// AFTER:
_handleNormalDrop(files) {
    const folder = this.uploadFolder === '/' ? '' : this.uploadFolder;
    this._uploadFilesWithFeedback(files, folder);
}
```

#### Affected Files & LOC Estimate

| File | Change | LOC |
|------|--------|-----|
| `src/x_ipe/static/js/features/kb-browse-modal.js` | Add `_triggerNormalFileInput()`, `_uploadFilesWithFeedback()`, `_showUploadFeedback()`; update `trigger-upload` handler and `_handleNormalDrop()` | ~35 |

#### AC Coverage

| AC | Design Coverage |
|----|-----------------|
| AC-049-F-01e | `_triggerNormalFileInput()` creates `<input type="file" multiple>` and triggers click |
| AC-049-F-01f | `_uploadFilesWithFeedback()` posts to `/api/kb/upload` and dispatches `kb:changed` |
| AC-049-F-01g | `_showUploadFeedback()` displays "✅ N file(s) uploaded" with 3s auto-clear |
| AC-049-F-01h | `_showUploadFeedback()` displays "❌ Upload failed" with 5s auto-clear |

---

### Step 13: Shared File Preview Component (CR-008)

> **Scope:** Extract `FilePreviewRenderer` to `core/file-preview-renderer.js`; unify KB browse and deliverable viewer preview rendering via a shared, configurable component.

#### Class Diagram

```mermaid
classDiagram
    class FilePreviewRenderer {
        -_options: Object
        -_currentBlobUrl: string|null
        -_requestCounter: number
        +constructor(options)
        +renderPreview(filePath, container) Promise~void~
        +destroy() void
        -_buildUrl(filePath) string
        -_showLoading(container) void
        -_showError(container, message, filePath) void
        -_renderImage(container, url, filePath) void
        -_renderPdf(container, url) void
        -_renderMarkdown(container, text) void
        -_renderConvertedHtml(container, html) void
        -_renderHtml(container, html) void
        -_renderCode(container, text, ext) void
        -_revokeBlobUrl() void
        -_createBlobIframe(container, content, type, sandbox) void
        +static detectType(filePath) string
        +static FILE_TYPES: Object
    }

    class ContentRenderer {
        +constructor(containerOrId)
        +renderMarkdown(content) void
    }

    class KBBrowseModal {
        -_filePreviewRenderer: FilePreviewRenderer
        +_renderArticleContent(data) void
    }

    class DeliverableViewer {
        -_filePreviewRenderer: FilePreviewRenderer
        +showPreview(filePath) void
    }

    FilePreviewRenderer --> ContentRenderer : uses for .md
    KBBrowseModal --> FilePreviewRenderer : delegates preview
    DeliverableViewer --> FilePreviewRenderer : delegates preview
```

#### Workflow Diagram

```mermaid
sequenceDiagram
    participant Consumer as KB Browse / Deliverable Viewer
    participant FPR as FilePreviewRenderer
    participant API as API Endpoint
    participant CR as ContentRenderer

    Consumer->>FPR: renderPreview(filePath, container)
    FPR->>FPR: validate path (reject "..")
    FPR->>FPR: detectType(filePath)
    FPR->>FPR: _showLoading(container)
    FPR->>FPR: _revokeBlobUrl() (cleanup previous)
    FPR->>FPR: _requestCounter++ (stale guard)

    alt Image file
        FPR->>Consumer: <img src=url> (no fetch)
    else PDF file
        FPR->>Consumer: <iframe src=url> (no fetch)
    else Text/Convertible file
        FPR->>API: fetch(_buildUrl(filePath))
        API-->>FPR: Response (text + headers)
        alt X-Converted: true (DOCX/MSG)
            FPR->>FPR: _createBlobIframe(sandbox=allow-same-origin)
        else Markdown (.md)
            FPR->>CR: renderMarkdown(text)
            CR-->>FPR: rendered HTML
        else HTML (.html/.htm)
            FPR->>FPR: _createBlobIframe(sandbox=allow-scripts allow-same-origin)
        else Code/Text
            FPR->>FPR: _renderCode(<pre> + highlight.js)
        end
    end

    alt HTTP Error
        FPR->>Consumer: _showError(413/415/generic)
    end
```

#### FilePreviewRenderer API Design

**Constructor Options:**

```javascript
/**
 * @param {Object} options
 * @param {string} options.apiEndpoint - URL pattern with {path} placeholder
 *   e.g., '/api/kb/files/{path}/raw' or '/api/ideas/file?path={path}'
 * @param {string} [options.endpointStyle='path'] - How {path} is interpolated:
 *   'path' → encodeURIComponent segments joined with '/'
 *   'query' → single encodeURIComponent for query param
 * @param {string} [options.downloadUrl] - URL pattern for download link on unsupported types
 */
```

**Static File Type Map:**

```javascript
static FILE_TYPES = {
    image: new Set(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'ico']),
    pdf: new Set(['pdf']),
    markdown: new Set(['md']),
    html: new Set(['html', 'htm']),
    code: new Set([
        'py', 'js', 'ts', 'json', 'yaml', 'yml', 'xml', 'css',
        'txt', 'sh', 'bash', 'go', 'java', 'rb', 'rs', 'c', 'cpp', 'h',
        'toml', 'ini', 'cfg', 'log', 'csv', 'sql', 'r', 'kt', 'swift'
    ])
};

static detectType(filePath) {
    const ext = filePath.split('.').pop().toLowerCase();
    if (FilePreviewRenderer.FILE_TYPES.image.has(ext)) return 'image';
    if (FilePreviewRenderer.FILE_TYPES.pdf.has(ext)) return 'pdf';
    if (FilePreviewRenderer.FILE_TYPES.markdown.has(ext)) return 'markdown';
    if (FilePreviewRenderer.FILE_TYPES.html.has(ext)) return 'html';
    if (FilePreviewRenderer.FILE_TYPES.code.has(ext)) return 'code';
    return 'unknown';
}
```

#### Core Method: `renderPreview(filePath, container)`

```javascript
async renderPreview(filePath, container) {
    // 1. Security: reject path traversal
    if (filePath.includes('..')) {
        this._showError(container, 'Invalid file path', filePath);
        return;
    }

    // 2. Stale request guard
    const requestId = ++this._requestCounter;

    // 3. Cleanup previous blob URL
    this._revokeBlobUrl();

    // 4. Loading indicator
    this._showLoading(container);

    // 5. Detect file type
    const type = FilePreviewRenderer.detectType(filePath);
    const url = this._buildUrl(filePath);

    // 6. Image — direct <img>, no fetch
    if (type === 'image') {
        this._renderImage(container, url, filePath);
        return;
    }

    // 7. PDF — direct <iframe>, no fetch
    if (type === 'pdf') {
        this._renderPdf(container, url);
        return;
    }

    // 8. Unknown type — show "cannot preview" immediately (no fetch)
    if (type === 'unknown') {
        this._showError(container, 'Cannot preview this file type', filePath);
        return;
    }

    // 9. Fetch text content (markdown, html, code, convertible)
    try {
        const resp = await fetch(url);

        // Stale response guard
        if (requestId !== this._requestCounter) return;

        if (!resp.ok) {
            const msg = resp.status === 413 ? 'File too large to preview (max 10MB)'
                      : resp.status === 415 ? 'Binary file \u2014 cannot preview'
                      : 'Failed to load file';
            this._showError(container, msg, filePath);
            return;
        }

        const text = await resp.text();
        if (requestId !== this._requestCounter) return;

        // 10. Check X-Converted header (DOCX/MSG → HTML)
        const isConverted = resp.headers.get('X-Converted') === 'true';
        if (isConverted) {
            this._renderConvertedHtml(container, text);
            return;
        }

        // 11. Route by type
        if (type === 'markdown') {
            this._renderMarkdown(container, text);
        } else if (type === 'html') {
            this._renderHtml(container, text);
        } else {
            this._renderCode(container, text, filePath.split('.').pop().toLowerCase());
        }
    } catch (err) {
        if (requestId !== this._requestCounter) return;
        this._showError(container, 'Failed to load file', filePath);
    }
}
```

#### Rendering Methods

**`_renderImage(container, url, filePath)`:**
```javascript
_renderImage(container, url, filePath) {
    container.innerHTML = '';
    const img = document.createElement('img');
    img.src = url;
    img.alt = filePath.split('/').pop();
    img.style.cssText = 'max-width:100%;max-height:100%;object-fit:contain;display:block;margin:auto';
    img.onerror = () => this._showError(container, 'Cannot preview this image', filePath);
    container.appendChild(img);
}
```

**`_renderPdf(container, url)`:**
```javascript
_renderPdf(container, url) {
    container.innerHTML = '';
    const iframe = document.createElement('iframe');
    iframe.src = url;
    iframe.style.cssText = 'width:100%;height:100%;border:none';
    container.appendChild(iframe);
}
```

**`_renderMarkdown(container, text)`:**
```javascript
_renderMarkdown(container, text) {
    container.innerHTML = '';
    if (typeof ContentRenderer !== 'undefined') {
        const renderer = new ContentRenderer(container);
        renderer.renderMarkdown(text);
    } else if (typeof marked !== 'undefined') {
        container.innerHTML = marked.parse(text);
    } else {
        const pre = document.createElement('pre');
        pre.textContent = text;
        container.appendChild(pre);
    }
}
```

**`_renderConvertedHtml(container, html)` and `_renderHtml(container, html)`:**
```javascript
_renderConvertedHtml(container, html) {
    // DOCX/MSG converted HTML — sandbox without scripts
    this._createBlobIframe(container, html, 'text/html', 'allow-same-origin');
}

_renderHtml(container, html) {
    // Native HTML — sandbox with scripts
    this._createBlobIframe(container, html, 'text/html', 'allow-scripts allow-same-origin');
}

_createBlobIframe(container, content, mimeType, sandbox) {
    container.innerHTML = '';
    const blob = new Blob([content], { type: mimeType });
    this._currentBlobUrl = URL.createObjectURL(blob);
    const iframe = document.createElement('iframe');
    iframe.src = this._currentBlobUrl;
    iframe.setAttribute('sandbox', sandbox);
    iframe.style.cssText = 'width:100%;height:100%;border:none';
    container.appendChild(iframe);
}
```

**`_renderCode(container, text, ext)`:**
```javascript
_renderCode(container, text, ext) {
    container.innerHTML = '';
    const pre = document.createElement('pre');
    const code = document.createElement('code');
    code.textContent = text;
    if (typeof hljs !== 'undefined') {
        code.className = `language-${ext}`;
        pre.appendChild(code);
        hljs.highlightElement(code);
    } else {
        pre.appendChild(code);
    }
    container.appendChild(pre);
}
```

#### Blob URL Lifecycle Management

```javascript
_revokeBlobUrl() {
    if (this._currentBlobUrl) {
        URL.revokeObjectURL(this._currentBlobUrl);
        this._currentBlobUrl = null;
    }
}

destroy() {
    this._revokeBlobUrl();
    this._requestCounter++;  // invalidate any in-flight requests
}
```

**Lifecycle rules:**
- `_revokeBlobUrl()` called at start of every `renderPreview()` — cleans up previous
- `destroy()` called when consumer removes the preview container (e.g., modal close, scene switch)
- `_requestCounter` incremented in `destroy()` to invalidate in-flight fetches

#### URL Building

```javascript
_buildUrl(filePath) {
    const pattern = this._options.apiEndpoint;
    if (this._options.endpointStyle === 'query') {
        return pattern.replace('{path}', encodeURIComponent(filePath));
    }
    // 'path' style: encode each segment separately
    const encodedPath = filePath.split('/').map(encodeURIComponent).join('/');
    return pattern.replace('{path}', encodedPath);
}
```

#### Loading and Error States

```javascript
_showLoading(container) {
    container.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#888">Loading preview...</div>';
}

_showError(container, message, filePath) {
    const name = filePath ? filePath.split('/').pop() : '';
    let html = `<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;color:#888;gap:8px">`;
    html += `<div>${message}</div>`;
    if (name) html += `<div style="font-size:0.85em;opacity:0.7">${name}</div>`;
    if (this._options.downloadUrl && filePath) {
        const dlUrl = this._options.downloadUrl.replace('{path}', encodeURIComponent(filePath));
        html += `<a href="${dlUrl}" download style="font-size:0.85em">Download file</a>`;
    }
    html += `</div>`;
    container.innerHTML = html;
}
```

#### Consumer Integration: KB Browse Modal

**Current code** in `kb-browse-modal.js` (lines 532-542):
```javascript
// BEFORE: Manual type detection + marked.js only
const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg']);
if (data.binary && IMAGE_EXTS.has((data.file_type || '').toLowerCase())) {
    const rawUrl = `${KBBrowseModal.API.FILES}/${encodeURIComponent(data.path)}/raw`;
    rendered = `<img class="kb-image-preview" src="${rawUrl}" alt="..." />`;
} else if (typeof marked !== 'undefined') {
    rendered = marked.parse(data.content || '');
} else {
    rendered = `<pre>${this._escapeHtml(data.content || '')}</pre>`;
}
```

**After: Delegate to FilePreviewRenderer:**
```javascript
// AFTER: Use shared FilePreviewRenderer
// In constructor or scene init:
this._filePreviewRenderer = new FilePreviewRenderer({
    apiEndpoint: '/api/kb/files/{path}/raw',
    endpointStyle: 'path'
});

// In _renderArticleContent():
const contentEl = articleMain.querySelector('.kb-article-body') || articleMain;
await this._filePreviewRenderer.renderPreview(data.path, contentEl);

// On scene switch / modal close:
if (this._filePreviewRenderer) {
    this._filePreviewRenderer.destroy();
}
```

#### Consumer Integration: Deliverable Viewer

**Current code** in `deliverable-viewer.js` (lines 155-280):
```javascript
// BEFORE: 120+ lines of inline type detection and rendering in showPreview()
```

**After: Delegate to FilePreviewRenderer:**
```javascript
// AFTER: Replace showPreview() body
async showPreview(filePath) {
    if (!this._filePreviewRenderer) {
        this._filePreviewRenderer = new FilePreviewRenderer({
            apiEndpoint: '/api/ideas/file?path={path}',
            endpointStyle: 'query'
        });
    }

    // Create modal backdrop + preview container (existing modal creation code stays)
    const backdrop = document.createElement('div');
    backdrop.className = 'deliverable-preview-backdrop';
    // ... existing modal structure ...

    const content = preview.querySelector('.deliverable-preview-content');
    await this._filePreviewRenderer.renderPreview(filePath, content);

    // On close:
    closeBtn.addEventListener('click', () => {
        this._filePreviewRenderer.destroy();
        backdrop.remove();
    });
}
```

#### Affected Files & LOC Estimate

| File | Change | LOC |
|------|--------|-----|
| `src/x_ipe/static/js/core/file-preview-renderer.js` | **NEW** — FilePreviewRenderer class with type detection, format-specific rendering, blob lifecycle, loading/error states | ~180 |
| `src/x_ipe/static/js/features/kb-browse-modal.js` | Replace manual preview in `_renderArticleContent()` with `FilePreviewRenderer`; add `_filePreviewRenderer` field; call `destroy()` on scene switch | ~15 (net reduction) |
| `src/x_ipe/static/js/features/deliverable-viewer.js` | Replace inline `showPreview()` body with `FilePreviewRenderer` delegation; add `destroy()` on close | ~15 (net reduction, removes ~100 lines) |
| `src/x_ipe/templates/index.html` (or equivalent) | Add `<script src="/static/js/core/file-preview-renderer.js">` | ~1 |

#### AC Coverage

| AC | Design Coverage |
|----|-----------------|
| AC-049-F-18a | `FilePreviewRenderer` class in `core/file-preview-renderer.js` with `renderPreview(filePath, container, options)` returning Promise |
| AC-049-F-18b | `_renderMarkdown()` delegates to `ContentRenderer` with DSL support (Mermaid, Architecture DSL, Infographic DSL) |
| AC-049-F-18c | `_renderImage()` creates `<img>` with max-width/height 100%, object-fit contain, onerror handler |
| AC-049-F-18d | `_renderConvertedHtml()` checks `X-Converted: true` header, creates blob iframe with `sandbox="allow-same-origin"` |
| AC-049-F-18e | `_renderPdf()` creates `<iframe>` with direct src URL, width/height 100%, border none |
| AC-049-F-18f | `_renderHtml()` fetches HTML, creates blob URL iframe with `sandbox="allow-scripts allow-same-origin"` |
| AC-049-F-18g | `_renderCode()` uses `<pre><code>` with highlight.js when available, plain text fallback |
| AC-049-F-18h | Constructor accepts `options.apiEndpoint` with `{path}` placeholder; `_buildUrl()` supports both path and query styles |
| AC-049-F-18i | `_showError()` displays "Cannot preview this file type" with filename and optional download link |
| AC-049-F-18j | `renderPreview()` maps HTTP errors: 413→"File too large to preview (max 10MB)", 415→"Binary file — cannot preview", else→"Failed to load file" |
| AC-049-F-18k | `_revokeBlobUrl()` called before every new render; `destroy()` for final cleanup |
| AC-049-F-18l | `renderPreview()` rejects paths with `..` before any fetch |
| AC-049-F-18m | KB browse modal integration: `_filePreviewRenderer` with KB endpoint, `destroy()` on scene switch |
| AC-049-F-18n | Deliverable viewer integration: `_filePreviewRenderer` with ideas endpoint, `destroy()` on modal close |
| AC-049-F-18o | `_showLoading()` displays centered "Loading preview..." text in container before content renders |
| AC-049-F-18p | `FILE_TYPES` static map includes all required image and code extensions |

| Date | Phase | Change Summary |
|------|-------|----------------|
| 03-16-2026 | Initial Design | Initial technical design for FEATURE-049-F. Backend: intake status service + 2 routes. Frontend: full intake scene matching mockup Scene 4 with status tracking, filters, per-file actions. Command fix: remove --workflow-mode. |
| 03-16-2026 | CR-001 Skill Design | Added Step 9: x-ipe-tool-kb-librarian skill design. program_type=skills. Sequence diagram, state machine, SKILL.md structure, AI classification approach, 6 DAO-driven decisions. |
| 03-17-2026 | CR-002 Metadata Registry | Added Step 10: .kb-index.json registry design. Replaces frontmatter-embedded metadata. Per-folder hidden JSON index, folder metadata support, description field (< 100 words). Migration strategy, API compatibility, new/refactored methods. |
| 03-18-2026 | CR-005 Folder Support | Backend: refactored `get_intake_files()` for nested tree via `_build_intake_tree()`, derived folder status via `_derive_folder_status()`, deep pending count. API response changed: `files`→`items`, added `children`/`type`/`item_count`/`pending_deep_count`. Frontend: added `_renderIntakeRow()` with indent/chevron/folder icons, `_toggleFolder()` for expand/collapse, `_filterIntakeItems()` for recursive filter propagation, folder-specific actions (Assign cascades, Remove recursive, Undo cascades). DAO decisions: derived status, pre-loaded tree, no lazy loading, deep-count badge. |
| 03-18-2026 | CR-006 Full-Width Layout | CSS-only: Remove `max-width: 780px` from `.kb-article-main` in `kb-browse-modal.css`. Existing `flex: 1` + `padding: 48px` handles expansion and margins. No JS, backend, or sidebar changes needed. ~1 LOC change. |
| 03-18-2026 | CR-007 Normal Upload UX | JS-only: Replace `trigger-upload` action (which opens KBFileUpload modal) with `_triggerNormalFileInput()` using hidden file input pattern from Intake zone. Add `_showUploadFeedback()` to display temporary success/error message in `.kb-upload-zone`. ~30 LOC change. |
| 03-18-2026 | CR-008 Shared File Preview | New `FilePreviewRenderer` class in `core/file-preview-renderer.js` (~180 LOC). Extracts and unifies preview logic from deliverable-viewer.js and kb-browse-modal.js. Configurable API endpoint (path vs query style), static file type detection map, format-specific renderers (image/PDF/markdown+DSL/DOCX-MSG/HTML/code), blob URL lifecycle management (`_revokeBlobUrl`/`destroy`), stale request guard, loading/error states. KB browse modal and deliverable viewer delegate to shared renderer. Net code reduction ~100 LOC. |
