← back to Fleet Tv
initial scaffold (gitify-all 2026-05-06)
8537479afd969f87c00c76068f468d84f091b20b · 2026-05-06 10:25:18 -0700 · Steve Abrams
Files touched
A .gitignoreA package-lock.jsonA package.jsonA public/atlas.htmlA public/favicon.svgA public/index.htmlA public/matrix.htmlA public/mosaic.htmlA public/search.htmlA public/stats.htmlA public/today.htmlA public/widget/embed.htmlA public/widget/spotlight.htmlA server.js
Diff
commit 8537479afd969f87c00c76068f468d84f091b20b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 6 10:25:18 2026 -0700
initial scaffold (gitify-all 2026-05-06)
---
.gitignore | 12 +
package-lock.json | 842 +++++++++++++++++++++++++++++++++++++++++++
package.json | 8 +
public/atlas.html | 216 +++++++++++
public/favicon.svg | 4 +
public/index.html | 355 ++++++++++++++++++
public/matrix.html | 318 ++++++++++++++++
public/mosaic.html | 223 ++++++++++++
public/search.html | 274 ++++++++++++++
public/stats.html | 268 ++++++++++++++
public/today.html | 288 +++++++++++++++
public/widget/embed.html | 189 ++++++++++
public/widget/spotlight.html | 196 ++++++++++
server.js | 564 +++++++++++++++++++++++++++++
14 files changed, 3757 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7e6a9c3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+node_modules/
+.env
+.env.local
+.env.*.local
+.env.*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+*.bak
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..9686547
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,842 @@
+{
+ "name": "fleet-tv",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "fleet-tv",
+ "version": "0.1.0",
+ "dependencies": {
+ "express": "^4.21.0"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.5",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
+ "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.15.1",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/body-parser/node_modules/qs": {
+ "version": "6.15.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
+ "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
+ "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.3",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.14.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.14.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
+ "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..1885ae6
--- /dev/null
+++ b/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "fleet-tv",
+ "version": "0.1.0",
+ "description": "Live wall display cycling every Steve-owned site — stadium scoreboard for personal SaaS.",
+ "main": "server.js",
+ "scripts": { "start": "node server.js" },
+ "dependencies": { "express": "^4.21.0" }
+}
diff --git a/public/atlas.html b/public/atlas.html
new file mode 100644
index 0000000..9fc3203
--- /dev/null
+++ b/public/atlas.html
@@ -0,0 +1,216 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
+<title>Fleet TV — Atlas</title>
+<style>
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+ :root {
+ --bg: #07080a;
+ --fg: #e8e6e0;
+ --dim: #6a6862;
+ --rule: rgba(255,255,255,0.08);
+ --pane: #11141a;
+ --accent: #c9a14b;
+ }
+ html, body { background: var(--bg); color: var(--fg); font-family: -apple-system, "Helvetica Neue", "Segoe UI", system-ui, sans-serif; min-height: 100vh; }
+
+ .head {
+ position: sticky; top: 0; z-index: 5;
+ display: flex; align-items: baseline; gap: 1.5rem;
+ padding: 1rem 1.5rem;
+ background: linear-gradient(180deg, rgba(7,8,10,0.94), rgba(7,8,10,0.6));
+ backdrop-filter: blur(10px);
+ border-bottom: 1px solid var(--rule);
+ }
+ .head h1 { font-size: 0.95rem; letter-spacing: 0.18em; text-transform: uppercase; font-weight: 500; }
+ .head .crumb { color: var(--dim); font-size: 0.78rem; letter-spacing: 0.16em; text-transform: uppercase; }
+ .head .crumb a { color: var(--dim); text-decoration: none; border-bottom: 1px solid transparent; }
+ .head .crumb a:hover { color: var(--fg); border-color: var(--rule); }
+ .head .stamp { margin-left: auto; color: var(--dim); font-size: 0.78rem; letter-spacing: 0.18em; text-transform: uppercase; font-variant-numeric: tabular-nums; }
+
+ /* — KPI strip — */
+ .kpis {
+ display: grid; gap: 0.6rem; padding: 1rem 1.5rem;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ }
+ .kpi {
+ background: var(--pane); border: 1px solid var(--rule); border-radius: 5px;
+ padding: 0.85rem 1rem;
+ }
+ .kpi .label { font-size: 0.66rem; letter-spacing: 0.22em; text-transform: uppercase; color: var(--dim); }
+ .kpi .value { font-size: 1.6rem; line-height: 1.1; margin-top: 0.25rem; font-weight: 300; letter-spacing: -0.01em; font-variant-numeric: tabular-nums; }
+ .kpi.ok .value { color: #6dbe7d; }
+ .kpi.warn .value { color: #d9a04a; }
+ .kpi.err .value { color: #d96a6a; }
+
+ /* — Categories — */
+ .cats { padding: 0.5rem 1.5rem 4rem; display: grid; gap: 1.25rem; }
+ .cat {
+ background: var(--pane); border: 1px solid var(--rule); border-radius: 6px;
+ overflow: hidden; border-left: 3px solid currentColor;
+ color: var(--accent);
+ }
+ .cat header {
+ padding: 0.85rem 1.25rem; border-bottom: 1px solid var(--rule);
+ display: flex; align-items: baseline; gap: 0.85rem; color: var(--fg);
+ }
+ .cat header .name { font-size: 0.85rem; letter-spacing: 0.2em; text-transform: uppercase; font-weight: 500; }
+ .cat header .count { color: var(--dim); font-size: 0.7rem; letter-spacing: 0.18em; text-transform: uppercase; }
+ .cat header .swatch { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
+
+ .tiles {
+ display: grid; gap: 0.5rem; padding: 0.75rem;
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+ }
+ .tile {
+ background: var(--bg); border: 1px solid var(--rule); border-radius: 4px;
+ padding: 0.75rem 0.85rem;
+ display: grid; gap: 0.4rem;
+ transition: background 0.2s ease, border-color 0.2s ease;
+ position: relative;
+ color: var(--fg);
+ }
+ .tile:hover { background: #15181f; border-color: rgba(255,255,255,0.18); }
+
+ .tile .top { display: flex; align-items: center; gap: 0.5rem; }
+ .tile .dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
+ .tile .dot.online { background: #6dbe7d; box-shadow: 0 0 6px rgba(109,190,125,0.5); }
+ .tile .dot.errored { background: #d96a6a; }
+ .tile .dot.stopped { background: #888; }
+ .tile .name { font-size: 0.85rem; line-height: 1.1; word-break: break-word; }
+
+ .tile .desc { font-size: 0.72rem; line-height: 1.4; color: var(--dim); }
+ .tile .desc.muted { font-style: italic; opacity: 0.5; }
+
+ .tile .meta {
+ display: flex; gap: 0.65rem; align-items: baseline;
+ color: var(--dim); font-size: 0.62rem; letter-spacing: 0.16em; text-transform: uppercase;
+ font-variant-numeric: tabular-nums;
+ border-top: 1px dashed var(--rule); padding-top: 0.4rem; margin-top: 0.1rem;
+ }
+ .tile .meta b { color: var(--fg); font-weight: 500; }
+ .tile .restart-warn { color: #d9a04a; }
+
+ .splash {
+ position: fixed; inset: 0; display: grid; place-items: center;
+ background: var(--bg); z-index: 10;
+ transition: opacity 0.5s ease;
+ }
+ .splash.hide { opacity: 0; pointer-events: none; }
+ .splash .mark {
+ font-size: 1rem; letter-spacing: 0.42em; text-transform: uppercase;
+ color: var(--accent);
+ animation: pulse 1.6s ease-in-out infinite;
+ }
+ @keyframes pulse { 0%,100% { opacity: 0.4; } 50% { opacity: 1; } }
+</style>
+</head>
+<body>
+
+<div class="splash" id="splash"><div class="mark">Atlas · scanning portfolio</div></div>
+
+<div class="head">
+ <h1>Fleet TV</h1>
+ <span class="crumb"><a href="/">wall</a> · <a href="/mosaic.html">mosaic</a> · <a href="/stats.html">stats</a> · <a href="/search.html">search</a> · <a href="/today.html">today</a> · atlas</span>
+ <div class="stamp" id="stamp">—</div>
+</div>
+
+<div class="kpis">
+ <div class="kpi"><div class="label">Total processes</div><div class="value" id="kpi-total">—</div></div>
+ <div class="kpi ok"><div class="label">Online</div><div class="value" id="kpi-online">—</div></div>
+ <div class="kpi err"><div class="label">Errored / stopped</div><div class="value" id="kpi-err">—</div></div>
+ <div class="kpi warn"><div class="label">Total restarts</div><div class="value" id="kpi-restarts">—</div></div>
+ <div class="kpi"><div class="label">RSS total</div><div class="value" id="kpi-mem">—</div></div>
+</div>
+
+<div class="cats" id="cats"></div>
+
+<script>
+(() => {
+ const $ = (id) => document.getElementById(id);
+
+ function humanUptime(ms) {
+ if (!ms || ms < 0) return '—';
+ const s = Math.floor(ms / 1000);
+ if (s < 60) return s + 's';
+ if (s < 3600) return Math.floor(s/60) + 'm';
+ if (s < 86400) return Math.floor(s/3600) + 'h';
+ return Math.floor(s/86400) + 'd';
+ }
+
+ async function load() {
+ $('splash').classList.remove('hide');
+ try {
+ const r = await fetch('/api/atlas', { cache: 'no-store' });
+ const j = await r.json();
+
+ $('kpi-total').textContent = j.summary.total;
+ $('kpi-online').textContent = j.summary.online;
+ $('kpi-err').textContent = j.summary.errored;
+ $('kpi-restarts').textContent = j.summary.totalRestarts.toLocaleString();
+ $('kpi-mem').textContent = j.summary.totalMemMb >= 1024
+ ? (j.summary.totalMemMb / 1024).toFixed(1) + 'gb'
+ : j.summary.totalMemMb + 'mb';
+ $('stamp').textContent = new Date(j.generatedAt).toTimeString().slice(0,8) + ' · R refresh';
+
+ const cats = $('cats');
+ cats.innerHTML = '';
+ for (const c of j.categories) {
+ const sec = document.createElement('section');
+ sec.className = 'cat';
+ sec.style.color = c.accent;
+ sec.innerHTML = `
+ <header>
+ <span class="swatch"></span>
+ <span class="name">${escape(c.name)}</span>
+ <span class="count">${c.projects.length} ${c.projects.length === 1 ? 'process' : 'processes'}</span>
+ </header>
+ <div class="tiles">
+ ${c.projects.map(p => `
+ <div class="tile">
+ <div class="top">
+ <span class="dot ${escape(p.status)}"></span>
+ <span class="name">${escape(p.name)}</span>
+ </div>
+ <div class="desc ${p.description ? '' : 'muted'}">${p.description ? escape(p.description) : '(no description on file)'}</div>
+ <div class="meta">
+ <span>${escape(p.status)}</span>
+ <span>${humanUptime(p.uptimeMs)}</span>
+ <span><b>${p.memMb}</b>mb</span>
+ <span class="${p.restartTime > 20 ? 'restart-warn' : ''}"><b>${p.restartTime}</b>↺</span>
+ </div>
+ </div>
+ `).join('')}
+ </div>
+ `;
+ cats.appendChild(sec);
+ }
+ } catch (e) {
+ console.error(e);
+ } finally {
+ setTimeout(() => $('splash').classList.add('hide'), 250);
+ }
+ }
+
+ document.addEventListener('keydown', (e) => {
+ if (e.key.toLowerCase() === 'r') load();
+ if (e.key.toLowerCase() === 'w') location.href = '/';
+ if (e.key.toLowerCase() === 'm') location.href = '/mosaic.html';
+ if (e.key.toLowerCase() === 's' && !e.metaKey && !e.ctrlKey) location.href = '/stats.html';
+ if (e.key.toLowerCase() === 't') location.href = '/today.html';
+ if (e.key === '/') { e.preventDefault(); location.href = '/search.html'; }
+ if (e.key.toLowerCase() === 'x') location.href = '/matrix.html';
+ });
+
+ function escape(s) {
+ return String(s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
+ }
+
+ load();
+ setInterval(load, 30 * 1000); // live-ish: re-scan pm2 every 30s
+})();
+</script>
+</body>
+</html>
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..83cf488
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+<rect width="32" height="32" rx="6" fill="#10b981"/>
+<text x="50%" y="55%" text-anchor="middle" dominant-baseline="middle" font-size="20" font-family="Apple Color Emoji, Segoe UI Emoji, sans-serif" fill="white">F</text>
+</svg>
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..a421e0a
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,355 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
+<title>Fleet TV — every site, on the wall</title>
+<style>
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+ :root {
+ --bg: #07080a;
+ --fg: #e8e6e0;
+ --dim: #6a6862;
+ --accent: #c9a14b;
+ --pane: rgba(12,14,18,0.85);
+ --rule: rgba(255,255,255,0.08);
+ --rotation-ms: 5000;
+ --xfade-ms: 600;
+ }
+ html, body { height: 100%; background: var(--bg); color: var(--fg); font-family: -apple-system, "Helvetica Neue", "Segoe UI", system-ui, sans-serif; overflow: hidden; }
+
+ .stage {
+ position: fixed; inset: 0;
+ background: var(--bg);
+ display: grid;
+ grid-template-rows: auto 1fr auto;
+ }
+
+ /* — Top ribbon — */
+ .ribbon {
+ display: flex; align-items: center; gap: 1.25rem;
+ padding: 0.75rem 1.25rem;
+ background: linear-gradient(180deg, rgba(0,0,0,0.55), rgba(0,0,0,0));
+ backdrop-filter: blur(8px);
+ border-bottom: 1px solid var(--rule);
+ z-index: 4;
+ }
+ .ribbon .badge {
+ width: 12px; height: 12px; border-radius: 50%;
+ background: var(--accent); box-shadow: 0 0 18px var(--accent);
+ transition: background 0.45s ease, box-shadow 0.45s ease;
+ }
+ .ribbon h1 { font-size: 0.95rem; letter-spacing: 0.18em; text-transform: uppercase; color: var(--fg); font-weight: 500; }
+ .ribbon .now {
+ margin-left: auto; display: flex; gap: 1.5rem; align-items: baseline; color: var(--dim);
+ font-variant-numeric: tabular-nums; font-size: 0.85rem; letter-spacing: 0.1em; text-transform: uppercase;
+ }
+ .ribbon .now strong { color: var(--fg); font-weight: 500; letter-spacing: 0.16em; }
+
+ /* — Iframe stage — */
+ .frame-wrap { position: relative; overflow: hidden; }
+ .frame-wrap iframe {
+ position: absolute; inset: 0;
+ width: 100%; height: 100%;
+ border: 0; background: var(--bg);
+ opacity: 0;
+ transition: opacity var(--xfade-ms) ease;
+ }
+ .frame-wrap iframe.live { opacity: 1; }
+ .scrim {
+ position: absolute; inset: 0; pointer-events: none;
+ background: radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.18) 100%);
+ }
+
+ /* — Lower-third caption (cinema-style) — */
+ .lower-third {
+ position: absolute; left: 0; right: 0; bottom: 0;
+ padding: 1rem 1.5rem 1.25rem;
+ display: grid; grid-template-columns: auto 1fr auto; gap: 1.25rem; align-items: end;
+ background: linear-gradient(0deg, rgba(0,0,0,0.78), rgba(0,0,0,0));
+ z-index: 3; pointer-events: none;
+ }
+ .lower-third .swatch {
+ width: 56px; height: 56px; border-radius: 4px;
+ background: var(--accent);
+ border: 1px solid rgba(255,255,255,0.2);
+ box-shadow: 0 0 24px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.05);
+ transition: background 0.45s ease;
+ }
+ .lower-third .name {
+ font-size: 1.85rem; line-height: 1.05; letter-spacing: 0.02em; color: #fff;
+ text-shadow: 0 2px 14px rgba(0,0,0,0.7);
+ }
+ .lower-third .url {
+ margin-top: 0.25rem; font-size: 0.75rem; color: rgba(255,255,255,0.6);
+ letter-spacing: 0.18em; text-transform: uppercase; font-variant-numeric: tabular-nums;
+ }
+ .lower-third .meta {
+ text-align: right; font-size: 0.75rem; color: rgba(255,255,255,0.6);
+ letter-spacing: 0.16em; text-transform: uppercase;
+ display: flex; flex-direction: column; gap: 0.25rem;
+ }
+ .lower-third .meta b { color: #fff; font-weight: 500; font-size: 0.95rem; letter-spacing: 0.12em; }
+
+ /* — Footer ticker — */
+ .ticker {
+ padding: 0.65rem 1.25rem;
+ border-top: 1px solid var(--rule);
+ background: rgba(0,0,0,0.55);
+ color: var(--dim); font-size: 0.75rem; letter-spacing: 0.18em; text-transform: uppercase;
+ display: flex; gap: 1.5rem; align-items: center; justify-content: space-between;
+ z-index: 2;
+ }
+ .ticker .progress {
+ flex: 1; max-width: 480px; height: 2px;
+ background: rgba(255,255,255,0.06); border-radius: 1px; overflow: hidden;
+ margin: 0 1.25rem;
+ }
+ .ticker .bar {
+ height: 100%; width: 0; background: var(--accent);
+ transition: background 0.45s ease;
+ }
+ .ticker .legend { display: flex; gap: 1.5rem; }
+ .ticker .legend span { color: var(--dim); }
+ .ticker .legend b { color: var(--fg); font-weight: 500; margin-left: 0.4rem; }
+
+ /* — Idle / loading state — */
+ .splash {
+ position: fixed; inset: 0; display: grid; place-items: center;
+ background: var(--bg); z-index: 10;
+ transition: opacity 0.5s ease;
+ }
+ .splash.hide { opacity: 0; pointer-events: none; }
+ .splash .mark {
+ font-size: 1rem; letter-spacing: 0.42em; text-transform: uppercase;
+ color: var(--accent);
+ animation: pulse 1.6s ease-in-out infinite;
+ }
+ @keyframes pulse { 0%,100% { opacity: 0.4; } 50% { opacity: 1; } }
+
+ /* — Pause overlay — */
+ .paused-tag {
+ position: fixed; top: 1rem; right: 1.25rem; z-index: 20;
+ padding: 0.4rem 0.85rem; background: rgba(0,0,0,0.7);
+ color: var(--accent); border: 1px solid var(--accent);
+ font-size: 0.7rem; letter-spacing: 0.3em; text-transform: uppercase;
+ opacity: 0; transition: opacity 0.3s ease;
+ }
+ .paused-tag.show { opacity: 1; }
+</style>
+</head>
+<body>
+
+<div class="splash" id="splash"><div class="mark">Fleet TV · loading channels</div></div>
+
+<div class="paused-tag" id="paused-tag">paused — press space</div>
+
+<div class="stage">
+
+ <div class="ribbon">
+ <span class="badge" id="badge"></span>
+ <h1>Fleet TV</h1>
+ <div class="now">
+ <span>channel <strong id="ch-idx">—</strong> of <strong id="ch-total">—</strong></span>
+ <span><strong id="clock">—</strong></span>
+ </div>
+ </div>
+
+ <div class="frame-wrap" id="frame-wrap">
+ <iframe id="iframe-a" referrerpolicy="no-referrer" loading="lazy"></iframe>
+ <iframe id="iframe-b" referrerpolicy="no-referrer" loading="lazy"></iframe>
+
+ <div class="scrim"></div>
+
+ <div class="lower-third">
+ <div class="swatch" id="swatch"></div>
+ <div>
+ <div class="name" id="ch-name">—</div>
+ <div class="url" id="ch-url">—</div>
+ </div>
+ <div class="meta">
+ <span id="meta-label">kind</span>
+ <b id="ch-kind">—</b>
+ </div>
+ </div>
+ </div>
+
+ <div class="ticker">
+ <div class="legend">
+ <span>fleet · <b id="legend-total">—</b></span>
+ <span>uptime · <b id="legend-uptime">0s</b></span>
+ </div>
+ <div class="progress"><div class="bar" id="bar"></div></div>
+ <div class="legend"><span>next in <b id="legend-next">—</b></span></div>
+ </div>
+</div>
+
+<script>
+(() => {
+ const ROTATE_MS = 5000;
+ const XFADE_MS = 600;
+ let channels = [];
+ let idx = 0;
+ let active = 'a'; // which iframe is currently showing
+ let paused = false;
+ let nextTimer = null;
+ let progressRaf = null;
+ let lastSwitchAt = 0;
+ const startedAt = Date.now();
+
+ const $ = (id) => document.getElementById(id);
+ const splash = $('splash');
+ const ifA = $('iframe-a');
+ const ifB = $('iframe-b');
+ const badge = $('badge');
+ const swatch = $('swatch');
+ const bar = $('bar');
+
+ async function loadChannels() {
+ try {
+ const r = await fetch('/api/channels', { cache: 'no-store' });
+ const j = await r.json();
+ channels = j.channels || [];
+ $('ch-total').textContent = String(channels.length);
+ $('legend-total').textContent = String(channels.length);
+ if (!channels.length) {
+ $('ch-name').textContent = 'no channels';
+ return;
+ }
+ // Preload first two so the very first transition is also smooth
+ ifA.src = channels[0].url;
+ ifB.src = channels[1 % channels.length].url;
+ ifA.classList.add('live');
+ paint(channels[0]);
+ $('ch-idx').textContent = '1';
+ lastSwitchAt = Date.now();
+ setTimeout(() => splash.classList.add('hide'), 700);
+ schedule();
+ } catch (e) {
+ $('ch-name').textContent = 'channels failed to load';
+ console.error(e);
+ }
+ }
+
+ function paint(ch) {
+ $('ch-name').textContent = ch.name;
+ $('ch-url').textContent = ch.url.replace(/^https?:\/\//, '').replace(/\/$/, '');
+ if (typeof ch.productCount === 'number') {
+ $('meta-label').textContent = 'products';
+ $('ch-kind').textContent = ch.productCount.toLocaleString();
+ } else {
+ $('meta-label').textContent = 'kind';
+ $('ch-kind').textContent = ch.kind || '—';
+ }
+ swatch.style.background = ch.accent || '#888';
+ badge.style.background = ch.accent || '#888';
+ badge.style.boxShadow = '0 0 18px ' + (ch.accent || '#888');
+ bar.style.background = ch.accent || '#888';
+ }
+
+ function rotate() {
+ if (!channels.length || paused) return;
+ idx = (idx + 1) % channels.length;
+ const next = channels[idx];
+ const nextIdx = (idx + 1) % channels.length;
+ const incoming = active === 'a' ? ifB : ifA;
+ const outgoing = active === 'a' ? ifA : ifB;
+
+ incoming.src = next.url;
+ incoming.classList.add('live');
+ outgoing.classList.remove('live');
+
+ paint(next);
+ $('ch-idx').textContent = String(idx + 1);
+
+ // After the cross-fade, preload the iframe AFTER the next one (warm cache, smooth chain)
+ setTimeout(() => {
+ outgoing.src = channels[nextIdx].url;
+ }, XFADE_MS + 50);
+
+ active = active === 'a' ? 'b' : 'a';
+ lastSwitchAt = Date.now();
+ }
+
+ function schedule() {
+ clearTimeout(nextTimer);
+ nextTimer = setTimeout(() => { rotate(); schedule(); }, ROTATE_MS);
+ }
+
+ function tick() {
+ const elapsed = Math.min(ROTATE_MS, Date.now() - lastSwitchAt);
+ const pct = paused ? bar.style.width : (elapsed / ROTATE_MS * 100) + '%';
+ bar.style.width = pct;
+ $('legend-next').textContent = paused
+ ? '— paused —'
+ : Math.max(0, Math.ceil((ROTATE_MS - elapsed) / 1000)) + 's';
+ $('legend-uptime').textContent = humanUptime(Math.floor((Date.now() - startedAt) / 1000));
+ const now = new Date();
+ $('clock').textContent = now.toTimeString().slice(0, 8);
+ progressRaf = requestAnimationFrame(tick);
+ }
+
+ function humanUptime(s) {
+ if (s < 60) return s + 's';
+ if (s < 3600) return Math.floor(s/60) + 'm ' + (s%60) + 's';
+ return Math.floor(s/3600) + 'h ' + Math.floor((s%3600)/60) + 'm';
+ }
+
+ // — Keyboard: space pauses, ←/→ steps, R reloads channel list — //
+ document.addEventListener('keydown', (e) => {
+ if (e.code === 'Space') {
+ e.preventDefault();
+ paused = !paused;
+ $('paused-tag').classList.toggle('show', paused);
+ if (paused) clearTimeout(nextTimer);
+ else { lastSwitchAt = Date.now(); schedule(); }
+ } else if (e.code === 'ArrowRight') {
+ clearTimeout(nextTimer); rotate(); schedule();
+ } else if (e.code === 'ArrowLeft') {
+ clearTimeout(nextTimer);
+ idx = (idx - 2 + channels.length) % channels.length; // -2 because rotate() adds 1
+ rotate(); schedule();
+ } else if (e.key.toLowerCase() === 'r') {
+ loadChannels();
+ } else if (e.key.toLowerCase() === 'f') {
+ if (document.fullscreenElement) document.exitFullscreen();
+ else document.documentElement.requestFullscreen();
+ } else if (e.key.toLowerCase() === 'm') {
+ location.href = '/mosaic.html';
+ } else if (e.key.toLowerCase() === 's') {
+ location.href = '/stats.html';
+ } else if (e.key === '/') {
+ e.preventDefault();
+ location.href = '/search.html';
+ } else if (e.key.toLowerCase() === 't') {
+ location.href = '/today.html';
+ } else if (e.key.toLowerCase() === 'a') {
+ location.href = '/atlas.html';
+ } else if (e.key.toLowerCase() === 'x') {
+ location.href = '/matrix.html';
+ }
+ });
+
+ // — Refresh channel list every 60s so newly-started pm2 sites flow in automatically —
+ setInterval(async () => {
+ try {
+ const r = await fetch('/api/channels', { cache: 'no-store' });
+ const j = await r.json();
+ if (j.channels && j.channels.length) {
+ const cur = channels[idx]?.name;
+ channels = j.channels;
+ $('ch-total').textContent = String(channels.length);
+ $('legend-total').textContent = String(channels.length);
+ // re-anchor idx to the same name if still present
+ const newIdx = channels.findIndex(c => c.name === cur);
+ if (newIdx >= 0) idx = newIdx;
+ }
+ } catch {}
+ }, 60_000);
+
+ loadChannels();
+ tick();
+})();
+</script>
+</body>
+</html>
diff --git a/public/matrix.html b/public/matrix.html
new file mode 100644
index 0000000..6be92c1
--- /dev/null
+++ b/public/matrix.html
@@ -0,0 +1,318 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
+<title>Fleet TV — Matrix</title>
+<style>
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+ :root {
+ --bg: #07080a;
+ --fg: #e8e6e0;
+ --dim: #6a6862;
+ --rule: rgba(255,255,255,0.08);
+ --accent: #c9a14b;
+ }
+ html, body { height: 100%; background: var(--bg); color: var(--fg); font-family: -apple-system, "Helvetica Neue", "Segoe UI", system-ui, sans-serif; overflow: hidden; }
+
+ .stage {
+ height: 100vh; display: grid; grid-template-rows: auto 1fr auto;
+ }
+
+ .ribbon {
+ display: flex; align-items: center; gap: 1.25rem;
+ padding: 0.65rem 1.25rem;
+ background: linear-gradient(180deg, rgba(0,0,0,0.55), rgba(0,0,0,0));
+ backdrop-filter: blur(8px);
+ border-bottom: 1px solid var(--rule);
+ z-index: 4;
+ }
+ .ribbon h1 { font-size: 0.85rem; letter-spacing: 0.18em; text-transform: uppercase; font-weight: 500; }
+ .ribbon .crumb { color: var(--dim); font-size: 0.72rem; letter-spacing: 0.16em; text-transform: uppercase; }
+ .ribbon .crumb a { color: var(--dim); text-decoration: none; border-bottom: 1px solid transparent; }
+ .ribbon .crumb a:hover { color: var(--fg); border-color: var(--rule); }
+ .ribbon .now { margin-left: auto; color: var(--dim); font-size: 0.72rem; letter-spacing: 0.18em; text-transform: uppercase; font-variant-numeric: tabular-nums; }
+ .ribbon .now strong { color: var(--fg); font-weight: 500; }
+
+ /* — Matrix grid: 4 cols × 2 rows = 8 tiles. Adjusts to viewport. — */
+ .grid {
+ display: grid; gap: 4px;
+ padding: 4px;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ grid-template-rows: repeat(2, minmax(0, 1fr));
+ min-height: 0;
+ }
+
+ .tile {
+ position: relative; overflow: hidden;
+ background: #0c0e12;
+ border: 1px solid var(--rule);
+ border-radius: 3px;
+ }
+ .tile iframe {
+ position: absolute; inset: 0; width: 100%; height: 100%;
+ border: 0;
+ opacity: 0;
+ transition: opacity 0.4s ease;
+ }
+ .tile iframe.live { opacity: 1; }
+
+ .tile .pill {
+ position: absolute; top: 0.4rem; left: 0.4rem; z-index: 2;
+ padding: 0.18rem 0.55rem 0.2rem;
+ font-size: 0.58rem; letter-spacing: 0.18em; text-transform: uppercase;
+ background: rgba(0,0,0,0.78);
+ border: 1px solid currentColor;
+ border-radius: 999px;
+ color: #fff;
+ backdrop-filter: blur(4px);
+ pointer-events: none;
+ }
+ .tile .open {
+ position: absolute; top: 0.4rem; right: 0.4rem; z-index: 2;
+ padding: 0.18rem 0.55rem 0.2rem;
+ font-size: 0.58rem; letter-spacing: 0.18em; text-transform: uppercase;
+ background: rgba(0,0,0,0.78);
+ border: 1px solid var(--rule);
+ border-radius: 999px;
+ color: var(--fg);
+ text-decoration: none;
+ transition: border-color 0.2s ease;
+ }
+ .tile .open:hover { border-color: var(--accent); color: var(--accent); }
+ .tile .progress {
+ position: absolute; left: 0; right: 0; bottom: 0; height: 2px; z-index: 1;
+ background: rgba(255,255,255,0.04);
+ }
+ .tile .progress > div {
+ height: 100%; width: 0; background: var(--accent);
+ transition: background 0.4s ease;
+ }
+ .tile .scrim {
+ position: absolute; inset: 0; pointer-events: none;
+ background: radial-gradient(ellipse at center, transparent 60%, rgba(0,0,0,0.18) 100%);
+ }
+
+ .ticker {
+ padding: 0.5rem 1.25rem;
+ border-top: 1px solid var(--rule);
+ background: rgba(0,0,0,0.5);
+ color: var(--dim); font-size: 0.7rem; letter-spacing: 0.18em; text-transform: uppercase;
+ display: flex; gap: 1.5rem; align-items: center; justify-content: space-between;
+ }
+ .ticker b { color: var(--fg); font-weight: 500; }
+ .ticker .keys { color: var(--dim); }
+ .ticker .keys kbd {
+ background: rgba(255,255,255,0.04); border: 1px solid var(--rule); border-radius: 3px;
+ padding: 0.1rem 0.4rem; margin-right: 0.15rem;
+ font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 0.65rem; color: var(--fg);
+ }
+
+ .splash {
+ position: fixed; inset: 0; display: grid; place-items: center;
+ background: var(--bg); z-index: 10;
+ transition: opacity 0.5s ease;
+ }
+ .splash.hide { opacity: 0; pointer-events: none; }
+ .splash .mark {
+ font-size: 1rem; letter-spacing: 0.42em; text-transform: uppercase;
+ color: var(--accent);
+ animation: pulse 1.6s ease-in-out infinite;
+ }
+ @keyframes pulse { 0%,100% { opacity: 0.4; } 50% { opacity: 1; } }
+
+ /* — Tile-count toggle — */
+ .stage[data-cols="3"] .grid { grid-template-columns: repeat(3, 1fr); grid-template-rows: repeat(3, 1fr); }
+ .stage[data-cols="2"] .grid { grid-template-columns: repeat(2, 1fr); grid-template-rows: repeat(2, 1fr); }
+ .stage[data-cols="5"] .grid { grid-template-columns: repeat(5, 1fr); grid-template-rows: repeat(2, 1fr); }
+</style>
+</head>
+<body>
+
+<div class="splash" id="splash"><div class="mark">Matrix · loading channels</div></div>
+
+<div class="stage" id="stage" data-cols="4">
+
+ <div class="ribbon">
+ <h1>Fleet TV</h1>
+ <span class="crumb"><a href="/">wall</a> · <a href="/mosaic.html">mosaic</a> · <a href="/stats.html">stats</a> · <a href="/search.html">search</a> · <a href="/today.html">today</a> · <a href="/atlas.html">atlas</a> · matrix</span>
+ <div class="now">
+ tiles <strong id="tiles-count">8</strong> · pool <strong id="pool-count">—</strong> · <strong id="clock">—</strong>
+ </div>
+ </div>
+
+ <div class="grid" id="grid"></div>
+
+ <div class="ticker">
+ <span>matrix · 8 fleet sites · independent rotation 15s · staggered phase</span>
+ <span class="keys"><kbd>2</kbd><kbd>3</kbd><kbd>4</kbd><kbd>5</kbd> tiles · <kbd>R</kbd> reload · <kbd>Space</kbd> pause · <kbd>W</kbd>all <kbd>M</kbd>osaic <kbd>S</kbd>tats <kbd>T</kbd>oday <kbd>A</kbd>tlas <kbd>/</kbd>search</span>
+ </div>
+
+</div>
+
+<script>
+(() => {
+ const ROTATE_MS = 15000;
+ let channels = [];
+ let tiles = [];
+ let paused = false;
+ let pickIdx = 0; // global cursor through the channel pool
+ const $ = (id) => document.getElementById(id);
+
+ function nextChannel() {
+ if (!channels.length) return null;
+ const c = channels[pickIdx % channels.length];
+ pickIdx = (pickIdx + 1) % channels.length;
+ return c;
+ }
+
+ function buildTiles(n) {
+ const grid = $('grid');
+ grid.innerHTML = '';
+ tiles.forEach(t => clearTimeout(t._timer));
+ tiles = [];
+ pickIdx = 0;
+ for (let i = 0; i < n; i++) {
+ const tile = document.createElement('div');
+ tile.className = 'tile';
+ tile.innerHTML = `
+ <span class="pill">—</span>
+ <a class="open" target="_blank" rel="noopener" href="#">open ↗</a>
+ <iframe loading="lazy" referrerpolicy="no-referrer"></iframe>
+ <iframe loading="lazy" referrerpolicy="no-referrer"></iframe>
+ <div class="scrim"></div>
+ <div class="progress"><div></div></div>
+ `;
+ grid.appendChild(tile);
+ const t = {
+ el: tile,
+ pill: tile.querySelector('.pill'),
+ open: tile.querySelector('.open'),
+ ifA: tile.querySelectorAll('iframe')[0],
+ ifB: tile.querySelectorAll('iframe')[1],
+ bar: tile.querySelector('.progress > div'),
+ active: 'a',
+ startedAt: 0,
+ _timer: null,
+ _channel: null,
+ };
+ tiles.push(t);
+
+ // Initial channel + staggered first rotation
+ const ch = nextChannel(); if (!ch) continue;
+ t._channel = ch;
+ t.ifA.src = ch.url;
+ t.ifA.classList.add('live');
+ paint(t, ch);
+ // Stagger the first rotation so tiles don't all flip at the same instant
+ const phase = Math.floor((ROTATE_MS / n) * i);
+ t.startedAt = Date.now() - phase;
+ scheduleRotation(t);
+ }
+ $('tiles-count').textContent = String(n);
+ }
+
+ function paint(t, ch) {
+ t._channel = ch;
+ t.pill.textContent = ch.name.replace(/wallpaper$|wallcoverings?$|walls$/i, m => '·' + m);
+ t.pill.style.color = ch.accent || '#c9a14b';
+ t.bar.style.background = ch.accent || '#c9a14b';
+ t.open.href = ch.url;
+ }
+
+ function rotate(t) {
+ if (paused) { scheduleRotation(t); return; }
+ const ch = nextChannel(); if (!ch) return;
+ const incoming = t.active === 'a' ? t.ifB : t.ifA;
+ const outgoing = t.active === 'a' ? t.ifA : t.ifB;
+ incoming.src = ch.url;
+ incoming.classList.add('live');
+ outgoing.classList.remove('live');
+ t.active = t.active === 'a' ? 'b' : 'a';
+ paint(t, ch);
+ t.startedAt = Date.now();
+ scheduleRotation(t);
+ }
+
+ function scheduleRotation(t) {
+ clearTimeout(t._timer);
+ const elapsed = Date.now() - t.startedAt;
+ const remain = Math.max(500, ROTATE_MS - elapsed);
+ t._timer = setTimeout(() => rotate(t), remain);
+ }
+
+ function tickProgress() {
+ const now = Date.now();
+ for (const t of tiles) {
+ const elapsed = Math.min(ROTATE_MS, now - t.startedAt);
+ t.bar.style.width = (elapsed / ROTATE_MS * 100) + '%';
+ }
+ $('clock').textContent = new Date().toTimeString().slice(0,8);
+ requestAnimationFrame(tickProgress);
+ }
+
+ async function loadChannels() {
+ const r = await fetch('/api/channels', { cache: 'no-store' });
+ const j = await r.json();
+ channels = j.channels || [];
+ $('pool-count').textContent = String(channels.length);
+ if (!channels.length) {
+ $('splash').querySelector('.mark').textContent = 'no channels available';
+ return;
+ }
+ // Shuffle so different visits show different sets initially
+ for (let i = channels.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [channels[i], channels[j]] = [channels[j], channels[i]];
+ }
+ setTimeout(() => $('splash').classList.add('hide'), 300);
+ buildTiles(8);
+ }
+
+ // Keyboard: number keys swap tile counts; space pauses; nav letters
+ document.addEventListener('keydown', (e) => {
+ if (['2','3','4','5'].includes(e.key)) {
+ const n = parseInt(e.key, 10) ** 2 / (e.key === '5' ? 2.5 : (e.key === '2' ? 1 : (e.key === '3' ? 1 : 1.6)));
+ // Direct mapping is cleaner — 2→4, 3→6, 4→8, 5→10
+ const map = { '2': 4, '3': 6, '4': 8, '5': 10 };
+ const cols = parseInt(e.key, 10);
+ $('stage').dataset.cols = String(cols);
+ buildTiles(map[e.key]);
+ } else if (e.code === 'Space') {
+ e.preventDefault();
+ paused = !paused;
+ } else if (e.key.toLowerCase() === 'r') {
+ loadChannels();
+ } else if (e.key.toLowerCase() === 'w') {
+ location.href = '/';
+ } else if (e.key.toLowerCase() === 'm') {
+ location.href = '/mosaic.html';
+ } else if (e.key.toLowerCase() === 's' && !e.metaKey && !e.ctrlKey) {
+ location.href = '/stats.html';
+ } else if (e.key.toLowerCase() === 't') {
+ location.href = '/today.html';
+ } else if (e.key.toLowerCase() === 'a') {
+ location.href = '/atlas.html';
+ } else if (e.key === '/') {
+ e.preventDefault(); location.href = '/search.html';
+ }
+ });
+
+ // Refresh channel pool every 60s so newly-started sites appear
+ setInterval(async () => {
+ try {
+ const r = await fetch('/api/channels', { cache: 'no-store' });
+ const j = await r.json();
+ if (j.channels && j.channels.length) {
+ channels = j.channels;
+ $('pool-count').textContent = String(channels.length);
+ }
+ } catch {}
+ }, 60_000);
+
+ loadChannels();
+ tickProgress();
+})();
+</script>
+</body>
+</html>
diff --git a/public/mosaic.html b/public/mosaic.html
new file mode 100644
index 0000000..c760b84
--- /dev/null
+++ b/public/mosaic.html
@@ -0,0 +1,223 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
+<title>Fleet TV — Mosaic</title>
+<style>
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+ :root {
+ --bg: #07080a;
+ --fg: #e8e6e0;
+ --dim: #6a6862;
+ --rule: rgba(255,255,255,0.08);
+ }
+ html, body { background: var(--bg); color: var(--fg); font-family: -apple-system, "Helvetica Neue", "Segoe UI", system-ui, sans-serif; }
+ body { padding: 0; min-height: 100vh; }
+
+ .head {
+ position: sticky; top: 0; z-index: 5;
+ display: flex; align-items: baseline; gap: 1.5rem;
+ padding: 1rem 1.5rem;
+ background: linear-gradient(180deg, rgba(7,8,10,0.92), rgba(7,8,10,0.6));
+ backdrop-filter: blur(10px);
+ border-bottom: 1px solid var(--rule);
+ }
+ .head h1 { font-size: 0.95rem; letter-spacing: 0.18em; text-transform: uppercase; font-weight: 500; }
+ .head .crumb { color: var(--dim); font-size: 0.78rem; letter-spacing: 0.16em; text-transform: uppercase; }
+ .head .crumb a { color: var(--dim); text-decoration: none; border-bottom: 1px solid transparent; }
+ .head .crumb a:hover { color: var(--fg); border-color: var(--rule); }
+ .head .stats { margin-left: auto; display: flex; gap: 1.5rem; color: var(--dim); font-size: 0.78rem; letter-spacing: 0.16em; text-transform: uppercase; }
+ .head .stats b { color: var(--fg); font-weight: 500; }
+
+ .grid {
+ columns: 5 220px;
+ column-gap: 0.6rem;
+ padding: 0.6rem;
+ }
+
+ figure {
+ break-inside: avoid;
+ margin: 0 0 0.6rem;
+ position: relative;
+ overflow: hidden;
+ border-radius: 4px;
+ background: #1a1c20;
+ cursor: pointer;
+ transform: translateY(8px);
+ opacity: 0;
+ animation: fadeIn 0.6s ease forwards;
+ }
+ @keyframes fadeIn { to { transform: translateY(0); opacity: 1; } }
+
+ figure img {
+ display: block; width: 100%; height: auto;
+ transition: transform 0.6s ease;
+ }
+ figure:hover img { transform: scale(1.04); }
+
+ figure figcaption {
+ position: absolute; left: 0; right: 0; bottom: 0;
+ padding: 0.65rem 0.8rem 0.55rem;
+ background: linear-gradient(0deg, rgba(0,0,0,0.85), rgba(0,0,0,0));
+ color: #fff;
+ transform: translateY(105%);
+ transition: transform 0.35s ease;
+ }
+ figure:hover figcaption { transform: translateY(0); }
+ figure figcaption .t { font-size: 0.78rem; line-height: 1.25; max-height: 2.4em; overflow: hidden; }
+ figure figcaption .v { display: block; margin-top: 0.2rem; font-size: 0.62rem; letter-spacing: 0.18em; text-transform: uppercase; opacity: 0.65; }
+
+ .pill {
+ position: absolute; top: 0.5rem; left: 0.5rem;
+ padding: 0.18rem 0.55rem 0.2rem;
+ font-size: 0.6rem; letter-spacing: 0.18em; text-transform: uppercase;
+ background: rgba(0,0,0,0.7);
+ border: 1px solid currentColor;
+ border-radius: 999px;
+ color: #fff;
+ backdrop-filter: blur(4px);
+ }
+
+ .splash {
+ position: fixed; inset: 0; display: grid; place-items: center;
+ background: var(--bg); z-index: 10;
+ transition: opacity 0.5s ease;
+ }
+ .splash.hide { opacity: 0; pointer-events: none; }
+ .splash .mark {
+ font-size: 1rem; letter-spacing: 0.42em; text-transform: uppercase;
+ color: #c9a14b;
+ animation: pulse 1.6s ease-in-out infinite;
+ }
+ @keyframes pulse { 0%,100% { opacity: 0.4; } 50% { opacity: 1; } }
+
+ .empty { padding: 4rem; text-align: center; color: var(--dim); }
+
+ /* — Lightbox — */
+ .lightbox {
+ position: fixed; inset: 0; z-index: 50;
+ display: none; align-items: center; justify-content: center;
+ background: rgba(0,0,0,0.92); padding: 2rem;
+ }
+ .lightbox.open { display: flex; }
+ .lightbox img {
+ max-width: 92vw; max-height: 80vh;
+ border-radius: 4px;
+ box-shadow: 0 30px 100px rgba(0,0,0,0.7);
+ }
+ .lightbox .info {
+ position: absolute; bottom: 2rem; left: 50%; transform: translateX(-50%);
+ text-align: center; color: var(--fg);
+ background: rgba(20,22,26,0.92); padding: 1rem 1.5rem; border-radius: 4px;
+ backdrop-filter: blur(6px);
+ max-width: 80vw;
+ }
+ .lightbox .info .t { font-size: 1rem; line-height: 1.3; }
+ .lightbox .info .v { font-size: 0.7rem; letter-spacing: 0.18em; text-transform: uppercase; margin-top: 0.4rem; opacity: 0.7; }
+ .lightbox .info a { display: inline-block; margin-top: 0.6rem; color: #c9a14b; text-decoration: none; font-size: 0.75rem; letter-spacing: 0.16em; text-transform: uppercase; border-bottom: 1px solid currentColor; padding-bottom: 1px; }
+ .lightbox .close { position: absolute; top: 1.5rem; right: 1.5rem; color: var(--fg); font-size: 1rem; letter-spacing: 0.18em; text-transform: uppercase; cursor: pointer; }
+</style>
+</head>
+<body>
+
+<div class="splash" id="splash"><div class="mark">Mosaic · loading the fleet</div></div>
+
+<div class="head">
+ <h1>Fleet TV</h1>
+ <span class="crumb"><a href="/">← back to wall</a> · mosaic</span>
+ <div class="stats">
+ <span>products · <b id="stat-total">—</b></span>
+ <span>sites · <b id="stat-sites">—</b></span>
+ <span>refresh · <b id="stat-refresh">— · R</b></span>
+ </div>
+</div>
+
+<div class="grid" id="grid"></div>
+<div class="empty" id="empty" style="display:none">no products available — fleet may still be warming up</div>
+
+<div class="lightbox" id="lightbox">
+ <span class="close" id="lb-close">close ✕</span>
+ <img id="lb-img" alt="">
+ <div class="info">
+ <div class="t" id="lb-title">—</div>
+ <div class="v" id="lb-vendor">—</div>
+ <a id="lb-link" target="_blank" rel="noopener">view on site →</a>
+ </div>
+</div>
+
+<script>
+(() => {
+ const $ = (id) => document.getElementById(id);
+ const grid = $('grid'), splash = $('splash'), empty = $('empty');
+ const lightbox = $('lightbox'), lbImg = $('lb-img'), lbTitle = $('lb-title'),
+ lbVendor = $('lb-vendor'), lbLink = $('lb-link'), lbClose = $('lb-close');
+
+ async function load() {
+ grid.innerHTML = '';
+ splash.classList.remove('hide');
+ try {
+ const r = await fetch('/api/products-mosaic?per=6', { cache: 'no-store' });
+ const j = await r.json();
+ const sites = new Set(j.items.map(i => i.site));
+ $('stat-total').textContent = j.total.toLocaleString();
+ $('stat-sites').textContent = String(sites.size);
+ $('stat-refresh').textContent = new Date(j.generatedAt).toTimeString().slice(0,5) + ' · R';
+ if (!j.items.length) { empty.style.display = 'block'; }
+ else {
+ const frag = document.createDocumentFragment();
+ j.items.forEach((p, i) => {
+ const fig = document.createElement('figure');
+ fig.style.animationDelay = (Math.min(i, 80) * 22) + 'ms';
+ fig.dataset.idx = String(i);
+ fig.innerHTML = `
+ <span class="pill" style="color:${p.accent}">${p.site.replace(/wallpaper$|wallcoverings?$|walls$/i, m => '·' + m)}</span>
+ <img loading="lazy" referrerpolicy="no-referrer" src="${p.image}" alt="${escape(p.title)}" onerror="this.parentNode.style.display='none'">
+ <figcaption><span class="t">${escape(p.title)}</span><span class="v">${escape(p.vendor || p.site)}</span></figcaption>
+ `;
+ fig.addEventListener('click', () => openLightbox(p));
+ frag.appendChild(fig);
+ });
+ grid.appendChild(frag);
+ }
+ } catch (e) {
+ console.error(e);
+ empty.style.display = 'block';
+ empty.textContent = 'failed to load mosaic — ' + e.message;
+ } finally {
+ setTimeout(() => splash.classList.add('hide'), 250);
+ }
+ }
+
+ function openLightbox(p) {
+ lbImg.src = p.image;
+ lbTitle.textContent = p.title || '(untitled)';
+ lbVendor.textContent = (p.vendor || '') + (p.vendor && p.site ? ' · ' : '') + p.site;
+ lbLink.href = p.productUrl;
+ lightbox.classList.add('open');
+ }
+ lbClose.addEventListener('click', () => lightbox.classList.remove('open'));
+ lightbox.addEventListener('click', (e) => { if (e.target === lightbox) lightbox.classList.remove('open'); });
+
+ document.addEventListener('keydown', (e) => {
+ if (e.key.toLowerCase() === 'r') load();
+ if (e.key === 'Escape') lightbox.classList.remove('open');
+ if (e.key.toLowerCase() === 'w') location.href = '/';
+ if (e.key.toLowerCase() === 's') location.href = '/stats.html';
+ if (e.key === '/') { e.preventDefault(); location.href = '/search.html'; }
+ if (e.key.toLowerCase() === 't') location.href = '/today.html';
+ if (e.key.toLowerCase() === 'a') location.href = '/atlas.html';
+ if (e.key.toLowerCase() === 'x') location.href = '/matrix.html';
+ });
+
+ function escape(s) {
+ return String(s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
+ }
+
+ load();
+ // Auto-refresh every 5 minutes (newest products first when DW catalog updates)
+ setInterval(load, 5 * 60 * 1000);
+})();
+</script>
+</body>
+</html>
diff --git a/public/search.html b/public/search.html
new file mode 100644
index 0000000..c1828f9
--- /dev/null
+++ b/public/search.html
@@ -0,0 +1,274 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
+<title>Fleet TV — Search</title>
+<style>
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+ :root {
+ --bg: #07080a;
+ --fg: #e8e6e0;
+ --dim: #6a6862;
+ --accent: #c9a14b;
+ --pane: #11141a;
+ --rule: rgba(255,255,255,0.08);
+ }
+ html, body { background: var(--bg); color: var(--fg); font-family: -apple-system, "Helvetica Neue", "Segoe UI", system-ui, sans-serif; min-height: 100vh; }
+
+ .head {
+ position: sticky; top: 0; z-index: 5;
+ display: grid; grid-template-columns: auto 1fr auto; gap: 1.25rem; align-items: center;
+ padding: 1rem 1.5rem;
+ background: linear-gradient(180deg, rgba(7,8,10,0.94), rgba(7,8,10,0.6));
+ backdrop-filter: blur(10px);
+ border-bottom: 1px solid var(--rule);
+ }
+ .head .title { display: flex; gap: 1rem; align-items: baseline; }
+ .head h1 { font-size: 0.95rem; letter-spacing: 0.18em; text-transform: uppercase; font-weight: 500; }
+ .head .crumb { color: var(--dim); font-size: 0.78rem; letter-spacing: 0.16em; text-transform: uppercase; }
+ .head .crumb a { color: var(--dim); text-decoration: none; border-bottom: 1px solid transparent; }
+ .head .crumb a:hover { color: var(--fg); border-color: var(--rule); }
+
+ .search-box {
+ display: flex; gap: 0.5rem; align-items: center;
+ background: var(--pane); border: 1px solid var(--rule); border-radius: 4px;
+ padding: 0.5rem 0.85rem;
+ }
+ .search-box input {
+ flex: 1; background: transparent; border: 0; outline: none; color: var(--fg);
+ font-family: inherit; font-size: 0.95rem; letter-spacing: 0.02em;
+ }
+ .search-box input::placeholder { color: var(--dim); }
+ .search-box .glyph { color: var(--accent); font-size: 0.85rem; }
+ .search-box .clear {
+ background: transparent; border: 0; cursor: pointer; color: var(--dim);
+ font-size: 0.7rem; letter-spacing: 0.18em; text-transform: uppercase;
+ }
+ .search-box .clear:hover { color: var(--fg); }
+
+ .stamp { color: var(--dim); font-size: 0.75rem; letter-spacing: 0.18em; text-transform: uppercase; font-variant-numeric: tabular-nums; text-align: right; }
+ .stamp b { color: var(--fg); font-weight: 500; }
+
+ /* — Suggestion pills — */
+ .suggestions {
+ display: flex; flex-wrap: wrap; gap: 0.4rem;
+ padding: 0.85rem 1.5rem 0.5rem;
+ border-bottom: 1px solid var(--rule);
+ }
+ .suggestions .label { color: var(--dim); font-size: 0.7rem; letter-spacing: 0.22em; text-transform: uppercase; align-self: center; margin-right: 0.5rem; }
+ .suggestions button {
+ background: var(--pane); color: var(--fg); border: 1px solid var(--rule);
+ padding: 0.35rem 0.85rem; border-radius: 999px;
+ font-family: inherit; font-size: 0.78rem; letter-spacing: 0.04em;
+ cursor: pointer; transition: background 0.2s ease, border-color 0.2s ease;
+ }
+ .suggestions button:hover { background: #1a1d24; border-color: var(--accent); }
+
+ /* — Results grid — */
+ .grid {
+ columns: 5 220px;
+ column-gap: 0.6rem;
+ padding: 0.6rem;
+ }
+ figure {
+ break-inside: avoid;
+ margin: 0 0 0.6rem;
+ position: relative;
+ overflow: hidden;
+ border-radius: 4px;
+ background: #1a1c20;
+ cursor: pointer;
+ transform: translateY(8px);
+ opacity: 0;
+ animation: fadeIn 0.5s ease forwards;
+ }
+ @keyframes fadeIn { to { transform: translateY(0); opacity: 1; } }
+ figure img { display: block; width: 100%; height: auto; transition: transform 0.6s ease; }
+ figure:hover img { transform: scale(1.04); }
+ figure figcaption {
+ position: absolute; left: 0; right: 0; bottom: 0;
+ padding: 0.65rem 0.8rem 0.55rem;
+ background: linear-gradient(0deg, rgba(0,0,0,0.85), rgba(0,0,0,0));
+ color: #fff;
+ transform: translateY(105%);
+ transition: transform 0.35s ease;
+ }
+ figure:hover figcaption { transform: translateY(0); }
+ figure figcaption .t { font-size: 0.78rem; line-height: 1.25; max-height: 2.4em; overflow: hidden; }
+ figure figcaption .v { display: block; margin-top: 0.2rem; font-size: 0.62rem; letter-spacing: 0.18em; text-transform: uppercase; opacity: 0.65; }
+ .pill {
+ position: absolute; top: 0.5rem; left: 0.5rem;
+ padding: 0.18rem 0.55rem 0.2rem;
+ font-size: 0.6rem; letter-spacing: 0.18em; text-transform: uppercase;
+ background: rgba(0,0,0,0.7);
+ border: 1px solid currentColor;
+ border-radius: 999px;
+ color: #fff;
+ backdrop-filter: blur(4px);
+ }
+
+ .empty { padding: 4rem; text-align: center; color: var(--dim); font-size: 0.95rem; line-height: 1.6; }
+
+ .lightbox { position: fixed; inset: 0; z-index: 50; display: none; align-items: center; justify-content: center; background: rgba(0,0,0,0.92); padding: 2rem; }
+ .lightbox.open { display: flex; }
+ .lightbox img { max-width: 92vw; max-height: 80vh; border-radius: 4px; box-shadow: 0 30px 100px rgba(0,0,0,0.7); }
+ .lightbox .info { position: absolute; bottom: 2rem; left: 50%; transform: translateX(-50%); text-align: center; color: var(--fg); background: rgba(20,22,26,0.92); padding: 1rem 1.5rem; border-radius: 4px; backdrop-filter: blur(6px); max-width: 80vw; }
+ .lightbox .info .t { font-size: 1rem; line-height: 1.3; }
+ .lightbox .info .v { font-size: 0.7rem; letter-spacing: 0.18em; text-transform: uppercase; margin-top: 0.4rem; opacity: 0.7; }
+ .lightbox .info a { display: inline-block; margin-top: 0.6rem; color: #c9a14b; text-decoration: none; font-size: 0.75rem; letter-spacing: 0.16em; text-transform: uppercase; border-bottom: 1px solid currentColor; padding-bottom: 1px; }
+ .lightbox .close { position: absolute; top: 1.5rem; right: 1.5rem; color: var(--fg); font-size: 1rem; letter-spacing: 0.18em; text-transform: uppercase; cursor: pointer; }
+</style>
+</head>
+<body>
+
+<div class="head">
+ <div class="title">
+ <h1>Fleet TV</h1>
+ <span class="crumb"><a href="/">wall</a> · <a href="/mosaic.html">mosaic</a> · <a href="/stats.html">stats</a> · search</span>
+ </div>
+ <div class="search-box">
+ <span class="glyph">⌕</span>
+ <input id="q" type="text" autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="search across the fleet — flocked, art deco, kravet, gold, hollywood…">
+ <button class="clear" id="clear-btn">clear</button>
+ </div>
+ <div class="stamp" id="stamp">type to search</div>
+</div>
+
+<div class="suggestions">
+ <span class="label">try</span>
+ <button data-q="metallic">metallic</button>
+ <button data-q="art deco">art deco</button>
+ <button data-q="kravet">kravet</button>
+ <button data-q="gold">gold</button>
+ <button data-q="hollywood">hollywood</button>
+ <button data-q="paisley">paisley</button>
+ <button data-q="grass">grass</button>
+ <button data-q="silk">silk</button>
+ <button data-q="memphis">memphis</button>
+ <button data-q="velvet">velvet</button>
+ <button data-q="floral">floral</button>
+</div>
+
+<div class="grid" id="grid"></div>
+<div class="empty" id="empty">type a query above to fan out across all 41 fleet sites in parallel.<br>tip: keys / focus search · esc clear · r refresh</div>
+
+<div class="lightbox" id="lightbox">
+ <span class="close" id="lb-close">close ✕</span>
+ <img id="lb-img" alt="">
+ <div class="info">
+ <div class="t" id="lb-title">—</div>
+ <div class="v" id="lb-vendor">—</div>
+ <a id="lb-link" target="_blank" rel="noopener">view on site →</a>
+ </div>
+</div>
+
+<script>
+(() => {
+ const $ = (id) => document.getElementById(id);
+ const grid = $('grid'), empty = $('empty');
+ const lightbox = $('lightbox'), lbImg = $('lb-img'), lbTitle = $('lb-title'),
+ lbVendor = $('lb-vendor'), lbLink = $('lb-link'), lbClose = $('lb-close');
+ const qInput = $('q');
+
+ let lastReq = 0;
+ let debounceTimer = null;
+
+ async function search(q) {
+ const reqId = ++lastReq;
+ if (!q.trim()) {
+ grid.innerHTML = '';
+ empty.style.display = 'block';
+ empty.textContent = 'type a query above — fans out across all 41 fleet sites in parallel.';
+ $('stamp').textContent = 'idle';
+ return;
+ }
+ $('stamp').textContent = 'searching…';
+ grid.innerHTML = '';
+ empty.style.display = 'none';
+ try {
+ const r = await fetch('/api/search?q=' + encodeURIComponent(q) + '&per=8', { cache: 'no-store' });
+ if (reqId !== lastReq) return; // a newer request is in flight
+ const j = await r.json();
+ $('stamp').textContent = `${j.total} hits · ${j.sites}/${j.sitesQueried} sites`;
+ if (!j.items.length) {
+ empty.style.display = 'block';
+ empty.textContent = `no fleet matches for "${q}". try a vendor (kravet, thibaut), a material (silk, grass), or a motif (paisley, deco).`;
+ return;
+ }
+ const frag = document.createDocumentFragment();
+ j.items.forEach((p, i) => {
+ const fig = document.createElement('figure');
+ fig.style.animationDelay = (Math.min(i, 80) * 18) + 'ms';
+ fig.innerHTML = `
+ <span class="pill" style="color:${p.accent}">${p.site.replace(/wallpaper$|wallcoverings?$|walls$/i, m => '·' + m)}</span>
+ <img loading="lazy" referrerpolicy="no-referrer" src="${p.image}" alt="${escape(p.title)}" onerror="this.parentNode.style.display='none'">
+ <figcaption><span class="t">${escape(p.title)}</span><span class="v">${escape(p.vendor || p.site)}</span></figcaption>
+ `;
+ fig.addEventListener('click', () => openLightbox(p));
+ frag.appendChild(fig);
+ });
+ grid.appendChild(frag);
+ } catch (e) {
+ console.error(e);
+ empty.style.display = 'block';
+ empty.textContent = 'search failed — ' + e.message;
+ }
+ }
+
+ function debouncedSearch() {
+ clearTimeout(debounceTimer);
+ debounceTimer = setTimeout(() => search(qInput.value), 250);
+ }
+
+ function openLightbox(p) {
+ lbImg.src = p.image;
+ lbTitle.textContent = p.title || '(untitled)';
+ lbVendor.textContent = (p.vendor || '') + (p.vendor && p.site ? ' · ' : '') + p.site;
+ lbLink.href = p.productUrl;
+ lightbox.classList.add('open');
+ }
+ lbClose.addEventListener('click', () => lightbox.classList.remove('open'));
+ lightbox.addEventListener('click', (e) => { if (e.target === lightbox) lightbox.classList.remove('open'); });
+
+ qInput.addEventListener('input', debouncedSearch);
+ qInput.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') { qInput.value = ''; debouncedSearch(); qInput.blur(); }
+ });
+ $('clear-btn').addEventListener('click', () => { qInput.value = ''; debouncedSearch(); qInput.focus(); });
+
+ document.querySelectorAll('.suggestions button').forEach(b => {
+ b.addEventListener('click', () => { qInput.value = b.dataset.q; debouncedSearch(); qInput.focus(); });
+ });
+
+ // Global keys: / focuses the search; w/m/s navigate; esc closes lightbox
+ document.addEventListener('keydown', (e) => {
+ if (e.key === '/' && document.activeElement !== qInput) {
+ e.preventDefault(); qInput.focus();
+ } else if (e.key === 'Escape') {
+ lightbox.classList.remove('open');
+ } else if (document.activeElement !== qInput) {
+ const k = e.key.toLowerCase();
+ if (k === 'w') location.href = '/';
+ if (k === 'm') location.href = '/mosaic.html';
+ if (k === 's' && !e.metaKey && !e.ctrlKey) location.href = '/stats.html';
+ if (k === 't') location.href = '/today.html';
+ if (k === 'a') location.href = '/atlas.html';
+ if (k === 'x') location.href = '/matrix.html';
+ if (k === 'r') debouncedSearch();
+ }
+ });
+
+ function escape(s) {
+ return String(s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
+ }
+
+ // Load query from ?q= if present
+ const initialQ = new URLSearchParams(location.search).get('q');
+ if (initialQ) { qInput.value = initialQ; debouncedSearch(); }
+
+ qInput.focus();
+})();
+</script>
+</body>
+</html>
diff --git a/public/stats.html b/public/stats.html
new file mode 100644
index 0000000..e50c6b3
--- /dev/null
+++ b/public/stats.html
@@ -0,0 +1,268 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
+<title>Fleet TV — Stats</title>
+<style>
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+ :root {
+ --bg: #07080a;
+ --fg: #e8e6e0;
+ --dim: #6a6862;
+ --accent: #c9a14b;
+ --pane: #11141a;
+ --rule: rgba(255,255,255,0.08);
+ }
+ html, body { background: var(--bg); color: var(--fg); font-family: -apple-system, "Helvetica Neue", "Segoe UI", system-ui, sans-serif; min-height: 100vh; }
+ body { padding-bottom: 4rem; }
+
+ .head {
+ position: sticky; top: 0; z-index: 5;
+ display: flex; align-items: baseline; gap: 1.5rem;
+ padding: 1rem 1.5rem;
+ background: linear-gradient(180deg, rgba(7,8,10,0.94), rgba(7,8,10,0.6));
+ backdrop-filter: blur(10px);
+ border-bottom: 1px solid var(--rule);
+ }
+ .head h1 { font-size: 0.95rem; letter-spacing: 0.18em; text-transform: uppercase; font-weight: 500; }
+ .head .crumb { color: var(--dim); font-size: 0.78rem; letter-spacing: 0.16em; text-transform: uppercase; }
+ .head .crumb a { color: var(--dim); text-decoration: none; border-bottom: 1px solid transparent; }
+ .head .crumb a:hover { color: var(--fg); border-color: var(--rule); }
+ .head .stamp { margin-left: auto; color: var(--dim); font-size: 0.78rem; letter-spacing: 0.18em; text-transform: uppercase; font-variant-numeric: tabular-nums; }
+
+ /* — Stat cards row — */
+ .row {
+ display: grid; gap: 0.85rem; padding: 1.5rem;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ }
+ .card {
+ background: var(--pane); border: 1px solid var(--rule); border-radius: 6px;
+ padding: 1.25rem 1.5rem;
+ }
+ .card .label {
+ font-size: 0.72rem; letter-spacing: 0.22em; text-transform: uppercase; color: var(--dim);
+ }
+ .card .value {
+ font-size: 2.4rem; line-height: 1.1; margin-top: 0.4rem; font-weight: 300; letter-spacing: -0.02em;
+ font-variant-numeric: tabular-nums;
+ }
+ .card .sub { margin-top: 0.4rem; font-size: 0.7rem; letter-spacing: 0.18em; text-transform: uppercase; color: var(--dim); }
+
+ /* — Two-column layout for ranked lists — */
+ .grid {
+ display: grid; gap: 1rem; padding: 0 1.5rem;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ .panel {
+ background: var(--pane); border: 1px solid var(--rule); border-radius: 6px;
+ overflow: hidden;
+ }
+ .panel header {
+ padding: 0.85rem 1.25rem;
+ border-bottom: 1px solid var(--rule);
+ display: flex; align-items: baseline; gap: 0.75rem;
+ }
+ .panel header .title {
+ font-size: 0.78rem; letter-spacing: 0.22em; text-transform: uppercase; font-weight: 500;
+ }
+ .panel header .hint { color: var(--dim); font-size: 0.68rem; letter-spacing: 0.18em; text-transform: uppercase; margin-left: auto; }
+ .panel .body { padding: 0.5rem 0.5rem 0.85rem; }
+
+ .bar-row {
+ display: grid; grid-template-columns: 12rem 1fr auto; gap: 0.85rem;
+ align-items: center; padding: 0.4rem 0.85rem;
+ border-radius: 4px;
+ }
+ .bar-row:hover { background: rgba(255,255,255,0.03); }
+ .bar-row .name { font-size: 0.85rem; letter-spacing: 0.02em; color: var(--fg); }
+ .bar-row .name a { color: inherit; text-decoration: none; border-bottom: 1px dotted transparent; }
+ .bar-row .name a:hover { border-color: var(--rule); }
+ .bar-row .bar {
+ height: 8px; border-radius: 2px;
+ background: rgba(255,255,255,0.05);
+ position: relative; overflow: hidden;
+ }
+ .bar-row .bar > div {
+ position: absolute; inset: 0 auto 0 0;
+ background: var(--accent); border-radius: 2px;
+ transition: width 0.6s ease;
+ }
+ .bar-row .count {
+ font-variant-numeric: tabular-nums; font-size: 0.85rem;
+ color: var(--fg); min-width: 4rem; text-align: right;
+ }
+
+ /* — Big "all sites" treemap-ish list, full width — */
+ .all-sites { padding: 1.5rem; }
+ .all-sites .header {
+ display: flex; align-items: baseline; gap: 1rem;
+ margin-bottom: 1rem;
+ }
+ .all-sites .header .title {
+ font-size: 0.78rem; letter-spacing: 0.22em; text-transform: uppercase; font-weight: 500;
+ }
+ .all-sites .header .hint { color: var(--dim); font-size: 0.68rem; letter-spacing: 0.18em; text-transform: uppercase; margin-left: auto; }
+ .tile-grid {
+ display: grid; gap: 0.4rem;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+ }
+ .tile {
+ background: var(--pane); border: 1px solid var(--rule); border-radius: 4px;
+ padding: 0.7rem 0.85rem;
+ border-left: 3px solid currentColor;
+ transition: transform 0.2s ease, background 0.2s ease;
+ color: #888;
+ }
+ .tile:hover { background: #15181f; transform: translateY(-1px); }
+ .tile .n { font-size: 0.78rem; color: var(--fg); letter-spacing: 0.01em; word-break: break-word; }
+ .tile .c { font-size: 0.7rem; color: var(--dim); letter-spacing: 0.18em; text-transform: uppercase; margin-top: 0.25rem; font-variant-numeric: tabular-nums; }
+
+ .splash {
+ position: fixed; inset: 0; display: grid; place-items: center;
+ background: var(--bg); z-index: 10;
+ transition: opacity 0.5s ease;
+ }
+ .splash.hide { opacity: 0; pointer-events: none; }
+ .splash .mark {
+ font-size: 1rem; letter-spacing: 0.42em; text-transform: uppercase;
+ color: var(--accent);
+ animation: pulse 1.6s ease-in-out infinite;
+ }
+ @keyframes pulse { 0%,100% { opacity: 0.4; } 50% { opacity: 1; } }
+</style>
+</head>
+<body>
+
+<div class="splash" id="splash"><div class="mark">Stats · aggregating fleet</div></div>
+
+<div class="head">
+ <h1>Fleet TV</h1>
+ <span class="crumb"><a href="/">← wall</a> · <a href="/mosaic.html">mosaic</a> · stats</span>
+ <div class="stamp" id="stamp">—</div>
+</div>
+
+<div class="row">
+ <div class="card">
+ <div class="label">Sites aired</div>
+ <div class="value" id="kpi-sites">—</div>
+ <div class="sub">≥10 products each</div>
+ </div>
+ <div class="card">
+ <div class="label">Products in fleet</div>
+ <div class="value" id="kpi-products">—</div>
+ <div class="sub">across niches + parents</div>
+ </div>
+ <div class="card">
+ <div class="label">Avg per site</div>
+ <div class="value" id="kpi-avg">—</div>
+ <div class="sub">products / aired site</div>
+ </div>
+ <div class="card">
+ <div class="label">Sampled for breakdown</div>
+ <div class="value" id="kpi-sampled">—</div>
+ <div class="sub">vendor + aesthetic facets</div>
+ </div>
+</div>
+
+<div class="grid">
+ <div class="panel">
+ <header><span class="title">Top sites by inventory</span><span class="hint">click → open</span></header>
+ <div class="body" id="top-sites"></div>
+ </div>
+ <div class="panel">
+ <header><span class="title">Top vendors</span><span class="hint">across sampled products</span></header>
+ <div class="body" id="top-vendors"></div>
+ </div>
+</div>
+
+<div class="grid" style="margin-top: 0.5rem;">
+ <div class="panel">
+ <header><span class="title">Top aesthetics</span><span class="hint">tags + niche labels</span></header>
+ <div class="body" id="top-aesthetics"></div>
+ </div>
+ <div class="panel">
+ <header><span class="title">Bottom 5</span><span class="hint">closest to MIN_PRODUCTS</span></header>
+ <div class="body" id="bottom-sites"></div>
+ </div>
+</div>
+
+<div class="all-sites">
+ <div class="header">
+ <span class="title">All aired sites</span>
+ <span class="hint">sorted by inventory</span>
+ </div>
+ <div class="tile-grid" id="all-sites"></div>
+</div>
+
+<script>
+(() => {
+ const $ = (id) => document.getElementById(id);
+ const splash = $('splash');
+
+ function bar(items, totalForScale, opts = {}) {
+ const max = Math.max(...items.map(i => i.value));
+ const denom = totalForScale || max;
+ return items.map(i => `
+ <div class="bar-row" style="color:${i.accent || 'var(--accent)'}">
+ <div class="name">${i.link ? `<a href="${i.link}" target="_blank" rel="noopener">${escape(i.name)}</a>` : escape(i.name)}</div>
+ <div class="bar"><div style="width:${(i.value / denom * 100).toFixed(1)}%; background: ${i.accent || 'var(--accent)'}"></div></div>
+ <div class="count">${i.value.toLocaleString()}</div>
+ </div>
+ `).join('');
+ }
+
+ async function load() {
+ splash.classList.remove('hide');
+ try {
+ const r = await fetch('/api/stats', { cache: 'no-store' });
+ const j = await r.json();
+
+ $('kpi-sites').textContent = j.fleet.sitesAired.toLocaleString();
+ $('kpi-products').textContent = j.fleet.totalProducts.toLocaleString();
+ $('kpi-avg').textContent = j.fleet.averagePerSite.toLocaleString();
+ $('kpi-sampled').textContent = j.fleet.sampledForDistribution.toLocaleString();
+ $('stamp').textContent = new Date(j.generatedAt).toTimeString().slice(0,8) + ' · R refresh';
+
+ $('top-sites').innerHTML = bar(j.topSites.map(s => ({
+ name: s.name, value: s.productCount, accent: s.accent,
+ link: 'https://' + s.name + (s.name.endsWith('.com') ? '' : '.com')
+ })));
+
+ $('top-vendors').innerHTML = bar(j.topVendors.map(v => ({ name: v.vendor, value: v.count })));
+ $('top-aesthetics').innerHTML = bar(j.topAesthetics.map(a => ({ name: a.aesthetic, value: a.count })));
+ $('bottom-sites').innerHTML = bar(j.bottomSites.map(s => ({ name: s.name, value: s.productCount, accent: s.accent })));
+
+ $('all-sites').innerHTML = j.allSites.map(s => `
+ <div class="tile" style="color:${s.accent}">
+ <div class="n">${escape(s.name)}</div>
+ <div class="c">${s.productCount.toLocaleString()} · products</div>
+ </div>
+ `).join('');
+ } catch (e) {
+ console.error(e);
+ } finally {
+ setTimeout(() => splash.classList.add('hide'), 250);
+ }
+ }
+
+ document.addEventListener('keydown', (e) => {
+ if (e.key.toLowerCase() === 'r') load();
+ if (e.key.toLowerCase() === 'w') location.href = '/';
+ if (e.key.toLowerCase() === 'm') location.href = '/mosaic.html';
+ if (e.key === '/') { e.preventDefault(); location.href = '/search.html'; }
+ if (e.key.toLowerCase() === 't') location.href = '/today.html';
+ if (e.key.toLowerCase() === 'a') location.href = '/atlas.html';
+ if (e.key.toLowerCase() === 'x') location.href = '/matrix.html';
+ });
+
+ function escape(s) {
+ return String(s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
+ }
+
+ load();
+ setInterval(load, 5 * 60 * 1000);
+})();
+</script>
+</body>
+</html>
diff --git a/public/today.html b/public/today.html
new file mode 100644
index 0000000..afcf6ca
--- /dev/null
+++ b/public/today.html
@@ -0,0 +1,288 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
+<title>Fleet TV — Today</title>
+<style>
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+ :root {
+ --bg: #07080a;
+ --fg: #e8e6e0;
+ --dim: #6a6862;
+ --rule: rgba(255,255,255,0.08);
+ --pane: #11141a;
+ --accent: #c9a14b;
+ }
+ html, body { background: var(--bg); color: var(--fg); font-family: -apple-system, "Helvetica Neue", "Segoe UI", system-ui, sans-serif; min-height: 100vh; }
+
+ .head {
+ position: sticky; top: 0; z-index: 5;
+ display: flex; align-items: baseline; gap: 1.5rem;
+ padding: 1rem 1.5rem;
+ background: linear-gradient(180deg, rgba(7,8,10,0.94), rgba(7,8,10,0.6));
+ backdrop-filter: blur(10px);
+ border-bottom: 1px solid var(--rule);
+ }
+ .head h1 { font-size: 0.95rem; letter-spacing: 0.18em; text-transform: uppercase; font-weight: 500; }
+ .head .crumb { color: var(--dim); font-size: 0.78rem; letter-spacing: 0.16em; text-transform: uppercase; }
+ .head .crumb a { color: var(--dim); text-decoration: none; border-bottom: 1px solid transparent; }
+ .head .crumb a:hover { color: var(--fg); border-color: var(--rule); }
+ .head .stamp { margin-left: auto; color: var(--dim); font-size: 0.78rem; letter-spacing: 0.18em; text-transform: uppercase; font-variant-numeric: tabular-nums; }
+
+ /* Hero */
+ .hero {
+ position: relative;
+ padding: 4rem 1.5rem 2.5rem;
+ background:
+ linear-gradient(180deg, var(--theme-accent, #c9a14b) 0%, transparent 70%),
+ var(--bg);
+ border-bottom: 1px solid var(--rule);
+ overflow: hidden;
+ }
+ .hero::before {
+ content: ''; position: absolute; inset: 0;
+ background: radial-gradient(ellipse at 30% 30%, rgba(255,255,255,0.06) 0%, transparent 50%);
+ pointer-events: none;
+ }
+ .hero-content {
+ position: relative; z-index: 1;
+ max-width: 1100px; margin: 0 auto;
+ display: grid; grid-template-columns: auto 1fr; gap: 2.5rem; align-items: center;
+ }
+ .day-card {
+ width: 140px; aspect-ratio: 1;
+ background: rgba(0,0,0,0.45);
+ border: 1px solid rgba(255,255,255,0.18);
+ border-radius: 6px;
+ display: grid; place-content: center;
+ text-align: center;
+ backdrop-filter: blur(6px);
+ }
+ .day-card .dow {
+ font-size: 0.78rem; letter-spacing: 0.32em; text-transform: uppercase; color: rgba(255,255,255,0.7);
+ }
+ .day-card .day {
+ font-size: 3.4rem; line-height: 1; font-weight: 200; margin: 0.4rem 0;
+ color: #fff; letter-spacing: -0.02em;
+ }
+ .day-card .month {
+ font-size: 0.7rem; letter-spacing: 0.32em; text-transform: uppercase; color: rgba(255,255,255,0.55);
+ }
+
+ .theme-name {
+ font-size: 3rem; line-height: 1.05; letter-spacing: -0.015em; font-weight: 300;
+ color: #fff;
+ text-shadow: 0 2px 30px rgba(0,0,0,0.4);
+ }
+ .theme-sub {
+ margin-top: 0.85rem;
+ font-size: 1.05rem; line-height: 1.5;
+ color: rgba(255,255,255,0.75);
+ max-width: 38rem;
+ font-weight: 300;
+ }
+ .theme-keys {
+ margin-top: 1.25rem;
+ display: flex; flex-wrap: wrap; gap: 0.4rem;
+ }
+ .theme-keys span {
+ padding: 0.32rem 0.8rem 0.34rem;
+ font-size: 0.65rem; letter-spacing: 0.2em; text-transform: uppercase;
+ background: rgba(0,0,0,0.4);
+ border: 1px solid rgba(255,255,255,0.18);
+ border-radius: 999px;
+ color: rgba(255,255,255,0.85);
+ backdrop-filter: blur(4px);
+ }
+
+ /* Editorial grid below the hero */
+ .editorial {
+ max-width: 1100px;
+ margin: 0 auto;
+ padding: 2.5rem 1.5rem 4rem;
+ display: grid; gap: 0.6rem;
+ /* asymmetric editorial layout: 2 large + small grid */
+ grid-template-columns: repeat(6, 1fr);
+ grid-auto-rows: 200px;
+ }
+ .editorial figure {
+ position: relative; overflow: hidden; border-radius: 4px;
+ background: var(--pane);
+ cursor: pointer;
+ transition: transform 0.3s ease;
+ grid-column: span 2;
+ grid-row: span 1;
+ transform: translateY(8px); opacity: 0;
+ animation: fadeIn 0.55s ease forwards;
+ }
+ .editorial figure:hover { transform: translateY(-2px); }
+ /* Make every 7th and 11th tile big to create rhythm */
+ .editorial figure:nth-child(7n+1) { grid-column: span 3; grid-row: span 2; }
+ .editorial figure:nth-child(11n+5) { grid-column: span 4; grid-row: span 1; }
+ @keyframes fadeIn { to { transform: translateY(0); opacity: 1; } }
+
+ .editorial figure img {
+ width: 100%; height: 100%; object-fit: cover; display: block;
+ transition: transform 0.6s ease;
+ }
+ .editorial figure:hover img { transform: scale(1.04); }
+ .editorial figure figcaption {
+ position: absolute; left: 0; right: 0; bottom: 0;
+ padding: 0.65rem 0.8rem 0.55rem;
+ background: linear-gradient(0deg, rgba(0,0,0,0.85), rgba(0,0,0,0));
+ color: #fff;
+ transform: translateY(105%); transition: transform 0.3s ease;
+ }
+ .editorial figure:hover figcaption { transform: translateY(0); }
+ .editorial figcaption .t { font-size: 0.78rem; line-height: 1.3; }
+ .editorial figcaption .v { display: block; margin-top: 0.2rem; font-size: 0.62rem; letter-spacing: 0.18em; text-transform: uppercase; opacity: 0.65; }
+
+ .pill {
+ position: absolute; top: 0.5rem; left: 0.5rem;
+ padding: 0.18rem 0.55rem 0.2rem;
+ font-size: 0.6rem; letter-spacing: 0.18em; text-transform: uppercase;
+ background: rgba(0,0,0,0.7);
+ border: 1px solid currentColor;
+ border-radius: 999px;
+ color: #fff;
+ backdrop-filter: blur(4px);
+ }
+
+ .splash {
+ position: fixed; inset: 0; display: grid; place-items: center;
+ background: var(--bg); z-index: 10;
+ transition: opacity 0.5s ease;
+ }
+ .splash.hide { opacity: 0; pointer-events: none; }
+ .splash .mark {
+ font-size: 1rem; letter-spacing: 0.42em; text-transform: uppercase;
+ color: var(--accent);
+ animation: pulse 1.6s ease-in-out infinite;
+ }
+ @keyframes pulse { 0%,100% { opacity: 0.4; } 50% { opacity: 1; } }
+
+ .lightbox { position: fixed; inset: 0; z-index: 50; display: none; align-items: center; justify-content: center; background: rgba(0,0,0,0.92); padding: 2rem; }
+ .lightbox.open { display: flex; }
+ .lightbox img { max-width: 92vw; max-height: 80vh; border-radius: 4px; box-shadow: 0 30px 100px rgba(0,0,0,0.7); }
+ .lightbox .info { position: absolute; bottom: 2rem; left: 50%; transform: translateX(-50%); text-align: center; color: var(--fg); background: rgba(20,22,26,0.92); padding: 1rem 1.5rem; border-radius: 4px; backdrop-filter: blur(6px); max-width: 80vw; }
+ .lightbox .info .t { font-size: 1rem; line-height: 1.3; }
+ .lightbox .info .v { font-size: 0.7rem; letter-spacing: 0.18em; text-transform: uppercase; margin-top: 0.4rem; opacity: 0.7; }
+ .lightbox .info a { display: inline-block; margin-top: 0.6rem; color: #c9a14b; text-decoration: none; font-size: 0.75rem; letter-spacing: 0.16em; text-transform: uppercase; border-bottom: 1px solid currentColor; padding-bottom: 1px; }
+ .lightbox .close { position: absolute; top: 1.5rem; right: 1.5rem; color: var(--fg); font-size: 1rem; letter-spacing: 0.18em; text-transform: uppercase; cursor: pointer; }
+</style>
+</head>
+<body>
+
+<div class="splash" id="splash"><div class="mark">Today · curating</div></div>
+
+<div class="head">
+ <h1>Fleet TV</h1>
+ <span class="crumb"><a href="/">wall</a> · <a href="/mosaic.html">mosaic</a> · <a href="/stats.html">stats</a> · <a href="/search.html">search</a> · today</span>
+ <div class="stamp" id="stamp">—</div>
+</div>
+
+<section class="hero" id="hero">
+ <div class="hero-content">
+ <div class="day-card">
+ <div class="dow" id="dow">—</div>
+ <div class="day" id="day">—</div>
+ <div class="month" id="month">—</div>
+ </div>
+ <div>
+ <div class="theme-name" id="theme-name">—</div>
+ <div class="theme-sub" id="theme-sub">—</div>
+ <div class="theme-keys" id="theme-keys"></div>
+ </div>
+ </div>
+</section>
+
+<div class="editorial" id="editorial"></div>
+
+<div class="lightbox" id="lightbox">
+ <span class="close" id="lb-close">close ✕</span>
+ <img id="lb-img" alt="">
+ <div class="info">
+ <div class="t" id="lb-title">—</div>
+ <div class="v" id="lb-vendor">—</div>
+ <a id="lb-link" target="_blank" rel="noopener">view on site →</a>
+ </div>
+</div>
+
+<script>
+(() => {
+ const $ = (id) => document.getElementById(id);
+ const lightbox = $('lightbox'), lbImg = $('lb-img'), lbTitle = $('lb-title'),
+ lbVendor = $('lb-vendor'), lbLink = $('lb-link'), lbClose = $('lb-close');
+
+ async function load() {
+ $('splash').classList.remove('hide');
+ try {
+ const r = await fetch('/api/today', { cache: 'no-store' });
+ const j = await r.json();
+ const t = j.theme;
+ const now = new Date();
+
+ $('dow').textContent = t.weekday;
+ $('day').textContent = String(now.getDate());
+ $('month').textContent = now.toLocaleString(undefined, { month: 'short' });
+ $('theme-name').textContent = t.name;
+ $('theme-sub').textContent = t.subtitle;
+ $('theme-keys').innerHTML = t.keywords.map(k => `<span>${escape(k)}</span>`).join('');
+ $('hero').style.setProperty('--theme-accent', t.accent);
+ $('stamp').textContent = j.total + ' picks · ' + new Date(j.generatedAt).toTimeString().slice(0,5);
+
+ const ed = $('editorial');
+ ed.innerHTML = '';
+ const frag = document.createDocumentFragment();
+ j.items.forEach((p, i) => {
+ const fig = document.createElement('figure');
+ fig.style.animationDelay = (Math.min(i, 30) * 35) + 'ms';
+ fig.innerHTML = `
+ <span class="pill" style="color:${p.accent}">${p.site.replace(/wallpaper$|wallcoverings?$|walls$/i, m => '·' + m)}</span>
+ <img loading="lazy" referrerpolicy="no-referrer" src="${p.image}" alt="${escape(p.title)}" onerror="this.parentNode.style.display='none'">
+ <figcaption><span class="t">${escape(p.title)}</span><span class="v">${escape(p.vendor || p.site)} · ${escape(p.matchedKeyword)}</span></figcaption>
+ `;
+ fig.addEventListener('click', () => openLightbox(p));
+ frag.appendChild(fig);
+ });
+ ed.appendChild(frag);
+ } catch (e) {
+ console.error(e);
+ } finally {
+ setTimeout(() => $('splash').classList.add('hide'), 300);
+ }
+ }
+
+ function openLightbox(p) {
+ lbImg.src = p.image;
+ lbTitle.textContent = p.title || '(untitled)';
+ lbVendor.textContent = (p.vendor || '') + (p.vendor && p.site ? ' · ' : '') + p.site;
+ lbLink.href = p.productUrl;
+ lightbox.classList.add('open');
+ }
+ lbClose.addEventListener('click', () => lightbox.classList.remove('open'));
+ lightbox.addEventListener('click', (e) => { if (e.target === lightbox) lightbox.classList.remove('open'); });
+
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') lightbox.classList.remove('open');
+ if (e.key.toLowerCase() === 'r') load();
+ if (e.key.toLowerCase() === 'w') location.href = '/';
+ if (e.key.toLowerCase() === 'm') location.href = '/mosaic.html';
+ if (e.key.toLowerCase() === 's') location.href = '/stats.html';
+ if (e.key === '/') { e.preventDefault(); location.href = '/search.html'; }
+ if (e.key.toLowerCase() === 'a') location.href = '/atlas.html';
+ if (e.key.toLowerCase() === 'x') location.href = '/matrix.html';
+ });
+
+ function escape(s) {
+ return String(s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
+ }
+
+ load();
+ // Re-check at top of every hour in case the day rolled over while the tab was open
+ setInterval(load, 60 * 60 * 1000);
+})();
+</script>
+</body>
+</html>
diff --git a/public/widget/embed.html b/public/widget/embed.html
new file mode 100644
index 0000000..3090dac
--- /dev/null
+++ b/public/widget/embed.html
@@ -0,0 +1,189 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Fleet TV — Embed Snippet</title>
+<style>
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+ :root {
+ --bg: #07080a; --fg: #e8e6e0; --dim: #6a6862; --accent: #c9a14b;
+ --pane: #11141a; --rule: rgba(255,255,255,0.08);
+ }
+ html, body { background: var(--bg); color: var(--fg); font-family: -apple-system, "Helvetica Neue", "Segoe UI", system-ui, sans-serif; min-height: 100vh; padding: 1.5rem; }
+ h1 { font-size: 0.95rem; letter-spacing: 0.18em; text-transform: uppercase; font-weight: 500; margin-bottom: 0.4rem; }
+ .crumb { color: var(--dim); font-size: 0.78rem; letter-spacing: 0.16em; text-transform: uppercase; margin-bottom: 2rem; }
+ .crumb a { color: var(--dim); text-decoration: none; border-bottom: 1px solid transparent; }
+ .crumb a:hover { color: var(--fg); border-color: var(--rule); }
+
+ .layout { display: grid; grid-template-columns: 360px 1fr; gap: 2rem; max-width: 1100px; }
+
+ .preview {
+ background: var(--pane); border: 1px solid var(--rule); border-radius: 6px;
+ overflow: hidden;
+ aspect-ratio: 320/420;
+ }
+ .preview iframe { width: 100%; height: 100%; border: 0; display: block; }
+
+ .right { display: grid; gap: 1.25rem; }
+
+ .panel {
+ background: var(--pane); border: 1px solid var(--rule); border-radius: 6px;
+ overflow: hidden;
+ }
+ .panel header {
+ padding: 0.85rem 1.25rem;
+ border-bottom: 1px solid var(--rule);
+ font-size: 0.78rem; letter-spacing: 0.22em; text-transform: uppercase; font-weight: 500;
+ }
+ .panel .body { padding: 1rem 1.25rem; }
+ .panel .body p { color: var(--dim); font-size: 0.85rem; line-height: 1.5; margin-bottom: 0.5rem; }
+
+ pre {
+ background: #0a0c10;
+ border: 1px solid var(--rule);
+ border-radius: 4px;
+ padding: 0.85rem 1rem;
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
+ font-size: 0.78rem;
+ color: var(--fg);
+ overflow-x: auto;
+ white-space: pre;
+ line-height: 1.5;
+ }
+ .copy-btn {
+ margin-top: 0.5rem;
+ background: transparent; color: var(--accent);
+ border: 1px solid var(--accent); border-radius: 999px;
+ padding: 0.35rem 0.85rem; cursor: pointer;
+ font-family: inherit; font-size: 0.7rem; letter-spacing: 0.22em; text-transform: uppercase;
+ }
+ .copy-btn:hover { background: var(--accent); color: var(--bg); }
+ .copy-btn.copied { background: var(--accent); color: var(--bg); }
+
+ .controls {
+ display: grid; gap: 0.75rem;
+ }
+ .controls label {
+ display: grid; grid-template-columns: 110px 1fr; gap: 0.85rem; align-items: center;
+ font-size: 0.78rem; letter-spacing: 0.04em;
+ }
+ .controls input {
+ background: var(--bg); color: var(--fg); border: 1px solid var(--rule);
+ border-radius: 4px; padding: 0.45rem 0.65rem; font-family: inherit;
+ }
+ .controls input:focus { outline: none; border-color: var(--accent); }
+ .hint { color: var(--dim); font-size: 0.7rem; letter-spacing: 0.18em; text-transform: uppercase; }
+</style>
+</head>
+<body>
+
+<h1>Fleet TV · Embed Snippet</h1>
+<div class="crumb"><a href="/">← wall</a> · <a href="/mosaic.html">mosaic</a> · <a href="/stats.html">stats</a> · <a href="/search.html">search</a> · widget</div>
+
+<div class="layout">
+
+ <div>
+ <div class="preview">
+ <iframe id="preview-iframe" src="/widget/spotlight.html" referrerpolicy="no-referrer"></iframe>
+ </div>
+ <div class="hint" style="margin-top:0.75rem;">live preview — rotates every 30s</div>
+ </div>
+
+ <div class="right">
+
+ <div class="panel">
+ <header>customize</header>
+ <div class="body controls">
+ <label>width <input id="opt-w" type="text" value="320"></label>
+ <label>height <input id="opt-h" type="text" value="420"></label>
+ <label>rotate (ms) <input id="opt-r" type="text" value="30000" placeholder="≥ 5000"></label>
+ <label>host <input id="opt-host" type="text" value=""></label>
+ <p class="hint" style="margin-top:0.25rem;">host = the URL where Fleet TV is reachable. For a local-only widget keep it as 127.0.0.1; for embedding on a public site, expose Fleet TV through your tailnet or a proxy.</p>
+ </div>
+ </div>
+
+ <div class="panel">
+ <header>iframe snippet (paste anywhere)</header>
+ <div class="body">
+ <pre id="snippet-iframe">—</pre>
+ <button class="copy-btn" data-target="snippet-iframe">copy</button>
+ </div>
+ </div>
+
+ <div class="panel">
+ <header>shopify section / liquid</header>
+ <div class="body">
+ <pre id="snippet-liquid">—</pre>
+ <button class="copy-btn" data-target="snippet-liquid">copy</button>
+ <p class="hint" style="margin-top:0.5rem;">drop into a Liquid section as a "Featured Cross-Fleet" block.</p>
+ </div>
+ </div>
+
+ <div class="panel">
+ <header>raw JSON endpoint</header>
+ <div class="body">
+ <pre id="snippet-api">—</pre>
+ <button class="copy-btn" data-target="snippet-api">copy</button>
+ <p class="hint" style="margin-top:0.5rem;">use this if you want a fully custom card. <code>?count=N</code> returns up to 20 random products.</p>
+ </div>
+ </div>
+
+ </div>
+
+</div>
+
+<script>
+(() => {
+ const $ = (id) => document.getElementById(id);
+ $('opt-host').value = location.origin;
+
+ function update() {
+ const w = ($('opt-w').value || '320').trim();
+ const h = ($('opt-h').value || '420').trim();
+ const r = ($('opt-r').value || '30000').trim();
+ const host = ($('opt-host').value || location.origin).trim().replace(/\/$/, '');
+ const src = `${host}/widget/spotlight.html?rotate=${r}`;
+
+ $('snippet-iframe').textContent =
+`<iframe src="${src}"
+ width="${w}" height="${h}"
+ style="border:0;border-radius:6px;overflow:hidden;"
+ loading="lazy" referrerpolicy="no-referrer"
+ title="Fleet Spotlight"></iframe>`;
+
+ $('snippet-liquid').textContent =
+`{%- comment -%} Fleet TV cross-fleet spotlight {%- endcomment -%}
+<div class="fleet-spotlight" style="max-width:${w}px;margin:1.5rem auto;">
+ <iframe src="${src}"
+ width="${w}" height="${h}"
+ style="border:0;border-radius:6px;overflow:hidden;display:block;"
+ loading="lazy" referrerpolicy="no-referrer"
+ title="Featured wallcovering"></iframe>
+</div>`;
+
+ $('snippet-api').textContent = `${host}/api/random?count=1`;
+ $('preview-iframe').src = `/widget/spotlight.html?rotate=${r}`;
+ }
+
+ ['opt-w','opt-h','opt-r','opt-host'].forEach(id => $(id).addEventListener('input', update));
+
+ document.querySelectorAll('.copy-btn').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ const id = btn.dataset.target;
+ const text = $(id).textContent;
+ try {
+ await navigator.clipboard.writeText(text);
+ const original = btn.textContent;
+ btn.classList.add('copied');
+ btn.textContent = 'copied ✓';
+ setTimeout(() => { btn.classList.remove('copied'); btn.textContent = original; }, 1500);
+ } catch (e) { alert('clipboard blocked — select & copy manually'); }
+ });
+ });
+
+ update();
+})();
+</script>
+</body>
+</html>
diff --git a/public/widget/spotlight.html b/public/widget/spotlight.html
new file mode 100644
index 0000000..2c99cb2
--- /dev/null
+++ b/public/widget/spotlight.html
@@ -0,0 +1,196 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Fleet Spotlight</title>
+<style>
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+ :root {
+ --bg: #0c0d10;
+ --fg: #f4f1eb;
+ --dim: rgba(244,241,235,0.55);
+ --accent: #c9a14b;
+ --rule: rgba(255,255,255,0.07);
+ }
+ html, body { height: 100%; background: var(--bg); color: var(--fg); font-family: -apple-system, "Helvetica Neue", "Segoe UI", system-ui, sans-serif; overflow: hidden; }
+
+ .card {
+ height: 100%;
+ display: grid; grid-template-rows: 1fr auto;
+ background: var(--bg);
+ position: relative; overflow: hidden;
+ }
+ .image-wrap {
+ position: relative; overflow: hidden; min-height: 0;
+ background: #1a1c20;
+ }
+ .image-wrap img {
+ position: absolute; inset: 0; width: 100%; height: 100%;
+ object-fit: cover;
+ opacity: 0;
+ transform: scale(1.04);
+ transition: opacity 0.7s ease, transform 8s ease-out;
+ }
+ .image-wrap img.live { opacity: 1; transform: scale(1.0); }
+
+ /* Vignette overlay so caption reads on bright wallpaper images */
+ .vignette {
+ position: absolute; inset: 0; pointer-events: none;
+ background: linear-gradient(to bottom, rgba(0,0,0,0) 60%, rgba(0,0,0,0.55) 100%);
+ }
+
+ .pill {
+ position: absolute; top: 0.65rem; left: 0.65rem;
+ padding: 0.22rem 0.6rem 0.24rem;
+ font-size: 0.6rem; letter-spacing: 0.2em; text-transform: uppercase;
+ background: rgba(0,0,0,0.65);
+ border: 1px solid currentColor;
+ border-radius: 999px;
+ color: #fff;
+ backdrop-filter: blur(4px);
+ z-index: 2;
+ }
+
+ .meta {
+ padding: 0.85rem 1rem 1rem;
+ border-top: 1px solid var(--rule);
+ background: linear-gradient(180deg, rgba(0,0,0,0.18), var(--bg));
+ display: grid; gap: 0.4rem;
+ }
+ .meta .title {
+ font-size: 0.85rem; line-height: 1.3; max-height: 2.6em; overflow: hidden;
+ color: var(--fg);
+ }
+ .meta .row {
+ display: flex; justify-content: space-between; align-items: baseline;
+ color: var(--dim);
+ font-size: 0.62rem; letter-spacing: 0.2em; text-transform: uppercase;
+ }
+ .meta .row .vendor { color: var(--fg); font-weight: 500; }
+ .meta .row .site { font-weight: 500; }
+ .meta a { color: inherit; text-decoration: none; }
+ .meta a.cta {
+ display: inline-block; margin-top: 0.25rem; color: var(--accent);
+ border-bottom: 1px solid currentColor; padding-bottom: 1px;
+ font-size: 0.65rem; letter-spacing: 0.22em; text-transform: uppercase;
+ }
+ .meta a.cta:hover { color: var(--fg); }
+
+ /* Loading state */
+ .splash {
+ position: absolute; inset: 0; display: grid; place-items: center;
+ background: var(--bg); z-index: 5;
+ color: var(--accent);
+ transition: opacity 0.4s ease;
+ font-size: 0.7rem; letter-spacing: 0.32em; text-transform: uppercase;
+ }
+ .splash.hide { opacity: 0; pointer-events: none; }
+ .splash::after { content: 'Fleet Spotlight'; animation: pulse 1.6s ease-in-out infinite; }
+ @keyframes pulse { 0%,100% { opacity: 0.4; } 50% { opacity: 1; } }
+
+ /* Compact rotation indicator (very subtle) */
+ .progress {
+ position: absolute; left: 0; right: 0; bottom: 0; height: 2px;
+ z-index: 1;
+ background: rgba(255,255,255,0.04);
+ }
+ .progress > div {
+ height: 100%; width: 0; background: var(--accent);
+ transition: width 0.2s linear;
+ }
+</style>
+</head>
+<body>
+<div class="card">
+ <div class="image-wrap">
+ <span class="pill" id="pill" style="display:none">—</span>
+ <img id="img-a" alt="" referrerpolicy="no-referrer">
+ <img id="img-b" alt="" referrerpolicy="no-referrer">
+ <div class="vignette"></div>
+ <div class="splash" id="splash"></div>
+ <div class="progress"><div id="bar"></div></div>
+ </div>
+ <div class="meta">
+ <a id="cta-link" target="_blank" rel="noopener">
+ <div class="title" id="title">—</div>
+ </a>
+ <div class="row">
+ <span class="vendor" id="vendor">—</span>
+ <span class="site" id="site">—</span>
+ </div>
+ <a id="cta" class="cta" target="_blank" rel="noopener">view product →</a>
+ </div>
+</div>
+
+<script>
+(() => {
+ const ROTATE_MS = 30_000;
+ const params = new URLSearchParams(location.search);
+ const customRotate = parseInt(params.get('rotate') || '', 10);
+ const interval = (Number.isFinite(customRotate) && customRotate >= 5_000) ? customRotate : ROTATE_MS;
+
+ const $ = (id) => document.getElementById(id);
+ const imgA = $('img-a'), imgB = $('img-b');
+ const splash = $('splash'), pill = $('pill'), bar = $('bar');
+ let active = 'a', tickStart = 0, raf;
+
+ async function next() {
+ try {
+ const r = await fetch('/api/random?count=1', { cache: 'no-store' });
+ const j = await r.json();
+ const p = (j.items || [])[0];
+ if (!p) return;
+
+ const incoming = active === 'a' ? imgB : imgA;
+ const outgoing = active === 'a' ? imgA : imgB;
+
+ // Preload to avoid empty flash
+ const tmp = new Image();
+ tmp.onload = () => {
+ incoming.src = p.image;
+ incoming.classList.add('live');
+ outgoing.classList.remove('live');
+ active = active === 'a' ? 'b' : 'a';
+
+ $('title').textContent = p.title || '(untitled)';
+ $('vendor').textContent = p.vendor || '—';
+ $('site').textContent = p.site;
+ pill.style.display = 'inline-block';
+ pill.textContent = p.site.replace(/wallpaper$|wallcoverings?$|walls$/i, m => '·' + m);
+ pill.style.color = p.accent || '#c9a14b';
+ $('cta-link').href = p.productUrl;
+ $('cta').href = p.productUrl;
+ splash.classList.add('hide');
+ bar.style.background = p.accent || '#c9a14b';
+ };
+ tmp.onerror = () => { setTimeout(next, 1500); }; // try another product if image fails
+ tmp.referrerPolicy = 'no-referrer';
+ tmp.src = p.image;
+
+ tickStart = Date.now();
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ function tick() {
+ const elapsed = Date.now() - tickStart;
+ const pct = Math.min(100, (elapsed / interval) * 100);
+ bar.style.width = pct + '%';
+ raf = requestAnimationFrame(tick);
+ }
+
+ next();
+ setInterval(next, interval);
+ tick();
+
+ // Pause rotation when document is hidden (saves cycles when tab is in background)
+ document.addEventListener('visibilitychange', () => {
+ if (document.hidden) cancelAnimationFrame(raf);
+ else { tickStart = Date.now(); tick(); }
+ });
+})();
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..556e701
--- /dev/null
+++ b/server.js
@@ -0,0 +1,564 @@
+// Fleet TV — live wall display cycling every Steve-owned site.
+// Auto-builds the channel lineup from running pm2 processes + a curated
+// "external" list (anything not in pm2 but worth showing).
+//
+// Only sites with REAL product inventory are aired — empty/stranded niches
+// (those with /api/products total < MIN_PRODUCTS) are filtered out so the
+// wall doesn't show empty grids.
+const express = require('express');
+const path = require('path');
+const http = require('http');
+const { execSync } = require('child_process');
+
+const app = express();
+const PORT = parseInt(process.env.PORT || '9913', 10);
+const MIN_PRODUCTS = parseInt(process.env.MIN_PRODUCTS || '10', 10);
+
+// Allow widgets to be iframed from any origin; serve them via a static handler
+// that also sets explicit frame-friendly headers BEFORE the general static mount.
+const widgetStatic = express.static(path.join(__dirname, 'public/widget'), {
+ setHeaders(res) {
+ res.set('X-Frame-Options', 'ALLOWALL');
+ res.set('Content-Security-Policy', "frame-ancestors *");
+ res.set('Cache-Control', 'public, max-age=60');
+ },
+});
+app.use('/widget', widgetStatic);
+
+app.use(express.static(path.join(__dirname, 'public')));
+
+// Hand-curated extras — sites running on Kamatera or external that we want on the wall too.
+// These show as live iframes against their public URL (CSP permitting).
+const EXTERNAL_CHANNELS = [
+ { name: 'designerwallcoverings.com', url: 'https://designerwallcoverings.com', kind: 'parent', accent: '#c9a14b' },
+ { name: 'philipperomano.com', url: 'https://philipperomano.com', kind: 'parent', accent: '#a89060' },
+ { name: 'novasuede.com', url: 'https://novasuede.com', kind: 'parent', accent: '#6b7a8f' },
+ { name: 'wholivedthere.com', url: 'https://wholivedthere.com', kind: 'archive', accent: '#7a5a3a' },
+ { name: 'claimmyaddress.com', url: 'https://claimmyaddress.com', kind: 'archive', accent: '#5a7a3a' },
+ { name: 'bubbesblock.com', url: 'https://bubbesblock.com', kind: 'archive', accent: '#7a3a5a' },
+];
+
+// Sites known to be auth-gated on the local mirror — skip in the rotator
+// (iframes return 401 and look broken on the wall).
+const AUTH_GATED = new Set(['retrowalls', 'silkwallpaper']);
+
+async function buildPm2Channels() {
+ let pm2List;
+ try {
+ pm2List = JSON.parse(execSync('pm2 jlist', { encoding: 'utf8' }));
+ } catch (e) {
+ console.warn('[fleet-tv] pm2 jlist failed:', e.message);
+ return [];
+ }
+
+ // Build candidate list first
+ const candidates = [];
+ for (const p of pm2List) {
+ if (p.pm2_env.status !== 'online') continue;
+ if (AUTH_GATED.has(p.name)) continue;
+ if (!/wallpaper|wallcovering|wallcoverings|walls/.test(p.name)) continue;
+
+ const port = p.pm2_env.PORT || extractPortFromEnv(p) || sniffDefaultPort(p);
+ if (!port) continue;
+
+ candidates.push({
+ name: p.name,
+ url: `http://127.0.0.1:${port}/`,
+ kind: 'dw-microsite',
+ accent: pickAccent(p.name),
+ pid: p.pid,
+ port,
+ });
+ }
+
+ // Probe each candidate's /api/products in parallel, filter to real-inventory sites
+ const probes = await Promise.all(candidates.map(c => probeProductCount(c)));
+ return probes.filter(c => c.productCount >= MIN_PRODUCTS);
+}
+
+function probeProductCount(channel) {
+ return new Promise((resolve) => {
+ const req = http.get(`${channel.url}api/products?limit=1`, { timeout: 1500 }, (res) => {
+ let body = '';
+ res.on('data', (c) => body += c);
+ res.on('end', () => {
+ try {
+ const j = JSON.parse(body);
+ // /api/products shape is { total, items: [...] } in DW micro-sites
+ const total = typeof j.total === 'number'
+ ? j.total
+ : Array.isArray(j) ? j.length
+ : Array.isArray(j.items) ? j.items.length
+ : 0;
+ resolve({ ...channel, productCount: total });
+ } catch (e) {
+ resolve({ ...channel, productCount: 0 });
+ }
+ });
+ });
+ req.on('error', () => resolve({ ...channel, productCount: 0 }));
+ req.on('timeout', () => { req.destroy(); resolve({ ...channel, productCount: 0 }); });
+ });
+}
+
+function extractPortFromEnv(p) {
+ // pm2_env spreads the env block; PORT may live there directly or under env_pm2
+ return p.pm2_env.PORT || (p.pm2_env.env && p.pm2_env.env.PORT) || null;
+}
+
+function sniffDefaultPort(p) {
+ // Best-effort: read server.js for `process.env.PORT || NNNN`
+ try {
+ const fs = require('fs');
+ const src = fs.readFileSync(p.pm2_env.pm_exec_path, 'utf8').slice(0, 4000);
+ const m = src.match(/process\.env\.PORT\s*\|\|\s*(\d+)/);
+ return m ? parseInt(m[1], 10) : null;
+ } catch (e) {
+ return null;
+ }
+}
+
+function pickAccent(name) {
+ // Niche-aware accent palette — gives each channel a distinct ribbon color.
+ const palette = {
+ aged: '#8c6d4a', apartment: '#7a8a92', architectural: '#3e5161', block: '#3a4a3e',
+ cork: '#a07b54', embroidered: '#9a4665', fabric: '#5e7a4e', glitter: '#c0a060',
+ green: '#4a7a4a', handcrafted: '#8a6a4a', healthcare: '#6a8a9a', hollywood: '#b89060',
+ hospitality: '#9a7a4a', hotel: '#6a4a3a', jute: '#a08054', linen: '#a89060',
+ madagascar: '#6a8a4a', metallic: '#9a9aaa', mica: '#aaaa9a', museum: '#5a4a4a',
+ mylar: '#9aaaaa', natural: '#7a8a5a', pastel: '#dab8c8', raffia: '#a09060',
+ recycled: '#5a7a4a', restaurant: '#8a4a3a', restoration: '#6a5a4a', retro: '#d97a3a',
+ saloon: '#7a3a3a', screen: '#3a5a3a', selfadhesive: '#6a8aaa', silk: '#c8a8c8',
+ silver: '#aaaaaa', string: '#9a8060', suede: '#7a5a4a', textile: '#5a8a8a',
+ vinyl: '#3a3a5a', '1800': '#5a4a3a', '1890': '#6a5a4a', '1900': '#5a5a3a',
+ '1920': '#9a6a3a', '1930': '#8a7a3a', '1940': '#7a6a4a', '1950': '#cae0e0',
+ '1960': '#d96a8a', '1970': '#d97a3a', '1980': '#3acac9', wallpapersback: '#c0c0c0',
+ };
+ for (const k in palette) if (name.includes(k)) return palette[k];
+ return '#888';
+}
+
+app.get('/api/channels', async (req, res) => {
+ const dw = await buildPm2Channels();
+ const total = dw.length + EXTERNAL_CHANNELS.length;
+ res.json({
+ total,
+ minProducts: MIN_PRODUCTS,
+ generatedAt: new Date().toISOString(),
+ channels: [
+ ...EXTERNAL_CHANNELS,
+ ...dw.sort((a, b) => a.name.localeCompare(b.name)),
+ ],
+ });
+});
+
+// Pinterest-style cross-fleet product mosaic: pulls N products from each
+// real-inventory channel in parallel, returns a flattened randomized list
+// for the /mosaic discover page.
+app.get('/api/products-mosaic', async (req, res) => {
+ const perSite = Math.min(20, parseInt(req.query.per || '6', 10));
+ const dw = await buildPm2Channels(); // already filtered by MIN_PRODUCTS
+ const fetched = await Promise.all(dw.map(c => fetchProducts(c, perSite)));
+ const all = [];
+ for (const { channel, items } of fetched) {
+ for (const p of items) {
+ if (!p.image_url) continue;
+ all.push({
+ image: p.image_url,
+ title: p.title || p.handle || '',
+ vendor: p.vendor || '',
+ site: channel.name,
+ siteUrl: channel.url,
+ productUrl: p.product_url || channel.url,
+ accent: channel.accent,
+ });
+ }
+ }
+ // Fisher-Yates shuffle so the wall mixes vendors instead of clustering by site
+ for (let i = all.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [all[i], all[j]] = [all[j], all[i]];
+ }
+ res.json({ total: all.length, perSite, generatedAt: new Date().toISOString(), items: all });
+});
+
+function fetchProducts(channel, n) {
+ return new Promise((resolve) => {
+ http.get(`${channel.url}api/products?limit=${n}`, { timeout: 2000 }, (response) => {
+ let body = '';
+ response.on('data', (c) => body += c);
+ response.on('end', () => {
+ try {
+ const j = JSON.parse(body);
+ const items = Array.isArray(j) ? j : Array.isArray(j.items) ? j.items : [];
+ resolve({ channel, items });
+ } catch (e) {
+ resolve({ channel, items: [] });
+ }
+ });
+ }).on('error', () => resolve({ channel, items: [] }))
+ .on('timeout', function () { this.destroy(); resolve({ channel, items: [] }); });
+ });
+}
+
+// Aggregate fleet stats — totals, rankings, vendor & aesthetic distribution.
+app.get('/api/stats', async (req, res) => {
+ const dw = await buildPm2Channels(); // already filtered by MIN_PRODUCTS
+ // Pull a deeper sample (50 per site) for vendor/aesthetic distribution
+ const samples = await Promise.all(dw.map(c => fetchProducts(c, 50)));
+
+ const vendorCount = {};
+ const aestheticCount = {};
+ let totalSampled = 0;
+ for (const { items } of samples) {
+ for (const p of items) {
+ totalSampled++;
+ if (p.vendor) vendorCount[p.vendor] = (vendorCount[p.vendor] || 0) + 1;
+ if (p.aesthetic) aestheticCount[p.aesthetic] = (aestheticCount[p.aesthetic] || 0) + 1;
+ }
+ }
+
+ const ranked = [...dw].sort((a, b) => b.productCount - a.productCount);
+ const topVendors = Object.entries(vendorCount).sort((a, b) => b[1] - a[1]).slice(0, 12);
+ const topAesthetics = Object.entries(aestheticCount).sort((a, b) => b[1] - a[1]).slice(0, 12);
+
+ const totalProducts = dw.reduce((s, c) => s + c.productCount, 0);
+
+ res.json({
+ generatedAt: new Date().toISOString(),
+ minProducts: MIN_PRODUCTS,
+ fleet: {
+ sitesAired: dw.length,
+ totalProducts,
+ averagePerSite: dw.length ? Math.round(totalProducts / dw.length) : 0,
+ sampledForDistribution: totalSampled,
+ },
+ topSites: ranked.slice(0, 10).map(c => ({ name: c.name, productCount: c.productCount, accent: c.accent })),
+ bottomSites: ranked.slice(-5).map(c => ({ name: c.name, productCount: c.productCount, accent: c.accent })),
+ topVendors: topVendors.map(([vendor, count]) => ({ vendor, count })),
+ topAesthetics: topAesthetics.map(([aesthetic, count]) => ({ aesthetic, count })),
+ allSites: ranked.map(c => ({ name: c.name, productCount: c.productCount, accent: c.accent })),
+ });
+});
+
+// Cross-fleet search — fans out /api/products?q= to every aired channel,
+// merges results with site provenance, deduplicates by image URL.
+app.get('/api/search', async (req, res) => {
+ const q = (req.query.q || '').trim();
+ const limit = Math.min(40, parseInt(req.query.per || '8', 10));
+ if (!q) return res.json({ q: '', total: 0, sites: 0, items: [] });
+
+ const dw = await buildPm2Channels();
+ const results = await Promise.all(dw.map(c => searchChannel(c, q, limit)));
+
+ const seen = new Set();
+ const merged = [];
+ for (const { channel, items } of results) {
+ for (const p of items) {
+ if (!p.image_url) continue;
+ if (seen.has(p.image_url)) continue;
+ seen.add(p.image_url);
+ merged.push({
+ image: p.image_url,
+ title: p.title || p.handle || '',
+ vendor: p.vendor || '',
+ site: channel.name,
+ siteUrl: channel.url,
+ productUrl: p.product_url || channel.url,
+ accent: channel.accent,
+ });
+ }
+ }
+ res.json({
+ q,
+ total: merged.length,
+ sites: results.filter(r => r.items.length > 0).length,
+ sitesQueried: dw.length,
+ generatedAt: new Date().toISOString(),
+ items: merged,
+ });
+});
+
+function searchChannel(channel, q, limit) {
+ return new Promise((resolve) => {
+ const url = `${channel.url}api/products?q=${encodeURIComponent(q)}&limit=${limit}`;
+ http.get(url, { timeout: 1500 }, (response) => {
+ let body = '';
+ response.on('data', (c) => body += c);
+ response.on('end', () => {
+ try {
+ const j = JSON.parse(body);
+ const items = Array.isArray(j) ? j : Array.isArray(j.items) ? j.items : [];
+ resolve({ channel, items });
+ } catch (e) { resolve({ channel, items: [] }); }
+ });
+ }).on('error', () => resolve({ channel, items: [] }))
+ .on('timeout', function () { this.destroy(); resolve({ channel, items: [] }); });
+ });
+}
+
+// Single random product (or N) from the fleet — for embeddable widgets.
+// Lightweight: caches the channel pool for 30s to avoid hammering /api/products
+// on every widget tick.
+let randomCache = { at: 0, items: [] };
+async function getRandomPool() {
+ if (Date.now() - randomCache.at < 30_000 && randomCache.items.length > 0) return randomCache.items;
+ const dw = await buildPm2Channels();
+ const fetched = await Promise.all(dw.map(c => fetchProducts(c, 12)));
+ const all = [];
+ for (const { channel, items } of fetched) {
+ for (const p of items) {
+ if (!p.image_url) continue;
+ all.push({
+ image: p.image_url,
+ title: p.title || p.handle || '',
+ vendor: p.vendor || '',
+ site: channel.name,
+ siteUrl: channel.url,
+ productUrl: p.product_url || channel.url,
+ accent: channel.accent,
+ });
+ }
+ }
+ randomCache = { at: Date.now(), items: all };
+ return all;
+}
+
+app.get('/api/random', async (req, res) => {
+ const n = Math.min(20, Math.max(1, parseInt(req.query.count || '1', 10)));
+ const pool = await getRandomPool();
+ if (!pool.length) return res.json({ items: [] });
+ const out = [];
+ const used = new Set();
+ while (out.length < n && used.size < pool.length) {
+ const i = Math.floor(Math.random() * pool.length);
+ if (used.has(i)) continue;
+ used.add(i);
+ out.push(pool[i]);
+ }
+ res.set('Cache-Control', 'no-store');
+ res.json({ items: out, poolSize: pool.length, generatedAt: new Date().toISOString() });
+});
+
+// Daily-rotating editorial theme. Day of week → theme → keyword search across fleet.
+// Deterministic per-day so all viewers see the same curated card.
+const DAILY_THEMES = [
+ // Sun
+ { name: 'Texture Sunday', subtitle: 'Hand-felt fibers and material weight.', keywords: ['linen','grass','jute','silk','suede','raffia','cork'], accent: '#a08054' },
+ // Mon
+ { name: 'Bauhaus Monday', subtitle: 'Geometry, primary color, flat plane.', keywords: ['geometric','triangle','circle','grid','op art'], accent: '#3a4a6a' },
+ // Tue
+ { name: 'Florals Tuesday', subtitle: 'Botanical motif, painted and printed.', keywords: ['floral','rose','peony','botanical','foliage','garden','flower'], accent: '#9a5a7a' },
+ // Wed
+ { name: 'Maximalist Wednesday', subtitle: 'High pattern, high color, no apology.', keywords: ['paisley','damask','baroque','toile','chinoiserie','ornate'], accent: '#a04050' },
+ // Thu
+ { name: 'Earth Tones Thursday', subtitle: 'Clay, mushroom, terracotta, sand.', keywords: ['terracotta','sand','clay','mushroom','earth','rust','warm'], accent: '#a87850' },
+ // Fri
+ { name: 'Metallic Friday', subtitle: 'Foil, leaf, shimmer.', keywords: ['gold','silver','metallic','leaf','shimmer','mylar','glitter'], accent: '#c9a14b' },
+ // Sat
+ { name: 'Eras Saturday', subtitle: 'A walk through twentieth-century walls.', keywords: ['vintage','retro','art deco','midcentury','hollywood','memphis','1970'], accent: '#d97a3a' },
+];
+
+function todayTheme(date = new Date()) {
+ const dow = date.getDay(); // 0 = Sunday … 6 = Saturday
+ const t = DAILY_THEMES[dow];
+ // Date-deterministic but also seed-shifted so the keyword sample rotates daily within the theme
+ const seed = date.toISOString().slice(0,10);
+ return { ...t, weekday: ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][dow], seed };
+}
+
+app.get('/api/today', async (req, res) => {
+ const theme = todayTheme();
+ const dw = await buildPm2Channels();
+
+ // For each keyword, run a parallel cross-fleet search; merge dedup by image.
+ const queries = await Promise.all(theme.keywords.map(async kw => {
+ const probes = await Promise.all(dw.map(c => searchChannel(c, kw, 6)));
+ return { kw, probes };
+ }));
+
+ const seen = new Set();
+ const items = [];
+ for (const { kw, probes } of queries) {
+ for (const { channel, items: hits } of probes) {
+ for (const p of hits) {
+ if (!p.image_url) continue;
+ if (seen.has(p.image_url)) continue;
+ seen.add(p.image_url);
+ items.push({
+ image: p.image_url,
+ title: p.title || p.handle || '',
+ vendor: p.vendor || '',
+ site: channel.name,
+ siteUrl: channel.url,
+ productUrl: p.product_url || channel.url,
+ accent: channel.accent,
+ matchedKeyword: kw,
+ });
+ }
+ }
+ }
+
+ // Date-seeded shuffle so the daily order is stable but feels curated.
+ const seedNum = Number(theme.seed.replace(/-/g,''));
+ let s = seedNum;
+ for (let i = items.length - 1; i > 0; i--) {
+ s = (s * 9301 + 49297) % 233280;
+ const j = Math.floor((s / 233280) * (i + 1));
+ [items[i], items[j]] = [items[j], items[i]];
+ }
+
+ res.json({
+ theme,
+ total: items.length,
+ items: items.slice(0, 24),
+ generatedAt: new Date().toISOString(),
+ });
+});
+
+// ──────────────────── ATLAS — full Steve-portfolio map ────────────────────
+// Categorizes all running pm2 processes into themed groups for the /atlas.html
+// visualization. Each project gets: status, restart_time, memory, uptime,
+// category, and a one-liner description (curated below for known projects).
+const ATLAS_DESCRIPTIONS = {
+ 'fleet-tv': 'Live wall + mosaic + stats + search + today + widget across the DW fleet.',
+ 'sf-orchestrator': 'Site Factory orchestrator — 11-stage automated site-build pipeline.',
+ 'sf-viewer': 'Site Factory web viewer.',
+ 'sf-critic': 'Site Factory critic agent.',
+ 'sf-admin': 'Site Factory admin panel.',
+ 'ai-factory-orchestrator': 'AI Factory — qwen3 + Claude critic loop, agent skills/subagents shipping.',
+ 'ai-factory-viewer': 'AI Factory viewer.',
+ 'visual-factory-orchestrator': 'Visual Factory — qwen3 → Playwright → llava → Claude critic loop.',
+ 'hormuz-orchestrator': 'Hormuz / Four Horsemen orchestrator (Paper/Figma/Canva/21st).',
+ 'animal-agent': 'Animals directory agent.',
+ 'animals-viewer': 'Animals directory viewer.',
+ 'animals-ingest': 'Animals data ingest.',
+ 'animals-audit': 'Animals data audit.',
+ 'animals-leads': 'Animals B2B leads dashboard.',
+ 'pd-api': 'Professional Directory ("doctor") API.',
+ 'pd-crawler': 'Professional Directory crawler.',
+ 'pd-dedupe': 'Professional Directory dedupe.',
+ 'pd-enrich': 'Professional Directory enrich.',
+ 'pd-ingest': 'Professional Directory ingest.',
+ 'pd-preview': 'Professional Directory mockup preview.',
+ 'pd-web': 'Professional Directory public site.',
+ 'george-gmail': 'DW outbound-Gmail agent — 3 mailboxes via Google Workspace.',
+ 'claude-email-responder': 'Auto-reply to whitelisted Gmail senders via Claude CLI.',
+ 'morning-review': 'Morning-review feed of overnight findings.',
+ 'video-gallery': 'Persistent video gallery of build/demo videos.',
+ 'commerce-claw': 'Commerce Claw vault for OAuth tokens (libsodium-gated).',
+ 'commerce-claw-viewer': 'Commerce Claw viewer.',
+ 'momentum-pricer': 'DW Momentum pricing agent (sliding-window leak fix shipped).',
+ 'silas-sku-agent': 'DW SKU enrichment agent.',
+ 'shopify-expert-agent': 'Shopify expertise agent.',
+ 'vendor-discount-agent': 'DW vendor discount agent.',
+ 'vendor-onboarding': 'DW vendor onboarding agent.',
+ 'full-monty-agent': 'DW Full Monty enrichment pipeline.',
+ 'launch-supervisor': 'DW launch supervisor.',
+ 'timeout-agent': 'DW timeout monitor.',
+ 'archive-agent': 'DW archive agent.',
+ 'blog-agent': 'DW blog content agent.',
+ 'claudette-agent': 'DW Claudette assistant.',
+ 'color-search': 'DW color-search across catalog.',
+ 'compliance-agent': 'DW comms compliance enforcer.',
+ 'debate-ring': 'Multi-LLM debate ring.',
+ 'dex-dedup-agent': 'DW deduplication agent.',
+ 'garrett': 'DW Garrett agent.',
+ 'hawke': 'DW process-hawk (kept alive everything).',
+ 'artie-agent': 'DW Artie agent.',
+ 'kamatera-tunnels': 'Kamatera SSH/PG tunnels.',
+ 'norma-bluesky': "Norma social agent — Bluesky.",
+ 'norma-facebook': "Norma social agent — Facebook.",
+ 'norma-instagram': "Norma social agent — Instagram.",
+ 'norma-price': 'Norma price reporter.',
+ 'norma-twitter': 'Norma social agent — X / Twitter.',
+ 'national-paper-hangers': 'NPH installer marketplace (luxury wallcovering installers).',
+ 'pastdoor': 'pastdoor / stayclaim — address-first home history (Next.js).',
+ 'site-bubbesblock': 'BubbesBlock — neighborhood story site.',
+ 'site-claimmyaddress': 'ClaimMyAddress — homeowner self-claim flow.',
+ 'site-wholivedthere': 'WhoLivedThere — historic resident lookup.',
+ 'sister-sites-viewer': 'Sister-sites cross-link viewer.',
+ 'sku-check-skill': 'SKU check skill backend.',
+ 'slack-dm-viewer': 'Slack DM viewer.',
+ 'smb-builder': 'SMB website builder.',
+ 'wallpapersback': 'Wallpapers Back — vintage wallpaper relaunch.',
+ 'yolo-agent': 'Yuri / YOLO Agent — autonomous task runner.',
+ 'room-setting-agent': 'Room-setting image generation agent.',
+ 'ventura-corridor': 'Ventura Corridor LA-area lifestyle directory.',
+ 'bc-relay': 'Browser-Chrome relay.',
+};
+
+const ATLAS_CATEGORIES = [
+ { id: 'fleet-tv', name: 'Fleet TV', accent: '#c9a14b', match: n => n === 'fleet-tv' },
+ { id: 'dw-microsite', name: 'DW Micro-sites', accent: '#a89060', match: n => /wallpaper|wallcovering|wallcoverings|walls/.test(n) && n !== 'wallpapersback' },
+ { id: 'dw-platform', name: 'DW Platform', accent: '#8a6a4a', match: n => /^(george|momentum|silas|shopify|vendor|full-monty|launch|timeout|archive|blog|claudette|color|compliance|dex|garrett|hawke|artie|kamatera|claude-email|debate-ring|wallpapersback|sku-check)/.test(n) },
+ { id: 'site-factory', name: 'Site / AI / Visual Factories', accent: '#5a7a8a', match: n => /^(sf-|ai-factory|visual-factory|hormuz)/.test(n) },
+ { id: 'norma', name: 'Norma (social)', accent: '#9a4a5a', match: n => /^norma-/.test(n) },
+ { id: 'animals', name: 'Animals Directory', accent: '#4a7a4a', match: n => /^animal/.test(n) },
+ { id: 'doctor', name: 'Professional Directory', accent: '#6a8a9a', match: n => /^pd-/.test(n) },
+ { id: 'address', name: 'Address History', accent: '#7a5a3a', match: n => /^site-(bubbesblock|claimmyaddress|wholivedthere)$|^pastdoor$|^national-paper-hangers$/.test(n) },
+ { id: 'directories', name: 'Other directories', accent: '#5a5a8a', match: n => /^(sister-sites|smb-builder|ventura-corridor)/.test(n) },
+ { id: 'agents', name: 'Agents & Tools', accent: '#666', match: n => /^(yolo-agent|morning-review|video-gallery|room-setting|commerce-claw|slack-dm|bc-relay)/.test(n) },
+];
+
+function categorize(name) {
+ for (const c of ATLAS_CATEGORIES) if (c.match(name)) return c;
+ return { id: 'other', name: 'Other', accent: '#555' };
+}
+
+app.get('/api/atlas', async (req, res) => {
+ let pm2List;
+ try {
+ pm2List = JSON.parse(execSync('pm2 jlist', { encoding: 'utf8' }));
+ } catch (e) {
+ return res.json({ categories: [], error: e.message });
+ }
+
+ const projects = pm2List.map(p => {
+ const cat = categorize(p.name);
+ return {
+ name: p.name,
+ status: p.pm2_env.status,
+ restartTime: p.pm2_env.restart_time,
+ unstableRestarts: p.pm2_env.unstable_restarts,
+ uptimeMs: p.pm2_env.status === 'online' ? Date.now() - p.pm2_env.pm_uptime : 0,
+ memMb: p.monit && p.monit.memory ? Math.round(p.monit.memory / 1024 / 1024) : 0,
+ cpu: p.monit && typeof p.monit.cpu === 'number' ? p.monit.cpu : 0,
+ cwd: p.pm2_env.pm_cwd,
+ pid: p.pid,
+ categoryId: cat.id,
+ categoryName: cat.name,
+ categoryAccent: cat.accent,
+ description: ATLAS_DESCRIPTIONS[p.name] || null,
+ };
+ });
+
+ // Group by category, preserving canonical category order
+ const byCat = {};
+ for (const c of [...ATLAS_CATEGORIES, { id: 'other', name: 'Other', accent: '#555' }]) {
+ byCat[c.id] = { ...c, projects: [] };
+ }
+ for (const p of projects) byCat[p.categoryId].projects.push(p);
+ for (const id in byCat) byCat[id].projects.sort((a, b) => a.name.localeCompare(b.name));
+
+ const ordered = Object.values(byCat).filter(c => c.projects.length > 0);
+
+ // Health summary
+ const total = projects.length;
+ const online = projects.filter(p => p.status === 'online').length;
+ const errored = total - online;
+ const totalRestarts = projects.reduce((s, p) => s + p.restartTime, 0);
+ const totalMem = projects.reduce((s, p) => s + p.memMb, 0);
+
+ res.json({
+ generatedAt: new Date().toISOString(),
+ summary: { total, online, errored, totalRestarts, totalMemMb: totalMem },
+ categories: ordered,
+ });
+});
+
+app.get('/api/health', (req, res) => res.json({ status: 'ok', service: 'fleet-tv' }));
+
+app.listen(PORT, '127.0.0.1', () => {
+ console.log(`[fleet-tv] live wall display on http://127.0.0.1:${PORT}`);
+});
(oldest)
·
back to Fleet Tv
·
fleet-tv: broaden channel list beyond DW micro-sites — added dcbe0c8 →