[object Object]

← back to Fleet Tv

initial scaffold (gitify-all 2026-05-06)

8537479afd969f87c00c76068f468d84f091b20b · 2026-05-06 10:25:18 -0700 · Steve Abrams

Files touched

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 => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[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 => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[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 => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[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 => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[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 => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[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 →