[object Object]

← back to Big Red

initial scaffold — Big Red avatar app (chat + phone-cam analyze)

ed713991601ae4a1ea332a5018086d2b7f37cb1e · 2026-05-10 09:59:03 -0700 · Steve Abrams

Files touched

Diff

commit ed713991601ae4a1ea332a5018086d2b7f37cb1e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 10 09:59:03 2026 -0700

    initial scaffold — Big Red avatar app (chat + phone-cam analyze)
---
 .gitignore          |    9 +
 package-lock.json   | 1354 +++++++++++++++++++++++++++++++++++++++++++++++++++
 package.json        |   16 +
 public/desktop.html |  675 +++++++++++++++++++++++++
 public/index.html   |    1 +
 public/phone.html   |  170 +++++++
 server.js           |  290 +++++++++++
 7 files changed, 2515 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4a0b3ea
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+node_modules/
+.env
+.env.*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..8c1497f
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1354 @@
+{
+  "name": "big-red",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "big-red",
+      "version": "0.1.0",
+      "dependencies": {
+        "dotenv": "^16.4.5",
+        "express": "^4.21.0",
+        "multer": "^1.4.5-lts.1",
+        "qrcode": "^1.5.4",
+        "ws": "^8.18.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/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "license": "MIT",
+      "dependencies": {
+        "color-convert": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/append-field": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
+      "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
+      "license": "MIT"
+    },
+    "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/buffer-from": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+      "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+      "license": "MIT"
+    },
+    "node_modules/busboy": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
+      "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
+      "dependencies": {
+        "streamsearch": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=10.16.0"
+      }
+    },
+    "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/camelcase": {
+      "version": "5.3.1",
+      "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+      "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/cliui": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+      "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+      "license": "ISC",
+      "dependencies": {
+        "string-width": "^4.2.0",
+        "strip-ansi": "^6.0.0",
+        "wrap-ansi": "^6.2.0"
+      }
+    },
+    "node_modules/color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "license": "MIT",
+      "dependencies": {
+        "color-name": "~1.1.4"
+      },
+      "engines": {
+        "node": ">=7.0.0"
+      }
+    },
+    "node_modules/color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+      "license": "MIT"
+    },
+    "node_modules/concat-stream": {
+      "version": "1.6.2",
+      "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
+      "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
+      "engines": [
+        "node >= 0.8"
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "buffer-from": "^1.0.0",
+        "inherits": "^2.0.3",
+        "readable-stream": "^2.2.2",
+        "typedarray": "^0.0.6"
+      }
+    },
+    "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/core-util-is": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+      "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+      "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/decamelize": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+      "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.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/dijkstrajs": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
+      "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
+      "license": "MIT"
+    },
+    "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/emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "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/find-up": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+      "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+      "license": "MIT",
+      "dependencies": {
+        "locate-path": "^5.0.0",
+        "path-exists": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=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/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-caller-file": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+      "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+      "license": "ISC",
+      "engines": {
+        "node": "6.* || 8.* || >= 10.*"
+      }
+    },
+    "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/is-fullwidth-code-point": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/isarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+      "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+      "license": "MIT"
+    },
+    "node_modules/locate-path": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+      "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+      "license": "MIT",
+      "dependencies": {
+        "p-locate": "^4.1.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "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/minimist": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+      "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/mkdirp": {
+      "version": "0.5.6",
+      "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+      "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+      "license": "MIT",
+      "dependencies": {
+        "minimist": "^1.2.6"
+      },
+      "bin": {
+        "mkdirp": "bin/cmd.js"
+      }
+    },
+    "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/multer": {
+      "version": "1.4.5-lts.2",
+      "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
+      "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
+      "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
+      "license": "MIT",
+      "dependencies": {
+        "append-field": "^1.0.0",
+        "busboy": "^1.0.0",
+        "concat-stream": "^1.5.2",
+        "mkdirp": "^0.5.4",
+        "object-assign": "^4.1.1",
+        "type-is": "^1.6.4",
+        "xtend": "^4.0.0"
+      },
+      "engines": {
+        "node": ">= 6.0.0"
+      }
+    },
+    "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-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "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/p-limit": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+      "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+      "license": "MIT",
+      "dependencies": {
+        "p-try": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/p-locate": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+      "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+      "license": "MIT",
+      "dependencies": {
+        "p-limit": "^2.2.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/p-try": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+      "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "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-exists": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+      "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=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/pngjs": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+      "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/process-nextick-args": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+      "license": "MIT"
+    },
+    "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/qrcode": {
+      "version": "1.5.4",
+      "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
+      "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
+      "license": "MIT",
+      "dependencies": {
+        "dijkstrajs": "^1.0.1",
+        "pngjs": "^5.0.0",
+        "yargs": "^15.3.1"
+      },
+      "bin": {
+        "qrcode": "bin/qrcode"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "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/readable-stream": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+      "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+      "license": "MIT",
+      "dependencies": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      }
+    },
+    "node_modules/readable-stream/node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+      "license": "MIT"
+    },
+    "node_modules/require-directory": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+      "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/require-main-filename": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+      "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+      "license": "ISC"
+    },
+    "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/set-blocking": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+      "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+      "license": "ISC"
+    },
+    "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/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/streamsearch": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
+      "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
+    "node_modules/string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
+    "node_modules/string_decoder/node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+      "license": "MIT"
+    },
+    "node_modules/string-width": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "license": "MIT",
+      "dependencies": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=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/typedarray": {
+      "version": "0.0.6",
+      "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+      "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
+      "license": "MIT"
+    },
+    "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/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "license": "MIT"
+    },
+    "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/which-module": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
+      "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
+      "license": "ISC"
+    },
+    "node_modules/wrap-ansi": {
+      "version": "6.2.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+      "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+      "license": "MIT",
+      "dependencies": {
+        "ansi-styles": "^4.0.0",
+        "string-width": "^4.1.0",
+        "strip-ansi": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/ws": {
+      "version": "8.20.0",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
+      "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.0.0"
+      },
+      "peerDependencies": {
+        "bufferutil": "^4.0.1",
+        "utf-8-validate": ">=5.0.2"
+      },
+      "peerDependenciesMeta": {
+        "bufferutil": {
+          "optional": true
+        },
+        "utf-8-validate": {
+          "optional": true
+        }
+      }
+    },
+    "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"
+      }
+    },
+    "node_modules/y18n": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+      "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+      "license": "ISC"
+    },
+    "node_modules/yargs": {
+      "version": "15.4.1",
+      "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+      "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+      "license": "MIT",
+      "dependencies": {
+        "cliui": "^6.0.0",
+        "decamelize": "^1.2.0",
+        "find-up": "^4.1.0",
+        "get-caller-file": "^2.0.1",
+        "require-directory": "^2.1.1",
+        "require-main-filename": "^2.0.0",
+        "set-blocking": "^2.0.0",
+        "string-width": "^4.2.0",
+        "which-module": "^2.0.0",
+        "y18n": "^4.0.0",
+        "yargs-parser": "^18.1.2"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/yargs-parser": {
+      "version": "18.1.3",
+      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+      "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+      "license": "ISC",
+      "dependencies": {
+        "camelcase": "^5.0.0",
+        "decamelize": "^1.2.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..6573992
--- /dev/null
+++ b/package.json
@@ -0,0 +1,16 @@
+{
+  "name": "big-red",
+  "version": "0.1.0",
+  "description": "Big Red — animated Steve-avatar desktop app: chat with Claude + live phone-camera analysis",
+  "main": "server.js",
+  "scripts": {
+    "start": "node server.js"
+  },
+  "dependencies": {
+    "express": "^4.21.0",
+    "ws": "^8.18.0",
+    "qrcode": "^1.5.4",
+    "dotenv": "^16.4.5",
+    "multer": "^1.4.5-lts.1"
+  }
+}
diff --git a/public/desktop.html b/public/desktop.html
new file mode 100644
index 0000000..08105e7
--- /dev/null
+++ b/public/desktop.html
@@ -0,0 +1,675 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Big Red</title>
+<style>
+  :root {
+    --bg-1: #1a0506;
+    --bg-2: #320809;
+    --red:  #d8323a;
+    --red-deep: #8b1419;
+    --red-glow: #ff5560;
+    --skin: #f3c9b1;
+    --skin-shadow: #c89377;
+    --hair: #b32221;
+    --eye: #2a1a0e;
+    --paper: #f7efe6;
+    --ink: #1a0d0d;
+    --good: #4caf50;
+    --warn: #ffb84d;
+  }
+  * { box-sizing: border-box; }
+  html, body { margin: 0; padding: 0; height: 100%; }
+  body {
+    background:
+      radial-gradient(circle at 30% 10%, #44090b 0%, transparent 55%),
+      radial-gradient(circle at 80% 90%, #2a0506 0%, transparent 60%),
+      linear-gradient(160deg, var(--bg-1), var(--bg-2));
+    color: var(--paper);
+    font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", sans-serif;
+    overflow: hidden;
+  }
+  .app {
+    display: grid;
+    grid-template-columns: minmax(420px, 1.1fr) minmax(360px, 1fr);
+    grid-template-rows: 1fr;
+    height: 100vh;
+    gap: 0;
+  }
+  .stage {
+    position: relative;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    overflow: hidden;
+  }
+  .brand {
+    position: absolute;
+    top: 18px; left: 22px;
+    font-weight: 900;
+    letter-spacing: 0.18em;
+    font-size: 13px;
+    color: var(--red-glow);
+    text-shadow: 0 0 18px rgba(255,85,96,.45);
+  }
+  .status-dot {
+    position: absolute;
+    top: 18px; right: 22px;
+    display: flex; gap: 14px; align-items: center;
+    font-size: 12px;
+    color: rgba(255,255,255,.55);
+  }
+  .status-dot .pill {
+    display: inline-flex; align-items: center; gap: 6px;
+    padding: 4px 10px;
+    border-radius: 999px;
+    background: rgba(255,255,255,.06);
+    border: 1px solid rgba(255,255,255,.1);
+  }
+  .status-dot .dot {
+    width: 8px; height: 8px; border-radius: 50%;
+    background: var(--warn);
+    box-shadow: 0 0 8px currentColor;
+  }
+  .status-dot .dot.ok { background: var(--good); }
+  .avatar-wrap {
+    width: min(78%, 520px);
+    aspect-ratio: 1 / 1.05;
+    position: relative;
+    filter: drop-shadow(0 18px 60px rgba(0,0,0,.6)) drop-shadow(0 0 80px rgba(216,50,58,.18));
+  }
+  svg.avatar { width: 100%; height: 100%; display: block; }
+  /* Lip-sync: scale mouth height with --speak (0..1) */
+  .mouth-inner {
+    transform-origin: 250px 360px;
+    transform: scaleY(calc(0.15 + var(--speak, 0) * 1));
+    transition: transform 80ms ease-out;
+  }
+  /* Subtle idle bob */
+  @keyframes bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-6px); } }
+  .avatar { animation: bob 6s ease-in-out infinite; }
+  /* Eye blink */
+  @keyframes blink { 0%, 92%, 100% { transform: scaleY(1); } 95%, 97% { transform: scaleY(0.05); } }
+  .eye-lid { transform-origin: center; animation: blink 5.2s infinite; }
+
+  .caption {
+    position: absolute;
+    left: 50%;
+    bottom: 38px;
+    transform: translateX(-50%);
+    max-width: 80%;
+    text-align: center;
+    font-size: 17px;
+    line-height: 1.4;
+    color: rgba(255,255,255,.85);
+    background: rgba(0,0,0,.42);
+    border: 1px solid rgba(255,255,255,.08);
+    backdrop-filter: blur(10px);
+    border-radius: 14px;
+    padding: 10px 16px;
+    min-height: 22px;
+    opacity: 0;
+    transition: opacity .25s ease;
+  }
+  .caption.show { opacity: 1; }
+
+  .panel {
+    background: rgba(0,0,0,.32);
+    border-left: 1px solid rgba(255,255,255,.08);
+    display: flex;
+    flex-direction: column;
+    min-width: 0;
+  }
+  .tabs {
+    display: flex;
+    border-bottom: 1px solid rgba(255,255,255,.08);
+    background: rgba(0,0,0,.18);
+  }
+  .tab {
+    flex: 1; padding: 14px 12px;
+    text-align: center; font-size: 13px;
+    cursor: pointer;
+    color: rgba(255,255,255,.55);
+    border-bottom: 2px solid transparent;
+    user-select: none;
+  }
+  .tab.active { color: var(--paper); border-bottom-color: var(--red); }
+
+  .tab-body { flex: 1; min-height: 0; display: none; flex-direction: column; }
+  .tab-body.active { display: flex; }
+
+  /* chat */
+  .chat-log {
+    flex: 1; overflow-y: auto; padding: 18px;
+    display: flex; flex-direction: column; gap: 10px;
+  }
+  .msg {
+    max-width: 85%;
+    padding: 10px 14px;
+    border-radius: 14px;
+    font-size: 14px;
+    line-height: 1.45;
+    word-wrap: break-word;
+  }
+  .msg.user { align-self: flex-end; background: var(--red); color: #fff; border-bottom-right-radius: 4px; }
+  .msg.bot  { align-self: flex-start; background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.08); border-bottom-left-radius: 4px; }
+  .msg.sys  { align-self: center; font-size: 12px; color: rgba(255,255,255,.5); }
+  .composer {
+    display: flex; gap: 8px;
+    padding: 12px;
+    border-top: 1px solid rgba(255,255,255,.08);
+  }
+  .composer input {
+    flex: 1; padding: 12px 14px;
+    border-radius: 10px; border: 1px solid rgba(255,255,255,.12);
+    background: rgba(255,255,255,.06);
+    color: var(--paper);
+    font-size: 14px;
+    outline: none;
+  }
+  .composer input:focus { border-color: var(--red-glow); }
+  .btn {
+    padding: 12px 16px; border-radius: 10px;
+    border: 1px solid rgba(255,255,255,.12);
+    background: var(--red);
+    color: #fff;
+    font-weight: 600;
+    cursor: pointer;
+    font-size: 13px;
+  }
+  .btn:hover { background: var(--red-glow); }
+  .btn.ghost { background: transparent; color: rgba(255,255,255,.85); }
+  .btn.ghost:hover { background: rgba(255,255,255,.07); }
+  .btn:disabled { opacity: .5; cursor: not-allowed; }
+
+  /* phone tab */
+  .phone-pane {
+    flex: 1; padding: 18px;
+    display: flex; flex-direction: column; gap: 14px;
+    overflow-y: auto;
+  }
+  .pair-card {
+    background: rgba(255,255,255,.04);
+    border: 1px solid rgba(255,255,255,.1);
+    border-radius: 14px;
+    padding: 16px;
+    text-align: center;
+  }
+  .pair-card img { width: 220px; height: 220px; border-radius: 10px; background: #fff; padding: 6px; }
+  .pair-card .url { margin-top: 10px; font-size: 12px; color: rgba(255,255,255,.55); word-break: break-all; }
+
+  .video-card {
+    background: #000;
+    border-radius: 14px;
+    overflow: hidden;
+    aspect-ratio: 16/10;
+    position: relative;
+    border: 1px solid rgba(255,255,255,.1);
+  }
+  .video-card video { width: 100%; height: 100%; object-fit: cover; background: #000; }
+  .video-card .label {
+    position: absolute; top: 10px; left: 12px;
+    padding: 4px 10px;
+    font-size: 11px; font-weight: 700;
+    letter-spacing: .08em;
+    background: rgba(0,0,0,.55);
+    border-radius: 999px;
+    color: #fff;
+  }
+  .video-card .empty {
+    position: absolute; inset: 0;
+    display: flex; align-items: center; justify-content: center;
+    color: rgba(255,255,255,.45); font-size: 13px;
+    text-align: center; padding: 20px;
+  }
+  .analyze-row { display: flex; gap: 8px; }
+  .analyze-row input {
+    flex: 1;
+    padding: 10px 12px; border-radius: 10px;
+    border: 1px solid rgba(255,255,255,.12);
+    background: rgba(255,255,255,.05);
+    color: var(--paper); font-size: 13px;
+  }
+  .analyze-result {
+    background: rgba(255,255,255,.05);
+    border: 1px solid rgba(255,255,255,.08);
+    border-radius: 12px;
+    padding: 12px 14px;
+    font-size: 14px; line-height: 1.45;
+    min-height: 32px;
+  }
+  .typing { display: inline-block; opacity: .65; }
+  .typing::after {
+    content: '…';
+    animation: dots 1.2s infinite steps(4);
+  }
+  @keyframes dots { 0% { content: ''; } 25% { content: '.'; } 50% { content: '..'; } 75% { content: '...'; } 100% { content: ''; } }
+</style>
+</head>
+<body>
+<div class="app">
+  <section class="stage">
+    <div class="brand">BIG&nbsp;RED</div>
+    <div class="status-dot">
+      <span class="pill" id="pillVoice"><span class="dot" id="dotVoice"></span>voice</span>
+      <span class="pill" id="pillPhone"><span class="dot" id="dotPhone"></span>phone</span>
+    </div>
+
+    <div class="avatar-wrap">
+      <svg class="avatar" viewBox="0 0 500 525" xmlns="http://www.w3.org/2000/svg">
+        <defs>
+          <radialGradient id="skinGrad" cx="50%" cy="40%" r="60%">
+            <stop offset="0%" stop-color="#fbe0c8"/>
+            <stop offset="70%" stop-color="#f3c9b1"/>
+            <stop offset="100%" stop-color="#c89377"/>
+          </radialGradient>
+          <radialGradient id="hairGrad" cx="50%" cy="20%" r="80%">
+            <stop offset="0%" stop-color="#e84147"/>
+            <stop offset="60%" stop-color="#b32221"/>
+            <stop offset="100%" stop-color="#6b1010"/>
+          </radialGradient>
+          <radialGradient id="bgGrad" cx="50%" cy="50%" r="50%">
+            <stop offset="0%" stop-color="#5a0d10" stop-opacity=".55"/>
+            <stop offset="100%" stop-color="#1a0506" stop-opacity="0"/>
+          </radialGradient>
+          <linearGradient id="cheekGrad" x1="0" y1="0" x2="0" y2="1">
+            <stop offset="0%" stop-color="#e88a7a" stop-opacity=".0"/>
+            <stop offset="100%" stop-color="#e88a7a" stop-opacity=".55"/>
+          </linearGradient>
+        </defs>
+
+        <!-- soft glow halo -->
+        <ellipse cx="250" cy="250" rx="240" ry="240" fill="url(#bgGrad)"/>
+
+        <!-- neck/collar -->
+        <path d="M170 470 Q250 430 330 470 L340 525 L160 525 Z" fill="#202020"/>
+        <path d="M170 470 Q250 430 330 470 L335 495 Q250 470 165 495 Z" fill="#d8323a"/>
+
+        <!-- hair back -->
+        <path d="M110 240 Q90 110 240 80 Q420 80 400 250 Q400 300 380 320 L370 240 Q330 180 250 175 Q170 180 130 240 L120 320 Q105 300 110 240 Z" fill="url(#hairGrad)"/>
+
+        <!-- face -->
+        <ellipse cx="250" cy="290" rx="155" ry="180" fill="url(#skinGrad)"/>
+
+        <!-- jaw shadow -->
+        <path d="M120 320 Q250 480 380 320 Q380 410 250 460 Q120 410 120 320 Z" fill="#e0a988" opacity=".35"/>
+
+        <!-- hair front sweep (Steve red hair) -->
+        <path d="M115 240 Q130 145 245 130 Q345 135 388 220 Q360 200 320 195 Q280 165 235 175 Q170 188 130 250 Q118 245 115 240 Z" fill="url(#hairGrad)"/>
+
+        <!-- ears -->
+        <ellipse cx="98" cy="295" rx="18" ry="32" fill="#e8b59b"/>
+        <ellipse cx="402" cy="295" rx="18" ry="32" fill="#e8b59b"/>
+
+        <!-- brows -->
+        <path d="M165 250 Q200 235 235 250" stroke="#7a1612" stroke-width="9" stroke-linecap="round" fill="none"/>
+        <path d="M265 250 Q300 235 335 250" stroke="#7a1612" stroke-width="9" stroke-linecap="round" fill="none"/>
+
+        <!-- eyes -->
+        <g>
+          <ellipse cx="200" cy="285" rx="22" ry="14" fill="#fff"/>
+          <ellipse cx="200" cy="285" rx="9" ry="9" fill="#3a2a1c"/>
+          <ellipse cx="203" cy="282" rx="3" ry="3" fill="#fff"/>
+          <ellipse class="eye-lid" cx="200" cy="285" rx="22" ry="14" fill="url(#skinGrad)"/>
+        </g>
+        <g>
+          <ellipse cx="300" cy="285" rx="22" ry="14" fill="#fff"/>
+          <ellipse cx="300" cy="285" rx="9" ry="9" fill="#3a2a1c"/>
+          <ellipse cx="303" cy="282" rx="3" ry="3" fill="#fff"/>
+          <ellipse class="eye-lid" cx="300" cy="285" rx="22" ry="14" fill="url(#skinGrad)"/>
+        </g>
+
+        <!-- cheeks -->
+        <ellipse cx="170" cy="345" rx="28" ry="14" fill="url(#cheekGrad)" opacity=".6"/>
+        <ellipse cx="330" cy="345" rx="28" ry="14" fill="url(#cheekGrad)" opacity=".6"/>
+
+        <!-- nose -->
+        <path d="M250 290 Q243 330 235 345 Q250 358 265 345 Q257 330 250 290 Z" fill="#e0a988" opacity=".55"/>
+
+        <!-- beard / scruff (Steve has beard) -->
+        <path d="M150 360 Q250 470 350 360 Q360 420 250 455 Q140 420 150 360 Z" fill="#7a1612" opacity=".55"/>
+        <path d="M165 365 Q250 460 335 365 Q345 410 250 440 Q155 410 165 365 Z" fill="#a02320" opacity=".4"/>
+
+        <!-- mouth (animated) -->
+        <g>
+          <path d="M205 380 Q250 395 295 380" stroke="#5b0d10" stroke-width="3" fill="none" stroke-linecap="round"/>
+          <ellipse class="mouth-inner" cx="250" cy="385" rx="38" ry="9" fill="#3a0a0c"/>
+          <path d="M218 385 Q250 380 282 385" stroke="#fff" stroke-width="2" fill="none" opacity=".75"/>
+        </g>
+
+        <!-- glasses (Steve's signature look) -->
+        <g stroke="#1a0d0d" stroke-width="3" fill="none" opacity=".75">
+          <rect x="160" y="260" width="80" height="48" rx="12"/>
+          <rect x="260" y="260" width="80" height="48" rx="12"/>
+          <line x1="240" y1="278" x2="260" y2="278"/>
+          <line x1="160" y1="270" x2="135" y2="265"/>
+          <line x1="340" y1="270" x2="365" y2="265"/>
+        </g>
+      </svg>
+    </div>
+
+    <div class="caption" id="caption"></div>
+  </section>
+
+  <aside class="panel">
+    <div class="tabs">
+      <div class="tab active" data-tab="chat">Chat</div>
+      <div class="tab" data-tab="phone">Phone Camera</div>
+    </div>
+
+    <div class="tab-body active" id="tab-chat">
+      <div class="chat-log" id="chatLog">
+        <div class="msg sys">Big Red is online. Talk or type — I'll reply in Steve's voice.</div>
+      </div>
+      <form class="composer" id="chatForm">
+        <input id="chatInput" type="text" placeholder="Say something to Big Red…" autocomplete="off"/>
+        <button class="btn ghost" type="button" id="micBtn" title="Hold to speak">🎙</button>
+        <button class="btn" type="submit">Send</button>
+      </form>
+    </div>
+
+    <div class="tab-body" id="tab-phone">
+      <div class="phone-pane">
+        <div class="pair-card" id="pairCard">
+          <div style="font-weight:700; margin-bottom:10px;">Pair your phone</div>
+          <img id="qrImg" alt="QR pairing code"/>
+          <div class="url" id="pairUrl">…</div>
+          <div style="margin-top:10px; font-size:12px; color:rgba(255,255,255,.55)">Open this on your phone — same Wi-Fi.</div>
+        </div>
+
+        <div class="video-card" id="videoCard">
+          <span class="label">PHONE CAM</span>
+          <video id="phoneVideo" autoplay playsinline muted></video>
+          <div class="empty" id="videoEmpty">Waiting for phone…</div>
+        </div>
+
+        <div class="analyze-row">
+          <input id="analyzePrompt" type="text" placeholder="What should I look at? (default: describe scene)"/>
+          <button class="btn" id="analyzeBtn" disabled>Analyze frame</button>
+        </div>
+        <div class="analyze-result" id="analyzeResult">No analysis yet.</div>
+      </div>
+    </div>
+  </aside>
+</div>
+
+<canvas id="captureCanvas" style="display:none"></canvas>
+<audio id="ttsAudio" style="display:none"></audio>
+
+<script>
+(() => {
+  const $ = (id) => document.getElementById(id);
+  const chatLog = $('chatLog');
+  const chatForm = $('chatForm');
+  const chatInput = $('chatInput');
+  const ttsAudio = $('ttsAudio');
+  const captionEl = $('caption');
+  const phoneVideo = $('phoneVideo');
+  const videoEmpty = $('videoEmpty');
+  const analyzeBtn = $('analyzeBtn');
+  const analyzeResult = $('analyzeResult');
+  const captureCanvas = $('captureCanvas');
+  const dotVoice = $('dotVoice');
+  const dotPhone = $('dotPhone');
+
+  // -------- tabs --------
+  document.querySelectorAll('.tab').forEach(t => {
+    t.addEventListener('click', () => {
+      document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
+      document.querySelectorAll('.tab-body').forEach(x => x.classList.remove('active'));
+      t.classList.add('active');
+      $('tab-' + t.dataset.tab).classList.add('active');
+    });
+  });
+
+  // -------- avatar lip-sync --------
+  let audioCtx = null;
+  let analyser = null;
+  let speakRaf = null;
+
+  function setSpeak(level) {
+    document.documentElement.style.setProperty('--speak', String(Math.max(0, Math.min(1, level))));
+  }
+
+  function attachAnalyser(audioEl) {
+    if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
+    const src = audioCtx.createMediaElementSource(audioEl);
+    analyser = audioCtx.createAnalyser();
+    analyser.fftSize = 256;
+    src.connect(analyser);
+    analyser.connect(audioCtx.destination);
+    const data = new Uint8Array(analyser.frequencyBinCount);
+    function tick() {
+      analyser.getByteTimeDomainData(data);
+      let sum = 0;
+      for (let i = 0; i < data.length; i++) {
+        const v = (data[i] - 128) / 128;
+        sum += v * v;
+      }
+      const rms = Math.sqrt(sum / data.length);
+      setSpeak(rms * 4);
+      speakRaf = requestAnimationFrame(tick);
+    }
+    if (speakRaf) cancelAnimationFrame(speakRaf);
+    speakRaf = requestAnimationFrame(tick);
+  }
+
+  let analyserAttached = false;
+  ttsAudio.addEventListener('play', () => {
+    if (!analyserAttached) { attachAnalyser(ttsAudio); analyserAttached = true; }
+    if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
+  });
+  ttsAudio.addEventListener('ended', () => {
+    setSpeak(0);
+    if (speakRaf) { cancelAnimationFrame(speakRaf); speakRaf = null; }
+    captionEl.classList.remove('show');
+  });
+  ttsAudio.addEventListener('pause', () => setSpeak(0));
+
+  function showCaption(text) {
+    captionEl.textContent = text;
+    captionEl.classList.add('show');
+  }
+
+  async function speak(text) {
+    if (!text) return;
+    showCaption(text);
+    try {
+      const r = await fetch('/api/tts', {
+        method: 'POST',
+        headers: { 'content-type': 'application/json' },
+        body: JSON.stringify({ text })
+      });
+      if (!r.ok) {
+        dotVoice.classList.remove('ok');
+        return;
+      }
+      const blob = await r.blob();
+      const url = URL.createObjectURL(blob);
+      ttsAudio.src = url;
+      try { await ttsAudio.play(); dotVoice.classList.add('ok'); } catch { dotVoice.classList.remove('ok'); }
+    } catch {
+      dotVoice.classList.remove('ok');
+    }
+  }
+
+  // -------- chat --------
+  const history = [];
+  function addMsg(role, content) {
+    const div = document.createElement('div');
+    div.className = 'msg ' + (role === 'user' ? 'user' : role === 'sys' ? 'sys' : 'bot');
+    div.textContent = content;
+    chatLog.appendChild(div);
+    chatLog.scrollTop = chatLog.scrollHeight;
+    return div;
+  }
+
+  chatForm.addEventListener('submit', async (e) => {
+    e.preventDefault();
+    const text = chatInput.value.trim();
+    if (!text) return;
+    chatInput.value = '';
+    addMsg('user', text);
+    history.push({ role: 'user', content: text });
+    const thinking = addMsg('bot', '');
+    thinking.innerHTML = '<span class="typing">Big Red is thinking</span>';
+    try {
+      const r = await fetch('/api/chat', {
+        method: 'POST',
+        headers: { 'content-type': 'application/json' },
+        body: JSON.stringify({ message: text, history })
+      });
+      const j = await r.json();
+      const reply = j.reply || j.error || '(no reply)';
+      thinking.textContent = reply;
+      history.push({ role: 'assistant', content: reply });
+      speak(reply);
+    } catch (err) {
+      thinking.textContent = 'Error: ' + err.message;
+    }
+  });
+
+  // -------- voice input (Web Speech API) --------
+  const micBtn = $('micBtn');
+  const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
+  if (SR) {
+    const rec = new SR();
+    rec.continuous = false;
+    rec.interimResults = true;
+    rec.lang = 'en-US';
+    let listening = false;
+    micBtn.addEventListener('click', () => {
+      if (listening) { rec.stop(); return; }
+      try { rec.start(); listening = true; micBtn.style.background = 'var(--red-glow)'; } catch {}
+    });
+    rec.addEventListener('result', (e) => {
+      let txt = '';
+      for (const r of e.results) txt += r[0].transcript;
+      chatInput.value = txt;
+    });
+    rec.addEventListener('end', () => {
+      listening = false;
+      micBtn.style.background = '';
+      if (chatInput.value.trim()) chatForm.dispatchEvent(new Event('submit'));
+    });
+  } else {
+    micBtn.style.display = 'none';
+  }
+
+  // -------- pairing + WebRTC --------
+  let pc = null;
+  let ws = null;
+  let pairCode = null;
+  let phoneStream = null;
+
+  async function bootPairing() {
+    const r = await fetch('/api/pair/new');
+    const j = await r.json();
+    pairCode = j.code;
+    $('qrImg').src = j.qrDataUrl;
+    $('pairUrl').textContent = j.phoneUrl;
+    connectWs();
+  }
+
+  function connectWs() {
+    const proto = location.protocol === 'https:' ? 'wss' : 'ws';
+    ws = new WebSocket(`${proto}://${location.host}/ws`);
+    ws.addEventListener('open', () => {
+      ws.send(JSON.stringify({ type: 'hello', role: 'desktop', code: pairCode }));
+    });
+    ws.addEventListener('message', async (ev) => {
+      let msg; try { msg = JSON.parse(ev.data); } catch { return; }
+      if (msg.type === 'peer-joined' && msg.role === 'phone') {
+        await ensurePc();
+      }
+      if (msg.type === 'offer') {
+        await ensurePc();
+        await pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
+        const ans = await pc.createAnswer();
+        await pc.setLocalDescription(ans);
+        ws.send(JSON.stringify({ type: 'answer', sdp: pc.localDescription }));
+      }
+      if (msg.type === 'ice' && msg.candidate) {
+        try { await pc.addIceCandidate(msg.candidate); } catch {}
+      }
+      if (msg.type === 'peer-gone') {
+        teardownPc();
+      }
+    });
+    ws.addEventListener('close', () => {
+      setTimeout(() => bootPairing(), 1500);
+    });
+  }
+
+  async function ensurePc() {
+    if (pc) return;
+    pc = new RTCPeerConnection({
+      iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
+    });
+    pc.addEventListener('icecandidate', (e) => {
+      if (e.candidate) ws.send(JSON.stringify({ type: 'ice', candidate: e.candidate }));
+    });
+    pc.addEventListener('track', (e) => {
+      phoneStream = e.streams[0];
+      phoneVideo.srcObject = phoneStream;
+      videoEmpty.style.display = 'none';
+      analyzeBtn.disabled = false;
+      dotPhone.classList.add('ok');
+    });
+    pc.addEventListener('connectionstatechange', () => {
+      if (['failed', 'closed', 'disconnected'].includes(pc.connectionState)) {
+        teardownPc();
+      }
+    });
+  }
+
+  function teardownPc() {
+    try { pc && pc.close(); } catch {}
+    pc = null;
+    phoneStream = null;
+    phoneVideo.srcObject = null;
+    videoEmpty.style.display = 'flex';
+    analyzeBtn.disabled = true;
+    dotPhone.classList.remove('ok');
+  }
+
+  // -------- frame analysis --------
+  analyzeBtn.addEventListener('click', async () => {
+    if (!phoneStream) return;
+    const v = phoneVideo;
+    captureCanvas.width = v.videoWidth || 1280;
+    captureCanvas.height = v.videoHeight || 720;
+    const ctx = captureCanvas.getContext('2d');
+    ctx.drawImage(v, 0, 0, captureCanvas.width, captureCanvas.height);
+    const dataUrl = captureCanvas.toDataURL('image/jpeg', 0.85);
+    analyzeBtn.disabled = true;
+    analyzeResult.innerHTML = '<span class="typing">Looking</span>';
+    try {
+      const promptText = $('analyzePrompt').value.trim();
+      const r = await fetch('/api/analyze', {
+        method: 'POST',
+        headers: { 'content-type': 'application/json' },
+        body: JSON.stringify({ dataUrl, prompt: promptText || undefined })
+      });
+      const j = await r.json();
+      const reply = j.reply || j.error || '(no reply)';
+      analyzeResult.textContent = reply;
+      addMsg('sys', '📷 ' + (promptText || 'analyze frame'));
+      addMsg('bot', reply);
+      history.push({ role: 'assistant', content: reply });
+      speak(reply);
+    } catch (e) {
+      analyzeResult.textContent = 'Error: ' + e.message;
+    } finally {
+      analyzeBtn.disabled = !phoneStream;
+    }
+  });
+
+  // boot
+  bootPairing();
+})();
+</script>
+</body>
+</html>
diff --git a/public/index.html b/public/index.html
new file mode 120000
index 0000000..5be5b81
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1 @@
+desktop.html
\ No newline at end of file
diff --git a/public/phone.html b/public/phone.html
new file mode 100644
index 0000000..b994118
--- /dev/null
+++ b/public/phone.html
@@ -0,0 +1,170 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
+<title>Big Red — phone cam</title>
+<style>
+  :root {
+    --bg: #14060a;
+    --red: #d8323a;
+    --red-glow: #ff5560;
+    --paper: #f7efe6;
+  }
+  * { box-sizing: border-box; }
+  html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color: var(--paper); font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
+  .wrap { position: fixed; inset: 0; display: flex; flex-direction: column; }
+  video { flex: 1; width: 100%; height: 100%; object-fit: cover; background: #000; }
+  .topbar {
+    position: absolute; top: env(safe-area-inset-top, 0); left: 0; right: 0;
+    padding: 14px 16px;
+    display: flex; justify-content: space-between; align-items: center;
+    background: linear-gradient(to bottom, rgba(0,0,0,.65), rgba(0,0,0,0));
+    color: #fff; font-size: 14px;
+    pointer-events: none;
+  }
+  .brand { font-weight: 900; letter-spacing: .18em; color: var(--red-glow); text-shadow: 0 0 12px rgba(255,85,96,.55); }
+  .pill { padding: 4px 10px; border-radius: 999px; background: rgba(0,0,0,.5); font-size: 12px; pointer-events: auto; }
+  .pill .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #ffb84d; margin-right: 6px; vertical-align: middle; box-shadow: 0 0 6px currentColor; }
+  .pill .dot.ok { background: #4caf50; }
+  .start {
+    position: absolute; left: 50%; bottom: max(28px, env(safe-area-inset-bottom));
+    transform: translateX(-50%);
+    padding: 16px 28px; border-radius: 999px;
+    background: var(--red); color: #fff; border: none;
+    font-size: 16px; font-weight: 700; letter-spacing: .04em;
+    box-shadow: 0 12px 30px rgba(216,50,58,.45);
+  }
+  .start:active { background: var(--red-glow); }
+  .err {
+    position: absolute; left: 16px; right: 16px; bottom: 90px;
+    background: rgba(0,0,0,.7); border: 1px solid rgba(255,255,255,.15);
+    border-radius: 12px; padding: 12px 14px; font-size: 13px;
+    display: none;
+  }
+  .switch-cam {
+    position: absolute; right: 16px; bottom: max(28px, env(safe-area-inset-bottom));
+    padding: 12px; border-radius: 50%; background: rgba(0,0,0,.55); color: #fff; border: 1px solid rgba(255,255,255,.18);
+    font-size: 18px;
+  }
+</style>
+</head>
+<body>
+<div class="wrap">
+  <video id="cam" autoplay playsinline muted></video>
+  <div class="topbar">
+    <span class="brand">BIG&nbsp;RED</span>
+    <span class="pill"><span class="dot" id="dot"></span><span id="status">tap start</span></span>
+  </div>
+  <button class="start" id="startBtn">Start camera</button>
+  <button class="switch-cam" id="switchBtn" style="display:none">⇄</button>
+  <div class="err" id="err"></div>
+</div>
+
+<script>
+(() => {
+  const $ = (id) => document.getElementById(id);
+  const cam = $('cam');
+  const startBtn = $('startBtn');
+  const switchBtn = $('switchBtn');
+  const dot = $('dot');
+  const statusEl = $('status');
+  const errEl = $('err');
+
+  const params = new URLSearchParams(location.search);
+  const code = params.get('code');
+  if (!code) { errEl.style.display = 'block'; errEl.textContent = 'No pair code in URL.'; return; }
+
+  let stream = null;
+  let pc = null;
+  let ws = null;
+  let facing = 'environment';
+
+  function setStatus(s, ok) {
+    statusEl.textContent = s;
+    if (ok) dot.classList.add('ok'); else dot.classList.remove('ok');
+  }
+
+  async function getStream() {
+    if (stream) { stream.getTracks().forEach(t => t.stop()); }
+    stream = await navigator.mediaDevices.getUserMedia({
+      video: { facingMode: { ideal: facing }, width: { ideal: 1280 }, height: { ideal: 720 } },
+      audio: false
+    });
+    cam.srcObject = stream;
+  }
+
+  async function connect() {
+    try { await getStream(); }
+    catch (e) {
+      errEl.style.display = 'block';
+      errEl.textContent = 'Camera error: ' + e.message + '. Make sure you are on http://' + location.host + ' (not https), and grant camera access.';
+      return;
+    }
+    setStatus('connecting', false);
+    startBtn.style.display = 'none';
+    switchBtn.style.display = 'block';
+
+    const proto = location.protocol === 'https:' ? 'wss' : 'ws';
+    ws = new WebSocket(`${proto}://${location.host}/ws`);
+    ws.addEventListener('open', () => {
+      ws.send(JSON.stringify({ type: 'hello', role: 'phone', code }));
+    });
+    ws.addEventListener('message', async (ev) => {
+      let msg; try { msg = JSON.parse(ev.data); } catch { return; }
+      if (msg.type === 'hello-ok') setStatus('paired, calling…', false);
+      if (msg.type === 'peer-ready' || (msg.type === 'peer-joined' && msg.role === 'desktop')) {
+        await startCall();
+      }
+      if (msg.type === 'answer') {
+        await pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
+      }
+      if (msg.type === 'ice' && msg.candidate) {
+        try { await pc.addIceCandidate(msg.candidate); } catch {}
+      }
+      if (msg.type === 'error') {
+        errEl.style.display = 'block';
+        errEl.textContent = 'Error: ' + msg.error;
+      }
+    });
+    ws.addEventListener('close', () => {
+      setStatus('disconnected', false);
+    });
+  }
+
+  async function startCall() {
+    if (pc) try { pc.close(); } catch {}
+    pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
+    for (const track of stream.getVideoTracks()) pc.addTrack(track, stream);
+    pc.addEventListener('icecandidate', (e) => {
+      if (e.candidate) ws.send(JSON.stringify({ type: 'ice', candidate: e.candidate }));
+    });
+    pc.addEventListener('connectionstatechange', () => {
+      if (pc.connectionState === 'connected') setStatus('streaming', true);
+      else if (['failed', 'disconnected', 'closed'].includes(pc.connectionState)) setStatus('lost', false);
+    });
+    const offer = await pc.createOffer();
+    await pc.setLocalDescription(offer);
+    ws.send(JSON.stringify({ type: 'offer', sdp: pc.localDescription }));
+  }
+
+  startBtn.addEventListener('click', connect);
+  switchBtn.addEventListener('click', async () => {
+    facing = (facing === 'environment') ? 'user' : 'environment';
+    try {
+      await getStream();
+      // replace track on existing pc
+      if (pc) {
+        const sender = pc.getSenders().find(s => s.track && s.track.kind === 'video');
+        const newTrack = stream.getVideoTracks()[0];
+        if (sender && newTrack) sender.replaceTrack(newTrack);
+      }
+    } catch (e) {
+      errEl.style.display = 'block';
+      errEl.textContent = 'Switch failed: ' + e.message;
+    }
+  });
+})();
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..edad2c4
--- /dev/null
+++ b/server.js
@@ -0,0 +1,290 @@
+'use strict';
+
+require('dotenv').config();
+const express = require('express');
+const http = require('http');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const crypto = require('crypto');
+const { spawn } = require('child_process');
+const { WebSocketServer } = require('ws');
+const QRCode = require('qrcode');
+const multer = require('multer');
+
+const PORT = parseInt(process.env.PORT || '9935', 10);
+const ELEVEN_KEY = process.env.ELEVENLABS_API_KEY || '';
+const ELEVEN_VOICE = process.env.ELEVENLABS_VOICE_ID || 'Xa9qV4wNbvSkdUWsYLzq';
+const LAN_HOST = process.env.LAN_HOST || detectLanHost();
+
+// Frames go to /tmp (not ~/Projects/big-red/tmp) — keeping them outside any
+// directory tree that contains a CLAUDE.md cuts claude-CLI cold-start from
+// ~3min to ~25s because --add-dir won't trigger memory auto-discovery.
+const TMP = path.join(os.tmpdir(), 'big-red-frames');
+fs.mkdirSync(TMP, { recursive: true });
+
+function detectLanHost() {
+  const ifaces = os.networkInterfaces();
+  for (const name of Object.keys(ifaces)) {
+    for (const i of ifaces[name] || []) {
+      if (i.family === 'IPv4' && !i.internal) return i.address;
+    }
+  }
+  return 'localhost';
+}
+
+const app = express();
+app.use(express.json({ limit: '20mb' }));
+app.get('/phone', (req, res) => res.sendFile(path.join(__dirname, 'public', 'phone.html')));
+app.use(express.static(path.join(__dirname, 'public')));
+
+const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } });
+
+// ---------- pairing ----------
+const pairings = new Map(); // code -> { desktopWs, phoneWs, createdAt }
+
+function newPairCode() {
+  return crypto.randomBytes(4).toString('hex');
+}
+
+app.get('/api/pair/new', async (req, res) => {
+  const code = newPairCode();
+  pairings.set(code, { desktopWs: null, phoneWs: null, createdAt: Date.now() });
+  const phoneUrl = `http://${LAN_HOST}:${PORT}/phone?code=${code}`;
+  const qrDataUrl = await QRCode.toDataURL(phoneUrl, { width: 320, margin: 1 });
+  res.json({ code, phoneUrl, qrDataUrl, lanHost: LAN_HOST, port: PORT });
+});
+
+// ---------- claude CLI helpers ----------
+// Cold-start cost on the Max-plan claude CLI is ~10-25s, so we use the lightest
+// flags possible: haiku model + no session persistence + slash-commands off.
+// API keys MUST be stripped (Steve's standing rule — Max plan only, not API).
+function runClaude(prompt, { imagePath = null, model = 'haiku', timeoutMs = 240_000 } = {}) {
+  return new Promise((resolve, reject) => {
+    const env = { ...process.env };
+    delete env.ANTHROPIC_API_KEY;
+    delete env.ANTHROPIC_AUTH_TOKEN;
+
+    let fullPrompt = prompt;
+    const baseArgs = [];
+    if (imagePath) {
+      // --add-dir must come before --print; reference the image via @path in the prompt
+      baseArgs.push('--add-dir', path.dirname(imagePath));
+      fullPrompt = `${prompt}\n\nImage: @${imagePath}`;
+    }
+    const args = [
+      ...baseArgs,
+      '--print',
+      '--model', model,
+      '--no-session-persistence',
+      '--disable-slash-commands',
+      fullPrompt
+    ];
+
+    const child = spawn('claude', args, { env });
+    let out = '';
+    let err = '';
+    const t = setTimeout(() => {
+      try { child.kill('SIGTERM'); } catch {}
+      reject(new Error('claude CLI timed out'));
+    }, timeoutMs);
+    child.stdout.on('data', (d) => { out += d.toString(); });
+    child.stderr.on('data', (d) => { err += d.toString(); });
+    child.on('close', (code) => {
+      clearTimeout(t);
+      if (code !== 0) return reject(new Error(`claude exited ${code}: ${err.slice(0, 400)}`));
+      resolve(out.trim());
+    });
+    child.on('error', (e) => { clearTimeout(t); reject(e); });
+  });
+}
+
+// ---------- chat ----------
+app.post('/api/chat', async (req, res) => {
+  const { message, history } = req.body || {};
+  if (!message || typeof message !== 'string') {
+    return res.status(400).json({ error: 'message required' });
+  }
+  try {
+    const ctx = (history || [])
+      .slice(-10)
+      .map((m) => `${m.role === 'user' ? 'User' : 'BigRed'}: ${m.content}`)
+      .join('\n');
+    const prompt = [
+      'You are "Big Red", a friendly desktop avatar assistant for Steve.',
+      'Reply conversationally in 1-3 short sentences unless he asks for detail.',
+      'Plain prose only — no markdown, no lists, no code fences. The reply will be spoken aloud.',
+      ctx ? `\nConversation so far:\n${ctx}` : '',
+      `\nSteve: ${message}`,
+      '\nBigRed:'
+    ].join('');
+    const reply = await runClaude(prompt, { timeoutMs: 240_000 });
+    res.json({ reply });
+  } catch (e) {
+    res.status(500).json({ error: String(e.message || e) });
+  }
+});
+
+// ---------- frame analysis ----------
+app.post('/api/analyze', upload.single('frame'), async (req, res) => {
+  try {
+    let buf = null;
+    let prompt = req.body?.prompt || 'Describe what you see in this image. Be concise — 1 to 3 sentences. Plain prose, no lists.';
+    if (req.file?.buffer) {
+      buf = req.file.buffer;
+    } else if (req.body?.dataUrl && typeof req.body.dataUrl === 'string') {
+      const m = req.body.dataUrl.match(/^data:image\/(png|jpeg|jpg|webp);base64,(.+)$/);
+      if (!m) return res.status(400).json({ error: 'bad dataUrl' });
+      buf = Buffer.from(m[2], 'base64');
+    }
+    if (!buf) return res.status(400).json({ error: 'no image provided' });
+
+    const fname = `frame-${Date.now()}-${crypto.randomBytes(3).toString('hex')}.jpg`;
+    const fpath = path.join(TMP, fname);
+    fs.writeFileSync(fpath, buf);
+
+    const reply = await runClaude(prompt, { imagePath: fpath, timeoutMs: 300_000 });
+
+    // Best-effort cleanup after 60s so old frames don't pile up
+    setTimeout(() => { fs.unlink(fpath, () => {}); }, 60_000);
+
+    res.json({ reply });
+  } catch (e) {
+    res.status(500).json({ error: String(e.message || e) });
+  }
+});
+
+// ---------- TTS (ElevenLabs, Steve's voice) ----------
+app.post('/api/tts', async (req, res) => {
+  const { text } = req.body || {};
+  if (!text || typeof text !== 'string') return res.status(400).json({ error: 'text required' });
+  if (!ELEVEN_KEY) return res.status(503).json({ error: 'ELEVENLABS_API_KEY not configured' });
+  try {
+    const url = `https://api.elevenlabs.io/v1/text-to-speech/${ELEVEN_VOICE}/stream?optimize_streaming_latency=2`;
+    const r = await fetch(url, {
+      method: 'POST',
+      headers: {
+        'xi-api-key': ELEVEN_KEY,
+        'accept': 'audio/mpeg',
+        'content-type': 'application/json'
+      },
+      body: JSON.stringify({
+        text,
+        model_id: 'eleven_turbo_v2_5',
+        voice_settings: { stability: 0.4, similarity_boost: 0.85, style: 0.2, use_speaker_boost: true }
+      })
+    });
+    if (!r.ok) {
+      const errText = await r.text();
+      return res.status(502).json({ error: `eleven ${r.status}: ${errText.slice(0, 300)}` });
+    }
+    res.setHeader('content-type', 'audio/mpeg');
+    res.setHeader('cache-control', 'no-store');
+    const reader = r.body.getReader();
+    while (true) {
+      const { value, done } = await reader.read();
+      if (done) break;
+      res.write(Buffer.from(value));
+    }
+    res.end();
+  } catch (e) {
+    res.status(500).json({ error: String(e.message || e) });
+  }
+});
+
+app.get('/api/health', (req, res) => {
+  res.json({
+    ok: true,
+    port: PORT,
+    lan: `http://${LAN_HOST}:${PORT}`,
+    voice: ELEVEN_VOICE,
+    elevenConfigured: Boolean(ELEVEN_KEY),
+    pairings: pairings.size
+  });
+});
+
+// ---------- HTTP + WS ----------
+const server = http.createServer(app);
+const wss = new WebSocketServer({ server, path: '/ws' });
+
+wss.on('connection', (ws, req) => {
+  ws.role = null;
+  ws.code = null;
+
+  ws.on('message', (raw) => {
+    let msg;
+    try { msg = JSON.parse(raw.toString()); } catch { return; }
+
+    if (msg.type === 'hello') {
+      const code = String(msg.code || '');
+      const role = msg.role === 'phone' ? 'phone' : 'desktop';
+      if (!pairings.has(code)) {
+        // Allow desktop to register a fresh code; for phone require code to exist
+        if (role === 'phone') {
+          ws.send(JSON.stringify({ type: 'error', error: 'unknown pair code' }));
+          return ws.close();
+        }
+        pairings.set(code, { desktopWs: null, phoneWs: null, createdAt: Date.now() });
+      }
+      const p = pairings.get(code);
+      ws.role = role;
+      ws.code = code;
+      if (role === 'desktop') p.desktopWs = ws;
+      else p.phoneWs = ws;
+
+      ws.send(JSON.stringify({ type: 'hello-ok', role, code }));
+      // Notify counterpart
+      const other = role === 'desktop' ? p.phoneWs : p.desktopWs;
+      if (other && other.readyState === 1) {
+        other.send(JSON.stringify({ type: 'peer-joined', role }));
+        ws.send(JSON.stringify({ type: 'peer-ready' }));
+      }
+      return;
+    }
+
+    // Relay WebRTC signaling between paired peers
+    if (!ws.code || !pairings.has(ws.code)) return;
+    const p = pairings.get(ws.code);
+    const target = ws.role === 'desktop' ? p.phoneWs : p.desktopWs;
+    if (!target || target.readyState !== 1) return;
+
+    if (['offer', 'answer', 'ice', 'phone-status', 'desktop-status'].includes(msg.type)) {
+      target.send(JSON.stringify(msg));
+    }
+  });
+
+  ws.on('close', () => {
+    if (!ws.code || !pairings.has(ws.code)) return;
+    const p = pairings.get(ws.code);
+    if (ws.role === 'desktop' && p.desktopWs === ws) p.desktopWs = null;
+    if (ws.role === 'phone' && p.phoneWs === ws) p.phoneWs = null;
+    const other = ws.role === 'desktop' ? p.phoneWs : p.desktopWs;
+    if (other && other.readyState === 1) {
+      other.send(JSON.stringify({ type: 'peer-gone', role: ws.role }));
+    }
+    if (!p.desktopWs && !p.phoneWs) {
+      pairings.delete(ws.code);
+    }
+  });
+});
+
+// Reap stale pairings (>30 min, no peers)
+setInterval(() => {
+  const now = Date.now();
+  for (const [code, p] of pairings) {
+    if (!p.desktopWs && !p.phoneWs && now - p.createdAt > 30 * 60_000) {
+      pairings.delete(code);
+    }
+  }
+}, 60_000).unref();
+
+server.listen(PORT, '0.0.0.0', () => {
+  console.log(`Big Red listening on :${PORT}`);
+  console.log(`  desktop: http://localhost:${PORT}/`);
+  console.log(`  phone:   http://${LAN_HOST}:${PORT}/phone (use QR from desktop)`);
+
+  // Fire-and-forget warm-up so the first user request doesn't pay full cold-start.
+  runClaude('Reply with: ok', { timeoutMs: 240_000 })
+    .then(() => console.log('claude CLI warm'))
+    .catch((e) => console.log('warm-up skipped:', e.message));
+});

(oldest)  ·  back to Big Red  ·  feat(big-red): embeddable widget.js — floating launcher for 83c3c52 →