[object Object]

← back to Visual Factory

snapshot for codex review

7c502d828288a5602b26515e7cad460fc26665b4 · 2026-04-30 23:06:29 -0700 · temp

Files touched

Diff

commit 7c502d828288a5602b26515e7cad460fc26665b4
Author: temp <temp@local>
Date:   Thu Apr 30 23:06:29 2026 -0700

    snapshot for codex review
---
 .env.example               |   14 +
 .gitignore                 |    5 +
 CHANGES.md                 |   19 +
 package-lock.json          | 1047 ++++++++++++++++++++++++++++++++++++++++++++
 package.json               |   20 +
 public/index.html          |  421 ++++++++++++++++++
 scripts/clickthrough.js    |   87 ++++
 server.js                  |  696 +++++++++++++++++++++++++++++
 sql/001_init.sql           |   47 ++
 src/llm.js                 |  128 ++++++
 src/pipeline.js            |  236 ++++++++++
 src/stages/compose.js      |   57 +++
 src/stages/critic.js       |   52 +++
 src/stages/intake.js       |   43 ++
 src/stages/iterate.js      |   40 ++
 src/stages/render.js       |   37 ++
 src/stages/vision_check.js |   57 +++
 viewer.js                  |   12 +
 18 files changed, 3018 insertions(+)

diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..4847ee8
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,14 @@
+PG_HOST=/tmp
+PG_PORT=5432
+PG_DATABASE=visual_factory
+PG_USER=stevestudio2
+PG_PASSWORD=
+
+ORCHESTRATOR_PORT=9892
+VIEWER_PORT=9893
+
+OLLAMA_HOST=http://127.0.0.1:11434
+COMPOSE_MODEL=qwen3:14b
+VISION_MODEL=llava:latest
+
+CLAUDE_CLI=claude
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ba4f449
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+.env
+*.log
+.DS_Store
+output/
diff --git a/CHANGES.md b/CHANGES.md
new file mode 100644
index 0000000..93bfa25
--- /dev/null
+++ b/CHANGES.md
@@ -0,0 +1,19 @@
+## Applied
+- src/llm.js:10 — changed the Claude CLI fallback from a user-specific absolute path to `claude` from `PATH` — removes a hardcoded `/Users/...` path from source.
+- server.js:107 — added the same `CLAUDE_CLI || 'claude'` default for deep health checks — keeps server and LLM adapter behavior aligned.
+- server.js:109 — added a small executable lookup helper for absolute paths, relative paths, and `PATH` commands — lets `/health/deep` validate `claude` without a hardcoded home directory.
+- server.js:204 — changed the Claude CLI health probe to use the new lookup helper — avoids checking Steve's local absolute path.
+- .env.example:14 — changed `CLAUDE_CLI` to `claude` — documents the portable default without copying a real local path.
+
+## Deferred (needs Steve)
+- P0 — server.js:223 — create/retry/delete/activate/IG POST routes lack auth/CSRF — deferred because token, Origin, and CSRF policy are outside the safe fix list.
+- P0 — src/stages/compose.js:34, src/stages/render.js:21 — LLM HTML is written and opened as `file://` with JS/network behavior unresolved — deferred because sanitization/CSP/request-blocking choices need product/security decisions.
+- P0 — server.js:259 — retry/cancellation semantics for live runs still need an owner decision, especially `force=true` behavior — deferred because cancellation/locking is outside the safe fix list.
+- P0 — src/stages/intake.js:35, src/stages/render.js:19 — generated dimensions are not clamped before rendering — deferred because bounds policy is not in the safe fix list.
+- P1 — server.js:333 — `/runs/:id/png` sends DB paths without sandbox validation — deferred because artifact path trust policy is outside the requested safe fixes.
+- P1 — server.js:405 — activation validates `png_path` but not `html_path` before copying — deferred for the same sandbox policy reason.
+- P1 — src/stages/vision_check.js:40, src/stages/critic.js:21 — failed vision can still approve at quality 75 — deferred because changing approval policy affects generated asset workflow.
+- P1 — src/pipeline.js:195 — crash logging can use stale `run.current_stage` — deferred because it is not in safe fix items 1-7.
+- P1 — server.js:413, server.js:442 — activation copy and manifest update are not locked/atomic — deferred because concurrency design is outside the safe fix list.
+- P1 — src/stages/compose.js:16, src/stages/render.js:21 — Google Fonts plus `networkidle` can stall offline runs — deferred because asset bundling/request policy is outside the safe fix list.
+- P1 — package.json:10 — `npm run run` points to a missing script — deferred because script changes are not in safe fix items 1-7.
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..e313dd5
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1047 @@
+{
+  "name": "visual-factory",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "visual-factory",
+      "version": "0.1.0",
+      "dependencies": {
+        "dotenv": "^16.4.5",
+        "express": "^4.19.2",
+        "pg": "^8.12.0",
+        "playwright": "^1.45.0"
+      }
+    },
+    "node_modules/accepts": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+      "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "~2.1.34",
+        "negotiator": "0.6.3"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/array-flatten": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+      "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+      "license": "MIT"
+    },
+    "node_modules/body-parser": {
+      "version": "1.20.5",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
+      "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "content-type": "~1.0.5",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "~1.2.0",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "on-finished": "~2.4.1",
+        "qs": "~6.15.1",
+        "raw-body": "~2.5.3",
+        "type-is": "~1.6.18",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/body-parser/node_modules/qs": {
+      "version": "6.15.1",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
+      "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/content-disposition": {
+      "version": "0.5.4",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+      "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "5.2.1"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+      "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+      "license": "MIT"
+    },
+    "node_modules/debug": {
+      "version": "2.6.9",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/destroy": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+      "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/dotenv": {
+      "version": "16.6.1",
+      "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+      "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://dotenvx.com"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "license": "MIT"
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/express": {
+      "version": "4.22.1",
+      "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
+      "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "~1.3.8",
+        "array-flatten": "1.1.1",
+        "body-parser": "~1.20.3",
+        "content-disposition": "~0.5.4",
+        "content-type": "~1.0.4",
+        "cookie": "~0.7.1",
+        "cookie-signature": "~1.0.6",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "finalhandler": "~1.3.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.0",
+        "merge-descriptors": "1.0.3",
+        "methods": "~1.1.2",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "path-to-regexp": "~0.1.12",
+        "proxy-addr": "~2.0.7",
+        "qs": "~6.14.0",
+        "range-parser": "~1.2.1",
+        "safe-buffer": "5.2.1",
+        "send": "~0.19.0",
+        "serve-static": "~1.16.2",
+        "setprototypeof": "1.2.0",
+        "statuses": "~2.0.1",
+        "type-is": "~1.6.18",
+        "utils-merge": "1.0.1",
+        "vary": "~1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/finalhandler": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+      "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "statuses": "~2.0.2",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+      "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+      "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "inherits": "~2.0.4",
+        "setprototypeof": "~1.2.0",
+        "statuses": "~2.0.2",
+        "toidentifier": "~1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+      "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+      "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/methods": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+      "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+      "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+      "license": "MIT",
+      "bin": {
+        "mime": "cli.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+      "license": "MIT"
+    },
+    "node_modules/negotiator": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+      "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "0.1.13",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+      "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+      "license": "MIT"
+    },
+    "node_modules/pg": {
+      "version": "8.20.0",
+      "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
+      "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-connection-string": "^2.12.0",
+        "pg-pool": "^3.13.0",
+        "pg-protocol": "^1.13.0",
+        "pg-types": "2.2.0",
+        "pgpass": "1.0.5"
+      },
+      "engines": {
+        "node": ">= 16.0.0"
+      },
+      "optionalDependencies": {
+        "pg-cloudflare": "^1.3.0"
+      },
+      "peerDependencies": {
+        "pg-native": ">=3.0.1"
+      },
+      "peerDependenciesMeta": {
+        "pg-native": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/pg-cloudflare": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
+      "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/pg-connection-string": {
+      "version": "2.12.0",
+      "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
+      "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
+      "license": "MIT"
+    },
+    "node_modules/pg-int8": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+      "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/pg-pool": {
+      "version": "3.13.0",
+      "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
+      "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
+      "license": "MIT",
+      "peerDependencies": {
+        "pg": ">=8.0"
+      }
+    },
+    "node_modules/pg-protocol": {
+      "version": "1.13.0",
+      "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
+      "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
+      "license": "MIT"
+    },
+    "node_modules/pg-types": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+      "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-int8": "1.0.1",
+        "postgres-array": "~2.0.0",
+        "postgres-bytea": "~1.0.0",
+        "postgres-date": "~1.0.4",
+        "postgres-interval": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/pgpass": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+      "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+      "license": "MIT",
+      "dependencies": {
+        "split2": "^4.1.0"
+      }
+    },
+    "node_modules/playwright": {
+      "version": "1.59.1",
+      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
+      "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright-core": "1.59.1"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "fsevents": "2.3.2"
+      }
+    },
+    "node_modules/playwright-core": {
+      "version": "1.59.1",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
+      "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
+      "license": "Apache-2.0",
+      "bin": {
+        "playwright-core": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/postgres-array": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+      "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postgres-bytea": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+      "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-date": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+      "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-interval": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+      "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+      "license": "MIT",
+      "dependencies": {
+        "xtend": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.14.2",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
+      "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "2.5.3",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+      "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/send": {
+      "version": "0.19.2",
+      "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+      "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "1.2.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.1",
+        "mime": "1.6.0",
+        "ms": "2.1.3",
+        "on-finished": "~2.4.1",
+        "range-parser": "~1.2.1",
+        "statuses": "~2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/send/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/serve-static": {
+      "version": "1.16.3",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+      "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "parseurl": "~1.3.3",
+        "send": "~0.19.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+      "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3",
+        "side-channel-list": "^1.0.0",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+      "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/split2": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+      "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">= 10.x"
+      }
+    },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "1.6.18",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+      "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+      "license": "MIT",
+      "dependencies": {
+        "media-typer": "0.3.0",
+        "mime-types": "~2.1.24"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/utils-merge": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+      "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/xtend": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..d742988
--- /dev/null
+++ b/package.json
@@ -0,0 +1,20 @@
+{
+  "name": "visual-factory",
+  "version": "0.1.0",
+  "private": true,
+  "description": "Meta-orchestrator: brief -> rendered visual artifact (HTML→PNG via Playwright; llava vision review; Claude CLI critic).",
+  "main": "server.js",
+  "scripts": {
+    "start": "node server.js",
+    "viewer": "node viewer.js",
+    "run": "node scripts/run.js",
+    "pm2:start": "pm2 start server.js --name visual-factory-orchestrator && pm2 start viewer.js --name visual-factory-viewer && pm2 save",
+    "playwright:install": "npx playwright install chromium"
+  },
+  "dependencies": {
+    "dotenv": "^16.4.5",
+    "express": "^4.19.2",
+    "pg": "^8.12.0",
+    "playwright": "^1.45.0"
+  }
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..c013804
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,421 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width,initial-scale=1" />
+<title>The Visual Factory</title>
+<style>
+  :root {
+    --bg: #0e0e10; --panel: #16161a; --line: #2a2a30;
+    --text: #e8e8ea; --muted: #8a8a92; --accent: #d4af37;
+  }
+  html, body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.45 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; }
+  header { padding: 24px 32px; border-bottom: 1px solid var(--line); display: flex; align-items: baseline; gap: 16px; }
+  header h1 { margin: 0; font-size: 20px; letter-spacing: 0.02em; }
+  header .sub { color: var(--muted); font-size: 12px; }
+  main { padding: 24px 32px; max-width: 1400px; margin: 0 auto; }
+  form { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 16px; margin-bottom: 24px; display: grid; grid-template-columns: 1fr 1fr; gap: 12px; align-items: start; }
+  form .full { grid-column: 1 / -1; }
+  form label { display: block; font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 4px; }
+  input[type=text], textarea { width: 100%; box-sizing: border-box; background: #050507; border: 1px solid var(--line); color: var(--text); padding: 10px 12px; border-radius: 6px; font: inherit; }
+  textarea { resize: vertical; min-height: 60px; }
+  form .actions { grid-column: 1 / -1; display: flex; justify-content: space-between; align-items: center; gap: 12px; }
+  form .toggle { color: var(--muted); font-size: 12px; cursor: pointer; user-select: none; }
+  form .toggle:hover { color: var(--accent); }
+  button { background: var(--accent); color: #1b1b1b; border: 0; padding: 10px 16px; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer; }
+  button:disabled { opacity: 0.4; cursor: not-allowed; }
+  .activate-btn { padding: 4px 10px; font-size: 12px; }
+  .row-btn { padding: 4px 8px; font-size: 11px; background: transparent; color: var(--muted); border: 1px solid var(--line); }
+  .row-btn:hover { color: var(--text); border-color: var(--accent); }
+  .row-btn.danger:hover { color: #ff6b6b; border-color: #ff6b6b; }
+
+  .tabs { display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap; }
+  .tab { background: var(--panel); border: 1px solid var(--line); color: var(--muted); padding: 6px 12px; border-radius: 999px; font-size: 12px; cursor: pointer; user-select: none; }
+  .tab:hover { color: var(--text); }
+  .tab.active { background: var(--accent); color: #1b1b1b; border-color: var(--accent); font-weight: 600; }
+  .tab .count { opacity: 0.65; margin-left: 6px; font-size: 10px; }
+
+  .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
+  .card { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; cursor: pointer; transition: border-color 0.15s; }
+  .card:hover { border-color: var(--accent); }
+  .thumb { width: 100%; aspect-ratio: 1/1; background: #050507 center/contain no-repeat; border-bottom: 1px solid var(--line); }
+  .meta { padding: 12px 14px; }
+  .meta .name { font-weight: 600; }
+  .meta .brief { color: var(--muted); font-size: 12px; margin-top: 4px; line-height: 1.4; max-height: 3.6em; overflow: hidden; }
+  .meta .row { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; gap: 8px; }
+  .meta .status { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
+  .meta .status.activated { color: #5fb878; }
+  .meta .status.unapproved, .meta .status.completed_unapproved, .meta .status.failed, .meta .status.failed_intake, .meta .status.failed_orphan { color: #ff6b6b; }
+  .meta .status.awaiting_activation, .meta .status.awaiting_activation_v2 { color: var(--accent); }
+  .meta .status.running, .meta .status.pending { color: #6ab7ff; }
+  .meta .badges { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
+  .meta .badge { font-size: 10px; padding: 2px 7px; border-radius: 4px; background: #050507; border: 1px solid var(--line); color: var(--muted); letter-spacing: 0.04em; }
+  .meta .badge.score { color: #5fb878; border-color: #2c4d36; }
+  .meta .badge.iter { color: var(--accent); border-color: #5a4912; }
+  .meta .badge.stage { color: #6ab7ff; border-color: #1f3a5a; }
+  .meta .errsnip { color: #ff6b6b; font-size: 11px; margin-top: 6px; max-height: 2.6em; overflow: hidden; line-height: 1.3; }
+  .meta .actions-row { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; }
+  .empty { color: var(--muted); padding: 60px 0; text-align: center; }
+  .err { color: #ff6b6b; font-size: 12px; margin-top: 8px; }
+  .health { display: flex; gap: 12px; flex-wrap: wrap; padding: 10px 14px; margin-bottom: 16px; border-radius: 6px; background: var(--panel); border: 1px solid var(--line); font-size: 12px; align-items: center; }
+  .health.bad  { border-color: #ff6b6b; background: #2a1212; }
+  .health.warn { border-color: #f4b400; }
+  .health .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: middle; }
+  .health .ok  { background: #5fb878; }
+  .health .nok { background: #ff6b6b; }
+  .health .wn  { background: #f4b400; }
+  .health .lbl { color: var(--muted); }
+  .health .qd  { margin-left: auto; color: var(--muted); }
+
+  .modal-bg { position: fixed; inset: 0; background: rgba(0,0,0,0.85); display: none; align-items: center; justify-content: center; z-index: 100; padding: 24px; }
+  .modal-bg.open { display: flex; }
+  .modal { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; max-width: 1100px; width: 100%; max-height: 90vh; overflow: auto; padding: 20px 24px; }
+  .modal h2 { margin: 0 0 8px; font-size: 16px; }
+  .modal .brief-full { color: var(--muted); margin-bottom: 16px; }
+  .modal img { width: 100%; max-height: 60vh; object-fit: contain; background: #050507; border: 1px solid var(--line); border-radius: 4px; }
+  .event { display: grid; grid-template-columns: 60px 110px 50px 1fr; gap: 12px; padding: 4px 0; border-bottom: 1px dotted #222; font-size: 12px; }
+  .event:last-child { border-bottom: 0; }
+  .event .stg { color: var(--muted); } .event .nm { color: var(--accent); }
+  .event .lv { font-size: 10px; text-transform: uppercase; }
+  .event .lv.warn { color: #f4b400; } .event .lv.error { color: #ff6b6b; } .event .lv.info { color: #5fb878; }
+  .event details summary { color: var(--muted); cursor: pointer; font-size: 10px; }
+  .event pre { background: #050507; color: #d0d0d4; padding: 8px; margin: 4px 0 0; border-radius: 4px; font-size: 11px; overflow-x: auto; max-height: 200px; }
+</style>
+</head>
+<body>
+<header>
+  <h1>The Visual Factory</h1>
+  <span class="sub">brief → composed HTML → headless render → vision review · :9892</span>
+</header>
+<main>
+  <form id="newRun">
+    <div>
+      <label for="domain">Domain</label>
+      <input id="domain" type="text" placeholder="philipperomano.com" autocomplete="off" />
+    </div>
+    <div>
+      <label for="purpose">Purpose</label>
+      <input id="purpose" type="text" placeholder="Spring 2026 collection launch — Instagram feed" autocomplete="off" />
+    </div>
+    <div class="full">
+      <label for="details">Details (optional — palette, mood, copy, dimensions)</label>
+      <textarea id="details" placeholder="Editorial mood, ivory + deep green, bold serif headline `New: Mind the Gap collection`, 1080×1080…"></textarea>
+    </div>
+    <div class="full">
+      <label for="brief">Or paste a free-text brief instead (overrides above)</label>
+      <textarea id="brief" placeholder="Leave blank unless you want to skip the structured fields."></textarea>
+    </div>
+    <div class="actions">
+      <span class="toggle" id="exampleBtn">Fill example →</span>
+      <button type="submit">Build</button>
+    </div>
+  </form>
+  <div id="err" class="err"></div>
+  <div id="health"></div>
+  <div class="tabs" id="tabs"></div>
+  <div class="grid" id="grid"><div class="empty">No runs yet.</div></div>
+</main>
+
+<div class="modal-bg" id="modal-bg">
+  <div class="modal" id="modal">
+    <h2 id="m-title"></h2>
+    <div class="brief-full" id="m-brief"></div>
+    <img id="m-img" />
+    <div id="m-events" style="margin-top: 16px;"></div>
+  </div>
+</div>
+
+<script>
+// Same-origin: the orchestrator now serves this page, so paths are relative.
+const ORCH = '';
+const STAGE_NAMES = ['queued','intake','compose','render','vision_check','critic','iterate','catalog','activate'];
+let currentFilter = localStorage.getItem('vf:filter') || 'all';
+let lastRuns = [];
+
+// Bucket a run into a UI tab. Server statuses are messier than the user
+// thinks about them — collapse here so the tabs stay simple.
+function bucket(status) {
+  if (!status) return 'other';
+  if (status === 'pending' || status === 'running') return 'running';
+  if (status.startsWith('awaiting_activation')) return 'awaiting';
+  if (status === 'activated') return 'activated';
+  if (status.startsWith('failed') || status === 'completed_unapproved' || status === 'unapproved') return 'failed';
+  return 'other';
+}
+
+const TABS = [
+  { id: 'all',       label: 'All' },
+  { id: 'running',   label: 'Running' },
+  { id: 'awaiting',  label: 'Awaiting activation' },
+  { id: 'activated', label: 'Activated' },
+  { id: 'failed',    label: 'Failed' },
+];
+
+function renderTabs(runs) {
+  const counts = Object.fromEntries(TABS.map(t => [t.id, 0]));
+  counts.all = runs.length;
+  for (const r of runs) {
+    const b = bucket(r.status);
+    if (b in counts) counts[b]++;
+  }
+  document.getElementById('tabs').innerHTML = TABS.map(t =>
+    `<span class="tab ${currentFilter===t.id?'active':''}" data-filter="${t.id}">${t.label}<span class="count">${counts[t.id]||0}</span></span>`
+  ).join('');
+  document.querySelectorAll('.tab').forEach(el => {
+    el.addEventListener('click', () => {
+      currentFilter = el.dataset.filter;
+      localStorage.setItem('vf:filter', currentFilter);
+      renderTabs(lastRuns);
+      renderGrid(lastRuns);
+    });
+  });
+}
+
+function renderGrid(runs) {
+  const grid = document.getElementById('grid');
+  const filtered = currentFilter === 'all' ? runs : runs.filter(r => bucket(r.status) === currentFilter);
+  if (!filtered.length) { grid.innerHTML = `<div class="empty">No runs in &ldquo;${currentFilter}&rdquo;.</div>`; return; }
+  grid.innerHTML = filtered.map(r => {
+    const status = String(r.status || '');
+    const canActivate = status.startsWith('awaiting_activation');
+    const canRetry = /^(failed|completed_unapproved|unapproved)/.test(status);
+    const isRunning = status === 'running' || status === 'pending';
+    const dimText = `${r.width||'?'}×${r.height||'?'}`;
+    const domainBadge = r.domain
+      ? `<span style="color:var(--accent);font-size:10px;font-weight:600;letter-spacing:0.06em;text-transform:uppercase">${escapeHtml(r.domain)}</span>`
+      : '';
+    const subline = r.purpose ? escapeHtml(r.purpose) : escapeHtml(r.brief || '');
+    const vMatch = status.match(/^awaiting_activation_v(\d+)$/);
+    const badges = [];
+    if (isRunning && r.current_stage != null) {
+      const sn = STAGE_NAMES[r.current_stage] || `stage ${r.current_stage}`;
+      badges.push(`<span class="badge stage">▸ ${escapeHtml(sn)}</span>`);
+    }
+    if (r.score != null) badges.push(`<span class="badge score">★ ${r.score}/100</span>`);
+    if (vMatch) {
+      const v = Number(vMatch[1]);
+      badges.push(`<span class="badge iter">v${v} · iterated ${v - 1}×</span>`);
+    }
+    const errsnip = (status.startsWith('failed') || status === 'completed_unapproved') && r.last_error
+      ? `<div class="errsnip" title="${escapeHtml(r.last_error)}">${escapeHtml(r.last_error.slice(0, 120))}${r.last_error.length > 120 ? '…' : ''}</div>`
+      : '';
+    const actions = [];
+    if (canActivate) actions.push(`<button data-id="${r.id}" class="activate-btn">Activate</button>`);
+    if (canRetry)    actions.push(`<button data-id="${r.id}" class="row-btn retry-btn">Retry</button>`);
+    if (status === 'activated') actions.push(`<button data-id="${r.id}" class="row-btn ig-btn">Post to IG</button>`);
+    if (status !== 'activated' && !isRunning) actions.push(`<button data-id="${r.id}" class="row-btn danger delete-btn">Delete</button>`);
+    return `
+      <div class="card" data-id="${r.id}">
+        <div class="thumb" style="background-image:url('${ORCH}/runs/${r.id}/png?t=${Date.now()}')"></div>
+        <div class="meta">
+          ${domainBadge}
+          <div class="name">${escapeHtml(r.artifact_name || `run ${r.id}`)} <span style="color:var(--muted);font-weight:400;font-size:11px">${dimText}</span></div>
+          <div class="brief">${subline}</div>
+          ${badges.length ? `<div class="badges">${badges.join('')}</div>` : ''}
+          ${errsnip}
+          <div class="row"><span class="status ${status}">${escapeHtml(status)}</span></div>
+          ${actions.length ? `<div class="actions-row">${actions.join('')}</div>` : ''}
+        </div>
+      </div>`;
+  }).join('');
+
+  document.querySelectorAll('.activate-btn').forEach(btn => {
+    btn.addEventListener('click', async (e) => {
+      e.stopPropagation();
+      if (!confirm(`Publish run ${btn.dataset.id} to ~/Pictures/visual-factory-published/ ?`)) return;
+      btn.disabled = true;
+      const r = await fetch(`${ORCH}/runs/${btn.dataset.id}/activate`, { method: 'POST' });
+      const d = await r.json();
+      if (d.error) alert('Activate failed: ' + d.error + '\n' + (d.hint||''));
+      else alert('Published → ' + d.destPng);
+      loadRuns();
+    });
+  });
+  document.querySelectorAll('.retry-btn').forEach(btn => {
+    btn.addEventListener('click', async (e) => {
+      e.stopPropagation();
+      if (!confirm(`Retry run ${btn.dataset.id}? Wipes its events + artifact, re-runs the pipeline.`)) return;
+      btn.disabled = true;
+      const r = await fetch(`${ORCH}/runs/${btn.dataset.id}/retry`, { method: 'POST' });
+      const d = await r.json();
+      if (d.error) alert('Retry failed: ' + d.error);
+      loadRuns();
+    });
+  });
+  document.querySelectorAll('.delete-btn').forEach(btn => {
+    btn.addEventListener('click', async (e) => {
+      e.stopPropagation();
+      if (!confirm(`Delete run ${btn.dataset.id}? Removes its events, artifact row, and output dir. Activated PNGs (in Pictures) are NOT touched.`)) return;
+      btn.disabled = true;
+      const r = await fetch(`${ORCH}/runs/${btn.dataset.id}`, { method: 'DELETE' });
+      const d = await r.json();
+      if (d.error) alert('Delete failed: ' + d.error);
+      loadRuns();
+    });
+  });
+  document.querySelectorAll('.ig-btn').forEach(btn => {
+    btn.addEventListener('click', async (e) => {
+      e.stopPropagation();
+      if (!confirm(`Post run ${btn.dataset.id} to Instagram via Norma?\n\nNorma is in SIMULATION mode unless IG creds are set — this will probably just log a fake media_id. Real posts also require the image to be hosted at a public URL (loopback won't work for Meta).`)) return;
+      btn.disabled = true;
+      const r = await fetch(`${ORCH}/runs/${btn.dataset.id}/post-ig`, { method: 'POST' });
+      const d = await r.json();
+      if (d.ok) {
+        const sim = d.response?.result?.simulated;
+        alert(`IG post ${sim ? 'SIMULATED' : 'submitted'} → media_id=${d.response?.result?.media_id || '?'}\n\nCaption:\n${d.caption}`);
+      } else {
+        alert(`IG post failed: ${d.error || 'http ' + d.status}`);
+      }
+      loadRuns();
+    });
+  });
+  document.querySelectorAll('.card').forEach(c => {
+    c.addEventListener('click', () => openModal(c.dataset.id));
+  });
+}
+
+// Codex P2 #14 fix: cancel an in-flight loadRuns / openModal before issuing a
+// new one, so an older slow response can't overwrite newer state when the user
+// switches filters fast or the 2s modal poll laps the previous request.
+let _loadRunsCtrl = null;
+let _modalCtrl = null;
+
+async function loadRuns() {
+  if (_loadRunsCtrl) _loadRunsCtrl.abort();
+  const ctrl = (_loadRunsCtrl = new AbortController());
+  try {
+    const res = await fetch(ORCH + '/runs', { signal: ctrl.signal });
+    if (ctrl !== _loadRunsCtrl) return; // we were superseded mid-flight
+    const data = await res.json();
+    const errEl = document.getElementById('err');
+    if (data.error) { errEl.textContent = data.error + (data.hint ? ' — ' + data.hint : ''); return; }
+    errEl.textContent = '';
+    if (!data.runs) { document.getElementById('grid').innerHTML = '<div class="empty">No runs yet.</div>'; renderTabs([]); return; }
+    lastRuns = data.runs;
+    renderTabs(lastRuns);
+    renderGrid(lastRuns);
+  } catch (e) {
+    if (e.name === 'AbortError') return; // superseded — don't surface
+    document.getElementById('err').textContent = `orchestrator unreachable — ${e.message}`;
+  }
+}
+
+async function openModal(id, keepOpen) {
+  // Cancel any prior modal fetch so a slow `/events` response from a different
+  // run can't paint into the modal we're now showing for a different id.
+  if (_modalCtrl) _modalCtrl.abort();
+  const ctrl = (_modalCtrl = new AbortController());
+  // Capture the modal target locally; if modalRunId changes during await
+  // (user clicked another card / closed modal), bail before painting.
+  const targetId = String(id);
+  try {
+    const res = await fetch(ORCH + '/runs', { signal: ctrl.signal });
+    const d = await res.json();
+    if (ctrl !== _modalCtrl) return;
+    const r = (d.runs||[]).find(x => String(x.id) === targetId);
+    if (!r) return;
+    if (!keepOpen) modalRunId = id;
+    // After the await the user may have closed the modal; if so, don't paint.
+    if (keepOpen && String(modalRunId) !== targetId) return;
+    document.getElementById('m-title').textContent = `${r.artifact_name||'run '+id} — ${r.status}`;
+    document.getElementById('m-brief').textContent = r.brief;
+    // Only refresh the image src on open — re-setting it every 2s causes flicker.
+    if (!keepOpen) {
+      document.getElementById('m-img').src = `${ORCH}/runs/${id}/png?t=${Date.now()}`;
+    }
+    const evRes = await fetch(`${ORCH}/runs/${id}/events`, { signal: ctrl.signal });
+    if (ctrl !== _modalCtrl) return;
+    const ev = await evRes.json();
+    if (keepOpen && String(modalRunId) !== targetId) return;
+    const evEl = document.getElementById('m-events');
+    const wasAtBottom = evEl.scrollHeight - evEl.scrollTop - evEl.clientHeight < 40;
+    evEl.innerHTML = (ev.events||[]).map(e => {
+      const payload = e.payload ? `<details><summary>payload</summary><pre>${escapeHtml(JSON.stringify(e.payload, null, 2))}</pre></details>` : '';
+      return `<div class="event"><span class="stg">stage ${e.stage}</span><span class="nm">${e.stage_name}</span><span class="lv ${e.level}">${e.level}</span><span>${escapeHtml(e.message||'')}${payload}</span></div>`;
+    }).join('');
+    if (wasAtBottom) evEl.scrollTop = evEl.scrollHeight;
+    if (!keepOpen) document.getElementById('modal-bg').classList.add('open');
+  } catch (e) {
+    if (e.name !== 'AbortError') console.error('openModal failed:', e);
+  }
+}
+
+document.getElementById('modal-bg').addEventListener('click', (e) => {
+  if (e.target.id === 'modal-bg') {
+    e.target.classList.remove('open');
+    modalRunId = null;
+  }
+});
+
+document.getElementById('newRun').addEventListener('submit', async (e) => {
+  e.preventDefault();
+  const brief   = document.getElementById('brief').value.trim();
+  const domain  = document.getElementById('domain').value.trim();
+  const purpose = document.getElementById('purpose').value.trim();
+  const details = document.getElementById('details').value.trim();
+  if (!brief && (!domain || !purpose)) {
+    document.getElementById('err').textContent = 'Need either a brief OR domain + purpose.';
+    return;
+  }
+  document.getElementById('err').textContent = '';
+  const res = await fetch(ORCH + '/runs', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ brief, domain, purpose, details })
+  });
+  const data = await res.json();
+  if (data.error) { document.getElementById('err').textContent = data.error; return; }
+  ['brief','domain','purpose','details'].forEach(id => document.getElementById(id).value = '');
+  loadRuns();
+});
+
+document.getElementById('exampleBtn').addEventListener('click', () => {
+  document.getElementById('domain').value  = 'philipperomano.com';
+  document.getElementById('purpose').value = 'Spring 2026 collection launch — Instagram 1080×1080';
+  document.getElementById('details').value = 'Editorial mood, ivory + deep coastal blue palette, bold serif headline "Coastal: Spring 2026", subhead "Inspired by the New England shoreline", CTA pill "View collection →".';
+});
+
+function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
+
+// Health banner — calls /health/deep on load + every 30s. Shows red when DB or
+// Ollama is unreachable so users don't submit doomed runs. Queue depth + cap
+// shown on the right so concurrency caps are visible.
+async function loadHealth() {
+  const el = document.getElementById('health');
+  try {
+    const r = await fetch(ORCH + '/health/deep');
+    const d = await r.json();
+    const dot = (s) => s === 'ok' || (typeof s === 'string' && s.startsWith('ok'))
+      ? '<span class="dot ok"></span>'
+      : (typeof s === 'string' && s.includes('missing'))
+        ? '<span class="dot wn"></span>'
+        : '<span class="dot nok"></span>';
+    const cls = d.ok ? '' : 'bad';
+    const q = d.queue || {};
+    el.className = 'health ' + cls;
+    el.innerHTML = `
+      <span>${dot(d.checks.db)}<span class="lbl">db</span> ${escapeHtml(String(d.checks.db))}</span>
+      <span>${dot(d.checks.ollama)}<span class="lbl">ollama</span> ${escapeHtml(String(d.checks.ollama))}</span>
+      <span>${dot(d.checks.claude_cli)}<span class="lbl">critic</span> ${escapeHtml(String(d.checks.claude_cli))}</span>
+      <span>${dot(d.checks.playwright)}<span class="lbl">playwright</span> ${escapeHtml(String(d.checks.playwright))}</span>
+      <span class="qd">queue ${q.in_flight||0}/${q.capacity||'?'} in flight · ${q.queued||0} queued</span>`;
+  } catch (e) {
+    el.className = 'health bad';
+    el.innerHTML = `<span><span class="dot nok"></span>orchestrator unreachable</span>`;
+  }
+}
+
+let pollIv = setInterval(loadRuns, 4000);
+let modalRunId = null;
+async function refreshModal() {
+  if (!modalRunId) return;
+  await openModal(modalRunId, /* keepOpen */ true);
+}
+setInterval(refreshModal, 2000);
+
+loadRuns();
+loadHealth();
+setInterval(loadHealth, 30_000);
+</script>
+</body>
+</html>
diff --git a/scripts/clickthrough.js b/scripts/clickthrough.js
new file mode 100644
index 0000000..1c3f343
--- /dev/null
+++ b/scripts/clickthrough.js
@@ -0,0 +1,87 @@
+// Live click-through of the Visual Factory viewer.
+// Opens :9893, screenshots the gallery, clicks each row's modal, screenshots that.
+
+const { chromium } = require('playwright');
+const path = require('node:path');
+const fs = require('node:fs/promises');
+
+const VIEWER = 'http://127.0.0.1:9893';
+const ORCH   = 'http://127.0.0.1:9892';
+const OUT    = path.join(__dirname, '..', 'output', 'clickthrough');
+
+(async () => {
+  await fs.mkdir(OUT, { recursive: true });
+  const browser = await chromium.launch({ headless: true });
+  const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 });
+  const page = await ctx.newPage();
+  const consoleMsgs = [];
+  page.on('console', m => consoleMsgs.push(`[${m.type()}] ${m.text()}`));
+  page.on('pageerror', e => consoleMsgs.push(`[PAGEERROR] ${e.message}`));
+
+  console.log('STEP 1: gallery');
+  await page.goto(VIEWER, { waitUntil: 'networkidle', timeout: 15000 });
+  // Give the JS poll a moment to load runs from the orch
+  await page.waitForTimeout(1500);
+  await page.screenshot({ path: path.join(OUT, '01-gallery.png'), fullPage: true });
+
+  // How many cards rendered?
+  const cardCount = await page.locator('.card').count();
+  console.log(`  cards rendered: ${cardCount}`);
+
+  // Activate buttons present?
+  const activateCount = await page.locator('.activate-btn').count();
+  console.log(`  activate buttons: ${activateCount}`);
+
+  // STEP 2: click first card → modal
+  console.log('STEP 2: click first card');
+  if (cardCount > 0) {
+    // Click the card body (not the activate button). We click the .meta div to avoid stopPropagation issues.
+    await page.locator('.card').first().locator('.meta').click();
+    await page.waitForSelector('.modal-bg.open', { timeout: 5000 });
+    await page.waitForTimeout(800);
+    await page.screenshot({ path: path.join(OUT, '02-modal-first.png'), fullPage: false });
+    // Close the modal by clicking outside
+    await page.locator('.modal-bg').click({ position: { x: 10, y: 10 } });
+    await page.waitForTimeout(400);
+  }
+
+  // STEP 3: click third card (different visual_type)
+  console.log('STEP 3: click third card');
+  if (cardCount > 2) {
+    await page.locator('.card').nth(2).locator('.meta').click();
+    await page.waitForSelector('.modal-bg.open', { timeout: 5000 });
+    await page.waitForTimeout(800);
+    await page.screenshot({ path: path.join(OUT, '03-modal-third.png'), fullPage: false });
+    await page.locator('.modal-bg').click({ position: { x: 10, y: 10 } });
+    await page.waitForTimeout(400);
+  }
+
+  // STEP 4: click fifth card
+  console.log('STEP 4: click fifth card');
+  if (cardCount > 4) {
+    await page.locator('.card').nth(4).locator('.meta').click();
+    await page.waitForSelector('.modal-bg.open', { timeout: 5000 });
+    await page.waitForTimeout(800);
+    await page.screenshot({ path: path.join(OUT, '04-modal-fifth.png'), fullPage: false });
+  }
+
+  // Diagnostics
+  await fs.writeFile(path.join(OUT, 'console.log'), consoleMsgs.join('\n'), 'utf8');
+  const summary = {
+    viewer_status: 'reachable',
+    cards_rendered: cardCount,
+    activate_buttons: activateCount,
+    console_messages: consoleMsgs.length,
+    screenshots: ['01-gallery.png', '02-modal-first.png', '03-modal-third.png', '04-modal-fifth.png']
+  };
+  await fs.writeFile(path.join(OUT, 'summary.json'), JSON.stringify(summary, null, 2));
+  console.log('SUMMARY:', JSON.stringify(summary));
+
+  // Also probe orchestrator endpoints
+  const health = await (await fetch(`${ORCH}/health`)).json();
+  const runs   = await (await fetch(`${ORCH}/runs`)).json();
+  console.log(`ORCH /health: ${JSON.stringify(health)}`);
+  console.log(`ORCH /runs: ${runs.runs?.length || 0} runs`);
+
+  await browser.close();
+})().catch(e => { console.error(e); process.exit(1); });
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..d5066fd
--- /dev/null
+++ b/server.js
@@ -0,0 +1,696 @@
+// Visual Factory orchestrator (:9892)
+
+require('dotenv').config();
+const fs = require('node:fs/promises');
+const path = require('node:path');
+const os = require('node:os');
+const { spawn } = require('node:child_process');
+const express = require('express');
+const { Pool } = require('pg');
+
+const PG_DATABASE = process.env.PG_DATABASE || 'visual_factory';
+if (PG_DATABASE === 'dw_unified') {
+  console.error('[visual-factory] FATAL: PG_DATABASE=dw_unified is forbidden. Use the standalone `visual_factory` DB.');
+  process.exit(1);
+}
+
+const { runPipeline } = require('./src/pipeline');
+
+// In-process FIFO queue. Caps how many pipelines run concurrently so a flurry
+// of submissions doesn't crush Ollama / Playwright on this single Mac. Pending
+// runs sit at status='pending' (set by the INSERT) until promoted by pump().
+//
+// Codex P2 #13 fix: clamp to integer ≥1; 0/negative/NaN would silently deadlock
+// the queue (pump never moves anything) — fail fast on bad config.
+const PIPELINE_CONCURRENCY = (() => {
+  const raw = process.env.PIPELINE_CONCURRENCY;
+  const n = Number.parseInt(raw, 10);
+  if (raw !== undefined && (!Number.isFinite(n) || n < 1)) {
+    console.error(`[visual-factory] FATAL: PIPELINE_CONCURRENCY=${JSON.stringify(raw)} invalid; must be integer ≥1`);
+    process.exit(1);
+  }
+  return Number.isFinite(n) && n >= 1 ? n : 2;
+})();
+const _inFlight = new Set();
+const _queue = [];
+function enqueueRun(_pg, run) {
+  _queue.push(run);
+  _pump(_pg);
+}
+function _pump(_pg) {
+  while (_inFlight.size < PIPELINE_CONCURRENCY && _queue.length) {
+    const r = _queue.shift();
+    _inFlight.add(r.id);
+    runPipeline(_pg, r)
+      .catch(err => console.error('[visual-factory] pipeline crash:', err))
+      .finally(() => { _inFlight.delete(r.id); _pump(_pg); });
+  }
+}
+
+const app = express();
+app.use(express.json({ limit: '1mb' }));
+
+// Same-origin: the UI is served from this same Express, so CORS is unnecessary.
+// Kept commented as a record of the prior split-port architecture.
+// app.use((req, res, next) => { res.set('Access-Control-Allow-Origin','*'); ... });
+
+// Serve the static UI (public/index.html etc.) at the orchestrator root. This
+// collapses the previous 9892 (API) + 9893 (UI) split into a single port.
+app.use(express.static(path.join(__dirname, 'public')));
+
+const PORT = Number(process.env.ORCHESTRATOR_PORT || 9892);
+const PUBLISH_ROOT = process.env.PUBLISH_ROOT || path.join(os.homedir(), 'Pictures', 'visual-factory-published');
+const MANIFEST_PATH = path.join(PUBLISH_ROOT, 'manifest.json');
+const SANDBOX_OUTPUT = path.resolve(path.join(__dirname, 'output'));
+const PUBLISH_ROOT_RESOLVED = path.resolve(PUBLISH_ROOT);
+
+// Codex P1 #5/#6 fix: assert a path lives inside an allowlisted root before we
+// fs.copyFile / sendFile it. DB-resident paths can be poisoned (a buggy stage
+// or future bug could write `/etc/passwd` into artifacts.png_path); resolving
+// against a fixed root + path.sep prefix-check defeats relative-path traversal.
+function assertWithin(root, p) {
+  const resolvedRoot = path.resolve(root);
+  const resolved = path.resolve(p);
+  if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + path.sep)) {
+    const err = new Error(`path "${p}" escapes sandbox "${root}"`);
+    err.code = 'EPATHESCAPE';
+    throw err;
+  }
+  return resolved;
+}
+
+// Codex P1 #5 fix: artifact_name must be safe for use in a filename. Mirror the
+// kebab-case rule the intake stage already applies, but enforce server-side
+// before constructing destination paths.
+const ARTIFACT_NAME_RE = /^[a-z0-9][a-z0-9-]{0,79}$/;
+function isSafeArtifactName(name) {
+  return typeof name === 'string' && ARTIFACT_NAME_RE.test(name);
+}
+
+// Codex P1 #9 fix: manifest.json is shared mutable state across activations.
+// A simple promise-chained mutex serializes read-modify-write so concurrent
+// activations don't lose entries. Same-process only — fine since the
+// orchestrator runs as a single Node process.
+let _manifestChain = Promise.resolve();
+function withManifestLock(fn) {
+  const next = _manifestChain.then(() => fn(), () => fn());
+  _manifestChain = next.catch(() => {});
+  return next;
+}
+
+// Append (upsert by artifact_name) an entry to the publish-folder manifest.
+// Best-effort: never throws — activation must succeed even if manifest write fails.
+// Serialized via withManifestLock so concurrent activations don't clobber each other.
+// Atomic write via .tmp + rename so a crash mid-write can't leave a corrupt file.
+async function appendManifest(entry) {
+  return withManifestLock(async () => {
+    try {
+      let arr = [];
+      try {
+        const raw = await fs.readFile(MANIFEST_PATH, 'utf8');
+        const parsed = JSON.parse(raw);
+        if (Array.isArray(parsed)) arr = parsed;
+      } catch (e) { if (e.code !== 'ENOENT') throw e; }
+      const idx = arr.findIndex(e => e.artifact_name === entry.artifact_name);
+      if (idx >= 0) arr[idx] = entry; else arr.push(entry);
+      const tmp = MANIFEST_PATH + '.tmp.' + process.pid;
+      await fs.writeFile(tmp, JSON.stringify(arr, null, 2), 'utf8');
+      await fs.rename(tmp, MANIFEST_PATH);
+      return { ok: true, count: arr.length };
+    } catch (err) {
+      return { ok: false, error: err.message };
+    }
+  });
+}
+
+// Codex P1 #7 fix: validate any URL we hand off to Norma to fetch. Reject
+// non-HTTP(S), private/loopback IPs, and metadata-service ranges so a poisoned
+// `image_url` can't pivot Norma into reading internal resources.
+function isAllowedExternalUrl(input) {
+  let u;
+  try { u = new URL(input); } catch { return false; }
+  if (!/^https?:$/.test(u.protocol)) return false;
+  const host = u.hostname.toLowerCase();
+  if (host === 'localhost' || host === '0.0.0.0') return false;
+  // IPv4 literal — block loopback + RFC1918 + link-local + AWS/GCP metadata.
+  const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
+  if (v4) {
+    const [, a, b] = v4.map(Number);
+    if (a === 127 || a === 10 || a === 0) return false;
+    if (a === 169 && b === 254) return false; // 169.254.169.254 metadata
+    if (a === 172 && b >= 16 && b <= 31) return false;
+    if (a === 192 && b === 168) return false;
+  }
+  // IPv6 literal — block loopback / link-local. Browsers wrap in [].
+  if (host === '::1' || host.startsWith('fe80:') || host.startsWith('fc') || host.startsWith('fd')) return false;
+  return true;
+}
+
+// Reveal the published file in Finder. macOS-only; non-fatal if `open` is missing.
+function revealInFinder(filePath) {
+  try {
+    const child = spawn('open', ['-R', filePath], { detached: true, stdio: 'ignore' });
+    child.unref();
+    return true;
+  } catch { return false; }
+}
+
+// Norma instagram-agent (post.skill) host — single Instagram Business account.
+// Stays in simulation mode until IG_USER_ID + IG_ACCESS_TOKEN are set on Norma's
+// side. We just hand it a URL + caption and let it decide simulate vs real.
+//
+// Reconciled 2026-05-01: actual port is 9810 (not 9870 as skill docs originally
+// claimed). Norma uses Basic auth — default user `admin`, password from
+// AUTH_PASSWORD env (empty if unset). PID 26855 is the long-running healthy
+// instance; another pm2 entry with the same name crashes on require('express')
+// due to pm2's require-in-the-middle hook + root-shim conflict, but that
+// crashloop doesn't affect the running one.
+const NORMA_HOST = process.env.NORMA_HOST || 'http://127.0.0.1:9810';
+const NORMA_USER = process.env.NORMA_AUTH_USER || 'admin';
+const NORMA_PASS = process.env.NORMA_AUTH_PASS || '';
+const NORMA_AUTH_HEADER = 'Basic ' + Buffer.from(`${NORMA_USER}:${NORMA_PASS}`).toString('base64');
+const CLAUDE_CLI = process.env.CLAUDE_CLI || 'claude';
+
+async function assertExecutable(command) {
+  if (path.isAbsolute(command) || command.includes(path.sep)) {
+    await fs.access(command);
+    return;
+  }
+
+  for (const dir of (process.env.PATH || '').split(path.delimiter).filter(Boolean)) {
+    try {
+      await fs.access(path.join(dir, command));
+      return;
+    } catch {}
+  }
+  throw new Error(`${command} not found on PATH`);
+}
+
+function buildIgCaption(run, art) {
+  const lines = [];
+  if (run.purpose) lines.push(run.purpose);
+  if (run.domain && run.domain.includes('designerwallcoverings')) {
+    lines.push('', 'Designer Wallcoverings · One Stop Luxury Resource', '#DesignerWallcoverings #wallcovering #interiordesign');
+  } else if (run.domain) {
+    lines.push('', `via ${run.domain}`);
+  }
+  return lines.join('\n').trim() || (run.brief || '').slice(0, 280);
+}
+
+// Post the activated artifact to Norma's instagram-agent. Best-effort.
+// `imageUrl` should be a URL Norma can reach — for simulation mode any string
+// works; for real Meta-Graph posting it must be publicly fetchable.
+async function postToInstagramViaNorma({ run, art, imageUrl }) {
+  const caption = buildIgCaption(run, art);
+  const ctrl = new AbortController();
+  const t = setTimeout(() => ctrl.abort(), 30_000);
+  try {
+    const r = await fetch(`${NORMA_HOST}/api/skill/post`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        'Authorization': NORMA_AUTH_HEADER,
+      },
+      body: JSON.stringify({ image_url: imageUrl, caption, pipeline_id: `vf-run-${run.id}` }),
+      signal: ctrl.signal
+    });
+    clearTimeout(t);
+    const body = await r.json().catch(() => ({}));
+    return { ok: r.ok && body.success !== false, status: r.status, caption, response: body };
+  } catch (err) {
+    clearTimeout(t);
+    return { ok: false, error: err.message, caption };
+  }
+}
+
+const poolCfg = {
+  host: process.env.PG_HOST || '/tmp',
+  port: Number(process.env.PG_PORT || 5432),
+  database: PG_DATABASE,
+  user: process.env.PG_USER || 'stevestudio2'
+};
+if (process.env.PG_PASSWORD) poolCfg.password = process.env.PG_PASSWORD;
+const pg = new Pool(poolCfg);
+
+app.get('/health', (_req, res) => {
+  res.json({ ok: true, service: 'visual-factory-orchestrator', port: PORT, db: PG_DATABASE });
+});
+
+// Deep health: probes every dependency the pipeline needs. The viewer hits this
+// on load so the user gets a clear "Ollama is down" message before submitting
+// a run that's doomed to fail at compose. Returns 503 when any required dep is
+// unhealthy so external monitors can alert.
+app.get('/health/deep', async (_req, res) => {
+  const checks = {
+    db: 'unknown',
+    ollama: 'unknown',
+    claude_cli: 'unknown',
+    playwright: 'unknown',
+  };
+  const ollamaModels = { compose: process.env.COMPOSE_MODEL || 'qwen3:14b', vision: process.env.VISION_MODEL || 'llava:latest' };
+
+  await Promise.allSettled([
+    pg.query('SELECT 1').then(() => { checks.db = 'ok'; }, e => { checks.db = `error: ${e.message}`; }),
+    (async () => {
+      try {
+        const ctrl = new AbortController();
+        const t = setTimeout(() => ctrl.abort(), 3000);
+        const r = await fetch(`${process.env.OLLAMA_HOST || 'http://127.0.0.1:11434'}/api/tags`, { signal: ctrl.signal });
+        clearTimeout(t);
+        if (!r.ok) { checks.ollama = `http ${r.status}`; return; }
+        const j = await r.json();
+        const have = new Set((j.models || []).map(m => m.name));
+        const missing = Object.values(ollamaModels).filter(m => !have.has(m));
+        checks.ollama = missing.length ? `ok but missing: ${missing.join(', ')}` : 'ok';
+      } catch (e) { checks.ollama = `error: ${e.message}`; }
+    })(),
+    (async () => {
+      try {
+        await assertExecutable(CLAUDE_CLI);
+        checks.claude_cli = 'ok';
+      } catch { checks.claude_cli = 'missing — critic stage will fail'; }
+    })(),
+    (async () => {
+      try {
+        require.resolve('playwright');
+        checks.playwright = 'ok';
+      } catch { checks.playwright = 'missing'; }
+    })(),
+  ]);
+
+  // Codex P1 #12 fix: every required dep gates `ok`. The route docstring says
+  // "probes every dependency the pipeline needs," so missing claude_cli or
+  // playwright must surface as 503, not silently green. Ollama "ok but missing"
+  // (model not pulled) also blocks ok — pipeline will fail at compose/vision.
+  const ok =
+    checks.db === 'ok' &&
+    checks.ollama === 'ok' &&
+    checks.claude_cli === 'ok' &&
+    checks.playwright === 'ok';
+  res.status(ok ? 200 : 503).json({
+    ok,
+    checks,
+    queue: { in_flight: _inFlight.size, queued: _queue.length, capacity: PIPELINE_CONCURRENCY },
+  });
+});
+
+app.get('/runs', async (_req, res) => {
+  try {
+    // Enrich with critic score (latest artifact) and last_error message (latest
+    // error event) so the UI can show both on the card without per-row fetches.
+    const { rows } = await pg.query(`
+      SELECT r.id, r.brief, r.domain, r.purpose, r.visual_type, r.artifact_name,
+             r.status, r.current_stage, r.width, r.height, r.created_at, r.finished_at,
+             a.score, e.message AS last_error
+        FROM runs r
+        LEFT JOIN LATERAL (
+          SELECT score FROM artifacts WHERE run_id = r.id ORDER BY id DESC LIMIT 1
+        ) a ON TRUE
+        LEFT JOIN LATERAL (
+          SELECT message FROM events
+            WHERE run_id = r.id AND level = 'error'
+            ORDER BY id DESC LIMIT 1
+        ) e ON TRUE
+        ORDER BY r.id DESC
+        LIMIT 100
+    `);
+    res.json({ runs: rows });
+  } catch (err) {
+    res.status(500).json({ error: err.message, hint: 'Did you createdb visual_factory + apply sql/001_init.sql?' });
+  }
+});
+
+app.post('/runs', async (req, res) => {
+  let { brief, domain, purpose, details } = req.body || {};
+  domain  = (domain  || '').trim();
+  purpose = (purpose || '').trim();
+  details = (details || '').trim();
+  brief   = (brief   || '').trim();
+
+  // If domain+purpose given, construct a brief. Free-text brief still wins if provided.
+  if (!brief) {
+    if (!domain || !purpose) {
+      return res.status(400).json({ error: 'either `brief` (string) OR `domain` + `purpose` required' });
+    }
+    brief = `Create a visual for **${domain}**.\n\nPurpose: ${purpose}.`;
+    if (details) brief += `\n\nDetails: ${details}`;
+  }
+
+  try {
+    const { rows } = await pg.query(
+      'INSERT INTO runs (brief, domain, purpose) VALUES ($1,$2,$3) RETURNING *',
+      [brief, domain || null, purpose || null]
+    );
+    const run = rows[0];
+    enqueueRun(pg, run);
+    res.json({ run });
+  } catch (err) {
+    res.status(500).json({ error: err.message });
+  }
+});
+
+// Retry a failed / unapproved run. Resets status + current_stage, wipes its
+// events and artifact rows, deletes its output dir, then re-fires the pipeline
+// against the original brief/domain/purpose.
+//
+// Codex P0 #2 fix: refuse on `running`/`pending` (would race with an active
+// pipeline writing the same row); use an atomic UPDATE-with-WHERE-status guard
+// so two concurrent retry calls can't both proceed and double-fire the queue.
+app.post('/runs/:id/retry', async (req, res) => {
+  const runId = Number(req.params.id);
+  const force = req.query.force === 'true';
+  try {
+    const { rows: lookupRows } = await pg.query('SELECT * FROM runs WHERE id=$1', [runId]);
+    if (!lookupRows.length) return res.status(404).json({ error: 'run not found' });
+    const run = lookupRows[0];
+
+    // `running` / `pending` mean a live pipeline may still be writing this row
+    // (or it's queued behind one). Refuse unless force=true. `awaiting_activation*`
+    // has value worth preserving — delete-and-resubmit is the right move.
+    const retryable = /^(failed|completed_unapproved|unapproved)/.test(String(run.status));
+    if (!retryable && !force) {
+      return res.status(409).json({
+        error: `retry refused — status=${run.status}; pass ?force=true to override`,
+        hint: run.status === 'running' || run.status === 'pending'
+          ? 'live pipeline; force only after confirming the orchestrator no longer holds it'
+          : undefined,
+      });
+    }
+
+    // Atomic guard: only the first concurrent retry that flips this row from
+    // its eligible status into 'pending' wins. The losers see rowCount=0.
+    const eligibleStatusList = force
+      ? ['failed','failed_intake','failed_orphan','completed_unapproved','unapproved','running','pending']
+      : ['failed','failed_intake','failed_orphan','completed_unapproved','unapproved'];
+    const claim = await pg.query(
+      `UPDATE runs
+         SET status='pending', current_stage=0, finished_at=NULL, updated_at=now()
+       WHERE id=$1 AND status = ANY($2::text[])
+       RETURNING id`,
+      [runId, eligibleStatusList]
+    );
+    if (claim.rowCount === 0) {
+      return res.status(409).json({ error: 'retry race lost — status changed between read and claim; refresh and try again' });
+    }
+
+    await pg.query('DELETE FROM artifacts WHERE run_id=$1', [runId]);
+    await pg.query('DELETE FROM events WHERE run_id=$1', [runId]);
+    try { await fs.rm(path.join(__dirname, 'output', `run-${runId}`), { recursive: true, force: true }); } catch {}
+    enqueueRun(pg, { ...run, status: 'pending', current_stage: 0 });
+    res.json({ ok: true, runId });
+  } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+// Hard-delete a run: cascades to events, removes artifact rows, wipes output dir.
+// Refuses on activated rows by default (since publishing dir lives outside output/
+// the activated PNG survives; but the audit trail is gone — that's the user's call).
+//
+// Codex P1 #4 fix: also refuse on `running`/`pending` unless force=true. Deleting
+// an in-flight run leaves the pipeline writing to a row that no longer exists,
+// which fails the next logEvent insert and races against the fs.rm of output/run-N.
+app.delete('/runs/:id', async (req, res) => {
+  const runId = Number(req.params.id);
+  const force = req.query.force === 'true';
+  try {
+    const { rows } = await pg.query('SELECT status FROM runs WHERE id=$1', [runId]);
+    if (!rows.length) return res.status(404).json({ error: 'run not found' });
+    const status = rows[0].status;
+    const inFlight = status === 'running' || status === 'pending';
+    if (status === 'activated' && !force) {
+      return res.status(409).json({ error: 'run is activated; pass ?force=true to delete anyway' });
+    }
+    if (inFlight && !force) {
+      return res.status(409).json({
+        error: `run is ${status}; deleting an in-flight run will race the pipeline. pass ?force=true if you've confirmed it's not actually running.`,
+      });
+    }
+    await pg.query('DELETE FROM artifacts WHERE run_id=$1', [runId]);
+    await pg.query('DELETE FROM runs WHERE id=$1', [runId]); // events CASCADE
+    try { await fs.rm(path.join(__dirname, 'output', `run-${runId}`), { recursive: true, force: true }); } catch {}
+    res.json({ ok: true, runId });
+  } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+app.get('/runs/:id/events', async (req, res) => {
+  try {
+    const { rows } = await pg.query(
+      'SELECT id, stage, stage_name, level, message, payload, created_at FROM events WHERE run_id=$1 ORDER BY id ASC',
+      [Number(req.params.id)]
+    );
+    res.json({ events: rows });
+  } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+// Static-serve sandbox PNGs so the viewer can show thumbnails.
+//
+// Codex P1 #6 fix: assert png_path lives under our output sandbox (or the
+// publish root for activated artifacts) before sendFile, so a poisoned DB row
+// can't make us serve arbitrary files (e.g., /etc/passwd).
+app.get('/runs/:id/png', async (req, res) => {
+  try {
+    const { rows } = await pg.query("SELECT png_path, published_path FROM artifacts WHERE run_id=$1 ORDER BY id DESC LIMIT 1", [Number(req.params.id)]);
+    if (!rows.length || !rows[0].png_path) return res.status(404).json({ error: 'no png yet' });
+    const candidate = rows[0].published_path || rows[0].png_path;
+    let safe;
+    try {
+      // Allow either the per-run sandbox (drafts) or the publish root (activated).
+      safe = (() => {
+        try { return assertWithin(SANDBOX_OUTPUT, candidate); }
+        catch { return assertWithin(PUBLISH_ROOT_RESOLVED, candidate); }
+      })();
+    } catch (e) {
+      console.error('[visual-factory] png path escapes sandbox:', candidate);
+      return res.status(403).json({ error: 'png path outside sandbox' });
+    }
+    res.sendFile(safe);
+  } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+/**
+ * Post an activated run to Instagram via Norma's instagram-agent.
+ * Pre-conditions:
+ *   - run must be 'activated' (we want a published_path that exists on disk)
+ *   - Norma on :9870 reachable; if not, returns 502
+ * Caveats:
+ *   - Norma is in simulation mode unless IG_USER_ID + IG_ACCESS_TOKEN set on Norma's side
+ *   - Norma posts to a single Instagram Business account regardless of run.domain
+ *   - For real posting, image_url must be publicly fetchable; we pass our own
+ *     /runs/:id/png URL which is loopback-only, so real posting will fail at
+ *     Meta until this PNG is hosted somewhere public. Simulation mode doesn't
+ *     care — it just logs the URL.
+ */
+app.post('/runs/:id/post-ig', async (req, res) => {
+  const runId = Number(req.params.id);
+  try {
+    const { rows: runRows } = await pg.query('SELECT * FROM runs WHERE id=$1', [runId]);
+    if (!runRows.length) return res.status(404).json({ error: 'run not found' });
+    const run = runRows[0];
+    if (run.status !== 'activated' && req.query.force !== 'true') {
+      return res.status(409).json({ error: `run status is ${run.status}; post requires activated (or ?force=true)` });
+    }
+    const { rows: artRows } = await pg.query("SELECT * FROM artifacts WHERE run_id=$1 ORDER BY id DESC LIMIT 1", [runId]);
+    if (!artRows.length) return res.status(409).json({ error: 'no artifact for this run' });
+    const art = artRows[0];
+
+    // Loopback URL — Norma can fetch this if it lives on this Mac (same host),
+    // but Meta cannot reach it. For real posting, host the file publicly and
+    // override via req.body.image_url.
+    //
+    // Codex P1 #7 fix (SSRF): the override URL is forwarded to Norma, which
+    // fetches it. Reject anything that would let a poisoned body reach private
+    // ranges, file://, or AWS metadata. Only public http(s) hosts allowed.
+    let imageUrl = `http://127.0.0.1:${PORT}/runs/${runId}/png`;
+    if (req.body && typeof req.body.image_url === 'string' && req.body.image_url.length) {
+      if (!isAllowedExternalUrl(req.body.image_url)) {
+        return res.status(400).json({
+          error: 'image_url rejected — must be public http(s); private/loopback/metadata addresses blocked',
+        });
+      }
+      imageUrl = req.body.image_url;
+    }
+
+    const result = await postToInstagramViaNorma({ run, art, imageUrl });
+    await pg.query(
+      "INSERT INTO events (run_id, stage, stage_name, level, message, payload) VALUES ($1,$2,$3,$4,$5,$6)",
+      [runId, 9, 'social_post', result.ok ? 'info' : 'warn',
+        result.ok
+          ? `posted to IG (simulated=${result.response?.result?.simulated ?? 'unknown'}, media=${result.response?.result?.media_id || 'n/a'})`
+          : `IG post failed: ${result.error || 'http ' + result.status}`,
+        JSON.stringify(result)]
+    );
+    res.status(result.ok ? 200 : 502).json(result);
+  } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+/**
+ * Manual activation: copy approved PNG (and HTML) to PUBLISH_ROOT.
+ * Gates: run must be `awaiting_activation*` unless ?force=true. Won't overwrite without ?overwrite=true.
+ */
+app.post('/runs/:id/activate', async (req, res) => {
+  const runId = Number(req.params.id);
+  const force = req.query.force === 'true';
+  const overwrite = req.query.overwrite === 'true';
+  try {
+    const { rows: runRows } = await pg.query('SELECT * FROM runs WHERE id=$1', [runId]);
+    if (!runRows.length) return res.status(404).json({ error: 'run not found' });
+    const run = runRows[0];
+    if (!force && !String(run.status).startsWith('awaiting_activation')) {
+      return res.status(409).json({ error: `run status is ${run.status}; activation requires awaiting_activation* (or ?force=true)` });
+    }
+    const { rows: artRows } = await pg.query("SELECT * FROM artifacts WHERE run_id=$1 ORDER BY id DESC LIMIT 1", [runId]);
+    if (!artRows.length) return res.status(409).json({ error: 'no artifact for this run' });
+    const art = artRows[0];
+
+    // Codex P1 #5 fix: validate every input before constructing destination
+    // paths. artifact_name from the DB could be poisoned with `../etc/passwd`
+    // — re-validate against the kebab-case rule. html_path needs the same
+    // sandbox check we already had on png_path.
+    if (!isSafeArtifactName(art.artifact_name)) {
+      return res.status(400).json({ error: `unsafe artifact_name: ${JSON.stringify(art.artifact_name)}` });
+    }
+    let srcPng;
+    try { srcPng = assertWithin(SANDBOX_OUTPUT, art.png_path); }
+    catch { return res.status(400).json({ error: 'png_path escapes sandbox' }); }
+    let srcHtml = null;
+    if (art.html_path) {
+      try { srcHtml = assertWithin(SANDBOX_OUTPUT, art.html_path); }
+      catch { return res.status(400).json({ error: 'html_path escapes sandbox' }); }
+    }
+
+    await fs.mkdir(PUBLISH_ROOT, { recursive: true });
+    // Resolve destinations and confirm they live under PUBLISH_ROOT_RESOLVED
+    // (defense-in-depth: artifact_name is already validated, but path.join
+    // could surprise us on weird edge cases).
+    let destPng, destHtml;
+    try {
+      destPng = assertWithin(PUBLISH_ROOT_RESOLVED, path.join(PUBLISH_ROOT, `${art.artifact_name}.png`));
+      destHtml = assertWithin(PUBLISH_ROOT_RESOLVED, path.join(PUBLISH_ROOT, `${art.artifact_name}.html`));
+    } catch { return res.status(400).json({ error: 'destination escapes publish root' }); }
+
+    let existed = false;
+    try { await fs.access(destPng); existed = true; } catch {}
+    if (existed && !overwrite) return res.status(409).json({ error: `target exists: ${destPng}`, hint: 'pass ?overwrite=true' });
+
+    // Codex P1 #8 fix: copy to .tmp first, only rename into place once both
+    // files are written. Then commit the DB rows. If any step fails, clean up
+    // tmp files so the publish dir doesn't end up with orphan halves.
+    const tmpPng = destPng + '.tmp.' + process.pid + '.' + Date.now();
+    const tmpHtml = srcHtml ? destHtml + '.tmp.' + process.pid + '.' + Date.now() : null;
+    let pngCommitted = false, htmlCommitted = false;
+    try {
+      await fs.copyFile(srcPng, tmpPng);
+      if (tmpHtml) await fs.copyFile(srcHtml, tmpHtml);
+      await fs.rename(tmpPng, destPng); pngCommitted = true;
+      if (tmpHtml) { await fs.rename(tmpHtml, destHtml); htmlCommitted = true; }
+      await pg.query("UPDATE artifacts SET status='activated', published_path=$1 WHERE id=$2", [destPng, art.id]);
+      await pg.query("UPDATE runs SET status='activated', updated_at=now() WHERE id=$1", [runId]);
+    } catch (err) {
+      // Best-effort rollback: drop the partials. If we already renamed into
+      // dest but DB failed, leave the file (manifest will be inconsistent for
+      // one entry, which is recoverable; deleting a published file is worse).
+      try { await fs.rm(tmpPng, { force: true }); } catch {}
+      if (tmpHtml) { try { await fs.rm(tmpHtml, { force: true }); } catch {} }
+      if (!pngCommitted) {
+        return res.status(500).json({ error: `activate failed before publish: ${err.message}` });
+      }
+      // We did publish PNG but failed later; still respond with a partial-failure status.
+      return res.status(500).json({
+        error: `activate partially succeeded: ${err.message}`,
+        png_committed: pngCommitted,
+        html_committed: htmlCommitted,
+      });
+    }
+    await pg.query(
+      "INSERT INTO events (run_id, stage, stage_name, level, message, payload) VALUES ($1,$2,$3,$4,$5,$6)",
+      [runId, 8, 'activate', 'info', existed ? 'overwrote target' : 'wrote target', JSON.stringify({ srcPng, destPng })]
+    );
+    await pg.query(
+      "INSERT INTO events (run_id, stage, stage_name, level, message, payload) VALUES ($1,$2,$3,$4,$5,$6)",
+      [runId, 8, 'activate', 'info', existed ? 'overwrote target' : 'wrote target', JSON.stringify({ srcPng, destPng })]
+    );
+
+    // Manifest write + Finder reveal happen AFTER the source-of-truth DB writes succeed.
+    // Both are best-effort — failures here don't roll back the activation.
+    const manifestEntry = {
+      run_id: runId,
+      artifact_name: art.artifact_name,
+      visual_type: art.visual_type,
+      domain: run.domain || null,
+      purpose: run.purpose || null,
+      brief: run.brief,
+      width: art.width,
+      height: art.height,
+      score: art.score,
+      dest_png: destPng,
+      dest_html: art.html_path ? destHtml : null,
+      activated_at: new Date().toISOString()
+    };
+    const manifestResult = await appendManifest(manifestEntry);
+    const finderOpened = req.query.no_finder === 'true' ? false : revealInFinder(destPng);
+    await pg.query(
+      "INSERT INTO events (run_id, stage, stage_name, level, message, payload) VALUES ($1,$2,$3,$4,$5,$6)",
+      [runId, 8, 'activate', manifestResult.ok ? 'info' : 'warn',
+        `manifest=${manifestResult.ok ? `count ${manifestResult.count}` : 'failed'}, finder=${finderOpened}`,
+        JSON.stringify({ manifest: manifestResult, finderOpened })]
+    );
+
+    // Optional one-shot IG post on activation (?post_ig=true). Best-effort.
+    // Norma is in simulation mode by default — see instagram-post-scheduler skill.
+    let igResult = null;
+    if (req.query.post_ig === 'true') {
+      const imageUrl = `http://127.0.0.1:${PORT}/runs/${runId}/png`;
+      igResult = await postToInstagramViaNorma({ run, art, imageUrl });
+      await pg.query(
+        "INSERT INTO events (run_id, stage, stage_name, level, message, payload) VALUES ($1,$2,$3,$4,$5,$6)",
+        [runId, 9, 'social_post', igResult.ok ? 'info' : 'warn',
+          igResult.ok
+            ? `auto-posted to IG (simulated=${igResult.response?.result?.simulated ?? 'unknown'})`
+            : `auto IG post failed: ${igResult.error || 'http ' + igResult.status}`,
+          JSON.stringify(igResult)]
+      );
+    }
+
+    res.json({
+      ok: true,
+      srcPng,
+      destPng,
+      destHtml,
+      existed_before: existed,
+      manifest_path: MANIFEST_PATH,
+      manifest_ok: manifestResult.ok,
+      finder_opened: finderOpened,
+      ig_posted: igResult ? igResult.ok : null,
+      ig_result: igResult
+    });
+  } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+// Boot-time orphan cleanup: any run still at 'running' or 'pending' is by
+// definition orphaned, because at boot the in-memory queue and in-flight set
+// are both empty — no live process is working on them. Drop the 10-min age
+// filter (which would strand recent crashes); mark every non-terminal row.
+// Codex P0 #1 fix.
+async function reapOrphans() {
+  try {
+    const { rowCount, rows } = await pg.query(
+      `UPDATE runs
+         SET status='failed_orphan', finished_at=now(), updated_at=now()
+       WHERE status IN ('running','pending')
+       RETURNING id, current_stage`
+    );
+    if (rowCount > 0) {
+      console.log(`[visual-factory] reaped ${rowCount} orphan run(s):`, rows.map(r => `#${r.id}@stage${r.current_stage}`).join(', '));
+      for (const r of rows) {
+        await pg.query(
+          'INSERT INTO events (run_id, stage, stage_name, level, message, payload) VALUES ($1,$2,$3,$4,$5,$6)',
+          [r.id, r.current_stage, 'reap', 'error', 'orchestrator restarted while this run was in flight; marked orphaned', null]
+        );
+      }
+    }
+  } catch (err) { console.error('[visual-factory] reapOrphans failed:', err.message); }
+}
+
+app.listen(PORT, '127.0.0.1', async () => {
+  console.log(`[visual-factory] orchestrator listening on http://127.0.0.1:${PORT} (db=${PG_DATABASE})`);
+  await reapOrphans();
+});
diff --git a/sql/001_init.sql b/sql/001_init.sql
new file mode 100644
index 0000000..da7e34a
--- /dev/null
+++ b/sql/001_init.sql
@@ -0,0 +1,47 @@
+-- Visual Factory schema. Standalone PG `visual_factory` DB. NEVER point at dw_unified.
+-- Bootstrap: createdb visual_factory && psql -d visual_factory -f sql/001_init.sql
+
+CREATE TABLE IF NOT EXISTS runs (
+    id              BIGSERIAL PRIMARY KEY,
+    brief           TEXT NOT NULL,
+    domain          TEXT,
+    purpose         TEXT,
+    visual_type     TEXT,
+    artifact_name   TEXT,
+    status          TEXT NOT NULL DEFAULT 'pending',
+    current_stage   INTEGER NOT NULL DEFAULT 0,
+    width           INTEGER,
+    height          INTEGER,
+    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
+    updated_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
+    finished_at     TIMESTAMPTZ
+);
+
+CREATE TABLE IF NOT EXISTS events (
+    id              BIGSERIAL PRIMARY KEY,
+    run_id          BIGINT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
+    stage           INTEGER NOT NULL,
+    stage_name      TEXT NOT NULL,
+    level           TEXT NOT NULL DEFAULT 'info',
+    message         TEXT,
+    payload         JSONB,
+    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS artifacts (
+    id              BIGSERIAL PRIMARY KEY,
+    run_id          BIGINT REFERENCES runs(id) ON DELETE SET NULL,
+    visual_type     TEXT NOT NULL,
+    artifact_name   TEXT NOT NULL,
+    html_path       TEXT,
+    png_path        TEXT,
+    width           INTEGER,
+    height          INTEGER,
+    score           INTEGER,
+    status          TEXT NOT NULL DEFAULT 'draft',
+    published_path  TEXT,
+    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS events_run_idx ON events (run_id, stage);
+CREATE INDEX IF NOT EXISTS artifacts_name_idx ON artifacts (visual_type, artifact_name);
diff --git a/src/llm.js b/src/llm.js
new file mode 100644
index 0000000..548053e
--- /dev/null
+++ b/src/llm.js
@@ -0,0 +1,128 @@
+// Visual Factory — LLM adapters.
+// All-local, all-free. No Anthropic API.
+//   ollama()      → qwen3:14b for spec/HTML/iterate; llava for vision
+//   claudeCli()   → critic only (one-call-per-run, Max sub)
+
+const { spawn } = require('node:child_process');
+const fs = require('node:fs/promises');
+
+const OLLAMA_HOST = process.env.OLLAMA_HOST || 'http://127.0.0.1:11434';
+const CLAUDE_CLI  = process.env.CLAUDE_CLI || 'claude';
+
+async function ollama({ model, system, prompt, format = null, temperature = 0.2, images = null, signal = null }) {
+  const message = { role: 'user', content: prompt };
+  if (images && images.length) {
+    message.images = await Promise.all(images.map(async p => {
+      const buf = await fs.readFile(p);
+      return buf.toString('base64');
+    }));
+  }
+  // num_predict caps llava's output to prevent the degenerate "1000 empty strings"
+  // mode that breaks JSON parsing. 1500 tokens is plenty for the small JSON we want.
+  const body = {
+    model,
+    stream: false,
+    options: { temperature, num_predict: 1500 },
+    messages: [
+      ...(system ? [{ role: 'system', content: system }] : []),
+      message
+    ]
+  };
+  if (format === 'json') body.format = 'json';
+
+  // Retry transient fetch failures (socket drops under concurrent load).
+  // Backoff: 2s, 5s, 12s.
+  const delays = [2000, 5000, 12000];
+  let lastErr;
+  for (let attempt = 0; attempt <= delays.length; attempt++) {
+    if (signal?.aborted) throw new Error('ollama call aborted (stage timeout or cancellation)');
+    try {
+      const res = await fetch(`${OLLAMA_HOST}/api/chat`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(body),
+        signal: signal || undefined
+      });
+      if (!res.ok) {
+        const txt = await res.text();
+        // Treat 5xx as transient; 4xx as permanent
+        if (res.status >= 500 && attempt < delays.length) {
+          lastErr = new Error(`ollama ${model} http ${res.status}: ${txt.slice(0, 200)}`);
+        } else {
+          throw new Error(`ollama ${model} http ${res.status}: ${txt.slice(0, 200)}`);
+        }
+      } else {
+        const data = await res.json();
+        const content = data?.message?.content ?? '';
+        if (format === 'json') {
+          try { return JSON.parse(content); }
+          catch (e) { throw new Error(`ollama ${model} did not return valid JSON: ${content.slice(0, 200)}`); }
+        }
+        return content;
+      }
+    } catch (err) {
+      lastErr = err;
+      // AbortError = caller cancelled (stage timeout). Don't retry.
+      if (err?.name === 'AbortError' || signal?.aborted) {
+        throw new Error('ollama call aborted (stage timeout or cancellation)');
+      }
+      // TypeError: fetch failed = socket-level. Retry.
+      if (!(err instanceof TypeError) && !err.message?.includes('ollama') ) throw err;
+      if (attempt === delays.length) break;
+    }
+    // Honor abort during back-off too — otherwise a cancelled stage waits the full 19s.
+    await new Promise((resolve, reject) => {
+      const t = setTimeout(resolve, delays[attempt]);
+      if (signal) {
+        const onAbort = () => { clearTimeout(t); reject(new Error('ollama call aborted (during retry back-off)')); };
+        signal.addEventListener('abort', onAbort, { once: true });
+      }
+    });
+  }
+  throw lastErr || new Error(`ollama ${model} failed after retries`);
+}
+
+function claudeCli({ prompt, timeoutMs = 180000, signal = null }) {
+  // Pipe prompt via stdin instead of the `-p` CLI flag — avoids arg-length / quoting
+  // issues when the prompt contains JSON, newlines, or non-ASCII chars.
+  // signal: external AbortSignal (e.g., from stage timeout) — kills the child.
+  return new Promise((resolve, reject) => {
+    if (signal?.aborted) return reject(new Error('claude CLI aborted before spawn'));
+    const child = spawn(CLAUDE_CLI, ['-p'], { stdio: ['pipe', 'pipe', 'pipe'] });
+    let stdout = '';
+    let stderr = '';
+    let settled = false;
+    const cleanup = () => {
+      clearTimeout(timer);
+      if (signal && onAbort) signal.removeEventListener('abort', onAbort);
+    };
+    const finish = (fn, val) => { if (settled) return; settled = true; cleanup(); fn(val); };
+
+    const timer = setTimeout(() => {
+      try { child.kill('SIGTERM'); } catch {}
+      finish(reject, new Error(`claude CLI timed out after ${timeoutMs}ms`));
+    }, timeoutMs);
+
+    let onAbort = null;
+    if (signal) {
+      onAbort = () => {
+        try { child.kill('SIGTERM'); } catch {}
+        // SIGKILL fallback if it doesn't exit cleanly within 2s.
+        setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 2000).unref?.();
+        finish(reject, new Error('claude CLI aborted (stage timeout or cancellation)'));
+      };
+      signal.addEventListener('abort', onAbort, { once: true });
+    }
+
+    child.stdout.on('data', d => { stdout += d; });
+    child.stderr.on('data', d => { stderr += d; });
+    child.on('error', err => finish(reject, new Error(`claude CLI spawn error: ${err.message}`)));
+    child.on('close', code => {
+      if (code !== 0) return finish(reject, new Error(`claude CLI exit ${code}: ${stderr.slice(0, 400) || '(empty stderr)'}`));
+      finish(resolve, stdout.trim());
+    });
+    try { child.stdin.end(prompt); } catch (e) { finish(reject, new Error(`claude CLI stdin write failed: ${e.message}`)); }
+  });
+}
+
+module.exports = { ollama, claudeCli };
diff --git a/src/pipeline.js b/src/pipeline.js
new file mode 100644
index 0000000..6daa27b
--- /dev/null
+++ b/src/pipeline.js
@@ -0,0 +1,236 @@
+// Visual Factory pipeline. 9 stages.
+//   1 intake → 2 design_system (folded into intake) → 3 compose → 4 render
+//   → 5 vision_check ‖ 6 critic-prep → 6 critic → 7 iterate (re-render+re-vision+re-critic)
+//   → 8 catalog → 9 activate (manual gate, separate endpoint)
+
+const fs = require('node:fs/promises');
+const { runIntake }       = require('./stages/intake');
+const { runCompose }      = require('./stages/compose');
+const { runRender }       = require('./stages/render');
+const { runVisionCheck }  = require('./stages/vision_check');
+const { runCritic }       = require('./stages/critic');
+const { runIterate }      = require('./stages/iterate');
+
+const STAGES = ['intake','compose','render','vision_check','critic','iterate','catalog','activate','cleanup'];
+
+// Per-stage timeouts (ms). Tuned for local Ollama / Playwright / Claude CLI on
+// the Mac Studio. If a stage hangs longer than this, abort the whole run so the
+// UI doesn't show "running" forever. Critic + compose get longer budgets
+// because qwen3:14b on a complex HTML brief or claude CLI on a tricky review
+// can legitimately take 60–90s.
+const STAGE_TIMEOUT_MS = {
+  intake:       45_000,
+  compose:    180_000,
+  render:       90_000,
+  vision_check: 90_000,
+  critic:     120_000,
+  iterate:    300_000,
+};
+
+// Codex P0 #3 fix: real cancellation. Each stage receives an AbortSignal that
+// the underlying ollama fetch / Playwright browser / Claude CLI child honors,
+// so when the timeout fires we actually kill the work instead of leaking a
+// background browser/child holding GPU/CPU while the queue moves on.
+//
+// Usage: `await withTimeout('compose', signal => runCompose({runId, spec, signal}))`
+function withTimeout(name, factory) {
+  const ms = STAGE_TIMEOUT_MS[name] || 60_000;
+  const controller = new AbortController();
+  let timer;
+  const timeoutP = new Promise((_, rej) => {
+    timer = setTimeout(() => {
+      controller.abort();
+      rej(new Error(`stage "${name}" timed out after ${ms}ms`));
+    }, ms);
+  });
+  // Run factory inside try to convert sync throws to rejections.
+  let workP;
+  try { workP = Promise.resolve(factory(controller.signal)); }
+  catch (e) { workP = Promise.reject(e); }
+  return Promise.race([workP, timeoutP]).finally(() => clearTimeout(timer));
+}
+
+async function logEvent(pg, runId, stage, name, level, message, payload) {
+  await pg.query(
+    'INSERT INTO events (run_id, stage, stage_name, level, message, payload) VALUES ($1,$2,$3,$4,$5,$6)',
+    [runId, stage, name, level, message, payload ? JSON.stringify(payload) : null]
+  );
+}
+async function setStage(pg, runId, stage, status) {
+  await pg.query('UPDATE runs SET current_stage=$1, status=$2, updated_at=now() WHERE id=$3', [stage, status, runId]);
+}
+async function finishRun(pg, runId, status) {
+  await pg.query('UPDATE runs SET status=$1, finished_at=now(), updated_at=now() WHERE id=$2', [status, runId]);
+}
+
+async function runPipeline(pg, run) {
+  const runId = run.id;
+  // Codex P1 #11 fix: track the actual current stage locally so the crash
+  // handler logs the right one (run.current_stage is frozen at runPipeline
+  // call time and won't reflect the stage we were in when we threw).
+  let currentStage = run.current_stage || 0;
+  let currentStageName = STAGES[currentStage - 1] || 'unknown';
+  const trackStage = (n, name) => { currentStage = n; currentStageName = name; };
+  try {
+    // 1: intake
+    trackStage(1, 'intake');
+    await setStage(pg, runId, 1, 'running');
+    await logEvent(pg, runId, 1, 'intake', 'info', 'starting', null);
+    const { spec, errors: ie } = await withTimeout('intake', signal => runIntake({ brief: run.brief, signal }));
+    if (ie.length) {
+      await logEvent(pg, runId, 1, 'intake', 'error', 'spec validation failed', { spec, errors: ie });
+      await finishRun(pg, runId, 'failed_intake');
+      return;
+    }
+    await pg.query('UPDATE runs SET visual_type=$1, artifact_name=$2, width=$3, height=$4, updated_at=now() WHERE id=$5',
+      [spec.visual_type, spec.artifact_name, spec.width, spec.height, runId]);
+    await logEvent(pg, runId, 1, 'intake', 'info', 'spec extracted', { spec });
+
+    // 2 (folded into intake — palette/typography/layout already in spec)
+
+    // 3: compose
+    trackStage(2, 'compose');
+    await setStage(pg, runId, 2, 'running');
+    await logEvent(pg, runId, 2, 'compose', 'info', 'starting', null);
+    let composed = await withTimeout('compose', signal => runCompose({ runId, spec, signal }));
+    if (composed.errors.length) {
+      await logEvent(pg, runId, 2, 'compose', 'warn', 'validation issues', { errors: composed.errors });
+    } else {
+      await logEvent(pg, runId, 2, 'compose', 'info', 'wrote html', { htmlPath: composed.htmlPath, bytes: composed.html.length });
+    }
+
+    // 4: render
+    trackStage(3, 'render');
+    await setStage(pg, runId, 3, 'running');
+    await logEvent(pg, runId, 3, 'render', 'info', 'starting (playwright headless)', null);
+    let rendered = await withTimeout('render', signal => runRender({ htmlPath: composed.htmlPath, width: spec.width, height: spec.height, signal }));
+    await logEvent(pg, runId, 3, 'render', 'info', 'wrote png', { pngPath: rendered.pngPath, bytes: rendered.bytes });
+
+    await pg.query(
+      'INSERT INTO artifacts (run_id, visual_type, artifact_name, html_path, png_path, width, height, status) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)',
+      [runId, spec.visual_type, spec.artifact_name, composed.htmlPath, rendered.pngPath, spec.width, spec.height, 'draft']
+    );
+
+    // 5: vision_check
+    trackStage(4, 'vision_check');
+    await setStage(pg, runId, 4, 'running');
+    await logEvent(pg, runId, 4, 'vision_check', 'info', 'starting (llava)', null);
+    let vision = await withTimeout('vision_check', signal => runVisionCheck({ pngPath: rendered.pngPath, signal }));
+    await logEvent(pg, runId, 4, 'vision_check', vision.issues.length ? 'warn' : 'info',
+      `quality=${vision.overall_quality} text_seen=${vision.text_content.length}`, vision);
+
+    // 6: critic
+    trackStage(5, 'critic');
+    await setStage(pg, runId, 5, 'running');
+    await logEvent(pg, runId, 5, 'critic', 'info', 'starting (claude CLI)', null);
+    let review;
+    try {
+      review = await withTimeout('critic', signal => runCritic({ brief: run.brief, spec, vision, signal }));
+      await logEvent(pg, runId, 5, 'critic', review.approved ? 'info' : 'warn',
+        `score=${review.score} approved=${review.approved}`, { review });
+    } catch (err) {
+      review = { approved: false, score: 0, issues: [`critic call failed: ${err.message}`], fix_instructions: [] };
+      await logEvent(pg, runId, 5, 'critic', 'error', 'critic call failed', { error: err.message });
+    }
+
+    // 7: iterate — loop up to MAX_ITERATIONS times, breaking on approval.
+    // Each round: apply fixes → re-render → re-vision → re-critic.
+    // Track iteration count so the UI can label v2/v3/etc and we can cap budget.
+    trackStage(6, 'iterate');
+    await setStage(pg, runId, 6, 'running');
+    let finalReview = review;
+    let iterationCount = 0;
+    const MAX_ITERATIONS = Number(process.env.MAX_ITERATIONS || 3);
+
+    while (
+      !finalReview.approved &&
+      Array.isArray(finalReview.fix_instructions) &&
+      finalReview.fix_instructions.length &&
+      iterationCount < MAX_ITERATIONS
+    ) {
+      const iterNum = iterationCount + 1;
+      await logEvent(pg, runId, 6, 'iterate', 'info',
+        `iteration ${iterNum}/${MAX_ITERATIONS}: applying ${finalReview.fix_instructions.length} fixes (prev score=${finalReview.score})`,
+        { iteration: iterNum, fixes: finalReview.fix_instructions });
+      try {
+        const iter = await withTimeout('iterate', signal => runIterate({ htmlPath: composed.htmlPath, html: composed.html, spec, review: finalReview, signal }));
+        composed.html = iter.html;
+        await logEvent(pg, runId, 6, 'iterate', 'info', `iter ${iterNum}: rewrote html`, { iteration: iterNum, bytes: iter.html.length });
+
+        rendered = await withTimeout('render', signal => runRender({ htmlPath: composed.htmlPath, width: spec.width, height: spec.height, signal }));
+        await logEvent(pg, runId, 6, 'iterate', 'info', `iter ${iterNum}: re-rendered png`, { iteration: iterNum, bytes: rendered.bytes });
+
+        vision = await withTimeout('vision_check', signal => runVisionCheck({ pngPath: rendered.pngPath, signal }));
+        await logEvent(pg, runId, 6, 'iterate', vision.issues.length ? 'warn' : 'info',
+          `iter ${iterNum}: re-vision quality=${vision.overall_quality}`, { iteration: iterNum, ...vision });
+
+        // Codex P1 #10 fix: if re-critic fails, the rewritten HTML/PNG is already
+        // on disk but we can't fairly score it. Mark the run failed_iteration
+        // and bail — better than cataloging a stale review against a brand-new
+        // artifact (the user would see a v2 with score from v1).
+        try {
+          const reviewN = await withTimeout('critic', signal => runCritic({ brief: run.brief, spec, vision, signal }));
+          await logEvent(pg, runId, 6, 'iterate', reviewN.approved ? 'info' : 'warn',
+            `iter ${iterNum}: re-critic score=${reviewN.score} approved=${reviewN.approved} (was ${finalReview.score})`,
+            { iteration: iterNum, review: reviewN });
+          finalReview = reviewN;
+        } catch (err) {
+          await logEvent(pg, runId, 6, 'iterate', 'error', `iter ${iterNum}: re-critic failed — abandoning run`, { iteration: iterNum, error: err.message });
+          await pg.query("UPDATE artifacts SET status='failed_iteration' WHERE run_id=$1", [runId]);
+          await finishRun(pg, runId, 'failed_iteration');
+          return; // skip catalog/activate — the artifact never got a fair review
+        }
+        iterationCount = iterNum;
+      } catch (err) {
+        await logEvent(pg, runId, 6, 'iterate', 'error', `iter ${iterNum}: failed mid-loop`, { iteration: iterNum, error: err.message });
+        // Same reasoning: a partial iteration left the artifact in an unknown
+        // state vs the spec. Don't catalog it as awaiting_activation.
+        await pg.query("UPDATE artifacts SET status='failed_iteration' WHERE run_id=$1", [runId]);
+        await finishRun(pg, runId, 'failed_iteration');
+        return;
+      }
+    }
+
+    if (iterationCount === 0) {
+      await logEvent(pg, runId, 6, 'iterate', 'info',
+        review.approved ? 'critic approved — no iteration needed' : 'no fix_instructions to act on', null);
+    } else if (!finalReview.approved && iterationCount >= MAX_ITERATIONS) {
+      await logEvent(pg, runId, 6, 'iterate', 'warn',
+        `hit MAX_ITERATIONS=${MAX_ITERATIONS} without approval (final score=${finalReview.score})`, null);
+    }
+
+    // 8: catalog (update artifact row with final score + status)
+    trackStage(7, 'catalog');
+    await setStage(pg, runId, 7, 'running');
+    await pg.query("UPDATE artifacts SET score=$1, status=$2 WHERE run_id=$3",
+      [finalReview.score, finalReview.approved ? 'awaiting_activation' : 'unapproved', runId]);
+    await logEvent(pg, runId, 7, 'catalog', 'info', `final score=${finalReview.score}`, null);
+
+    // 9: activate (manual gate — see POST /runs/:id/activate)
+    trackStage(8, 'activate');
+    await setStage(pg, runId, 8, 'running');
+    await logEvent(pg, runId, 8, 'activate', 'info', 'awaiting manual activation (POST /runs/:id/activate)', null);
+
+    const finalStatus = finalReview.approved
+      ? (iterationCount > 0 ? `awaiting_activation_v${iterationCount + 1}` : 'awaiting_activation')
+      : 'completed_unapproved';
+    await finishRun(pg, runId, finalStatus);
+  } catch (err) {
+    // Codex P1 #11 fix: log against the stage we were ACTUALLY in (tracked
+    // locally), not run.current_stage which is frozen at function-entry. And
+    // wrap the logEvent in its own try so a logging failure can't skip the
+    // finishRun — leaving the run stuck "running" is the worst outcome.
+    try {
+      await logEvent(pg, runId, currentStage, currentStageName, 'error', err.message, { stack: err.stack, stage: currentStage });
+    } catch (logErr) {
+      console.error('[visual-factory] crash handler logEvent failed:', logErr.message);
+    }
+    try {
+      await finishRun(pg, runId, 'failed');
+    } catch (finishErr) {
+      console.error('[visual-factory] crash handler finishRun failed:', finishErr.message);
+    }
+  }
+}
+
+module.exports = { STAGES, runPipeline };
diff --git a/src/stages/compose.js b/src/stages/compose.js
new file mode 100644
index 0000000..9197acb
--- /dev/null
+++ b/src/stages/compose.js
@@ -0,0 +1,57 @@
+// Stage 3 — compose. qwen3:14b writes a single self-contained HTML+CSS doc that fills
+// the target dimensions exactly and respects the spec's palette/typography/content.
+
+const fs = require('node:fs/promises');
+const path = require('node:path');
+const { ollama } = require('../llm');
+
+const SANDBOX_ROOT = path.join(__dirname, '..', '..', 'output');
+
+const SYSTEM = `You write a SINGLE self-contained HTML document that renders a polished visual at exact target dimensions. Output ONLY raw HTML — no code fences, no commentary.
+
+REQUIREMENTS:
+- One <!DOCTYPE html> file, all CSS inline in <style>, no external assets, no JS frameworks.
+- The visible canvas is exactly width × height pixels. Use:
+    body { margin: 0; width: <W>px; height: <H>px; overflow: hidden; }
+- Use Google Fonts via <link> for typography ("serif"=Playfair Display, "sans"=Inter, "display"=Bebas Neue, "mono"=JetBrains Mono).
+- Honor the spec.palette colors precisely.
+- Render every content_block. Use spec.background ("solid"/"gradient"/"pattern") for the canvas.
+- **TEXT MUST READ.** If background is "pattern", confine the pattern to the EDGES (e.g., a top/bottom band, an asymmetric corner block) so headline + subhead sit on a clean solid panel — NEVER tile a busy stripe/dot pattern under the type. Headlines must hit AAA contrast against whatever is behind them.
+- For image_placeholder blocks, render a styled container with subtle texture (CSS gradients/patterns). Never reference external image URLs.
+- Accessibility-grade contrast on all text.
+- No lorem ipsum — use the block.text values verbatim.
+- Polished, intentional layout. NOT a generic landing-page hero.`;
+
+async function runCompose({ runId, spec }) {
+  const userPrompt = `Spec:
+${JSON.stringify(spec, null, 2)}
+
+Width: ${spec.width}px
+Height: ${spec.height}px
+
+Write the HTML now.`;
+
+  const html = stripFences(await ollama({
+    model: process.env.COMPOSE_MODEL || 'qwen3:14b',
+    system: SYSTEM,
+    prompt: userPrompt,
+    temperature: 0.4
+  })).trim();
+
+  const runDir = path.join(SANDBOX_ROOT, `run-${runId}`);
+  await fs.mkdir(runDir, { recursive: true });
+  const htmlPath = path.join(runDir, `${spec.artifact_name}.html`);
+  await fs.writeFile(htmlPath, html, 'utf8');
+
+  const errors = [];
+  if (!/<!DOCTYPE\s+html/i.test(html)) errors.push('missing <!DOCTYPE html>');
+  if (!/<style[\s>]/i.test(html))      errors.push('missing inline <style>');
+  if (html.length < 400)               errors.push(`html too short (${html.length} bytes)`);
+  return { htmlPath, html, errors };
+}
+
+function stripFences(s) {
+  return s.replace(/^```(?:html)?\s*\n/i, '').replace(/\n```\s*$/, '');
+}
+
+module.exports = { runCompose };
diff --git a/src/stages/critic.js b/src/stages/critic.js
new file mode 100644
index 0000000..904bf63
--- /dev/null
+++ b/src/stages/critic.js
@@ -0,0 +1,52 @@
+// Stage 6 — critic. Single Claude CLI subprocess call. Compares the llava description
+// of the rendered image against the original brief + spec; returns a JSON review.
+
+const { claudeCli } = require('../llm');
+
+const PROMPT = (brief, spec, vision) => `You are reviewing an automated visual generation.
+
+IMPORTANT CALIBRATION: The vision-model description below is from a small local model (llava). It frequently hallucinates colors and misses text. Treat \`vision.text_content\` as a LOWER BOUND on what's actually rendered, not the truth — the renderer wrote real HTML with the spec's content blocks, so they ARE present even if vision didn't read them. Treat \`vision.palette\` and \`vision.mood\` as soft signals, not facts. The strongest reliable signals are:
+  - \`vision.overall_quality\` (the model's holistic read — usually trustworthy)
+  - \`vision.issues\` (only if it flagged something concrete like clipping/overflow)
+
+ORIGINAL BRIEF:
+${brief}
+
+EXTRACTED SPEC:
+${JSON.stringify(spec, null, 2)}
+
+VISION-MODEL REPORT (treat per the calibration above):
+${JSON.stringify(vision, null, 2)}
+
+Decision policy:
+  - If \`vision.overall_quality\` ≥ 75 AND \`vision.issues\` is empty/minor → APPROVE (score = vision.overall_quality)
+  - If \`vision.issues\` flags clipping, broken layout, or lorem ipsum → REJECT with concrete fixes
+  - Don't reject for "missing palette colors" or "missing text" based on vision alone — those are unreliable
+  - Don't reject for subjective mood mismatch alone
+
+Return ONLY a JSON object, no prose, no fences:
+{
+  "approved": <true|false>,
+  "score": <0-100>,
+  "issues": [<short string per concrete problem actually evidenced>],
+  "fix_instructions": [<short string per actionable HTML/CSS revision; empty if approved>]
+}`;
+
+async function runCritic({ brief, spec, vision, signal = null }) {
+  const raw = await claudeCli({ prompt: PROMPT(brief, spec, vision), timeoutMs: 180000, signal });
+  const m = raw.match(/\{[\s\S]*\}/);
+  if (!m) return { approved: false, score: 0, issues: ['critic returned no JSON'], fix_instructions: [], raw_excerpt: raw.slice(0, 300) };
+  try {
+    const r = JSON.parse(m[0]);
+    return {
+      approved: !!r.approved,
+      score: Number(r.score) || 0,
+      issues: Array.isArray(r.issues) ? r.issues : [],
+      fix_instructions: Array.isArray(r.fix_instructions) ? r.fix_instructions : []
+    };
+  } catch (e) {
+    return { approved: false, score: 0, issues: [`critic JSON parse failed: ${e.message}`], fix_instructions: [] };
+  }
+}
+
+module.exports = { runCritic };
diff --git a/src/stages/intake.js b/src/stages/intake.js
new file mode 100644
index 0000000..81059fd
--- /dev/null
+++ b/src/stages/intake.js
@@ -0,0 +1,43 @@
+// Stage 1 — intake. Brief → structured visual spec.
+
+const { ollama } = require('../llm');
+
+const SYSTEM = `You convert a visual brief into a structured spec for "The Visual Factory".
+The Factory composes HTML+CSS, screenshots it via Playwright, and reviews the PNG.
+Return ONLY a JSON object:
+  visual_type     "social_post" | "hero_image" | "poster" | "diagram" | "card" | "ad_creative"
+  artifact_name   short kebab-case identifier
+  width           integer pixels (default 1200)
+  height          integer pixels (default 1200; for social_post 1080; hero 1200x600; poster 1200x1500)
+  palette         array of 3-5 hex colors that fit the mood
+  mood            one short phrase (e.g. "minimalist editorial", "bold maximalist")
+  typography      "serif" | "sans" | "display" | "mono"
+  content_blocks  array of objects: {kind: "headline"|"subhead"|"body"|"cta"|"image_placeholder", text: "..."} (max 6)
+  background      "solid" | "gradient" | "pattern"
+No prose, no markdown, no code fences.`;
+
+const KEBAB = /^[a-z0-9-]{2,60}$/;
+
+async function runIntake({ brief }) {
+  const spec = await ollama({
+    model: process.env.COMPOSE_MODEL || 'qwen3:14b',
+    system: SYSTEM,
+    prompt: brief,
+    format: 'json',
+    temperature: 0.2
+  });
+
+  const errors = [];
+  if (!spec.visual_type || !['social_post','hero_image','poster','diagram','card','ad_creative'].includes(spec.visual_type)) {
+    errors.push(`visual_type invalid: ${JSON.stringify(spec.visual_type)}`);
+  }
+  if (!spec.artifact_name || !KEBAB.test(spec.artifact_name)) errors.push(`artifact_name invalid: ${JSON.stringify(spec.artifact_name)}`);
+  spec.width = Number(spec.width) || 1200;
+  spec.height = Number(spec.height) || 1200;
+  spec.palette = Array.isArray(spec.palette) ? spec.palette.filter(c => /^#?[0-9a-fA-F]{6}$/.test(String(c))).map(c => c.startsWith('#') ? c : `#${c}`).slice(0, 5) : [];
+  if (!spec.palette.length) spec.palette = ['#0e0e10', '#e8e8ea', '#d4af37'];
+  spec.content_blocks = Array.isArray(spec.content_blocks) ? spec.content_blocks.slice(0, 6) : [];
+  return { spec, errors };
+}
+
+module.exports = { runIntake };
diff --git a/src/stages/iterate.js b/src/stages/iterate.js
new file mode 100644
index 0000000..e51c0a9
--- /dev/null
+++ b/src/stages/iterate.js
@@ -0,0 +1,40 @@
+// Stage 7 — iterate. qwen3:14b applies critic.fix_instructions to the HTML, single retry.
+
+const fs = require('node:fs/promises');
+const { ollama } = require('../llm');
+
+const SYSTEM = `You revise a single HTML+CSS document in place per the listed fixes.
+Output ONLY the revised raw HTML. No code fences, no prose. Same self-contained structure as before.`;
+
+async function runIterate({ htmlPath, html, spec, review, signal = null }) {
+  if (review.approved) return { changed: false, html };
+
+  const userPrompt = `Spec:
+${JSON.stringify(spec, null, 2)}
+
+Current HTML:
+---BEGIN HTML---
+${html}
+---END HTML---
+
+Issues found:
+${review.issues.map((s, i) => `${i + 1}. ${s}`).join('\n')}
+
+Fixes to apply:
+${review.fix_instructions.map((s, i) => `${i + 1}. ${s}`).join('\n')}
+
+Output the revised raw HTML now.`;
+
+  const revised = (await ollama({
+    model: process.env.COMPOSE_MODEL || 'qwen3:14b',
+    system: SYSTEM,
+    prompt: userPrompt,
+    temperature: 0.3,
+    signal
+  })).replace(/^```(?:html)?\s*\n/i, '').replace(/\n```\s*$/, '').trim();
+
+  await fs.writeFile(htmlPath, revised, 'utf8');
+  return { changed: true, html: revised };
+}
+
+module.exports = { runIterate };
diff --git a/src/stages/render.js b/src/stages/render.js
new file mode 100644
index 0000000..688dcc1
--- /dev/null
+++ b/src/stages/render.js
@@ -0,0 +1,37 @@
+// Stage 4 — render. Playwright headless Chromium → PNG screenshot at target dimensions.
+
+const fs = require('node:fs/promises');
+const path = require('node:path');
+const { chromium } = require('playwright');
+
+async function runRender({ htmlPath, width, height, signal = null }) {
+  if (signal?.aborted) throw new Error('render aborted before launch');
+  const pngPath = htmlPath.replace(/\.html$/, '.png');
+  const browser = await chromium.launch({ headless: true });
+  // Wire external abort to browser.close() so a stage timeout actually kills
+  // the headless Chromium instead of leaving a zombie process eating GPU.
+  let onAbort = null;
+  if (signal) {
+    onAbort = () => { browser.close().catch(() => {}); };
+    signal.addEventListener('abort', onAbort, { once: true });
+  }
+  try {
+    const ctx = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 2 });
+    const page = await ctx.newPage();
+    await page.goto(`file://${path.resolve(htmlPath)}`, { waitUntil: 'networkidle', timeout: 30000 });
+    // Allow Google Fonts to settle. Abortable so a cancelled stage doesn't
+    // sit here for the full 1.5s.
+    await Promise.race([
+      page.waitForTimeout(1500),
+      new Promise((_, rej) => signal?.addEventListener('abort', () => rej(new Error('render aborted during font-settle')), { once: true })),
+    ]);
+    await page.screenshot({ path: pngPath, fullPage: false, omitBackground: false, clip: { x: 0, y: 0, width, height } });
+    const stat = await fs.stat(pngPath);
+    return { pngPath, bytes: stat.size, width, height };
+  } finally {
+    if (signal && onAbort) signal.removeEventListener('abort', onAbort);
+    await browser.close().catch(() => {});
+  }
+}
+
+module.exports = { runRender };
diff --git a/src/stages/vision_check.js b/src/stages/vision_check.js
new file mode 100644
index 0000000..b3352fd
--- /dev/null
+++ b/src/stages/vision_check.js
@@ -0,0 +1,57 @@
+// Stage 5 — vision_check. llava describes what's actually on the rendered PNG.
+// This is the ground-truth visual signal that the critic uses to compare against the brief.
+
+const { ollama } = require('../llm');
+
+const SYSTEM = `You are a precise visual reviewer for a generated image.
+
+Output ONLY a JSON object:
+  text_content    array of every word or phrase you can read (best effort — leave [] if you cannot read text reliably)
+  layout          one short sentence on the composition
+  mood            one short phrase
+  issues          array — ONLY include CONCRETE visible problems: text clipping/overflow, lorem ipsum, broken/squished layout, all-blank canvas. Do NOT include subjective concerns. Empty array if the image looks intentional.
+  overall_quality integer 0-100 — your honest read of polish/intentionality. A clean editorial layout with clear hierarchy is 85+. A broken/blank/cluttered layout is <50.
+
+JSON only — no prose, no fences, no explanations.`;
+
+async function runVisionCheck({ pngPath, signal = null }) {
+  // Vision is best-effort. llava sometimes degenerates (loops empty strings,
+  // fails JSON parse). If it crashes, return a neutral "vision unavailable"
+  // shape so the pipeline can still finish and the critic can decide based
+  // on the brief alone. The PNG already exists — vision is just a signal.
+  try {
+    const desc = await ollama({
+      model: process.env.VISION_MODEL || 'llava:latest',
+      system: SYSTEM,
+      prompt: 'Describe this image as JSON per the system instructions.',
+      images: [pngPath],
+      format: 'json',
+      temperature: 0.1,
+      signal
+    });
+    return {
+      text_content: Array.isArray(desc.text_content) ? desc.text_content.filter(s => s && s.trim()).slice(0, 30) : [],
+      layout: String(desc.layout || ''),
+      mood: String(desc.mood || ''),
+      issues: Array.isArray(desc.issues) ? desc.issues.filter(s => s && s.trim()) : [],
+      overall_quality: Number(desc.overall_quality) || 0,
+      _vision_ok: true
+    };
+  } catch (err) {
+    // Don't swallow stage cancellation as "vision unavailable" — propagate so
+    // the pipeline correctly reports the run as timed out instead of silently
+    // moving on with a neutral score.
+    if (signal?.aborted || /aborted/i.test(err.message)) throw err;
+    return {
+      text_content: [],
+      layout: '',
+      mood: '',
+      issues: [],
+      overall_quality: 75, // neutral default — assume rendered OK; critic can override on other signals
+      _vision_ok: false,
+      _vision_error: err.message.slice(0, 200)
+    };
+  }
+}
+
+module.exports = { runVisionCheck };
diff --git a/viewer.js b/viewer.js
new file mode 100644
index 0000000..50b3cd8
--- /dev/null
+++ b/viewer.js
@@ -0,0 +1,12 @@
+require('dotenv').config();
+const express = require('express');
+const path = require('node:path');
+
+const app = express();
+const PORT = Number(process.env.VIEWER_PORT || 9893);
+
+app.use(express.static(path.join(__dirname, 'public')));
+
+app.listen(PORT, '127.0.0.1', () => {
+  console.log(`[visual-factory] viewer listening on http://127.0.0.1:${PORT}`);
+});

(oldest)  ·  back to Visual Factory  ·  apply codex p2/p2/p3 abort-handling fixes 287ac34 →