diff --git a/packages/factory/scripts/generate-placeholder-pngs.ts b/packages/factory/scripts/generate-placeholder-pngs.ts
index 5488afef..08ef7f8e 100644
--- a/packages/factory/scripts/generate-placeholder-pngs.ts
+++ b/packages/factory/scripts/generate-placeholder-pngs.ts
@@ -10,32 +10,19 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
-const SPRITE_SIZE = 32;
-const COLS = 4;
-const ROWS = 3;
-const WIDTH = SPRITE_SIZE * COLS;
-const HEIGHT = SPRITE_SIZE * ROWS;
-
-function generatePlaceholderSvg(color: string, label: string): string {
- const frames: string[] = [];
- for (let row = 0; row < ROWS; row++) {
- for (let col = 0; col < COLS; col++) {
- const x = col * SPRITE_SIZE;
- const y = row * SPRITE_SIZE;
- const frameNum = row * COLS + col;
- frames.push(
- ``,
- `${label}${frameNum}`,
- );
- }
- }
- return ``;
-}
+import { ORCHESTRATOR_POSES } from './sprites/orchestrator-poses.ts';
+import { ORCHESTRATOR_PALETTE, SUBAGENT_PALETTE } from './sprites/palettes.ts';
+import { SUBAGENT_POSES } from './sprites/subagent-poses.ts';
+import { renderSpriteSheet } from './sprites/svg-renderer.ts';
const outDir = join(import.meta.dirname, '../src/client/visualizations/catwalk/sprites/assets');
mkdirSync(outDir, { recursive: true });
-writeFileSync(join(outDir, 'subagent.svg'), generatePlaceholderSvg('#888899', 'S'));
-writeFileSync(join(outDir, 'orchestrator.svg'), generatePlaceholderSvg('#CCAA44', 'O'));
+// Generate both SVG strings in memory before writing either to disk
+const subagentSvg = renderSpriteSheet(SUBAGENT_POSES, SUBAGENT_PALETTE);
+const orchestratorSvg = renderSpriteSheet(ORCHESTRATOR_POSES, ORCHESTRATOR_PALETTE);
+
+writeFileSync(join(outDir, 'subagent.svg'), subagentSvg);
+writeFileSync(join(outDir, 'orchestrator.svg'), orchestratorSvg);
-console.info('Placeholder sprite sheets written to', outDir);
+console.info('Sprite sheets written to', outDir);
diff --git a/packages/factory/scripts/sprites/__tests__/svg-renderer.test.ts b/packages/factory/scripts/sprites/__tests__/svg-renderer.test.ts
new file mode 100644
index 00000000..ebc98694
--- /dev/null
+++ b/packages/factory/scripts/sprites/__tests__/svg-renderer.test.ts
@@ -0,0 +1,313 @@
+import { describe, expect, it } from 'vitest';
+
+import { ORCHESTRATOR_POSES } from '../orchestrator-poses.ts';
+import { ORCHESTRATOR_PALETTE, SUBAGENT_PALETTE } from '../palettes.ts';
+import { SUBAGENT_POSES } from '../subagent-poses.ts';
+import type { BodyPart, Palette, Pose } from '../svg-renderer.ts';
+import { composePose, renderSpriteSheet } from '../svg-renderer.ts';
+
+// ── composePose ─────────────────────────────────────────────────────────────
+
+describe('composePose', () => {
+ it('places a single body part at a known offset', () => {
+ const part: BodyPart = {
+ pixels: [
+ [1, 2],
+ [3, 0],
+ ],
+ offsetX: 5,
+ offsetY: 10,
+ };
+ const grid = composePose([part], 32);
+
+ expect(grid[10]?.[5]).toBe(1);
+ expect(grid[10]?.[6]).toBe(2);
+ expect(grid[11]?.[5]).toBe(3);
+ // Pixel value 0 leaves the grid at 0 (transparent)
+ expect(grid[11]?.[6]).toBe(0);
+ });
+
+ it('silently clips pixels that extend beyond the grid boundary', () => {
+ const part: BodyPart = {
+ pixels: [[1, 2, 3]],
+ offsetX: 30,
+ offsetY: 0,
+ };
+ const grid = composePose([part], 32);
+
+ // Only the first two pixels fit (indices 30 and 31)
+ expect(grid[0]?.[30]).toBe(1);
+ expect(grid[0]?.[31]).toBe(2);
+ // Index 32 is out of bounds -- the grid only has 32 columns (0-31)
+ expect(grid[0]?.length).toBe(32);
+ });
+
+ it('clips pixels with negative offsets', () => {
+ const part: BodyPart = {
+ pixels: [[1, 2, 3]],
+ offsetX: -1,
+ offsetY: 0,
+ };
+ const grid = composePose([part], 32);
+
+ // offsetX=-1: pixel at px=0 maps to gx=-1 (clipped), px=1 maps to gx=0, px=2 maps to gx=1
+ expect(grid[0]?.[0]).toBe(2);
+ expect(grid[0]?.[1]).toBe(3);
+ });
+
+ it('applies painter algorithm: later body parts overwrite earlier ones', () => {
+ const bottom: BodyPart = {
+ pixels: [[1]],
+ offsetX: 5,
+ offsetY: 5,
+ };
+ const top: BodyPart = {
+ pixels: [[2]],
+ offsetX: 5,
+ offsetY: 5,
+ };
+ const grid = composePose([bottom, top], 32);
+
+ // The second (top) body part should win
+ expect(grid[5]?.[5]).toBe(2);
+ });
+
+ it('transparent pixels (value 0) do not overwrite previously placed pixels', () => {
+ const bottom: BodyPart = {
+ pixels: [[3]],
+ offsetX: 5,
+ offsetY: 5,
+ };
+ const top: BodyPart = {
+ pixels: [[0]],
+ offsetX: 5,
+ offsetY: 5,
+ };
+ const grid = composePose([bottom, top], 32);
+
+ // The top part has 0 (transparent), so the bottom part's value should remain
+ expect(grid[5]?.[5]).toBe(3);
+ });
+
+ it('returns a grid of the requested size initialized to zeros', () => {
+ const grid = composePose([], 16);
+
+ expect(grid).toHaveLength(16);
+ for (const row of grid) {
+ expect(row).toHaveLength(16);
+ for (const cell of row) {
+ expect(cell).toBe(0);
+ }
+ }
+ });
+});
+
+// ── renderSpriteSheet ───────────────────────────────────────────────────────
+
+describe('renderSpriteSheet', () => {
+ const palette: Palette = ['', '#111', '#222', '#333', '#444', '#555'];
+
+ it('produces an SVG with correct 128x96 dimensions', () => {
+ const poses: Pose[] = Array.from({ length: 12 }, () => []);
+ const svg = renderSpriteSheet(poses, palette);
+
+ expect(svg).toContain('xmlns="http://www.w3.org/2000/svg"');
+ expect(svg).toContain('width="128"');
+ expect(svg).toContain('height="96"');
+ });
+
+ it('starts and ends with proper SVG tags', () => {
+ const poses: Pose[] = Array.from({ length: 12 }, () => []);
+ const svg = renderSpriteSheet(poses, palette);
+
+ expect(svg).toMatch(/^