# Figma Deck Builder — Workflow Document

## Architecture

```
Agent (writes .cjs script)
  ↓ WebSocket (ws://localhost:3055)
Relay (SSH tunnel → Mac)
  ↓ WebSocket
Figma Plugin ("Curious Endeavor", channel-based)
  ↓ Figma Plugin API
Figma Document
```

**Plugin location:** `/Users/assafdagan/clawd/openclaw-figma-plugin/`
**Relay server:** `cc-fig-mcp` (runs on Mac, tunneled to gateway)
**Scripts run from:** Gateway, using `NODE_PATH=/home/clawd/cc-fig-mcp/node_modules`

## Channel Convention

The Figma plugin connects to a named channel on the WebSocket relay. Assaf sets the channel when loading the plugin. The agent script must join the same channel.

```javascript
const channel = 'CHANNEL_NAME'; // Set by Assaf at plugin load
```

**Always confirm the channel name before running scripts.**

## Template Registry

| Template | Component ID | Text Nodes | Has Image |
|----------|-------------|-----------|-----------|
| `CE_Cover template` | `2014:9230` | Project Title, month and year, Assaf's email, center line | No |
| `CE_Chapter head template` | `2014:9231` | Chapter head centered | No |
| `CE_Text_no image slide` | `2014:9232` | Slide title text, Slide body, Sources | No |
| `CE_Text_and_image slide` | `2014:9248` | Slide context text, Slide title text, Slide body, Sources | Yes (`Slide image` rect) |

**Critical:** Always use `create_component_instance` with `componentId` (node ID), NOT `clone_node`. Clone creates components. Instance inherits from master.

## Workflow: Build a Slide

### Step 1: Create Instance

```javascript
const instance = await cmd('create_component_instance', {
  componentId: '2014:9232', // template node ID
  x: colIndex * 2040,       // 1920 + 120 gap
  y: rowIndex * 1280,       // 1080 + 200 gap
});
// instance.id = the new instance ID
// instance.type should be INSTANCE (not COMPONENT)
```

### Step 2: Scan Text Nodes

```javascript
const scan = await cmd('scan_text_nodes', { nodeId: instance.id, useChunking: false });
const textNodes = scan.textNodes || scan || [];
// Each: { name, id, characters, fontSize, ... }
```

### Step 3: Populate Text

```javascript
const updates = [];
for (const [name, text] of Object.entries(content)) {
  const node = textNodes.find(t => t.name === name);
  if (node) updates.push({ nodeId: node.id, text: text || ' ' });
}
await cmd('set_multiple_text_contents', { nodeId: instance.id, text: updates });
```

**Note:** Empty strings may fail. Use `' '` (single space) for blank fields.

### Step 4: Rename Instance

```javascript
await cmd('rename_node', { nodeId: instance.id, name: `Slide ${num} — ${title}` });
```

### Step 5: Set Image (if text_and_image template)

```javascript
// Find the image rectangle in the instance
const info = await cmd('get_node_info', { nodeId: instance.id });
const imageRect = info.children.find(c => c.type === 'RECTANGLE' && c.name.toLowerCase().includes('image'));

if (imageRect) {
  // Read image file and convert to base64
  const fs = require('fs');
  const imageBuffer = fs.readFileSync('/path/to/image.jpg');
  const imageB64 = imageBuffer.toString('base64');

  await cmd('set_image_fill', {
    nodeId: imageRect.id,
    imageData: imageB64,
    scaleMode: 'FILL', // FILL, FIT, CROP, or TILE
  });
}
```

**Image rectangle ID pattern:** For instances, the rectangle ID is `I{instanceId};{originalRectId}`. Example: `I2015:32;2013:7812`.

## Layout Convention

```
Row 0: [Cover] → [Slide 2] → [Slide 3]
Row 1: [Chapter 1] → [Slide 5] → [Slide 6] → [Slide 7] → ...
Row 2: [Chapter 2] → [Slide 13] → [Slide 14] → ...
```

- **Chapter head** always at column 0 of a new row
- **Content slides** flow horizontally to the right
- **Never stack slides vertically** within a row
- Horizontal pitch: **2040px** (1920 + 120 gap)
- Vertical pitch: **1280px** (1080 + 200 gap)

## Content Map Format

Each slide in the brief should specify:

