← 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
A .gitignoreA package-lock.jsonA package.jsonA public/desktop.htmlA public/index.htmlA public/phone.htmlA server.js
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 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 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 →