```javascript
{
  num: 5,                          // Slide number
  name: "The Promise",             // Short name for layers panel
  template: 'textAndImage',        // Template key
  row: 1,                          // Layout row
  col: 1,                          // Layout column (0 = chapter head)
  content: {
    'Slide context text': '...',   // Exact text node name → value
    'Slide title text': '...',
    'Slide body': '...',
    'Sources': '...',
  },
  imageBrief: 'Wilted plant...',   // For image slides
  imageSource: 'generate',         // 'generate' | 'web' | 'manual'
}
```

## Image Workflow

### Option A: Web Image
1. Download image via `curl` or `web_fetch`
2. Save to `/home/clawd/workspace/figma-exports/`
3. Read file → base64 → `set_image_fill`

### Option B: Generated Image (nano-banana-pro)
1. Run `uv run .../generate_image.py --prompt "..." --filename "output.png"`
2. Read file → base64 → `set_image_fill`
3. **Requires valid GEMINI_API_KEY** (currently broken — needs fix)

### Option C: Manual
1. Flag the slide for Assaf to add image manually in Figma
2. Leave the rectangle with template placeholder

### Image Sizing
- The `Slide image` rectangle in `CE_Text_and_image slide` is the right ~40% of the slide
- Use `scaleMode: 'FILL'` for photos (crops to fill)
- Use `scaleMode: 'FIT'` for illustrations (fits within, may letterbox)
- Aim for **3:4 portrait** or **1:1 square** source images for best fill

## Known Issues & Fixes Applied

| Issue | Fix | Status |
|-------|-----|--------|
| `create_component_instance` only worked with remote keys | Patched plugin to support `componentId` for local components | ✅ Fixed |
| `clone_node` on components creates new components | Use `create_component_instance` instead | ✅ Documented |
| No `set_image_fill` command | Added to plugin with manual base64 decoder | ✅ Fixed |
| `atob` not available in Figma sandbox | Wrote manual base64 lookup table decoder | ✅ Fixed |
| Two plugin folders causing confusion | Purged old `cc-fig-mcp/src/claude_mcp_plugin/` | ✅ Fixed |
| GEMINI_API_KEY invalid | Needs new key from Assaf | ❌ Pending |
| `create_component_instance` returns error with componentKey for local components | Use componentId (node ID) instead | ✅ Documented |

## Cleanup Protocol

After building slides:
1. **Verify all instances are type INSTANCE** (not COMPONENT)
2. **Check for orphaned test nodes** — delete any test rectangles or clones
3. **Rename all instances** with `Slide N — Name` convention
4. **Export and visually verify** key slides before reporting complete

```javascript
// Cleanup check
const doc = await cmd('get_document_info');
for (const child of doc.children) {
  if (child.type === 'COMPONENT' && !child.name.startsWith('CE_')) {
    console.log('⚠️ Orphan component:', child.id, child.name);
    // await cmd('delete_node', { nodeId: child.id });
  }
}
```

## Script Boilerplate

```javascript
// Save as: /home/clawd/workspace/scripts/<name>.cjs
// Run: NODE_PATH=/home/clawd/cc-fig-mcp/node_modules node /home/clawd/workspace/scripts/<name>.cjs
const WebSocket = require('ws');
const { v4: uuidv4 } = require('uuid');
const ws = new WebSocket('ws://localhost:3055');
const pending = new Map();
const channel = 'CHANNEL'; // ← Set this

function cmd(command, params = {}) {
  return new Promise((resolve, reject) => {
    const id = uuidv4();
    const t = setTimeout(() => { pending.delete(id); reject(new Error('Timeout: ' + command)); }, 15000);
    pending.set(id, { resolve, reject, timeout: t });
    ws.send(JSON.stringify({ id, type: command === 'join' ? 'join' : 'message', ...(command === 'join' ? { channel: params.channel } : { channel }), message: { id, command, params: { ...params, commandId: id } } }));
  });
}

ws.on('message', (data) => {
  const json = JSON.parse(data.toString());
  if (json.type === 'system') { if (json.message?.result) { for (const [id, r] of pending) { r.resolve(json.message); clearTimeout(r.timeout); pending.delete(id); break; } } return; }
  const msg = json.message || json;
  if (msg.id && pending.has(msg.id)) { const r = pending.get(msg.id); clearTimeout(r.timeout); pending.delete(msg.id); msg.error ? r.reject(new Error(msg.error)) : r.resolve(msg.result || msg); }
});

ws.on('open', async () => {
  try {
    await cmd('join', { channel });
    // === YOUR COMMANDS HERE ===
    console.log('Done!');
  } catch (e) { console.error('Error:', e.message); }
  ws.close(); setTimeout(() => process.exit(0), 1000);
});
ws.on('error', (e) => console.error('WS error:', e.message));
```
