From 70cc4b6067b02a32fabb1a35490509c432388a27 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Sat, 30 May 2026 16:28:39 -0400 Subject: [PATCH 001/201] Phase 2: Adopt Preact + signals (toolchain setup) - Add preact, @preact/signals, @preact/preset-vite - Wire preset into vite.config.js and vitest.config.js - tsconfig: jsx: preserve, jsxImportSource: preact, include *.tsx - Extract main.ts boot flow into src/boot.ts (bootApp + applyPendingTitle + EMPTY_MANIFEST) - New src/main.tsx renders into #app - New src/views/shell/App.tsx is a Phase-2 placeholder: layout JSX + useEffect that calls bootApp - Collapse index.html to
+ +
+ diff --git a/app/package-lock.json b/app/package-lock.json index 298958c1..bc0ca59b 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -9,14 +9,17 @@ "version": "1.0.0", "license": "AGPL-3.0", "dependencies": { + "@preact/signals": "^2.9.1", "highlight.js": "^11.11.1", "marked": "^18.0.3", "nanostores": "^1.3.0", + "preact": "^10.29.2", "rbush": "^4.0.1", "three": "^0.184.0" }, "devDependencies": { "@eslint/js": "^10.0.1", + "@preact/preset-vite": "^2.10.5", "@types/node": "^25.6.0", "@types/three": "^0.184.0", "@vitest/coverage-v8": "^4.1.7", @@ -83,6 +86,199 @@ "dev": true, "license": "MIT" }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", @@ -103,6 +299,30 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", @@ -119,6 +339,92 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.29.7.tgz", + "integrity": "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.29.7.tgz", + "integrity": "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", @@ -528,6 +834,28 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -585,6 +913,131 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@preact/preset-vite": { + "version": "2.10.5", + "resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.10.5.tgz", + "integrity": "sha512-p0vJpxiVO7KWWazWny3LUZ+saXyZKWv6Ju0bYMWNJRp2YveufRPgSUB1C4MTqGJfz07EehMgfN+AJNwQy+w6Iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1", + "@babel/plugin-transform-react-jsx-development": "^7.27.1", + "@prefresh/vite": "^2.4.11", + "@rollup/pluginutils": "^5.0.0", + "babel-plugin-transform-hook-names": "^1.0.2", + "debug": "^4.4.3", + "magic-string": "^0.30.21", + "picocolors": "^1.1.1", + "vite-prerender-plugin": "^0.5.8", + "zimmerframe": "^1.1.4" + }, + "peerDependencies": { + "@babel/core": "7.x", + "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x" + } + }, + "node_modules/@preact/signals": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@preact/signals/-/signals-2.9.1.tgz", + "integrity": "sha512-xVqN8mJjbSN5IB/8Ubmd9NN+Ew6zJswoRxrjZbH3YsgkMshFeO6d8zxEFpHRTq9GJZx7cnPs2CnCpFqtGXGNsw==", + "license": "MIT", + "dependencies": { + "@preact/signals-core": "^1.14.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact": ">= 10.25.0 || >=11.0.0-0" + } + }, + "node_modules/@preact/signals-core": { + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.2.tgz", + "integrity": "sha512-RZHdBj9ZF4n40Rp4jS052EHHjBWf96P9oNdXPfhQTovCuWY9iQn3Gq+gOTJSgBO9A/JBuPfMOWsSX/lIU9Pc/A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/@prefresh/babel-plugin": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@prefresh/babel-plugin/-/babel-plugin-0.5.3.tgz", + "integrity": "sha512-57LX2SHs4BX2s1IwCjNzTE2OJeEepRCNf1VTEpbNcUyHfMO68eeOWGDIt4ob9aYlW6PEWZ1SuwNikuoIXANDtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prefresh/core": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/@prefresh/core/-/core-1.5.10.tgz", + "integrity": "sha512-7yPTFbG56sutaFu8krp3B4a200KOFUvrtlllKWRuLjsYXo9UUucHOZRcer+gtgMkFTpv6ob8TGcTwA32bSwa1w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "preact": "^10.0.0 || ^11.0.0-0" + } + }, + "node_modules/@prefresh/utils": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@prefresh/utils/-/utils-1.2.1.tgz", + "integrity": "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prefresh/vite": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/@prefresh/vite/-/vite-2.4.12.tgz", + "integrity": "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.22.1", + "@prefresh/babel-plugin": "^0.5.2", + "@prefresh/core": "^1.5.0", + "@prefresh/utils": "^1.2.0", + "@rollup/pluginutils": "^4.2.1" + }, + "peerDependencies": { + "preact": "^10.4.0 || ^11.0.0-0", + "vite": ">=2.0.0" + } + }, + "node_modules/@prefresh/vite/node_modules/@rollup/pluginutils": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", + "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^2.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/@prefresh/vite/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prefresh/vite/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.15", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", @@ -872,6 +1325,36 @@ "dev": true, "license": "MIT" }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1415,6 +1898,16 @@ "js-tokens": "^10.0.0" } }, + "node_modules/babel-plugin-transform-hook-names": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz", + "integrity": "sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.12.10" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1446,6 +1939,19 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -1468,6 +1974,13 @@ "readable-stream": "^3.4.0" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, "node_modules/brace-expansion": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", @@ -1481,6 +1994,41 @@ "node": "18 || 20 || >=22" } }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -1506,6 +2054,27 @@ "ieee754": "^1.1.13" } }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/canvas": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.2.3.tgz", @@ -1561,6 +2130,23 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -1575,6 +2161,19 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -1657,6 +2256,85 @@ "node": ">=8" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.364", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", + "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==", + "dev": true, + "license": "ISC" + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -1687,6 +2365,16 @@ "dev": true, "license": "MIT" }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -2018,6 +2706,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -2061,6 +2759,16 @@ "node": ">=8" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/highlight.js": { "version": "11.11.1", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", @@ -2270,6 +2978,19 @@ } } }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -2291,6 +3012,19 @@ "dev": true, "license": "MIT" }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -2301,6 +3035,13 @@ "json-buffer": "3.0.1" } }, + "node_modules/kolorist": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", + "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", + "dev": true, + "license": "MIT" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -2787,6 +3528,40 @@ "dev": true, "license": "MIT" }, + "node_modules/node-html-parser": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz", + "integrity": "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -2911,7 +3686,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2948,6 +3722,17 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.29.2", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", + "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -3220,6 +4005,16 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-code-frame": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/simple-code-frame/-/simple-code-frame-1.3.0.tgz", + "integrity": "sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "kolorist": "^1.6.0" + } + }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -3267,6 +4062,16 @@ "simple-concat": "^1.0.0" } }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3277,6 +4082,16 @@ "node": ">=0.10.0" } }, + "node_modules/stack-trace": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-1.0.0.tgz", + "integrity": "sha512-H6D7134xi6qONvh7ZHKgviXf+rd3vhGBSvebPZCaUkd8zvQ+7PtDw6CljPTe4cXWNf2IKZGNqw6VJXSb9IgBpA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -3530,6 +4345,37 @@ "dev": true, "license": "MIT" }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -3626,6 +4472,24 @@ } } }, + "node_modules/vite-prerender-plugin": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/vite-prerender-plugin/-/vite-prerender-plugin-0.5.13.tgz", + "integrity": "sha512-IKSpYkzDBsKAxa05naRbj7GvNVMSdww/Z/E89oO3xndz+gWnOBOKOAbEXv7qDhktY/j3vHgJmoV1pPzqU2tx9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "kolorist": "^1.8.0", + "magic-string": "0.x >= 0.26.0", + "node-html-parser": "^6.1.12", + "simple-code-frame": "^1.3.0", + "source-map": "^0.7.4", + "stack-trace": "^1.0.0-pre2" + }, + "peerDependencies": { + "vite": "5.x || 6.x || 7.x || 8.x" + } + }, "node_modules/vitest": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", @@ -3832,6 +4696,13 @@ "dev": true, "license": "MIT" }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -3844,6 +4715,13 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" } } } diff --git a/app/package.json b/app/package.json index edf8a6d6..5d87a9b8 100644 --- a/app/package.json +++ b/app/package.json @@ -28,6 +28,7 @@ "homepage": "https://github.com/thalida/codecity#readme", "devDependencies": { "@eslint/js": "^10.0.1", + "@preact/preset-vite": "^2.10.5", "@types/node": "^25.6.0", "@types/three": "^0.184.0", "@vitest/coverage-v8": "^4.1.7", @@ -43,9 +44,11 @@ "vitest": "^4.1.4" }, "dependencies": { + "@preact/signals": "^2.9.1", "highlight.js": "^11.11.1", "marked": "^18.0.3", "nanostores": "^1.3.0", + "preact": "^10.29.2", "rbush": "^4.0.1", "three": "^0.184.0" } diff --git a/app/src/boot.ts b/app/src/boot.ts new file mode 100644 index 00000000..7078d7d7 --- /dev/null +++ b/app/src/boot.ts @@ -0,0 +1,420 @@ +// boot.ts — App bootstrap. Called from AppShell.tsx's useEffect after the +// initial Preact render places the canvas and DOM slots in the document. +// This module owns the work that used to live at the top level of main.ts: +// - hydrate persisted settings before first paint +// - decide cold-boot vs URL-param boot, run the manifest stream +// - kick off the render loop, attach commit reactions +// - wire the source picker + loading overlay + live updates +// +// Extracted in Phase 2 of the Preact migration. Behavior is identical to +// the pre-Phase-2 main.ts; only the call site changed (now invoked from +// inside Preact's lifecycle instead of at module load). + +import * as Config from './state/settings/index'; +import { REBUILD_STATUS } from './state/runtime/liveStatus'; +import { attachPersistence, persistAtomPerSource } from './state/persist'; +import { SYNTAX_THEME } from './state/settings/prefs/syntaxTheme'; +import { sourceKey, CURRENT_SOURCE_KEY } from './state/runtime/sourceContext'; +import { attachCommitReactions } from './state/reactions'; +import { setupLiveUpdates } from './state/runtime/liveUpdates'; +import { DOM_IDS } from './constants'; +import { NodeKind } from './types'; +import type { Manifest } from './types'; + +import { PICKER_SELECTION_KEY } from './scene/system/picker'; +import { manifestUrl } from './api/manifest'; +import { srcKind, labelFromUrl } from './utils/sources'; +import { applyHljsTheme } from './utils/syntaxTheme'; +import { buildIconAtlas } from './scene/components/buildings/iconAtlas'; +import { setIconAtlas } from './scene/components/buildings/buildings'; +import { setCellIconAtlas } from './scene/components/buildings/buildingsCell'; +import { createSourcePicker, type SourcePayload } from './views/components/sourcePicker'; +import { createLoadingOverlay } from './views/components/loadingOverlay'; +import { streamManifest } from './api/manifest'; +import { pushRecent } from './state/runtime/sourceRecents'; +import { startRenderLoop, _applyDisplayLabel } from './scene/renderLoop'; +import { getServerConfig } from './api/config'; + +/** + * Set document.title to "{label} (pending) — codecity" from a server-emitted + * `display_root`. Called from the source-applying loop on the FIRST stream + * event that carries display_root — before any manifest exists — so the tab + * title shows the project being loaded instead of the static page title. + * + * Once the final manifest lands, coordinator.ts's title-swap (using + * labelFromManifest) takes over and the "(pending)" suffix disappears. + * + * Exported so the corresponding unit test can drive it without booting + * the renderer. + */ +export function applyPendingTitle(displayRoot: string): void { + const label = labelFromUrl(displayRoot); + document.title = label ? `${label} (pending) — codecity` : 'codecity'; +} + +export const EMPTY_MANIFEST: Manifest = { + root: '', + scanned_at: new Date().toISOString(), + signature: '', + tree_signature: '', + tree: { + name: '', + type: NodeKind.Directory, + path: '', + fullPath: '', + children: [], + children_count: 0, + children_file_count: 0, + children_dir_count: 0, + descendants_count: 0, + descendants_file_count: 0, + descendants_dir_count: 0, + descendants_size: 0, + }, + repo: { + branch: null, + remote_url: null, + head_sha: null, + head_subject: null, + dirty: false, + }, + commits: [], +}; + +export async function bootApp(): Promise { + const canvas = document.getElementById(DOM_IDS.CANVAS) as HTMLCanvasElement | null; + if (!canvas) return; + + // One-shot flag: set by the source-picker onSubmit when the user ticked + // "Skip cache (fresh scan)". Consumed by the FIRST applyNewSource() call + // that follows, then cleared — subsequent live-update polls are unaffected. + let _pendingSkipCache = false; + + // Hydrate every config store from localStorage BEFORE the initial + // manifest fetch so user-persisted config values are applied before + // the first paint. + attachPersistence(Config); + + // Apply the persisted (or default) syntax theme immediately after + // hydration, then track future changes. The subscribe call fires + // synchronously on registration — that first fire covers the boot case. + SYNTAX_THEME.subscribe(applyHljsTheme); + + // Set CURRENT_SOURCE_KEY from URL params BEFORE wiring per-source + // persistence so hydration sees the right key. + { + const qp = new URLSearchParams(window.location.search); + if (qp.has('src')) { + CURRENT_SOURCE_KEY.set(sourceKey(qp.get('src')!, qp.get('branch') ?? undefined)); + } + } + + persistAtomPerSource('selection', PICKER_SELECTION_KEY, null); + + const qp = new URLSearchParams(window.location.search); + const hasSrc = qp.has('src'); + + const loadingOverlay = createLoadingOverlay(); + + // Forward REBUILD_STATUS → loadingOverlay so the loading card + // advances to "Adding decorations" while applyManifest is in its + // deferred foliage-build phase. Lives for the page lifetime so + // source-switches (which re-show the overlay) also get the step. + // setStep on a hidden overlay is a harmless DOM update. + REBUILD_STATUS.subscribe((s) => { + if (s === 'decorating') loadingOverlay.setStep('decorating'); + }); + + let initialManifest: Manifest = EMPTY_MANIFEST; + let initialError: string | null = null; + let handle: Awaited> | null = null; + if (hasSrc) { + const _bootSrc = qp.get('src')!; + const _bootBranch = qp.get('branch') ?? undefined; + loadingOverlay.show({ + kind: srcKind(_bootSrc), + label: labelFromUrl(_bootSrc) ?? _bootSrc, + branch: _bootBranch, + }); + try { + let _pendingTitleSet = false; + for await (const event of streamManifest(manifestUrl())) { + if (event.phase === 'error') throw new Error(event.error); + // First event carrying display_root (cloning for git, scanning + // for local) — set the pending document title AND the overlay + // header before the manifest lands, so both the tab and the + // loading card show the project name during clone/scan instead + // of just the static page title / generic spinner copy. + if (!_pendingTitleSet && 'display_root' in event && event.display_root) { + applyPendingTitle(event.display_root); + loadingOverlay.setPendingLabel(labelFromUrl(event.display_root)); + _pendingTitleSet = true; + } + // Lifecycle markers (cloning/scanning) carry no manifest — + // advance the overlay step and continue. The first manifest- + // bearing event (skeleton or final) does the bootstrap below. + if (event.phase === 'cloning' || event.phase === 'scanning') { + loadingOverlay.setStep(event.phase); + // Subsequent same-phase events carry running progress — + // render it as a tail on the active step row. Spec: ~250ms + // throttle is server-side; client just paints what arrives. + if (event.phase === 'cloning' && event.percent !== undefined) { + const stage = event.stage ? ` (${event.stage})` : ''; + loadingOverlay.setStepTail('cloning', `${event.percent}%${stage}`); + } else if (event.phase === 'scanning' && event.files_scanned !== undefined) { + loadingOverlay.setStepTail( + 'scanning', + `${event.files_scanned.toLocaleString()} files` + ); + } + continue; + } + const m = event.manifest; + // Advance the overlay step BEFORE the (synchronous-looking) work + // begins so the user sees the phase update before the city paints + // behind the semi-transparent backdrop. Clear any lingering + // tails from the now-completed phases so the step rows look + // clean as they collapse into the "done" state. + loadingOverlay.setStepTail('cloning', null); + loadingOverlay.setStepTail('scanning', null); + loadingOverlay.setStep(event.phase === 'skeleton' ? 'skeleton' : 'building'); + if (handle === null) { + // First manifest event — skeleton on cold cache, or final on + // cache hit. Either way: bootstrap the renderer NOW so the city + // becomes visible behind the overlay. The skeleton manifest has + // the full tree shape, so the icon atlas built from it is + // correct for the final manifest too — no rebuild needed when + // final arrives. world.applyManifest diff-and-tweens the + // skeleton → final transition. + try { + const _builtAtlas = await buildIconAtlas(m); + setIconAtlas(_builtAtlas); + setCellIconAtlas(_builtAtlas); + } catch (err) { + console.warn( + '[codecity] icon atlas build failed; roofs will render without icons', + err + ); + } + handle = await startRenderLoop(canvas, m); + attachCommitReactions({ + world: handle.world, + applyTheme: handle.applyTheme, + }); + } else { + // Second event (final after skeleton) — tween the city into its + // final state. startRenderLoop already applied the skeleton, so + // re-call applyManifest on the existing scene. Phase 2 swaps + // b.file to the fresh FileNode from the new manifest so colors, + // ages, and dimensions compute from real metadata on the cache-hit + // fast path. + _applyDisplayLabel(m); + await handle.world.applyManifest(m); + } + initialManifest = m; + } + if (handle === null) { + // Stream closed without emitting a single event. + throw new Error('No manifest received'); + } + } catch (err) { + initialError = err instanceof Error ? err.message : String(err); + // If we never constructed a renderer, do it with EMPTY now so the + // rest of bootApp (picker, Save-commit reactions) has a valid + // handle to work against. + if (handle === null) { + handle = await startRenderLoop(canvas, EMPTY_MANIFEST); + attachCommitReactions({ + world: handle.world, + applyTheme: handle.applyTheme, + }); + } + initialManifest = EMPTY_MANIFEST; + } finally { + // Hide only after both events (or cache-hit single final) have been + // fully applied — the spec's "modal stays up until the city is + // fully built" invariant. + loadingOverlay.hide(); + } + } else { + handle = await startRenderLoop(canvas, EMPTY_MANIFEST); + attachCommitReactions({ + world: handle.world, + applyTheme: handle.applyTheme, + }); + } + + let liveUpdatesStarted = false; + let _liveUpdates: { setSignature(sig: string): void } | null = null; + if (hasSrc && !initialError) { + _liveUpdates = setupLiveUpdates(handle, initialManifest.signature); + liveUpdatesStarted = true; + } + + // Remember the dismissible flag of the most recent open() call so error + // reopen preserves it (header-switch reopen stays dismissible after a + // failed submit; cold-boot reopen stays non-dismissible). + let _lastDismissible = false; + + async function applyNewSource(payload: SourcePayload): Promise { + // Clear the layout cache so the cell fast path doesn't reuse stale cells + // from the previous source. The cache is valid within a single source + // (skeleton → final live-update), but must be reset when switching sources + // since different repos can produce the same tree_signature by coincidence. + handle!.world.resetCache(); + const dismissibleOnError = _lastDismissible; + loadingOverlay.show({ + kind: srcKind(payload.src), + label: labelFromUrl(payload.src) ?? payload.src, + branch: payload.branch, + }); + try { + const url = new URL('/api/manifest', window.location.origin); + url.searchParams.set('src', payload.src); + if (payload.branch) url.searchParams.set('branch', payload.branch); + // Consume the one-shot skip-cache flag set by the source picker. + // Only this first fetch uses it; the poll loop is unaffected. + if (_pendingSkipCache) { + url.searchParams.set('no_cache', 'true'); + _pendingSkipCache = false; + } + + let manifest: Manifest | null = null; + let _pendingTitleSet = false; + for await (const event of streamManifest(url.toString())) { + if (event.phase === 'error') throw new Error(event.error); + if (!_pendingTitleSet && 'display_root' in event && event.display_root) { + applyPendingTitle(event.display_root); + loadingOverlay.setPendingLabel(labelFromUrl(event.display_root)); + _pendingTitleSet = true; + } + if (event.phase === 'cloning' || event.phase === 'scanning') { + loadingOverlay.setStep(event.phase); + if (event.phase === 'cloning' && event.percent !== undefined) { + const stage = event.stage ? ` (${event.stage})` : ''; + loadingOverlay.setStepTail('cloning', `${event.percent}%${stage}`); + } else if (event.phase === 'scanning' && event.files_scanned !== undefined) { + loadingOverlay.setStepTail( + 'scanning', + `${event.files_scanned.toLocaleString()} files` + ); + } + continue; + } + loadingOverlay.setStepTail('cloning', null); + loadingOverlay.setStepTail('scanning', null); + loadingOverlay.setStep(event.phase === 'skeleton' ? 'skeleton' : 'building'); + if (event.phase === 'skeleton') { + _applyDisplayLabel(event.manifest); + await handle!.world.applyManifest(event.manifest); + handle!.coordinator.setSourceInfo( + payload.branch, + srcKind(payload.src) === 'git' ? payload.src : undefined + ); + } + manifest = event.manifest; + } + if (!manifest) throw new Error('No manifest received'); + + const pageUrl = new URL(window.location.href); + pageUrl.searchParams.set('src', payload.src); + if (payload.branch) pageUrl.searchParams.set('branch', payload.branch); + else pageUrl.searchParams.delete('branch'); + pageUrl.searchParams.delete('git_window'); + history.replaceState(null, '', pageUrl.toString()); + + CURRENT_SOURCE_KEY.set(sourceKey(payload.src, payload.branch)); + + try { + const _builtAtlas = await buildIconAtlas(manifest); + setIconAtlas(_builtAtlas); + setCellIconAtlas(_builtAtlas); + } catch (err) { + console.warn('[codecity] icon atlas build failed', err); + } + + _applyDisplayLabel(manifest); + await handle!.world.applyManifest(manifest); + + const manifestBranch = manifest.repo.branch; + const looksLikeRealBranch = + !!manifestBranch && + !/\s/.test(manifestBranch) && + !manifestBranch.startsWith('(') && + !manifestBranch.startsWith('detached'); + const resolvedBranch = + payload.branch ?? (looksLikeRealBranch ? manifestBranch! : undefined); + const branchIsDefault = !payload.branch && looksLikeRealBranch; + + handle!.coordinator.setSourceInfo( + resolvedBranch, + srcKind(payload.src) === 'git' ? payload.src : undefined + ); + + _liveUpdates?.setSignature(manifest.signature); + pushRecent({ + src: payload.src, + branch: resolvedBranch, + branchIsDefault, + label: labelFromUrl(payload.src) ?? payload.src, + }); + + if (!liveUpdatesStarted) { + _liveUpdates = setupLiveUpdates(handle!, manifest.signature); + liveUpdatesStarted = true; + } + } catch (err) { + picker.open({ + dismissible: dismissibleOnError, + prefill: payload, + error: err instanceof Error ? err.message : String(err), + }); + return; + } finally { + loadingOverlay.hide(); + } + } + + const serverConfig = await getServerConfig(); + const picker = createSourcePicker({ + allowLocalRepos: serverConfig.allowLocalRepos, + onSubmit: (payload) => { + _pendingSkipCache = !!payload.skipCache; + picker.close(); + applyNewSource(payload); + }, + }); + + // Boot decisions: + if (initialError) { + _lastDismissible = false; + picker.open({ + dismissible: false, + prefill: { + src: qp.get('src')!, + branch: qp.get('branch') ?? undefined, + }, + error: initialError, + }); + } else if (!hasSrc) { + _lastDismissible = false; + picker.open({ dismissible: false }); + } else { + loadingOverlay.hide(); + } + + // Wire the header "switch source" button via a global hook. + (window as Window & { __openSourcePicker?: () => void }).__openSourcePicker = () => { + const cur = new URLSearchParams(window.location.search); + _lastDismissible = true; + picker.open({ + dismissible: true, + prefill: cur.has('src') + ? { + src: cur.get('src')!, + branch: cur.get('branch') ?? undefined, + } + : undefined, + }); + }; +} diff --git a/app/src/main.ts b/app/src/main.ts deleted file mode 100644 index 38a872f2..00000000 --- a/app/src/main.ts +++ /dev/null @@ -1,458 +0,0 @@ -// main.ts — Entry point. Fetches the manifest from the local Python server -// at /api/manifest, lays out the city, builds the scene, and starts the -// render loop with orbit/pan/zoom controls and raycast picking. - -import './styles.css'; - -import * as Config from './state/settings/index'; -import { REBUILD_STATUS } from './state/runtime/liveStatus'; -import { attachPersistence, persistAtomPerSource } from './state/persist'; -import { SYNTAX_THEME } from './state/settings/prefs/syntaxTheme'; -import { sourceKey, CURRENT_SOURCE_KEY } from './state/runtime/sourceContext'; -import { attachCommitReactions } from './state/reactions'; -import { setupLiveUpdates } from './state/runtime/liveUpdates'; -import { DOM_IDS } from './constants'; -import { NodeKind } from './types'; -import type { Manifest } from './types'; - -import { PICKER_SELECTION_KEY } from './scene/system/picker'; -import { manifestUrl } from './api/manifest'; -import { srcKind, labelFromUrl } from './utils/sources'; -import { applyHljsTheme } from './utils/syntaxTheme'; -import { buildIconAtlas } from './scene/components/buildings/iconAtlas'; -import { setIconAtlas } from './scene/components/buildings/buildings'; -import { setCellIconAtlas } from './scene/components/buildings/buildingsCell'; -import { createSourcePicker, type SourcePayload } from './views/components/sourcePicker'; -import { createLoadingOverlay } from './views/components/loadingOverlay'; -import { streamManifest } from './api/manifest'; -import { pushRecent } from './state/runtime/sourceRecents'; -import { startRenderLoop, _applyDisplayLabel } from './scene/renderLoop'; -import { getServerConfig } from './api/config'; - -/** - * Set document.title to "{label} (pending) — codecity" from a server-emitted - * `display_root`. Called from the source-applying loop on the FIRST stream - * event that carries display_root — before any manifest exists — so the tab - * title shows the project being loaded instead of the static page title. - * - * Once the final manifest lands, coordinator.ts's title-swap (using - * labelFromManifest) takes over and the "(pending)" suffix disappears. - * - * Exported so the corresponding unit test can drive it without booting - * the renderer. - */ -export function applyPendingTitle(displayRoot: string): void { - const label = labelFromUrl(displayRoot); - document.title = label ? `${label} (pending) — codecity` : 'codecity'; -} - -const EMPTY_MANIFEST: Manifest = { - root: '', - scanned_at: new Date().toISOString(), - signature: '', - tree_signature: '', - tree: { - name: '', - type: NodeKind.Directory, - path: '', - fullPath: '', - children: [], - children_count: 0, - children_file_count: 0, - children_dir_count: 0, - descendants_count: 0, - descendants_file_count: 0, - descendants_dir_count: 0, - descendants_size: 0, - }, - repo: { - branch: null, - remote_url: null, - head_sha: null, - head_subject: null, - dirty: false, - }, - commits: [], -}; - -// One-shot flag: set by the source-picker onSubmit when the user ticked -// "Skip cache (fresh scan)". Consumed by the FIRST applyNewSource() call -// that follows, then cleared — subsequent live-update polls are unaffected. -let _pendingSkipCache = false; - -// Boot. Guarded by a canvas check so unit tests can import this module -// without triggering any DOM/network side effects. -const _canvas = document.getElementById(DOM_IDS.CANVAS) as HTMLCanvasElement | null; -if (_canvas) { - (async function boot() { - // Hydrate every config store from localStorage BEFORE the initial - // manifest fetch so user-persisted config values are applied before - // the first paint. - attachPersistence(Config); - - // Apply the persisted (or default) syntax theme immediately after - // hydration, then track future changes. The subscribe call fires - // synchronously on registration — that first fire covers the boot case. - SYNTAX_THEME.subscribe(applyHljsTheme); - - // Set CURRENT_SOURCE_KEY from URL params BEFORE wiring per-source - // persistence so hydration sees the right key. - { - const qp = new URLSearchParams(window.location.search); - if (qp.has('src')) { - CURRENT_SOURCE_KEY.set(sourceKey(qp.get('src')!, qp.get('branch') ?? undefined)); - } - } - - persistAtomPerSource('selection', PICKER_SELECTION_KEY, null); - - const qp = new URLSearchParams(window.location.search); - const hasSrc = qp.has('src'); - - const loadingOverlay = createLoadingOverlay(); - - // Forward REBUILD_STATUS → loadingOverlay so the loading card - // advances to "Adding decorations" while applyManifest is in its - // deferred foliage-build phase. Lives for the page lifetime so - // source-switches (which re-show the overlay) also get the step. - // setStep on a hidden overlay is a harmless DOM update. - REBUILD_STATUS.subscribe((s) => { - if (s === 'decorating') loadingOverlay.setStep('decorating'); - }); - - let initialManifest: Manifest = EMPTY_MANIFEST; - let initialError: string | null = null; - let handle: Awaited> | null = null; - if (hasSrc) { - const _bootSrc = qp.get('src')!; - const _bootBranch = qp.get('branch') ?? undefined; - loadingOverlay.show({ - kind: srcKind(_bootSrc), - label: labelFromUrl(_bootSrc) ?? _bootSrc, - branch: _bootBranch, - }); - try { - let _pendingTitleSet = false; - for await (const event of streamManifest(manifestUrl())) { - if (event.phase === 'error') throw new Error(event.error); - // First event carrying display_root (cloning for git, scanning - // for local) — set the pending document title AND the overlay - // header before the manifest lands, so both the tab and the - // loading card show the project name during clone/scan instead - // of just the static page title / generic spinner copy. - if (!_pendingTitleSet && 'display_root' in event && event.display_root) { - applyPendingTitle(event.display_root); - loadingOverlay.setPendingLabel(labelFromUrl(event.display_root)); - _pendingTitleSet = true; - } - // Lifecycle markers (cloning/scanning) carry no manifest — - // advance the overlay step and continue. The first manifest- - // bearing event (skeleton or final) does the bootstrap below. - if (event.phase === 'cloning' || event.phase === 'scanning') { - loadingOverlay.setStep(event.phase); - // Subsequent same-phase events carry running progress — - // render it as a tail on the active step row. Spec: ~250ms - // throttle is server-side; client just paints what arrives. - if (event.phase === 'cloning' && event.percent !== undefined) { - const stage = event.stage ? ` (${event.stage})` : ''; - loadingOverlay.setStepTail('cloning', `${event.percent}%${stage}`); - } else if (event.phase === 'scanning' && event.files_scanned !== undefined) { - loadingOverlay.setStepTail( - 'scanning', - `${event.files_scanned.toLocaleString()} files` - ); - } - continue; - } - const m = event.manifest; - // Advance the overlay step BEFORE the (synchronous-looking) work - // begins so the user sees the phase update before the city paints - // behind the semi-transparent backdrop. Clear any lingering - // tails from the now-completed phases so the step rows look - // clean as they collapse into the "done" state. - loadingOverlay.setStepTail('cloning', null); - loadingOverlay.setStepTail('scanning', null); - loadingOverlay.setStep(event.phase === 'skeleton' ? 'skeleton' : 'building'); - if (handle === null) { - // First manifest event — skeleton on cold cache, or final on - // cache hit. Either way: bootstrap the renderer NOW so the city - // becomes visible behind the overlay. The skeleton manifest has - // the full tree shape, so the icon atlas built from it is - // correct for the final manifest too — no rebuild needed when - // final arrives. world.applyManifest diff-and-tweens the - // skeleton → final transition. - try { - const _builtAtlas = await buildIconAtlas(m); - setIconAtlas(_builtAtlas); - setCellIconAtlas(_builtAtlas); - } catch (err) { - console.warn( - '[codecity] icon atlas build failed; roofs will render without icons', - err - ); - } - handle = await startRenderLoop(_canvas, m); - attachCommitReactions({ - world: handle.world, - applyTheme: handle.applyTheme, - }); - } else { - // Second event (final after skeleton) — tween the city into its - // final state. startRenderLoop already applied the skeleton, so - // re-call applyManifest on the existing scene. Phase 2 swaps - // b.file to the fresh FileNode from the new manifest so colors, - // ages, and dimensions compute from real metadata on the cache-hit - // fast path. - _applyDisplayLabel(m); - await handle.world.applyManifest(m); - } - initialManifest = m; - } - if (handle === null) { - // Stream closed without emitting a single event. - throw new Error('No manifest received'); - } - } catch (err) { - initialError = err instanceof Error ? err.message : String(err); - // If we never constructed a renderer, do it with EMPTY now so the - // rest of main.ts (picker, Save-commit reactions) has a valid - // handle to work against. - if (handle === null) { - handle = await startRenderLoop(_canvas, EMPTY_MANIFEST); - attachCommitReactions({ - world: handle.world, - applyTheme: handle.applyTheme, - }); - } - initialManifest = EMPTY_MANIFEST; - } finally { - // Hide only after both events (or cache-hit single final) have been - // fully applied — the spec's "modal stays up until the city is - // fully built" invariant. - loadingOverlay.hide(); - } - } else { - handle = await startRenderLoop(_canvas, EMPTY_MANIFEST); - attachCommitReactions({ - world: handle.world, - applyTheme: handle.applyTheme, - }); - } - - let liveUpdatesStarted = false; - let _liveUpdates: { setSignature(sig: string): void } | null = null; - if (hasSrc && !initialError) { - _liveUpdates = setupLiveUpdates(handle, initialManifest.signature); - liveUpdatesStarted = true; - } - - // Remember the dismissible flag of the most recent open() call so error - // reopen preserves it (header-switch reopen stays dismissible after a - // failed submit; cold-boot reopen stays non-dismissible). - let _lastDismissible = false; - - async function applyNewSource(payload: SourcePayload): Promise { - // Clear the layout cache so the cell fast path doesn't reuse stale cells - // from the previous source. The cache is valid within a single source - // (skeleton → final live-update), but must be reset when switching sources - // since different repos can produce the same tree_signature by coincidence. - handle.world.resetCache(); - const dismissibleOnError = _lastDismissible; - loadingOverlay.show({ - kind: srcKind(payload.src), - label: labelFromUrl(payload.src) ?? payload.src, - branch: payload.branch, - }); - try { - const url = new URL('/api/manifest', window.location.origin); - url.searchParams.set('src', payload.src); - if (payload.branch) url.searchParams.set('branch', payload.branch); - // Consume the one-shot skip-cache flag set by the source picker. - // Only this first fetch uses it; the poll loop is unaffected. - if (_pendingSkipCache) { - url.searchParams.set('no_cache', 'true'); - _pendingSkipCache = false; - } - - let manifest: Manifest | null = null; - let _pendingTitleSet = false; - for await (const event of streamManifest(url.toString())) { - if (event.phase === 'error') throw new Error(event.error); - // First event carrying display_root (cloning for git, scanning - // for local) — set the pending document title AND the overlay - // header before the manifest lands, so both the tab and the - // loading card show the new project name during clone/scan - // after a source-switch. - if (!_pendingTitleSet && 'display_root' in event && event.display_root) { - applyPendingTitle(event.display_root); - loadingOverlay.setPendingLabel(labelFromUrl(event.display_root)); - _pendingTitleSet = true; - } - // Lifecycle markers (cloning/scanning) carry no manifest — - // advance the overlay step and continue. Progress fields - // (percent / files_scanned) render as a tail on the active - // step row; main flow is unchanged. - if (event.phase === 'cloning' || event.phase === 'scanning') { - loadingOverlay.setStep(event.phase); - if (event.phase === 'cloning' && event.percent !== undefined) { - const stage = event.stage ? ` (${event.stage})` : ''; - loadingOverlay.setStepTail('cloning', `${event.percent}%${stage}`); - } else if (event.phase === 'scanning' && event.files_scanned !== undefined) { - loadingOverlay.setStepTail( - 'scanning', - `${event.files_scanned.toLocaleString()} files` - ); - } - continue; - } - // Skeleton step covers the placeholder paint while the server - // resolves per-file metadata; building step covers the final - // tween. Overlay stays up through both — hidden only in finally. - // Clear lingering progress tails so the now-done step rows - // look clean. - loadingOverlay.setStepTail('cloning', null); - loadingOverlay.setStepTail('scanning', null); - loadingOverlay.setStep(event.phase === 'skeleton' ? 'skeleton' : 'building'); - if (event.phase === 'skeleton') { - // Apply the skeleton so the new city paints behind the overlay - // — the final event will tween into final heights. - _applyDisplayLabel(event.manifest); - await handle.world.applyManifest(event.manifest); - // Update the header (project label, branch pill) right after the - // skeleton lands so it reflects the new project immediately, - // not minutes later when the final manifest arrives. The - // post-loop call below covers the cache-hit case where this - // branch doesn't fire; both calls are idempotent for the same - // payload. - handle.coordinator.setSourceInfo( - payload.branch, - srcKind(payload.src) === 'git' ? payload.src : undefined - ); - } - manifest = event.manifest; - } - if (!manifest) throw new Error('No manifest received'); - - // Update URL first so per-source persistence subscriptions see the - // right CURRENT_SOURCE_KEY on the next tick. - const pageUrl = new URL(window.location.href); - pageUrl.searchParams.set('src', payload.src); - if (payload.branch) pageUrl.searchParams.set('branch', payload.branch); - else pageUrl.searchParams.delete('branch'); - // Strip any stale git_window left over from older bookmarks. - pageUrl.searchParams.delete('git_window'); - history.replaceState(null, '', pageUrl.toString()); - - CURRENT_SOURCE_KEY.set(sourceKey(payload.src, payload.branch)); - - try { - const _builtAtlas = await buildIconAtlas(manifest); - setIconAtlas(_builtAtlas); - setCellIconAtlas(_builtAtlas); - } catch (err) { - console.warn('[codecity] icon atlas build failed', err); - } - - _applyDisplayLabel(manifest); - await handle.world.applyManifest(manifest); - - // If the user didn't explicitly request a branch, fall back to - // the manifest's resolved HEAD (the repo's default branch) so - // both the header pill and the recents row reflect what was - // actually loaded instead of leaving the branch blank. - // - // Defensive guard: the scanner labels a detached HEAD with - // strings like "detached HEAD" or "detached @ a1b2c3d". Those - // are display labels, NOT real branch names — passing them to - // a later `git clone --branch …` would fail. Only treat the - // manifest branch as a usable default when it looks like a - // normal ref (no spaces, no leading parens/"detached" prefix). - const manifestBranch = manifest.repo.branch; - const looksLikeRealBranch = - !!manifestBranch && - !/\s/.test(manifestBranch) && - !manifestBranch.startsWith('(') && - !manifestBranch.startsWith('detached'); - const resolvedBranch = - payload.branch ?? (looksLikeRealBranch ? manifestBranch! : undefined); - const branchIsDefault = !payload.branch && looksLikeRealBranch; - - // Update the header (project label, branch pill) + footer (repo link) - // AFTER applyManifest so world.getManifest() inside the coordinator - // resolves to the just-applied manifest — otherwise the label is stale. - handle.coordinator.setSourceInfo( - resolvedBranch, - srcKind(payload.src) === 'git' ? payload.src : undefined - ); - - _liveUpdates?.setSignature(manifest.signature); - pushRecent({ - src: payload.src, - branch: resolvedBranch, - branchIsDefault, - label: labelFromUrl(payload.src) ?? payload.src, - }); - - if (!liveUpdatesStarted) { - _liveUpdates = setupLiveUpdates(handle, manifest.signature); - liveUpdatesStarted = true; - } - } catch (err) { - picker.open({ - dismissible: dismissibleOnError, - prefill: payload, - error: err instanceof Error ? err.message : String(err), - }); - return; - } finally { - loadingOverlay.hide(); - } - } - - const serverConfig = await getServerConfig(); - const picker = createSourcePicker({ - allowLocalRepos: serverConfig.allowLocalRepos, - onSubmit: (payload) => { - _pendingSkipCache = !!payload.skipCache; - picker.close(); - applyNewSource(payload); - }, - }); - - // Boot decisions: - if (initialError) { - // Direct-boot fetch failed → modal in non-dismissible mode with the error. - _lastDismissible = false; - picker.open({ - dismissible: false, - prefill: { - src: qp.get('src')!, - branch: qp.get('branch') ?? undefined, - }, - error: initialError, - }); - } else if (!hasSrc) { - // Cold boot, no URL params → modal in non-dismissible mode. - _lastDismissible = false; - picker.open({ dismissible: false }); - } else { - // Boot complete with manifest applied. - loadingOverlay.hide(); - } - - // Wire the header "switch source" button via a global hook. - (window as Window & { __openSourcePicker?: () => void }).__openSourcePicker = () => { - const cur = new URLSearchParams(window.location.search); - _lastDismissible = true; - picker.open({ - dismissible: true, - prefill: cur.has('src') - ? { - src: cur.get('src')!, - branch: cur.get('branch') ?? undefined, - } - : undefined, - }); - }; - })(); -} diff --git a/app/src/main.tsx b/app/src/main.tsx new file mode 100644 index 00000000..b0d5af98 --- /dev/null +++ b/app/src/main.tsx @@ -0,0 +1,15 @@ +// main.tsx — Entry point. Hands off to App, which kicks the existing +// boot flow inside its useEffect. + +import { render } from 'preact'; +import './styles.css'; +import { App } from './views/shell/App'; + +// Re-export so tests that import from '@/main' (e.g. tests/views/pendingTitle.test.ts) +// continue to resolve without changes during Phase 2. +export { applyPendingTitle, EMPTY_MANIFEST } from './boot'; + +const mount = document.getElementById('app'); +if (mount) { + render(, mount); +} diff --git a/app/src/views/shell/App.tsx b/app/src/views/shell/App.tsx new file mode 100644 index 00000000..66fde4c2 --- /dev/null +++ b/app/src/views/shell/App.tsx @@ -0,0 +1,37 @@ +// App.tsx — Phase 2 placeholder root component. Renders the static +// layout shell that index.html used to declare, then kicks off the +// existing vanilla boot flow inside useEffect so behavior is identical +// to pre-Phase-2 main.ts. Phase 3 replaces these slot divs with native +// Preact components. + +import { useEffect } from 'preact/hooks'; +import { bootApp } from '../../boot'; + +export function App() { + useEffect(() => { + // bootApp finds #city via getElementById; the canvas below is in + // the DOM by the time this effect runs (Preact has committed the + // render synchronously before useEffect fires). + bootApp(); + }, []); + + return ( + <> +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+ + ); +} diff --git a/app/tsconfig.json b/app/tsconfig.json index 60b664ff..3a49ce75 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -5,6 +5,9 @@ "moduleResolution": "bundler", "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "preserve", + "jsxImportSource": "preact", + // Strict flags will be flipped one at a time in a follow-up PR // (Phase 3 of the migration plan). Each flag will surface a few // hundred annotations and is best reviewed in isolation. See @@ -39,7 +42,7 @@ // declarations (XRSession etc.), so it must be listed here too. "types": ["three", "webxr"] }, - "include": ["**/*.js", "**/*.ts", "tests/**/*"], + "include": ["**/*.js", "**/*.ts", "**/*.tsx", "tests/**/*"], "exclude": [ "node_modules", // Node-side scripts — checked by tsconfig.node.json which adds @types/node. diff --git a/app/vite.config.js b/app/vite.config.js index 68769519..4bc53fa4 100644 --- a/app/vite.config.js +++ b/app/vite.config.js @@ -1,5 +1,6 @@ import { defineConfig } from 'vite'; import { resolve } from 'node:path'; +import preact from '@preact/preset-vite'; // Vite root is this directory. Build output lands in ./dist/ — the // Dockerfile copies app/dist/ into the runtime image's static dir, and @@ -13,6 +14,7 @@ const appDir = import.meta.dirname; export default defineConfig({ root: appDir, base: './', + plugins: [preact()], resolve: { // `@/` maps to app/src so cross-directory imports stay short and // survive file moves. Mirrored in tsconfig.json paths and diff --git a/app/vitest.config.js b/app/vitest.config.js index c706a713..970a6e34 100644 --- a/app/vitest.config.js +++ b/app/vitest.config.js @@ -1,5 +1,6 @@ import { defineConfig } from 'vitest/config'; import { resolve } from 'node:path'; +import preact from '@preact/preset-vite'; const appDir = import.meta.dirname; @@ -8,6 +9,9 @@ const appDir = import.meta.dirname; // - `bench` → bench/**/*.test.{js,ts} (run by `npm run bench`) // `extends: true` inherits the root resolve.alias so we don't duplicate it. export default defineConfig({ + // Preact plugin mirrors vite.config.js so vitest can parse JSX/TSX in + // source modules that tests transitively import. + plugins: [preact()], // Mirrors vite.config.js — must stay in sync so tests resolve `@/` // imports the same way the dev server does. resolve: { From 67bbd08e4aed9b7a9a37f5222358c01bc1f4533a Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Sat, 30 May 2026 16:38:51 -0400 Subject: [PATCH 002/201] Phase 2 followup: move flex column layout from body to #app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Phase 2 commit wrapped the existing layout in
without realizing the existing CSS treated as the flex column container holding header / #app-body / footer directly. With #app now sitting between them, those layout rules no longer reached their targets — produced a large dead area below the city and broke the right-sidebar / center-pane resize behavior. Fix: move the flex column rules onto #app (which already has height: 100% from inheriting body's full height), and drop them from body. Preact's root container now owns the layout, which is also where Phase 3 will put it permanently once App.tsx composes its children. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src/styles.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/styles.css b/app/src/styles.css index 2e7922e3..6925958b 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -773,7 +773,8 @@ body { row underneath, and a thin footer at the bottom. The 3-pane row is itself a flex row — left sidebar | center canvas | right sidebar. The center grows/shrinks instead of the sidebars overlaying it. */ -body { +#app { + height: 100%; display: flex; flex-direction: column; align-items: stretch; From 6a96e21af47a03f9da220c4e3add25597dff58e4 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Sat, 30 May 2026 17:40:50 -0400 Subject: [PATCH 003/201] =?UTF-8?q?Phase=203a:=20convert=20nanostores=20?= =?UTF-8?q?=E2=86=92=20@preact/signals=20across=20state=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweeps every nanostore atom/map/computed to @preact/signals equivalents: - `atom(v)` / `map(v)` → `signal(v)` - `.get()` → `.value`; `.set(v)` / `.setKey(k, v)` → `.value = v` or spread - `.subscribe(cb)` / `.listen(cb)` → `effect(() => cb(s.value))` - `computed([a, b], fn)` → `computed(() => fn(a.value, b.value))` Centerpiece: `state/persist.ts` rewrites the localStorage bridge to use signals. `persistAtomPerSource` uses `untracked()` to avoid the write effect overwriting localStorage before hydration finishes reading from it. Touches the state surface + every scene/* and runtime consumer + every relevant test fixture (~99 files, 27 of which directly imported nanostores). `nanostores` is removed from app/package.json. INTENTIONAL HALF-DONE STATE: `src/views/**/*.ts` still call `.get()` / `.subscribe()` on what are now signals, producing 216 typecheck errors and 28 test failures in `tests/views/*`. Those are Phase 3b scope — porting views to Preact components reading `.value`. The app does NOT boot to a usable UI between 3a and 3b; verify at the 3b stop point. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/package-lock.json | 16 -- app/package.json | 3 +- app/src/boot.ts | 4 +- app/src/coordinator.ts | 39 ++--- .../components/adPanels/adPanelsInstanced.ts | 10 +- .../components/buildings/buildingColor.ts | 2 +- .../components/buildings/buildingTilt.ts | 4 +- .../scene/components/buildings/buildings.ts | 70 ++++----- .../components/buildings/buildingsCell.ts | 2 +- .../scene/components/fireflies/fireflies.ts | 2 +- .../fireflies/firefliesPlacement.ts | 4 +- .../components/fireflies/firefliesRenderer.ts | 4 +- .../scene/components/fireflies/orbitRings.ts | 10 +- .../scene/components/footprint/footprint.ts | 4 +- app/src/scene/components/gem/gem.ts | 10 +- app/src/scene/components/island/islandMesh.ts | 12 +- .../scene/components/island/islandShader.ts | 2 +- app/src/scene/components/labels/labelsCell.ts | 2 +- app/src/scene/components/lighting/sunDir.ts | 2 +- .../scene/components/repoLabel/repoLabel.ts | 22 +-- app/src/scene/components/sky/sky.ts | 10 +- .../scene/components/streets/streetLabels.ts | 6 +- app/src/scene/components/streets/streets.ts | 4 +- .../scene/components/trees/treePlacement.ts | 12 +- .../components/trees/treePlacementClient.ts | 30 ++-- .../components/trees/treePlacementWorker.ts | 32 ++-- .../scene/components/trees/treeRenderer.ts | 8 +- app/src/scene/effects/buildingFader.ts | 9 +- app/src/scene/effects/ghostRenderer.ts | 18 ++- app/src/scene/effects/outlineRenderer.ts | 14 +- app/src/scene/effects/pathLineRenderer.ts | 39 ++--- app/src/scene/effects/treeOutlineRenderer.ts | 37 +++-- app/src/scene/layout/layout.ts | 20 +-- app/src/scene/layout/layoutClient.ts | 19 ++- app/src/scene/layout/layoutWorker.ts | 32 ++-- app/src/scene/layout/worldBounds.ts | 2 +- app/src/scene/renderLoop.ts | 34 ++--- app/src/scene/system/animator.ts | 2 +- app/src/scene/system/cameraRig.ts | 16 +- app/src/scene/system/inputHandlers.ts | 14 +- app/src/scene/system/picker.ts | 74 +++++----- app/src/scene/system/postFx.ts | 4 +- app/src/scene/world.ts | 30 ++-- app/src/state/drafts.ts | 70 +++++---- app/src/state/persist.ts | 138 +++++++++--------- app/src/state/reactions.ts | 102 ++++++++++--- app/src/state/runtime/liveStatus.ts | 8 +- app/src/state/runtime/liveUpdates.ts | 32 ++-- app/src/state/runtime/sourceContext.ts | 4 +- app/src/state/settings/components/adPanels.ts | 4 +- .../state/settings/components/buildings.ts | 12 +- app/src/state/settings/components/facade.ts | 8 +- .../state/settings/components/fireflies.ts | 4 +- .../state/settings/components/footprint.ts | 4 +- app/src/state/settings/components/gem.ts | 12 +- app/src/state/settings/components/island.ts | 6 +- app/src/state/settings/components/lighting.ts | 4 +- .../state/settings/components/repoLabel.ts | 4 +- app/src/state/settings/components/sky.ts | 6 +- app/src/state/settings/components/streets.ts | 16 +- app/src/state/settings/components/trees.ts | 6 +- app/src/state/settings/effects/effects.ts | 6 +- app/src/state/settings/prefs/liveUpdates.ts | 4 +- app/src/state/settings/prefs/syntaxTheme.ts | 6 +- app/src/state/settings/system/animator.ts | 4 +- app/src/state/settings/system/cameraRig.ts | 8 +- .../state/settings/system/inputHandlers.ts | 4 +- app/src/state/settings/system/tooltip.ts | 4 +- app/src/state/settings/world/world.ts | 6 +- app/tests/_helpers/cityFixtures.ts | 8 +- .../scene/cityFootprint/footprint.test.ts | 18 +-- .../adPanels/instanced-ad-panels.test.ts | 2 +- .../buildings/buildingColor.test.ts | 22 ++- .../labels/instanced-labels-cell.test.ts | 2 +- .../components/repoLabel/repoLabel.test.ts | 28 ++-- .../repoLabel/repoLabelPositioning.test.ts | 10 +- app/tests/scene/effects/buildingFader.test.ts | 16 +- .../scene/effects/pathLineRenderer.test.ts | 14 +- .../scene/effects/treeOutlineRenderer.test.ts | 38 ++--- app/tests/scene/fireflies/fireflies.test.ts | 12 +- .../fireflies/firefliesPlacement.test.ts | 2 +- app/tests/scene/island/islandMesh.test.ts | 8 +- app/tests/scene/layout/layout.test.ts | 13 +- app/tests/scene/lighting/sunDir.test.ts | 18 +-- app/tests/scene/sky/sky.test.ts | 12 +- app/tests/scene/sky/skyConfig.test.ts | 4 +- app/tests/scene/system/picker-commit.test.ts | 16 +- app/tests/scene/system/picker.test.ts | 36 ++--- app/tests/scene/trees/treePlacement.test.ts | 6 +- app/tests/scene/trees/treeRenderer.test.ts | 58 ++++---- .../trees/treeRendererCommitLookup.test.ts | 20 +-- app/tests/scene/world/worldBounds.test.ts | 4 +- app/tests/scene/worldLayoutCache.test.ts | 6 +- app/tests/state/drafts.test.ts | 51 ++++--- app/tests/state/persistPerSource.test.ts | 36 ++--- app/tests/state/runtime/sourceContext.test.ts | 8 +- .../settings/components/repoLabel.test.ts | 6 +- app/tests/state/settings/footprint.test.ts | 2 +- app/tests/state/settings/island.test.ts | 4 +- 99 files changed, 845 insertions(+), 807 deletions(-) diff --git a/app/package-lock.json b/app/package-lock.json index bc0ca59b..c4d60f75 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -12,7 +12,6 @@ "@preact/signals": "^2.9.1", "highlight.js": "^11.11.1", "marked": "^18.0.3", - "nanostores": "^1.3.0", "preact": "^10.29.2", "rbush": "^4.0.1", "three": "^0.184.0" @@ -3479,21 +3478,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/nanostores": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.3.0.tgz", - "integrity": "sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "engines": { - "node": "^20.0.0 || >=22.0.0" - } - }, "node_modules/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", diff --git a/app/package.json b/app/package.json index 5d87a9b8..d75fb0c6 100644 --- a/app/package.json +++ b/app/package.json @@ -47,8 +47,7 @@ "@preact/signals": "^2.9.1", "highlight.js": "^11.11.1", "marked": "^18.0.3", - "nanostores": "^1.3.0", - "preact": "^10.29.2", +"preact": "^10.29.2", "rbush": "^4.0.1", "three": "^0.184.0" } diff --git a/app/src/boot.ts b/app/src/boot.ts index 7078d7d7..cc7da4f4 100644 --- a/app/src/boot.ts +++ b/app/src/boot.ts @@ -105,7 +105,7 @@ export async function bootApp(): Promise { { const qp = new URLSearchParams(window.location.search); if (qp.has('src')) { - CURRENT_SOURCE_KEY.set(sourceKey(qp.get('src')!, qp.get('branch') ?? undefined)); + CURRENT_SOURCE_KEY.value = sourceKey(qp.get('src')!, qp.get('branch') ?? undefined); } } @@ -323,7 +323,7 @@ export async function bootApp(): Promise { pageUrl.searchParams.delete('git_window'); history.replaceState(null, '', pageUrl.toString()); - CURRENT_SOURCE_KEY.set(sourceKey(payload.src, payload.branch)); + CURRENT_SOURCE_KEY.value = sourceKey(payload.src, payload.branch); try { const _builtAtlas = await buildIconAtlas(manifest); diff --git a/app/src/coordinator.ts b/app/src/coordinator.ts index 4898891a..bee2def4 100644 --- a/app/src/coordinator.ts +++ b/app/src/coordinator.ts @@ -25,6 +25,7 @@ import { buildCommitPane } from './views/panes/commitPane'; import { buildStreetPane } from './views/panes/streetPane'; import { sameDayCommitCount } from './utils/commit'; import { labelFromManifest } from './utils/sources'; +import { effect } from '@preact/signals'; import { LIVE_UPDATES } from './state/settings/index'; import { REBUILD_STATUS, LAST_REBUILD_ERROR, LAST_UPDATED_AT } from './state/runtime/liveStatus'; import { DateSource, NodeKind } from './types'; @@ -88,7 +89,7 @@ export function createCoordinator({ world, picker, rig, resetView, applyTheme }: hideRightSidebar(); return; } - const sel = picker.selection.get(); + const sel = picker.selection.value; if (sidebarPane === 'file') { showRightSidebar(filePreview.pane); if (sel && sel.kind === NodeKind.File) filePreview.api.setFile(sel.file); @@ -159,7 +160,7 @@ export function createCoordinator({ world, picker, rig, resetView, applyTheme }: // canvas. Looks at the current picker selection and dispatches the // matching camera-rig call. onFocus() { - const sel = picker.selection.get(); + const sel = picker.selection.value; if (!sel) return; if (sel.kind === NodeKind.File) rig.focusBuilding(sel.mesh, sel.data); else if (sel.kind === NodeKind.Directory) rig.focusStreet(sel.street, null); @@ -183,24 +184,24 @@ export function createCoordinator({ world, picker, rig, resetView, applyTheme }: // already happened in startRenderLoop — world.onChange won't fire // for that, so without this the footer would render "—" until the // first poll lands a fresh manifest. - if (initialManifest) LAST_UPDATED_AT.set(Date.now()); + if (initialManifest) LAST_UPDATED_AT.value = Date.now(); function _refreshStatus(): void { appFooter.setStatus({ - liveEnabled: LIVE_UPDATES.get().ENABLED, - rebuildStatus: REBUILD_STATUS.get(), - lastUpdatedAt: LAST_UPDATED_AT.get(), - errorMessage: LAST_REBUILD_ERROR.get(), + liveEnabled: LIVE_UPDATES.value.ENABLED, + rebuildStatus: REBUILD_STATUS.value, + lastUpdatedAt: LAST_UPDATED_AT.value, + errorMessage: LAST_REBUILD_ERROR.value, }); } _refreshStatus(); - const _liveCfgUnsub = LIVE_UPDATES.subscribe(_refreshStatus); - const _statusUnsub = REBUILD_STATUS.subscribe(_refreshStatus); + const _liveCfgUnsub = effect(() => { void LIVE_UPDATES.value; _refreshStatus(); }); + const _statusUnsub = effect(() => { void REBUILD_STATUS.value; _refreshStatus(); }); // _errorUnsub catches updated error messages even when REBUILD_STATUS // is already 'error' (e.g. two failing polls in a row with different // messages) — the tooltip needs to refresh on every message change. - const _errorUnsub = LAST_REBUILD_ERROR.subscribe(_refreshStatus); - const _stampUnsub = LAST_UPDATED_AT.subscribe(_refreshStatus); + const _errorUnsub = effect(() => { void LAST_REBUILD_ERROR.value; _refreshStatus(); }); + const _stampUnsub = effect(() => { void LAST_UPDATED_AT.value; _refreshStatus(); }); // Re-render every second so the relative timestamp ("5s ago" → "6s // ago") advances smoothly while idle. const _tickHandle = window.setInterval(_refreshStatus, 1000); @@ -330,13 +331,14 @@ export function createCoordinator({ world, picker, rig, resetView, applyTheme }: // hover ends. Both subscriptions call this shared updater so the // displayed info is always consistent with whichever atom changed last. function _updateFooterFromState(): void { - const hov = picker.hover.get(); - const sel = picker.selection.get(); + const hov = picker.hover.value; + const sel = picker.selection.value; _setFooterForTarget(hov ?? sel); } // ── picker → sidebar reactions ───────────────────────────────────── - const _selUnsub = picker.selection.subscribe((sel: PickTarget | null) => { + const _selUnsub = effect(() => { + const sel = picker.selection.value; // Tree highlight follows selection. if (leftSidebarApi.setSelectedTreePath) { leftSidebarApi.setSelectedTreePath(_pathOf(sel)); @@ -384,7 +386,8 @@ export function createCoordinator({ world, picker, rig, resetView, applyTheme }: _renderSidebar(); }); - const _hovUnsub = picker.hover.subscribe((h: PickTarget | null) => { + const _hovUnsub = effect(() => { + const h = picker.hover.value; if (leftSidebarApi.setHoveredTreePath) { leftSidebarApi.setHoveredTreePath(_pathOf(h)); } @@ -408,7 +411,7 @@ export function createCoordinator({ world, picker, rig, resetView, applyTheme }: const freshLabel = labelFromManifest(m) ?? m.tree?.name ?? ''; document.title = freshLabel ? `${freshLabel} — codecity` : 'codecity'; } - LAST_UPDATED_AT.set(Date.now()); + LAST_UPDATED_AT.value = Date.now(); if (leftSidebarApi.setInfoManifest) { leftSidebarApi.setInfoManifest(m); } @@ -418,7 +421,7 @@ export function createCoordinator({ world, picker, rig, resetView, applyTheme }: if (leftSidebarApi.setSearchManifest) { leftSidebarApi.setSearchManifest(m); } - const _selForCommit = picker.selection.get(); + const _selForCommit = picker.selection.value; if (_selForCommit && _selForCommit.kind === NodeKind.Commit) { const _remote = m?.repo?.remote_url ?? null; const _commits = m?.commits ?? []; @@ -430,7 +433,7 @@ export function createCoordinator({ world, picker, rig, resetView, applyTheme }: color: _color, }); } - const _selForDir = picker.selection.get(); + const _selForDir = picker.selection.value; if (_selForDir && _selForDir.kind === NodeKind.Directory) { // Live-update poll may have swapped the DirNode under us — re-resolve // the directory by path from the freshly applied manifest and push the diff --git a/app/src/scene/components/adPanels/adPanelsInstanced.ts b/app/src/scene/components/adPanels/adPanelsInstanced.ts index edb15ab9..429275c5 100644 --- a/app/src/scene/components/adPanels/adPanelsInstanced.ts +++ b/app/src/scene/components/adPanels/adPanelsInstanced.ts @@ -113,7 +113,7 @@ export class InstancedAdPanels { const geo = new THREE.PlaneGeometry(1, 1); // Material — GLSL3 required for sampler2DArray. - const adCfg = AD_PANEL.get(); + const adCfg = AD_PANEL.value; const placeholderColor = new THREE.Color(adCfg.AD_PLACEHOLDER_COLOR); // Cached for markBuildingErrored — recolors a panel slot's iColor // when its image load/decode/upload fails permanently. Stored without @@ -121,7 +121,7 @@ export class InstancedAdPanels { // both placeholder and error colors so brightness stays consistent. this._errorColor = new THREE.Color(adCfg.AD_ERROR_COLOR); - const bloomCfg = BLOOM.get(); + const bloomCfg = BLOOM.value; const mat = new THREE.ShaderMaterial({ glslVersion: THREE.GLSL3, // AD_PANEL_MAX_PAGES injected as a shader #define so the @@ -252,8 +252,8 @@ export class InstancedAdPanels { return null; } - const cfg = AD_PANEL.get(); - const dims = BUILDING_DIMENSIONS.get(); + const cfg = AD_PANEL.value; + const dims = BUILDING_DIMENSIONS.value; // Aspect ratio: clamp degenerate or missing metadata to a square. const mw = b.file.media_width; @@ -431,7 +431,7 @@ export class InstancedAdPanels { * the uniform updates without a full scene rebuild. */ refresh(): void { - const bloomCfg = BLOOM.get(); + const bloomCfg = BLOOM.value; this._material.uniforms.uEmissionBoost.value = bloomCfg.ENABLED ? bloomCfg.AD_EMISSION : 1.0; } diff --git a/app/src/scene/components/buildings/buildingColor.ts b/app/src/scene/components/buildings/buildingColor.ts index e8958a68..97f05fac 100644 --- a/app/src/scene/components/buildings/buildingColor.ts +++ b/app/src/scene/components/buildings/buildingColor.ts @@ -272,7 +272,7 @@ export function getBuildingColor(file: FileLike, dateRanges: DateRangeStrings): // Prefer git dates, fall back to filesystem dates const modified = (file.git && file.git.modified) || file.modified || null; - const palette = BUILDING_PALETTE.get(); + const palette = BUILDING_PALETTE.value; const h = getHue(file.extension || '', palette.HUE_EXT_MAP); // Saturation and lightness both key off LAST-MODIFIED, normalized // against the repo's MODIFIED-date range (modifiedMin/Max). This diff --git a/app/src/scene/components/buildings/buildingTilt.ts b/app/src/scene/components/buildings/buildingTilt.ts index f7bd1d89..4fc3789d 100644 --- a/app/src/scene/components/buildings/buildingTilt.ts +++ b/app/src/scene/components/buildings/buildingTilt.ts @@ -55,7 +55,7 @@ const ZERO_TILT: BuildingTilt = { tiltX: 0, tiltZ: 0 }; * stable seed source), or when the building has no createdAge signal. */ export function getBuildingTilt(b: Building): BuildingTilt { - const aging = BUILDING_AGING.get(); + const aging = BUILDING_AGING.value; if (!aging.TILT_ENABLED) return ZERO_TILT; if (!b.file) return ZERO_TILT; const createdAge = b.createdAge ?? 0; @@ -101,7 +101,7 @@ export function attachLeanAwareRaycast(mesh: THREE.InstancedMesh): void { mesh.raycast = function (raycaster, intersects) { if (!mesh.visible) return; - const aging = BUILDING_AGING.get(); + const aging = BUILDING_AGING.value; const tiltMaxRad = aging.TILT_ENABLED ? (aging.TILT_DEGREES * Math.PI) / 180 : 0; const iIconUV = mesh.geometry.getAttribute('iIconUV') as diff --git a/app/src/scene/components/buildings/buildings.ts b/app/src/scene/components/buildings/buildings.ts index 455e8c0f..fac0e5bc 100644 --- a/app/src/scene/components/buildings/buildings.ts +++ b/app/src/scene/components/buildings/buildings.ts @@ -63,9 +63,9 @@ export function setIconAtlas(atlas: IconAtlas | null): void { // so the haze sits in the same relative band of the skyline regardless // of how the user has tuned building sizes. function _computeFogHeight(): number { - const dims = BUILDING_DIMENSIONS.get(); + const dims = BUILDING_DIMENSIONS.value; const maxHeight = Math.max(1, dims.MAX_FLOORS * dims.FLOOR_HEIGHT); - return SCENE_COLORS.get().FOG_HEIGHT_FRAC * maxHeight; + return SCENE_COLORS.value.FOG_HEIGHT_FRAC * maxHeight; } function getBuildingMaterial(): THREE.ShaderMaterial { @@ -81,7 +81,7 @@ function getBuildingMaterial(): THREE.ShaderMaterial { uniforms: { // Hidden-tier wireframe thickness in screen-pixels. Updated by // refreshBuildingMaterial() on Save via applyTheme(). - uOutlineWidth: { value: BUILDING_OUTLINE.get().WIDTH }, + uOutlineWidth: { value: BUILDING_OUTLINE.value.WIDTH }, // Atlas of file-type icons; sampled per-instance via iIconUV for // the roof face. Null until the atlas builds — the shader gates // sampling behind iIconUV.x >= 0. @@ -95,25 +95,25 @@ function getBuildingMaterial(): THREE.ShaderMaterial { // convention as uDimGlowColor. // uFogEnabled drives the boolean branch in the shared fog chunk; // uFogIntensity is still set to 0 when disabled (belt-and-suspenders). - uFogEnabled: { value: SCENE_COLORS.get().FOG_ENABLED }, + uFogEnabled: { value: SCENE_COLORS.value.FOG_ENABLED }, uFogColor: { - value: new THREE.Color().setStyle(SCENE_COLORS.get().FOG_COLOR, THREE.LinearSRGBColorSpace), + value: new THREE.Color().setStyle(SCENE_COLORS.value.FOG_COLOR, THREE.LinearSRGBColorSpace), }, - uFogIntensity: { value: SCENE_COLORS.get().FOG_INTENSITY }, + uFogIntensity: { value: SCENE_COLORS.value.FOG_INTENSITY }, uFogHeight: { value: _computeFogHeight() }, // Extra HDR emission applied to the freshest building's lit // windows on top of a baseline 1.0. 0 = no bloom contribution // from windows; higher = brighter glow on new buildings. - uWindowEmissionBoost: { value: BLOOM.get().WINDOW_EMISSION }, + uWindowEmissionBoost: { value: BLOOM.value.WINDOW_EMISSION }, // Age-driven decay uniforms (createdAge-gated, independent of // modifiedAge). See BUILDING_AGING config. uGrimeIntensity: { - value: BUILDING_AGING.get().GRIME_ENABLED ? BUILDING_AGING.get().GRIME_INTENSITY : 0, + value: BUILDING_AGING.value.GRIME_ENABLED ? BUILDING_AGING.value.GRIME_INTENSITY : 0, }, - uGrimeCoverage: { value: BUILDING_AGING.get().GRIME_COVERAGE }, + uGrimeCoverage: { value: BUILDING_AGING.value.GRIME_COVERAGE }, uTiltMaxRad: { - value: BUILDING_AGING.get().TILT_ENABLED - ? (BUILDING_AGING.get().TILT_DEGREES * Math.PI) / 180 + value: BUILDING_AGING.value.TILT_ENABLED + ? (BUILDING_AGING.value.TILT_DEGREES * Math.PI) / 180 : 0, }, // Scene directional lighting (LIGHTING store). uSunDirWorld is @@ -124,41 +124,41 @@ function getBuildingMaterial(): THREE.ShaderMaterial { // doesn't run, rather than the all-faces-shadow look a zero // vector would produce. uSunDirWorld: { value: new THREE.Vector3(0, 1, 0) }, - uAmbient: { value: LIGHTING.get().AMBIENT }, - uSunContrast: { value: LIGHTING.get().SUN_CONTRAST }, + uAmbient: { value: LIGHTING.value.AMBIENT }, + uSunContrast: { value: LIGHTING.value.SUN_CONTRAST }, // Procedural facade geometry (FACADE_GEOMETRY store). Seeded from // the current store snapshot so the first frame renders with the // configured values; refreshBuildingMaterial() pushes updates on // Save via applyTheme(). Only the shader-side keys appear here — the JS-side // keys (WINDOW_COLS_MAX, WIDTH_PER_WINDOW_COL, DOOR_WIDTH_FRAC) // bake into per-instance attributes in buildBuildingInstanceBuffer above. - uSlabHeightFrac: { value: FACADE_GEOMETRY.get().SLAB_HEIGHT_FRAC }, - uWindowWidthFrac: { value: FACADE_GEOMETRY.get().WINDOW_WIDTH_FRAC }, - uWindowHeightFrac: { value: FACADE_GEOMETRY.get().WINDOW_HEIGHT_FRAC }, - uWindowMarginFrac: { value: FACADE_GEOMETRY.get().WINDOW_MARGIN_FRAC }, - uDoorHeightFrac: { value: FACADE_GEOMETRY.get().DOOR_HEIGHT_FRAC }, - uRoofBorderFrac: { value: FACADE_GEOMETRY.get().ROOF_BORDER_FRAC }, + uSlabHeightFrac: { value: FACADE_GEOMETRY.value.SLAB_HEIGHT_FRAC }, + uWindowWidthFrac: { value: FACADE_GEOMETRY.value.WINDOW_WIDTH_FRAC }, + uWindowHeightFrac: { value: FACADE_GEOMETRY.value.WINDOW_HEIGHT_FRAC }, + uWindowMarginFrac: { value: FACADE_GEOMETRY.value.WINDOW_MARGIN_FRAC }, + uDoorHeightFrac: { value: FACADE_GEOMETRY.value.DOOR_HEIGHT_FRAC }, + uRoofBorderFrac: { value: FACADE_GEOMETRY.value.ROOF_BORDER_FRAC }, // FACADE_DETAIL store — HSL lightness deltas applied to slab, door, // and roof-border via shadeColor/shadeAndShiftHue in the shader. - uSlabLightnessDelta: { value: FACADE_DETAIL.get().SLAB_LIGHTNESS_DELTA }, - uDoorLightnessDelta: { value: FACADE_DETAIL.get().DOOR_LIGHTNESS_DELTA }, - uRoofBorderLightnessDelta: { value: FACADE_DETAIL.get().ROOF_BORDER_LIGHTNESS_DELTA }, + uSlabLightnessDelta: { value: FACADE_DETAIL.value.SLAB_LIGHTNESS_DELTA }, + uDoorLightnessDelta: { value: FACADE_DETAIL.value.DOOR_LIGHTNESS_DELTA }, + uRoofBorderLightnessDelta: { value: FACADE_DETAIL.value.ROOF_BORDER_LIGHTNESS_DELTA }, // WINDOW_LIGHTING store — per-cell lit/unlit lightness deltas, gap // thresholds, and the warm-amber tint for old/dim lit panes. - uWindowUnlitLightnessDelta: { value: WINDOW_LIGHTING.get().UNLIT_LIGHTNESS_DELTA }, - uWindowGapBaseThreshold: { value: WINDOW_LIGHTING.get().GAP_BASE_THRESHOLD }, - uWindowGapAgeBonus: { value: WINDOW_LIGHTING.get().GAP_AGE_BONUS }, + uWindowUnlitLightnessDelta: { value: WINDOW_LIGHTING.value.UNLIT_LIGHTNESS_DELTA }, + uWindowGapBaseThreshold: { value: WINDOW_LIGHTING.value.GAP_BASE_THRESHOLD }, + uWindowGapAgeBonus: { value: WINDOW_LIGHTING.value.GAP_AGE_BONUS }, // setStyle(..., LinearSRGBColorSpace) skips Three's automatic sRGB→linear // conversion. The shader consumes uDimGlowColor in sRGB space (the prior // hardcoded vec3(0.5, 0.4, 0.15) was sRGB), so we pass the hex bytes // through unchanged. uDimGlowColor: { value: new THREE.Color().setStyle( - WINDOW_LIGHTING.get().DIM_GLOW_COLOR, + WINDOW_LIGHTING.value.DIM_GLOW_COLOR, THREE.LinearSRGBColorSpace ), }, - uLitFreshnessExponent: { value: WINDOW_LIGHTING.get().LIT_FRESHNESS_EXPONENT }, + uLitFreshnessExponent: { value: WINDOW_LIGHTING.value.LIT_FRESHNESS_EXPONENT }, }, }); writeSunDir(_sharedMaterial.uniforms.uSunDirWorld.value as THREE.Vector3); @@ -186,9 +186,9 @@ export function getSharedBuildingUniforms(): Record { */ export function refreshBuildingMaterial(): void { if (!_sharedMaterial) return; - const sceneCfg = SCENE_COLORS.get(); - const bloomCfg = BLOOM.get(); - _sharedMaterial.uniforms.uOutlineWidth.value = BUILDING_OUTLINE.get().WIDTH; + const sceneCfg = SCENE_COLORS.value; + const bloomCfg = BLOOM.value; + _sharedMaterial.uniforms.uOutlineWidth.value = BUILDING_OUTLINE.value.WIDTH; // Height fog: uFogEnabled drives the GLSL branch; uFogIntensity is also // zeroed when disabled so the mix() is a no-op even if the bool branch // ever short-circuits differently on a given driver. @@ -205,14 +205,14 @@ export function refreshBuildingMaterial(): void { _sharedMaterial.uniforms.uWindowEmissionBoost.value = bloomCfg.ENABLED ? bloomCfg.WINDOW_EMISSION : 0; - const aging = BUILDING_AGING.get(); + const aging = BUILDING_AGING.value; _sharedMaterial.uniforms.uGrimeIntensity.value = aging.GRIME_ENABLED ? aging.GRIME_INTENSITY : 0; _sharedMaterial.uniforms.uGrimeCoverage.value = aging.GRIME_COVERAGE; _sharedMaterial.uniforms.uTiltMaxRad.value = aging.TILT_ENABLED ? (aging.TILT_DEGREES * Math.PI) / 180 : 0; // Scene directional lighting (LIGHTING store). - const lighting = LIGHTING.get(); + const lighting = LIGHTING.value; writeSunDir(_sharedMaterial.uniforms.uSunDirWorld.value as THREE.Vector3); _sharedMaterial.uniforms.uAmbient.value = lighting.AMBIENT; _sharedMaterial.uniforms.uSunContrast.value = lighting.SUN_CONTRAST; @@ -221,7 +221,7 @@ export function refreshBuildingMaterial(): void { // rebuild because they bake into per-instance attributes; hotReload.ts // routes the whole store through scheduleRebuild so the uniforms here // are kept fresh on the next rebuild without separate plumbing. - const facade = FACADE_GEOMETRY.get(); + const facade = FACADE_GEOMETRY.value; _sharedMaterial.uniforms.uSlabHeightFrac.value = facade.SLAB_HEIGHT_FRAC; _sharedMaterial.uniforms.uWindowWidthFrac.value = facade.WINDOW_WIDTH_FRAC; _sharedMaterial.uniforms.uWindowHeightFrac.value = facade.WINDOW_HEIGHT_FRAC; @@ -229,14 +229,14 @@ export function refreshBuildingMaterial(): void { _sharedMaterial.uniforms.uDoorHeightFrac.value = facade.DOOR_HEIGHT_FRAC; _sharedMaterial.uniforms.uRoofBorderFrac.value = facade.ROOF_BORDER_FRAC; // FACADE_DETAIL store — pure uniform refresh, no rebuild required. - const facadeDetail = FACADE_DETAIL.get(); + const facadeDetail = FACADE_DETAIL.value; _sharedMaterial.uniforms.uSlabLightnessDelta.value = facadeDetail.SLAB_LIGHTNESS_DELTA; _sharedMaterial.uniforms.uDoorLightnessDelta.value = facadeDetail.DOOR_LIGHTNESS_DELTA; _sharedMaterial.uniforms.uRoofBorderLightnessDelta.value = facadeDetail.ROOF_BORDER_LIGHTNESS_DELTA; // WINDOW_LIGHTING store — pure uniform refresh. .set(cssString) on the // pre-allocated THREE.Color preserves the linear-sRGB conversion path. - const windowLighting = WINDOW_LIGHTING.get(); + const windowLighting = WINDOW_LIGHTING.value; _sharedMaterial.uniforms.uWindowUnlitLightnessDelta.value = windowLighting.UNLIT_LIGHTNESS_DELTA; _sharedMaterial.uniforms.uWindowGapBaseThreshold.value = windowLighting.GAP_BASE_THRESHOLD; _sharedMaterial.uniforms.uWindowGapAgeBonus.value = windowLighting.GAP_AGE_BONUS; diff --git a/app/src/scene/components/buildings/buildingsCell.ts b/app/src/scene/components/buildings/buildingsCell.ts index c96dffce..4c59797a 100644 --- a/app/src/scene/components/buildings/buildingsCell.ts +++ b/app/src/scene/components/buildings/buildingsCell.ts @@ -210,7 +210,7 @@ export function writeBuildingToSlot(cell: CellTile, b: Building): void { const mesh = cell.detailMesh; // --- Config snapshot (mirrors buildBuildingInstanceBuffer) --- - const facade = FACADE_GEOMETRY.get(); + const facade = FACADE_GEOMETRY.value; const windowColsMax = facade.WINDOW_COLS_MAX; const widthPerWindowCol = facade.WIDTH_PER_WINDOW_COL; const doorWidthFrac = facade.DOOR_WIDTH_FRAC; diff --git a/app/src/scene/components/fireflies/fireflies.ts b/app/src/scene/components/fireflies/fireflies.ts index 548933a5..b29eb5f2 100644 --- a/app/src/scene/components/fireflies/fireflies.ts +++ b/app/src/scene/components/fireflies/fireflies.ts @@ -35,7 +35,7 @@ export function createFireflies( // Master config gate. When disabled, return an empty parent group so the // caller's group is still safe to add/dispose. - if (!FIREFLIES.get().FIREFLIES_ENABLED) { + if (!FIREFLIES.value.FIREFLIES_ENABLED) { const stub = createFireflyRenderer([]); return { group: parent, diff --git a/app/src/scene/components/fireflies/firefliesPlacement.ts b/app/src/scene/components/fireflies/firefliesPlacement.ts index 04cec28d..72cbb234 100644 --- a/app/src/scene/components/fireflies/firefliesPlacement.ts +++ b/app/src/scene/components/fireflies/firefliesPlacement.ts @@ -77,7 +77,7 @@ export function placeFireflies( ): FireflyPlacement[] { if (!commits || commits.length === 0) return []; - const fireflyConfig = FIREFLIES.get(); + const fireflyConfig = FIREFLIES.value; // Tally per-author commit count. A co-authored commit increments // each distinct author's count by 1 — co-authorship counts as full @@ -115,7 +115,7 @@ export function placeFireflies( } } - const cfg = TREES.get(); + const cfg = TREES.value; const minHeight = cfg.TREE_MIN_HEIGHT; const maxHeight = cfg.TREE_MAX_HEIGHT; const minRadius = cfg.TREE_MIN_WIDTH / 2; diff --git a/app/src/scene/components/fireflies/firefliesRenderer.ts b/app/src/scene/components/fireflies/firefliesRenderer.ts index 59c52375..dd8f98fc 100644 --- a/app/src/scene/components/fireflies/firefliesRenderer.ts +++ b/app/src/scene/components/fireflies/firefliesRenderer.ts @@ -38,7 +38,7 @@ export function createFireflyRenderer(orbs: FireflyPlacement[]): FireflyRenderer }; } - const cfg = FIREFLIES.get(); + const cfg = FIREFLIES.value; const geometry = new THREE.IcosahedronGeometry(1.0, 2); // Per-instance bob phase + pulse phase + orbit params. @@ -144,7 +144,7 @@ export function createFireflyRenderer(orbs: FireflyPlacement[]): FireflyRenderer uSelectedCommit.value = commitIndex ?? -1; }, refresh() { - const next = FIREFLIES.get(); + const next = FIREFLIES.value; uBobAmp.value = next.BOB_AMPLITUDE; uBobSpeed.value = next.BOB_SPEED; uPulseAmp.value = next.PULSE_AMPLITUDE; diff --git a/app/src/scene/components/fireflies/orbitRings.ts b/app/src/scene/components/fireflies/orbitRings.ts index 91ecf2a1..6b8bd5ce 100644 --- a/app/src/scene/components/fireflies/orbitRings.ts +++ b/app/src/scene/components/fireflies/orbitRings.ts @@ -127,7 +127,7 @@ const _rainbowTmpColor = new THREE.Color(); function writeRainbowToTube(mesh: THREE.Mesh, timeMs: number): void { const geom = mesh.geometry as THREE.BufferGeometry; const buf = ensureColorBuffer(geom); - const rb = RAINBOW.get(); + const rb = RAINBOW.value; // Match treeOutlineRenderer's convention: t = performance.now() * SPEED, // where SPEED is hue cycles per millisecond. const t = timeMs * rb.SPEED; @@ -152,7 +152,7 @@ export function createOrbitRings(orbs: FireflyPlacement[]): OrbitRings { const group = new THREE.Group(); group.name = 'firefly-orbit-rings'; - const cfg = FIREFLIES.get(); + const cfg = FIREFLIES.value; if (!cfg.ORBIT_RING_ENABLED || orbs.length === 0) { return { @@ -234,7 +234,7 @@ export function createOrbitRings(orbs: FireflyPlacement[]): OrbitRings { } function buildHoverMeshes(slotOrbs: FireflyPlacement[]): void { - const thickness = FIREFLIES.get().ORBIT_RING_THICKNESS; + const thickness = FIREFLIES.value.ORBIT_RING_THICKNESS; for (const orb of slotOrbs) { const geom = buildTubeGeometry(orb, thickness); const mat = makeHoverMaterial(orb.lightRgb); @@ -246,7 +246,7 @@ export function createOrbitRings(orbs: FireflyPlacement[]): OrbitRings { } function buildSelectedMeshes(slotOrbs: FireflyPlacement[]): void { - const thickness = FIREFLIES.get().ORBIT_RING_THICKNESS; + const thickness = FIREFLIES.value.ORBIT_RING_THICKNESS; for (const orb of slotOrbs) { const geom = buildTubeGeometry(orb, thickness); // Pre-allocate the color attribute so the renderer sees it on the @@ -310,7 +310,7 @@ export function createOrbitRings(orbs: FireflyPlacement[]): OrbitRings { }, refresh() { - const next = FIREFLIES.get(); + const next = FIREFLIES.value; group.visible = next.ORBIT_RING_ENABLED; }, diff --git a/app/src/scene/components/footprint/footprint.ts b/app/src/scene/components/footprint/footprint.ts index 479018e7..bf4fd499 100644 --- a/app/src/scene/components/footprint/footprint.ts +++ b/app/src/scene/components/footprint/footprint.ts @@ -96,7 +96,7 @@ void main() { `; export function createCityFootprint(layout: CityLayout): CityFootprint { - const cfg = FOOTPRINT.get(); + const cfg = FOOTPRINT.value; const halo = Math.max(0, cfg.HALO_WIDTH); // Halo at zero (or negative — clamped to 0 above) means the footprint @@ -172,7 +172,7 @@ export function createCityFootprint(layout: CityLayout): CityFootprint { group.add(mesh); function refresh(): void { - const c = FOOTPRINT.get(); + const c = FOOTPRINT.value; setColorFromHex(material.uniforms.uColor.value as THREE.Color, c.COLOR); material.uniforms.uCornerRadius.value = Math.max(0, c.CORNER_RADIUS) * Math.max(0, c.HALO_WIDTH); diff --git a/app/src/scene/components/gem/gem.ts b/app/src/scene/components/gem/gem.ts index 954d4b3b..6092fcc0 100644 --- a/app/src/scene/components/gem/gem.ts +++ b/app/src/scene/components/gem/gem.ts @@ -67,8 +67,8 @@ function _makeGlowTexture(): THREE.CanvasTexture | null { // size scales with the street's width (must match the radius the // layout reserves — see GEM_SIZING). export function createRootGem(street: Street): THREE.Group { - const sizing = GEM_SIZING.get(); - const appearance = GEM_APPEARANCE.get(); + const sizing = GEM_SIZING.value; + const appearance = GEM_APPEARANCE.value; const edgeColor = appearance.EDGE_COLOR; const group = new THREE.Group(); @@ -93,7 +93,7 @@ export function createRootGem(street: Street): THREE.Group { const gemZ = anchor.y; // ---- Gem: per-face colored polyhedron ------------------------------------- - const sides = GEM_SIZING.get().SIDES; + const sides = GEM_SIZING.value.SIDES; let geo: THREE.BufferGeometry; switch (sides) { case '4': @@ -108,7 +108,7 @@ export function createRootGem(street: Street): THREE.Group { break; } - const palette = GEM_FACE_PALETTE.get(); + const palette = GEM_FACE_PALETTE.value; const paletteHexes = [ palette.FACE_1, palette.FACE_2, @@ -179,7 +179,7 @@ export function createRootGem(street: Street): THREE.Group { // // Skipped when _makeGlowTexture returns null (jsdom test env). const gem = new THREE.Group(); - const glowCfg = GEM_GLOW.get(); + const glowCfg = GEM_GLOW.value; const glowTex = _makeGlowTexture(); let innerGlowSprite: THREE.Sprite | null = null; let outerGlowSprite: THREE.Sprite | null = null; diff --git a/app/src/scene/components/island/islandMesh.ts b/app/src/scene/components/island/islandMesh.ts index 260941b0..b20dbce7 100644 --- a/app/src/scene/components/island/islandMesh.ts +++ b/app/src/scene/components/island/islandMesh.ts @@ -29,7 +29,7 @@ export interface Island { } function buildParams(bounds: WorldBounds, seedFromBounds: number): IslandBuildParams { - const g = ISLAND_GEOMETRY.get(); + const g = ISLAND_GEOMETRY.value; return { sides: g.SIDES, irregularity: g.IRREGULARITY, @@ -63,11 +63,11 @@ export function createIsland(initialBounds: WorldBounds | null): Island { let currentBounds = initialBounds ?? getWorldBounds(null); const group = new THREE.Group(); group.position.set(currentBounds.cx, ISLAND_TOP_Y, currentBounds.cz); - group.visible = ISLAND_GEOMETRY.get().ENABLED; + group.visible = ISLAND_GEOMETRY.value.ENABLED; // Island mesh. let params = buildParams(currentBounds, islandSeedFromBounds(currentBounds)); - const mats = ISLAND_MATERIALS.get(); + const mats = ISLAND_MATERIALS.value; let geometry = buildIslandGeometry(params, { GRASS: mats.GRASS_COLOR, GRASS_SIDE: mats.GRASS_SIDE_COLOR, @@ -84,7 +84,7 @@ export function createIsland(initialBounds: WorldBounds | null): Island { currentBounds = newBounds; geometry.dispose(); params = buildParams(currentBounds, islandSeedFromBounds(currentBounds)); - const m = ISLAND_MATERIALS.get(); + const m = ISLAND_MATERIALS.value; geometry = buildIslandGeometry(params, { GRASS: m.GRASS_COLOR, GRASS_SIDE: m.GRASS_SIDE_COLOR, @@ -98,8 +98,8 @@ export function createIsland(initialBounds: WorldBounds | null): Island { // Geometry colors changed → rebuild (vertex colors are baked into the // geometry, not pushed through uniforms). This is cheap for ~1-2k verts. setBounds(currentBounds); - group.visible = ISLAND_GEOMETRY.get().ENABLED; - const mats = ISLAND_MATERIALS.get(); + group.visible = ISLAND_GEOMETRY.value.ENABLED; + const mats = ISLAND_MATERIALS.value; (material.uniforms.uHemiSkyColor!.value as THREE.Color).set(mats.HEMI_SKY_COLOR); (material.uniforms.uHemiGroundColor!.value as THREE.Color).set(mats.HEMI_GROUND_COLOR); } diff --git a/app/src/scene/components/island/islandShader.ts b/app/src/scene/components/island/islandShader.ts index 61c06f40..e77dd40e 100644 --- a/app/src/scene/components/island/islandShader.ts +++ b/app/src/scene/components/island/islandShader.ts @@ -57,7 +57,7 @@ void main() { `; export function createIslandMaterial(): THREE.ShaderMaterial { - const mats = ISLAND_MATERIALS.get(); + const mats = ISLAND_MATERIALS.value; return new THREE.ShaderMaterial({ vertexShader: vertSrc, fragmentShader: fragSrc, diff --git a/app/src/scene/components/labels/labelsCell.ts b/app/src/scene/components/labels/labelsCell.ts index 6a484502..287ce474 100644 --- a/app/src/scene/components/labels/labelsCell.ts +++ b/app/src/scene/components/labels/labelsCell.ts @@ -145,7 +145,7 @@ export function writeLabelToSlot(cell: CellTile, b: Building, atlas: LabelAtlasR if (!cell.labelMesh) return; const slot = b.slotId!; const mesh = cell.labelMesh; - const label = LABEL_TYPOGRAPHY.get(); + const label = LABEL_TYPOGRAPHY.value; const text = b.file?.name ?? ''; const rect = text ? atlas.rectByText.get(text) : undefined; diff --git a/app/src/scene/components/lighting/sunDir.ts b/app/src/scene/components/lighting/sunDir.ts index f5aa8f23..3efb67a0 100644 --- a/app/src/scene/components/lighting/sunDir.ts +++ b/app/src/scene/components/lighting/sunDir.ts @@ -19,7 +19,7 @@ import * as THREE from 'three'; import { LIGHTING } from '@/state/settings/components/lighting'; export function writeSunDir(out: THREE.Vector3): void { - const lighting = LIGHTING.get(); + const lighting = LIGHTING.value; const az = (lighting.SUN_AZIMUTH_DEG * Math.PI) / 180; const el = (lighting.SUN_ELEVATION_DEG * Math.PI) / 180; const cosEl = Math.cos(el); diff --git a/app/src/scene/components/repoLabel/repoLabel.ts b/app/src/scene/components/repoLabel/repoLabel.ts index f390df97..478b9916 100644 --- a/app/src/scene/components/repoLabel/repoLabel.ts +++ b/app/src/scene/components/repoLabel/repoLabel.ts @@ -168,11 +168,11 @@ export function createRepoLabel(): RepoLabel { // bob animation). function _updateBeamGeometry(): void { if (!beamMesh) return; - const cfg = REPO_LABEL.get(); + const cfg = REPO_LABEL.value; const halfFont = cfg.FONT_SIZE / 2; const beamRadius = cfg.FONT_SIZE * BEAM_RADIUS_FRAC; - const dims = BUILDING_DIMENSIONS.get(); + const dims = BUILDING_DIMENSIONS.value; const maxBldgH = dims.MAX_FLOORS * dims.FLOOR_HEIGHT; const heightWorld = maxBldgH * (cfg.HEIGHT_PCT / 100); @@ -189,9 +189,9 @@ export function createRepoLabel(): RepoLabel { } function _applyTransform(): void { - const cfg = REPO_LABEL.get(); + const cfg = REPO_LABEL.value; const halfFont = cfg.FONT_SIZE / 2; - const dims = BUILDING_DIMENSIONS.get(); + const dims = BUILDING_DIMENSIONS.value; const maxBldgH = dims.MAX_FLOORS * dims.FLOOR_HEIGHT; const heightWorld = maxBldgH * (cfg.HEIGHT_PCT / 100); // Group origin = panel center. Panel bottom = heightWorld above the @@ -210,13 +210,13 @@ export function createRepoLabel(): RepoLabel { } function _applyOpacity(): void { - const opacity = REPO_LABEL.get().OPACITY; + const opacity = REPO_LABEL.value.OPACITY; if (panelMat) panelMat.uniforms.uOpacity.value = opacity; if (beamMat) beamMat.uniforms.uOpacity.value = opacity; } function _applyColors(): void { - const cfg = REPO_LABEL.get(); + const cfg = REPO_LABEL.value; if (beamMat) (beamMat.uniforms.uColor.value as THREE.Color).set(cfg.BEAM_COLOR); if (panelMat) (panelMat.uniforms.uTint.value as THREE.Color).set(cfg.TEXT_COLOR); } @@ -260,7 +260,7 @@ export function createRepoLabel(): RepoLabel { depthWrite: false, side: THREE.DoubleSide, uniforms: { - uColor: { value: new THREE.Color(REPO_LABEL.get().BEAM_COLOR) }, + uColor: { value: new THREE.Color(REPO_LABEL.value.BEAM_COLOR) }, uTime: { value: 0 }, uOpacity: { value: 1.0 }, }, @@ -282,7 +282,7 @@ export function createRepoLabel(): RepoLabel { uniforms: { uMap: { value: textTex.texture }, uTime: { value: 0 }, - uTint: { value: new THREE.Color(REPO_LABEL.get().TEXT_COLOR) }, + uTint: { value: new THREE.Color(REPO_LABEL.value.TEXT_COLOR) }, uOpacity: { value: 1.0 }, }, }); @@ -334,7 +334,7 @@ export function createRepoLabel(): RepoLabel { function tick(dtSeconds: number, camera: THREE.Camera): void { if (!panelMesh || !panelMat) return; - const cfg = REPO_LABEL.get(); + const cfg = REPO_LABEL.value; if (!cfg.ENABLED) return; const dtScaled = dtSeconds * cfg.ANIMATION_SPEED; panelMat.uniforms.uTime.value += dtScaled; @@ -382,10 +382,10 @@ export function createRepoLabel(): RepoLabel { halfHeight: number; } | null { if (!panelMesh || !textTex) return null; - const cfg = REPO_LABEL.get(); + const cfg = REPO_LABEL.value; if (!cfg.ENABLED) return null; const halfFont = cfg.FONT_SIZE / 2; - const dims = BUILDING_DIMENSIONS.get(); + const dims = BUILDING_DIMENSIONS.value; const maxBldgH = dims.MAX_FLOORS * dims.FLOOR_HEIGHT; const heightWorld = maxBldgH * (cfg.HEIGHT_PCT / 100); // Mirror _applyTransform: panel center sits at anchor.y + heightWorld diff --git a/app/src/scene/components/sky/sky.ts b/app/src/scene/components/sky/sky.ts index 6ac630b5..5844eb4a 100644 --- a/app/src/scene/components/sky/sky.ts +++ b/app/src/scene/components/sky/sky.ts @@ -80,12 +80,12 @@ export function createSky(): Sky { // camera FAR plane is itself a fixed user config (default 20000) // and changes only require a fresh boot, so this radius does not // need to track FAR live. - const radius = CAMERA_PERSPECTIVE.get().FAR * RADIUS_FAR_FRAC; + const radius = CAMERA_PERSPECTIVE.value.FAR * RADIUS_FAR_FRAC; const geometry = new THREE.IcosahedronGeometry(radius, ICOSAHEDRON_DETAIL); - const sky = SKY.get(); - const stars = SKY_STARS.get(); + const sky = SKY.value; + const stars = SKY_STARS.value; const material = new THREE.ShaderMaterial({ vertexShader: skyVertSrc, @@ -122,8 +122,8 @@ export function createSky(): Sky { mesh.userData.cyberpunkValley = 'sky'; function refresh(): void { - const k = SKY.get(); - const s = SKY_STARS.get(); + const k = SKY.value; + const s = SKY_STARS.value; setColorFromHex(material.uniforms.uSkyColor.value as THREE.Color, k.COLOR); diff --git a/app/src/scene/components/streets/streetLabels.ts b/app/src/scene/components/streets/streetLabels.ts index 774201aa..c1c53a02 100644 --- a/app/src/scene/components/streets/streetLabels.ts +++ b/app/src/scene/components/streets/streetLabels.ts @@ -44,7 +44,7 @@ function _buildLabelTexture( // High source resolution so close-zoom doesn't reveal bilinear blur. // The world-space plane size is unchanged — we're just packing more // texels into the same footprint. - const label = LABEL_TYPOGRAPHY.get(); + const label = LABEL_TYPOGRAPHY.value; const fontSpec = `${label.FONT_WEIGHT} ${LABEL_FONT_SIZE_PX}px ${label.FONT_FAMILY}`; const measure = document.createElement('canvas').getContext('2d')!; measure.font = fontSpec; @@ -107,8 +107,8 @@ export function createStreetLabels(street: Street): THREE.Group[] { const text = street.label || ''; if (!text) return []; - const label = LABEL_TYPOGRAPHY.get(); - const asphaltCfg = ASPHALT.get(); + const label = LABEL_TYPOGRAPHY.value; + const asphaltCfg = ASPHALT.value; const orders = RENDER_ORDERS; // Usable road length: the rectangular label sits along the flat middle of diff --git a/app/src/scene/components/streets/streets.ts b/app/src/scene/components/streets/streets.ts index 44929242..0869df5f 100644 --- a/app/src/scene/components/streets/streets.ts +++ b/app/src/scene/components/streets/streets.ts @@ -118,8 +118,8 @@ function _buildStadiumGeometry( // points back to the layout street so raycaster hits can recover the // directory this street represents. export function createStreetMesh(street: StreetWithJoin, yBase: number): THREE.Group { - const asphaltCfg = ASPHALT.get(); - const sidewalkCfg = SIDEWALK_COLORS.get(); + const asphaltCfg = ASPHALT.value; + const sidewalkCfg = SIDEWALK_COLORS.value; const group = new THREE.Group(); const asphaltWidth = street.width * asphaltCfg.WIDTH_FRAC; // For concentric caps the asphalt must be shorter by exactly the sidewalk diff --git a/app/src/scene/components/trees/treePlacement.ts b/app/src/scene/components/trees/treePlacement.ts index 41cce472..d40b1dd5 100644 --- a/app/src/scene/components/trees/treePlacement.ts +++ b/app/src/scene/components/trees/treePlacement.ts @@ -141,7 +141,7 @@ export function placeTrees( bboxOverride?: CityBbox, options: PlaceTreesOptions = { commitCount: 0 } ): TreePlacement[] { - const cfg = TREES.get(); + const cfg = TREES.value; if (!cfg.TREES_ENABLED) return []; const bbox = bboxOverride ?? layout.bbox; @@ -150,7 +150,7 @@ export function placeTrees( const treeTarget = Math.max(0, options.commitCount | 0); if (treeTarget === 0) return []; - const footprint = FOOTPRINT.get(); + const footprint = FOOTPRINT.value; const halo = footprint.ENABLED ? Math.max(0, footprint.HALO_WIDTH) : 0; // Build rbush of every layout rect, inflated by the halo. @@ -167,7 +167,7 @@ export function placeTrees( if (rects.length > 0) rtree.load(rects); const hasRects = rects.length > 0; - const dims = BUILDING_DIMENSIONS.get(); + const dims = BUILDING_DIMENSIONS.value; const halfFoot = (cfg.SCATTER_FOOTPRINT_FRAC_OF_MAX_WIDTH * dims.MAX_WIDTH) / 2; const bounds = getWorldBounds(bbox, options.cityHeight ?? 0); @@ -190,9 +190,9 @@ export function placeTrees( // only the smaller bounds rect, leaving the polygon's expanded edges // empty. Worst-case polygon vertex sits at halfWidth × baseScale × // (1 + IRREGULARITY) (outward jitter), so sample to that extent. - const sides = options.islandGeoOverride?.SIDES ?? ISLAND_GEOMETRY.get().SIDES; + const sides = options.islandGeoOverride?.SIDES ?? ISLAND_GEOMETRY.value.SIDES; const irregularity = - options.islandGeoOverride?.IRREGULARITY ?? ISLAND_GEOMETRY.get().IRREGULARITY; + options.islandGeoOverride?.IRREGULARITY ?? ISLAND_GEOMETRY.value.IRREGULARITY; // Irregularity is now reductive (vertices shrink inward), so the polygon's // max extent is bounded by the unjittered baseScale — no (1 + irregularity) // expansion factor needed. @@ -230,7 +230,7 @@ export function placeTrees( // of IRREGULARITY (which makes edges sit at varying distances from origin). let islandPolygon: THREE.Vector3[] | null = null; if (options.islandGeoOverride !== null) { - const islandGeo = options.islandGeoOverride ?? ISLAND_GEOMETRY.get(); + const islandGeo = options.islandGeoOverride ?? ISLAND_GEOMETRY.value; if (islandGeo.ENABLED) { const rawPolygon = buildTopPolygon({ sides: islandGeo.SIDES, diff --git a/app/src/scene/components/trees/treePlacementClient.ts b/app/src/scene/components/trees/treePlacementClient.ts index 2d6856c5..93ba9f39 100644 --- a/app/src/scene/components/trees/treePlacementClient.ts +++ b/app/src/scene/components/trees/treePlacementClient.ts @@ -8,11 +8,11 @@ import { placeTrees, type TreePlacement } from './treePlacement'; import { MSG } from './treePlacementProtocol'; -import { TREES } from '@/state/settings/components/trees'; -import { BUILDING_DIMENSIONS } from '@/state/settings/components/buildings'; -import { FOOTPRINT } from '@/state/settings/components/footprint'; -import { ISLAND_GEOMETRY } from '@/state/settings/components/island'; -import { WORLD } from '@/state/settings/world/world'; +import { TREES, type TreesConfig } from '@/state/settings/components/trees'; +import { BUILDING_DIMENSIONS, type BuildingDimensionsConfig } from '@/state/settings/components/buildings'; +import { FOOTPRINT, type FootprintConfig } from '@/state/settings/components/footprint'; +import { ISLAND_GEOMETRY, type IslandGeometryConfig } from '@/state/settings/components/island'; +import { WORLD, type WorldConfig } from '@/state/settings/world/world'; import type { CityBbox, CityLayout } from '@/types'; interface PendingRequest { @@ -21,11 +21,11 @@ interface PendingRequest { } interface ConfigSnapshot { - trees: ReturnType; - buildingDims: ReturnType; - footprint: ReturnType; - islandGeo: ReturnType; - world: ReturnType; + trees: TreesConfig; + buildingDims: BuildingDimensionsConfig; + footprint: FootprintConfig; + islandGeo: IslandGeometryConfig; + world: WorldConfig; } export interface TreePlacementClient { @@ -40,11 +40,11 @@ export interface TreePlacementClient { function _snapshot(): ConfigSnapshot { return { - trees: TREES.get(), - buildingDims: BUILDING_DIMENSIONS.get(), - footprint: FOOTPRINT.get(), - islandGeo: ISLAND_GEOMETRY.get(), - world: WORLD.get(), + trees: TREES.value, + buildingDims: BUILDING_DIMENSIONS.value, + footprint: FOOTPRINT.value, + islandGeo: ISLAND_GEOMETRY.value, + world: WORLD.value, }; } diff --git a/app/src/scene/components/trees/treePlacementWorker.ts b/app/src/scene/components/trees/treePlacementWorker.ts index c6166847..1d099ff0 100644 --- a/app/src/scene/components/trees/treePlacementWorker.ts +++ b/app/src/scene/components/trees/treePlacementWorker.ts @@ -5,17 +5,17 @@ // DOM, no three.js references. import { placeTrees, type TreePlacement } from './treePlacement'; -import { TREES } from '@/state/settings/components/trees'; -import { BUILDING_DIMENSIONS } from '@/state/settings/components/buildings'; -import { FOOTPRINT } from '@/state/settings/components/footprint'; -import { WORLD } from '@/state/settings/world/world'; +import { TREES, type TreesConfig } from '@/state/settings/components/trees'; +import { BUILDING_DIMENSIONS, type BuildingDimensionsConfig } from '@/state/settings/components/buildings'; +import { FOOTPRINT, type FootprintConfig } from '@/state/settings/components/footprint'; +import { WORLD, type WorldConfig } from '@/state/settings/world/world'; import type { IslandGeometryConfig } from '@/state/settings/components/island'; import type { CityBbox, CityLayout } from '@/types'; -type TreesValue = ReturnType; -type BuildingDimsValue = ReturnType; -type FootprintValue = ReturnType; -type WorldValue = ReturnType; +type TreesValue = TreesConfig; +type BuildingDimsValue = BuildingDimensionsConfig; +type FootprintValue = FootprintConfig; +type WorldValue = WorldConfig; import { MSG } from './treePlacementProtocol'; @@ -46,18 +46,10 @@ type PlaceResponse = | { type: typeof MSG.RESPONSE_ERROR; id: number; message: string }; function _applySnapshot(snap: PlaceRequest['configSnapshot']): void { - for (const k of Object.keys(snap.trees) as Array) { - TREES.setKey(k, snap.trees[k]); - } - for (const k of Object.keys(snap.buildingDims) as Array) { - BUILDING_DIMENSIONS.setKey(k, snap.buildingDims[k]); - } - for (const k of Object.keys(snap.footprint) as Array) { - FOOTPRINT.setKey(k, snap.footprint[k]); - } - for (const k of Object.keys(snap.world) as Array) { - WORLD.setKey(k, snap.world[k]); - } + TREES.value = { ...TREES.value, ...snap.trees }; + BUILDING_DIMENSIONS.value = { ...BUILDING_DIMENSIONS.value, ...snap.buildingDims }; + FOOTPRINT.value = { ...FOOTPRINT.value, ...snap.footprint }; + WORLD.value = { ...WORLD.value, ...snap.world }; } self.addEventListener('message', (event: MessageEvent) => { diff --git a/app/src/scene/components/trees/treeRenderer.ts b/app/src/scene/components/trees/treeRenderer.ts index 3929239b..c4b758f4 100644 --- a/app/src/scene/components/trees/treeRenderer.ts +++ b/app/src/scene/components/trees/treeRenderer.ts @@ -137,7 +137,7 @@ function buildCanopyGeometry(detail: DetailLevel): THREE.BufferGeometry { // dense vertical samples near the apex make the silhouette read // as a smooth dome rather than a sharp spike. const profile = CANOPY_PROFILE as THREE.Vector2[]; - const cfg = TREES.get(); + const cfg = TREES.value; const segments = detail === 0 ? cfg.TREE_FACETS_LOW : detail === 1 ? cfg.TREE_FACETS_MID : cfg.TREE_FACETS_HIGH; const geom = new THREE.LatheGeometry(profile, segments); @@ -157,7 +157,7 @@ function buildCanopyGeometry(detail: DetailLevel): THREE.BufferGeometry { * * Consumed by `scene/effects/treeOutlineRenderer.ts`. */ export function buildCanopyEdges(detail: DetailLevel): THREE.EdgesGeometry { - const cfg = TREES.get(); + const cfg = TREES.value; const segments = detail === 0 ? cfg.TREE_FACETS_LOW : detail === 1 ? cfg.TREE_FACETS_MID : cfg.TREE_FACETS_HIGH; const lathe = new THREE.LatheGeometry(CANOPY_PROFILE as THREE.Vector2[], segments); @@ -239,7 +239,7 @@ export function createTreeRenderer( placements: TreePlacement[], commits: CommitEntry[] | null ): Trees { - let cfg = TREES.get(); + let cfg = TREES.value; // Height and width in absolute world units, independent of buildings. // Config exposes DIAMETER for width; convert to radius for the canopy. @@ -475,7 +475,7 @@ export function createTreeRenderer( group.add(trunkMesh); function refresh(): void { - cfg = TREES.get(); + cfg = TREES.value; group.visible = cfg.TREES_ENABLED; for (const rec of canopyRecords) rec.mesh.visible = cfg.TREES_ENABLED; trunkMesh.visible = cfg.TREES_ENABLED; diff --git a/app/src/scene/effects/buildingFader.ts b/app/src/scene/effects/buildingFader.ts index c2af6f38..6c6b4b69 100644 --- a/app/src/scene/effects/buildingFader.ts +++ b/app/src/scene/effects/buildingFader.ts @@ -16,6 +16,7 @@ import * as THREE from 'three'; import { BUILDING_FADE } from '@/state/settings/index'; +import type { BuildingFadeConfig } from '@/state/settings/components/buildings'; import { FadeDetail, NodeKind } from '@/types'; import type { DirNode, FileNode, PickTarget } from '@/types'; import { parentDirPath } from '@/scene/utils/path'; @@ -94,7 +95,7 @@ export function createBuildingFader({ bldgTargetFile: FileNode | null, dirTarget: DirNode | null, hoverFile: FileNode | null, - fadeCfg: ReturnType + fadeCfg: BuildingFadeConfig ): TierResult { // Hover wins — its tier values overwrite any selection/dir-tree result // unconditionally, so check first and skip the more expensive @@ -160,14 +161,14 @@ export function createBuildingFader({ } function _sweepAll(): void { - const sel = picker.selection.get(); - const hov = picker.hover.get(); + const sel = picker.selection.value; + const hov = picker.hover.value; const bldgTargetFile = sel && sel.kind === NodeKind.File ? sel.file : null; const dirTarget = _resolveDirTarget(sel, hov); const hoverFile = hov && hov.kind === NodeKind.File ? hov.file : null; - const fadeCfg = BUILDING_FADE.get(); + const fadeCfg = BUILDING_FADE.value; // Collected by the cell sweep, drained into the ad-panel sweep below // so a media building's ad panel dims by exactly the same factor as diff --git a/app/src/scene/effects/ghostRenderer.ts b/app/src/scene/effects/ghostRenderer.ts index 1a672eee..9d5b57d1 100644 --- a/app/src/scene/effects/ghostRenderer.ts +++ b/app/src/scene/effects/ghostRenderer.ts @@ -20,6 +20,7 @@ // automatically. import * as THREE from 'three'; +import { effect } from '@preact/signals'; import { NodeKind } from '@/types'; import { RENDER_ORDERS } from '@/constants'; import type { createWorld } from '@/scene/world'; @@ -113,8 +114,9 @@ export function createGhostRenderer({ // Ghost is shown only when there is a file hover AND the hovered // building is NOT the currently selected building (same dedup rule // as the hover outline in outlineRenderer). - picker.hover.subscribe((h) => { - const sel = picker.selection.get(); + const _disposeHoverEffect = effect(() => { + const h = picker.hover.value; + const sel = picker.selection.value; const selPath = sel?.kind === NodeKind.File ? sel.file?.path : null; if (h && h.kind === NodeKind.File && h.file?.path !== selPath) { _syncGhostToTarget(h); @@ -126,9 +128,9 @@ export function createGhostRenderer({ // Also hide the ghost when selection changes to the currently-hovered // building (so the ghost disappears on click without waiting for hover-end). - picker.selection.subscribe(() => { - const hov = picker.hover.get(); - const sel = picker.selection.get(); + const _disposeSelectionEffect = effect(() => { + const hov = picker.hover.value; + const sel = picker.selection.value; const selPath = sel?.kind === NodeKind.File ? sel.file?.path : null; if (!hov || hov.kind !== NodeKind.File || hov.file?.path === selPath) { ghostMesh.visible = false; @@ -140,8 +142,8 @@ export function createGhostRenderer({ // still animating (entering tween growing scale.y). function update(_dtMs: number): void { if (!ghostMesh.visible) return; - const hov = picker.hover.get(); - const sel = picker.selection.get(); + const hov = picker.hover.value; + const sel = picker.selection.value; const selPath = sel?.kind === NodeKind.File ? sel.file?.path : null; if (hov && hov.kind === NodeKind.File && hov.file?.path !== selPath) { _syncGhostToTarget(hov); @@ -149,6 +151,8 @@ export function createGhostRenderer({ } function dispose(): void { + _disposeHoverEffect(); + _disposeSelectionEffect(); if (ghostMesh.parent) ghostMesh.parent.remove(ghostMesh); _ghostGeo.dispose(); _ghostMat.dispose(); diff --git a/app/src/scene/effects/outlineRenderer.ts b/app/src/scene/effects/outlineRenderer.ts index 51ba1198..589dab2b 100644 --- a/app/src/scene/effects/outlineRenderer.ts +++ b/app/src/scene/effects/outlineRenderer.ts @@ -41,7 +41,7 @@ export function createOutlineRenderer({ world: ReturnType; picker: ReturnType; }) { - const _bo = BUILDING_OUTLINE.get(); + const _bo = BUILDING_OUTLINE.value; // ── Hover outline (single shared mesh, retransformed per frame) ───── const _unitEdgesGeo = new LineSegmentsGeometry(); @@ -171,7 +171,7 @@ export function createOutlineRenderer({ } function _setSegHueGradient(segIdx: number, hueStart: number, hueEnd: number): void { - const rb = RAINBOW.get(); + const rb = RAINBOW.value; const k = segIdx * 6; _tmpHsl.setHSL(((hueStart % 1) + 1) % 1, rb.SATURATION, rb.LIGHTNESS); _selectedColors[k] = _tmpHsl.r; @@ -201,7 +201,7 @@ export function createOutlineRenderer({ // block share the same mesh object, so reference comparison would wrongly // hide the hover outline for any two buildings in the same block. picker.hover.subscribe((h) => { - const sel = picker.selection.get(); + const sel = picker.selection.value; const selPath = sel?.kind === NodeKind.File ? sel.file?.path : null; if (h && h.kind === NodeKind.File && h.file?.path !== selPath) { _syncOutlineToTarget(hoverOutline, h); @@ -219,7 +219,7 @@ export function createOutlineRenderer({ // instance AND advance the rainbow color chase. Bottom + top form // continuous 4-edge loops; verticals take a single hue from their // bottom corner so the loop chase stays seamless. - const sel = picker.selection.get(); + const sel = picker.selection.value; if (sel && sel.kind === NodeKind.File) { _syncOutlineToTarget(selectedOutline, sel); // Rainbow chase around the cube. The 12 cube edges split into 3 @@ -231,7 +231,7 @@ export function createOutlineRenderer({ // the quartered cycle, hinting at where the bottom/top edges // start and end so the rainbow reads as continuous around the // entire silhouette - const t = performance.now() * RAINBOW.get().SPEED; + const t = performance.now() * RAINBOW.value.SPEED; const HUE_STEPS = 4; // edges per face → quartered hue cycle const HUE_STEP = 1 / HUE_STEPS; for (let i = 0; i < HUE_STEPS; i++) { @@ -246,7 +246,7 @@ export function createOutlineRenderer({ } // Hover: keep transform pinned in case the building is still animating. - const hov = picker.hover.get(); + const hov = picker.hover.value; const selPath = sel?.kind === NodeKind.File ? sel.file?.path : null; if (hov && hov.kind === NodeKind.File && hov.file?.path !== selPath) { _syncOutlineToTarget(hoverOutline, hov); @@ -256,7 +256,7 @@ export function createOutlineRenderer({ // applyTheme() coordinator hook: push fresh BUILDING_OUTLINE values // into the two outline materials we own. function refreshMaterials(): void { - const outline = BUILDING_OUTLINE.get(); + const outline = BUILDING_OUTLINE.value; hoverLineMat.color.set(outline.HOVER_COLOR); hoverLineMat.linewidth = outline.WIDTH; hoverLineMat.opacity = outline.HOVER_OPACITY; diff --git a/app/src/scene/effects/pathLineRenderer.ts b/app/src/scene/effects/pathLineRenderer.ts index 34fc4931..d5a7bc25 100644 --- a/app/src/scene/effects/pathLineRenderer.ts +++ b/app/src/scene/effects/pathLineRenderer.ts @@ -9,6 +9,7 @@ // changes into the materials. import * as THREE from 'three'; +import { effect } from '@preact/signals'; import { LineSegments2 } from 'three/addons/lines/LineSegments2.js'; import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js'; import { LineMaterial } from 'three/addons/lines/LineMaterial.js'; @@ -23,7 +24,7 @@ import { PATH_LINE, HOVER_PATH_LINE, RAINBOW, STREET_TIERS } from '@/state/setti * so this keeps lines proportional to the narrowest street at any zoom. */ export function computePathLinewidthPixels(pct: number): number { - const tiers = STREET_TIERS.get(); + const tiers = STREET_TIERS.value; if (!tiers.length) return pct / 100; // degenerate fallback const minWidth = Math.min(...tiers.map((t) => t.width)); return minWidth * (pct / 100); @@ -46,7 +47,7 @@ export function createPathLineRenderer({ picker: ReturnType; }) { // ── Selection path line (rainbow vertex colors) ──────────────────── - const _pl = PATH_LINE.get(); + const _pl = PATH_LINE.value; const pathLineMat = new LineMaterial({ vertexColors: true, linewidth: computePathLinewidthPixels(_pl.LINEWIDTH_PCT), @@ -71,8 +72,8 @@ export function createPathLineRenderer({ // ── Hover preview path line (single solid color, faded) ──────────── // Width is shared with the selection line — reads PATH_LINE.LINEWIDTH_PCT. const hoverPathLineMat = new LineMaterial({ - color: HOVER_PATH_LINE.get().COLOR, - linewidth: computePathLinewidthPixels(PATH_LINE.get().LINEWIDTH_PCT), + color: HOVER_PATH_LINE.value.COLOR, + linewidth: computePathLinewidthPixels(PATH_LINE.value.LINEWIDTH_PCT), transparent: true, opacity: 0.0, depthTest: true, @@ -88,8 +89,8 @@ export function createPathLineRenderer({ scene.add(hoverPathLine); function _isHoverSameAsSelection(): boolean { - const hov = picker.hover.get(); - const sel = picker.selection.get(); + const hov = picker.hover.value; + const sel = picker.selection.value; if (!hov || !sel) return false; if (hov.kind !== sel.kind) return false; if (hov.kind === NodeKind.File && sel.kind === NodeKind.File) return hov.mesh === sel.mesh; @@ -100,7 +101,7 @@ export function createPathLineRenderer({ } function _updatePathLine(): void { - const sel = picker.selection.get(); + const sel = picker.selection.value; const gemPos = world.getGemWorldPos(); if (!gemPos || !sel) { pathLine.visible = false; @@ -115,7 +116,7 @@ export function createPathLineRenderer({ pathSegmentCount = 0; return; } - const elev = PATH_LINE.get().ELEVATION; + const elev = PATH_LINE.value.ELEVATION; const flat: number[] = []; for (let i = 0; i < pts.length - 1; i++) { const a = pts[i], @@ -134,14 +135,14 @@ export function createPathLineRenderer({ if (_pathColorsBuf.length !== pathSegmentCount * 6) { _pathColorsBuf = new Float32Array(pathSegmentCount * 6); } - pathLineMat.opacity = PATH_LINE.get().OPACITY; + pathLineMat.opacity = PATH_LINE.value.OPACITY; pathLine.visible = true; } function _updateHoverPathLine(): void { - const hov = picker.hover.get(); + const hov = picker.hover.value; const gemPos = world.getGemWorldPos(); - const cfg = HOVER_PATH_LINE.get(); + const cfg = HOVER_PATH_LINE.value; function hide() { hoverPathLine.visible = false; hoverPathLineMat.opacity = 0; @@ -167,11 +168,13 @@ export function createPathLineRenderer({ } // Reactive: rebuild geometry on selection / hover / world change. - picker.selection.subscribe(() => { + const _disposeSelectionEffect = effect(() => { + void picker.selection.value; _updatePathLine(); _updateHoverPathLine(); }); - picker.hover.subscribe(() => { + const _disposeHoverEffect = effect(() => { + void picker.hover.value; _updateHoverPathLine(); }); world.onChange(() => { @@ -182,7 +185,7 @@ export function createPathLineRenderer({ // ── Per-frame: rainbow chase on the selection line ───────────────── function update(_dtMs: number): void { if (pathSegmentCount <= 0 || !pathLine.visible) return; - const rb = RAINBOW.get(); + const rb = RAINBOW.value; const t = performance.now() * rb.SPEED; const n = pathSegmentCount; for (let s = 0; s < n; s++) { @@ -201,12 +204,12 @@ export function createPathLineRenderer({ } function refreshMaterials(): void { - const pl = PATH_LINE.get(); + const pl = PATH_LINE.value; pathLineMat.linewidth = computePathLinewidthPixels(pl.LINEWIDTH_PCT); if (pathLine.visible) pathLineMat.opacity = pl.OPACITY; - const hpl = HOVER_PATH_LINE.get(); + const hpl = HOVER_PATH_LINE.value; hoverPathLineMat.color.set(hpl.COLOR); - hoverPathLineMat.linewidth = computePathLinewidthPixels(PATH_LINE.get().LINEWIDTH_PCT); + hoverPathLineMat.linewidth = computePathLinewidthPixels(PATH_LINE.value.LINEWIDTH_PCT); _updateHoverPathLine(); } @@ -216,6 +219,8 @@ export function createPathLineRenderer({ } function dispose() { + _disposeSelectionEffect(); + _disposeHoverEffect(); if (pathLine.parent) pathLine.parent.remove(pathLine); if (hoverPathLine.parent) hoverPathLine.parent.remove(hoverPathLine); if (pathLineGeo && pathLineGeo.dispose) pathLineGeo.dispose(); diff --git a/app/src/scene/effects/treeOutlineRenderer.ts b/app/src/scene/effects/treeOutlineRenderer.ts index 015abe4d..dd9bc7c7 100644 --- a/app/src/scene/effects/treeOutlineRenderer.ts +++ b/app/src/scene/effects/treeOutlineRenderer.ts @@ -25,30 +25,31 @@ import { RENDER_ORDERS } from '@/constants'; import { NodeKind } from '@/types'; import { buildCanopyEdges } from '@/scene/components/trees/treeRenderer'; import type { PickTarget } from '@/types/picker'; -import type { ReadableAtom } from 'nanostores'; +import type { ReadonlySignal } from '@preact/signals'; +import { effect } from '@preact/signals'; interface TreesHandle { getInstanceTransform(sha: string, out: THREE.Matrix4): boolean; findTreeBySha(sha: string): { mesh: THREE.InstancedMesh; instanceId: number } | null; } -/** Minimal picker surface consumed by this renderer (hover + selection atoms). */ -interface PickerAtoms { - hover: ReadableAtom; - selection: ReadableAtom; +/** Minimal picker surface consumed by this renderer (hover + selection signals). */ +interface PickerSignals { + hover: ReadonlySignal; + selection: ReadonlySignal; } interface CreateArgs { canvas: HTMLCanvasElement; scene: THREE.Scene; - picker: PickerAtoms; + picker: PickerSignals; /** Late-bound: trees are built after this renderer is created. Returns * null when no manifest has been applied yet. */ getTrees: () => TreesHandle | null; } export function createTreeOutlineRenderer({ canvas, scene, picker, getTrees }: CreateArgs) { - const _cfg = TREE_OUTLINE.get(); + const _cfg = TREE_OUTLINE.value; // Build one EdgesGeometry per detail tier. The active outline mesh // points at whichever tier matches the active tree's mesh on snap. @@ -125,14 +126,15 @@ export function createTreeOutlineRenderer({ canvas, scene, picker, getTrees }: C /** True iff hover and selection are the same tree (so hover should hide). */ function _hoverIsSelected(): boolean { - const sel = picker.selection.get(); - const hov = picker.hover.get(); + const sel = picker.selection.value; + const hov = picker.hover.value; if (!sel || sel.kind !== NodeKind.Commit) return false; if (!hov || hov.kind !== NodeKind.Commit) return false; return sel.commit.sha === hov.commit.sha; } - picker.selection.subscribe((sel) => { + const _disposeSelectionEffect = effect(() => { + const sel = picker.selection.value; if (sel && sel.kind === NodeKind.Commit) { const ok = _syncOutline(selectedOutline, sel.commit.sha); selectedOutline.visible = ok; @@ -141,7 +143,8 @@ export function createTreeOutlineRenderer({ canvas, scene, picker, getTrees }: C } }); - picker.hover.subscribe((h) => { + const _disposeHoverEffect = effect(() => { + const h = picker.hover.value; if (h && h.kind === NodeKind.Commit && !_hoverIsSelected()) { const ok = _syncOutline(hoverOutline, h.commit.sha); hoverOutline.visible = ok; @@ -170,7 +173,7 @@ export function createTreeOutlineRenderer({ canvas, scene, picker, getTrees }: C function _writeRainbow(t: number): void { if (!_selColorBuf) return; - const rb = RAINBOW.get(); + const rb = RAINBOW.value; // One hue per segment, rotating around the silhouette over time. for (let i = 0; i < _selSegCount; i++) { const hue = (((t + i / _selSegCount) % 1) + 1) % 1; @@ -192,23 +195,23 @@ export function createTreeOutlineRenderer({ canvas, scene, picker, getTrees }: C function update(_dtMs: number): void { // Selected: re-snap transform (in case the tree's instance matrix // changed via a refresh / animation) and advance rainbow chase. - const sel = picker.selection.get(); + const sel = picker.selection.value; if (sel && sel.kind === NodeKind.Commit) { _syncOutline(selectedOutline, sel.commit.sha); _ensureColorBuffer(selectedOutline.geometry as LineSegmentsGeometry); - const t = performance.now() * RAINBOW.get().SPEED; + const t = performance.now() * RAINBOW.value.SPEED; _writeRainbow(t); } // Hover: re-snap in case the tree moved. - const hov = picker.hover.get(); + const hov = picker.hover.value; if (hov && hov.kind === NodeKind.Commit && !_hoverIsSelected()) { _syncOutline(hoverOutline, hov.commit.sha); } } function refreshMaterials(): void { - const c = TREE_OUTLINE.get(); + const c = TREE_OUTLINE.value; hoverLineMat.color.set(c.HOVER_COLOR); hoverLineMat.linewidth = c.WIDTH; hoverLineMat.opacity = c.HOVER_OPACITY; @@ -224,6 +227,8 @@ export function createTreeOutlineRenderer({ canvas, scene, picker, getTrees }: C } function dispose(): void { + _disposeSelectionEffect(); + _disposeHoverEffect(); if (hoverOutline.parent) hoverOutline.parent.remove(hoverOutline); if (selectedOutline.parent) selectedOutline.parent.remove(selectedOutline); for (const g of _edgesByDetail) g.dispose(); diff --git a/app/src/scene/layout/layout.ts b/app/src/scene/layout/layout.ts index 21a0969d..b90f892d 100644 --- a/app/src/scene/layout/layout.ts +++ b/app/src/scene/layout/layout.ts @@ -163,7 +163,7 @@ interface ManifestLike { // the tier with the highest min_descendants that `count` meets. The last // tier (largest min_descendants) acts as the catch-all for big directories. export function getStreetWidth(count: number, tiers?: StreetTier[]): number { - const arr = tiers && tiers.length ? tiers : STREET_TIERS.get(); + const arr = tiers && tiers.length ? tiers : STREET_TIERS.value; let chosen = arr[0].width; for (let i = 0; i < arr.length; i++) { if (count >= arr[i].min_descendants) chosen = arr[i].width; @@ -233,7 +233,7 @@ export function getBuildingDimensions( lineStats?: RangeStat, byteStats?: RangeStat ): { w: number; d: number; h: number; floors: number } { - const dims = BUILDING_DIMENSIONS.get(); + const dims = BUILDING_DIMENSIONS.value; const maxFloorsCap = dims.MAX_FLOORS != null ? dims.MAX_FLOORS : 30; // ---- Floors from line count (sqrt-normalized over project range) ---- @@ -705,13 +705,13 @@ export function estimateDirReaches( const cached = cache.get(dir); if (cached) return cached; - const streetLayout = STREET_LAYOUT.get(); + const streetLayout = STREET_LAYOUT.value; const childGap = streetLayout.CHILD_GAP; const parentJoinPad = streetLayout.PARENT_JOIN_PAD; const rootEndPad = streetLayout.ROOT_END_PAD; - const bldgDims = BUILDING_DIMENSIONS.get(); + const bldgDims = BUILDING_DIMENSIONS.value; const distFromRoad = bldgDims.DISTANCE_FROM_ROAD; - const gemSizing = GEM_SIZING.get(); + const gemSizing = GEM_SIZING.value; const gemRadiusFrac = gemSizing.RADIUS_AS_STREET_FRAC; // Padding chain — mirrors _layoutDir exactly so the estimate matches the @@ -820,12 +820,12 @@ function _layoutDir( * body exists. */ parentFinalAlongReach?: number ): void { - // ----- Tunables (one .get() per call) ----- - const streetLayout = STREET_LAYOUT.get(); + // ----- Tunables (one .value per call) ----- + const streetLayout = STREET_LAYOUT.value; const childGap = streetLayout.CHILD_GAP; const parentJoinPad = streetLayout.PARENT_JOIN_PAD; const rootEndPad = streetLayout.ROOT_END_PAD; - const bldgDims = BUILDING_DIMENSIONS.get(); + const bldgDims = BUILDING_DIMENSIONS.value; const distFromRoad = bldgDims.DISTANCE_FROM_ROAD; // ----- Padding chain ----- @@ -835,7 +835,7 @@ function _layoutDir( const endPad = parentStreetWidth ? Math.max(joinEndBaseline, openEndPad) : Math.max(rootEndPad, openEndPad); - const gemSizing = GEM_SIZING.get(); + const gemSizing = GEM_SIZING.value; const gemRadiusFrac = gemSizing.RADIUS_AS_STREET_FRAC; // Derive the plaza clearance from the gem's own diameter so the dead // space scales with the gem. Mirror the same MIN_RADIUS floor that @@ -1293,7 +1293,7 @@ function _layoutCityInternal( // ----------------------------------------------------------------------------- export function _streetWidthForDir(dir: DirLike | null | undefined): number { const count = (dir && (dir.descendants_count || dir.children_count)) || 0; - return getStreetWidth(count, STREET_TIERS.get()); + return getStreetWidth(count, STREET_TIERS.value); } // ----------------------------------------------------------------------------- diff --git a/app/src/scene/layout/layoutClient.ts b/app/src/scene/layout/layoutClient.ts index b42295ec..b9806fae 100644 --- a/app/src/scene/layout/layoutClient.ts +++ b/app/src/scene/layout/layoutClient.ts @@ -21,6 +21,9 @@ import { GEM_SIZING, STREET_TIERS, } from '@/state/settings/index'; +import type { StreetLayoutConfig, StreetTier } from '@/state/settings/components/streets'; +import type { BuildingDimensionsConfig } from '@/state/settings/components/buildings'; +import type { GemSizingConfig } from '@/state/settings/components/gem'; import { layoutCity, makeHeightContext, recomputeBuildingDimensions } from './layout'; import type { Manifest, CityLayout, FileNode, TreeNode } from '@/types'; @@ -30,10 +33,10 @@ interface PendingRequest { } interface ConfigSnapshot { - streetLayout: ReturnType; - buildingDimensions: ReturnType; - gemSizing: ReturnType; - streetTiers: ReturnType; + streetLayout: StreetLayoutConfig; + buildingDimensions: BuildingDimensionsConfig; + gemSizing: GemSizingConfig; + streetTiers: StreetTier[]; } export interface LayoutComputeOpts { @@ -114,10 +117,10 @@ function reuseLayout(prior: CityLayout, newManifest: Manifest): CityLayout { function _snapshot(): ConfigSnapshot { return { - streetLayout: STREET_LAYOUT.get(), - buildingDimensions: BUILDING_DIMENSIONS.get(), - gemSizing: GEM_SIZING.get(), - streetTiers: STREET_TIERS.get(), + streetLayout: STREET_LAYOUT.value, + buildingDimensions: BUILDING_DIMENSIONS.value, + gemSizing: GEM_SIZING.value, + streetTiers: STREET_TIERS.value, }; } diff --git a/app/src/scene/layout/layoutWorker.ts b/app/src/scene/layout/layoutWorker.ts index 64b8bd4a..9d9a336c 100644 --- a/app/src/scene/layout/layoutWorker.ts +++ b/app/src/scene/layout/layoutWorker.ts @@ -10,23 +10,21 @@ import { GEM_SIZING, STREET_TIERS, } from '@/state/settings/index'; +import type { StreetLayoutConfig, StreetTier } from '@/state/settings/components/streets'; +import type { BuildingDimensionsConfig } from '@/state/settings/components/buildings'; +import type { GemSizingConfig } from '@/state/settings/components/gem'; import type { Manifest } from '@/types'; import type { CityLayout } from '@/types'; -type StreetLayoutValue = ReturnType; -type BuildingDimensionsValue = ReturnType; -type GemSizingValue = ReturnType; -type StreetTiersValue = ReturnType; - interface LayoutRequest { type: 'layout'; id: number; manifest: Manifest; configSnapshot: { - streetLayout: StreetLayoutValue; - buildingDimensions: BuildingDimensionsValue; - gemSizing: GemSizingValue; - streetTiers: StreetTiersValue; + streetLayout: StreetLayoutConfig; + buildingDimensions: BuildingDimensionsConfig; + gemSizing: GemSizingConfig; + streetTiers: StreetTier[]; }; } @@ -35,18 +33,10 @@ type LayoutResponse = | { type: 'layout-error'; id: number; message: string }; function _applySnapshot(snap: LayoutRequest['configSnapshot']): void { - // map-shaped stores get setKey for each key; atom-shaped stores get - // a single set. STREET_TIERS is an atom (whole-array value). - for (const k of Object.keys(snap.streetLayout) as Array) { - STREET_LAYOUT.setKey(k, snap.streetLayout[k]); - } - for (const k of Object.keys(snap.buildingDimensions) as Array) { - BUILDING_DIMENSIONS.setKey(k, snap.buildingDimensions[k]); - } - for (const k of Object.keys(snap.gemSizing) as Array) { - GEM_SIZING.setKey(k, snap.gemSizing[k]); - } - STREET_TIERS.set(snap.streetTiers); + STREET_LAYOUT.value = { ...STREET_LAYOUT.value, ...snap.streetLayout }; + BUILDING_DIMENSIONS.value = { ...BUILDING_DIMENSIONS.value, ...snap.buildingDimensions }; + GEM_SIZING.value = { ...GEM_SIZING.value, ...snap.gemSizing }; + STREET_TIERS.value = snap.streetTiers; } self.addEventListener('message', (event: MessageEvent) => { diff --git a/app/src/scene/layout/worldBounds.ts b/app/src/scene/layout/worldBounds.ts index 15a624ff..bfd71c3a 100644 --- a/app/src/scene/layout/worldBounds.ts +++ b/app/src/scene/layout/worldBounds.ts @@ -62,7 +62,7 @@ export function getWorldBounds( halfDepth: FALLBACK_HALF_DIM, }; } - const bufferFrac = WORLD.get().GROUND_BUFFER_PERCENT / 100; + const bufferFrac = WORLD.value.GROUND_BUFFER_PERCENT / 100; const characteristicSize = Math.max(bbox.width, bbox.depth, cityHeight); const buffer = characteristicSize * bufferFrac; return { diff --git a/app/src/scene/renderLoop.ts b/app/src/scene/renderLoop.ts index 278393cb..6064d7ff 100644 --- a/app/src/scene/renderLoop.ts +++ b/app/src/scene/renderLoop.ts @@ -175,7 +175,7 @@ export async function startRenderLoop(canvas: HTMLCanvasElement, manifest: Manif // the per-frame tint loop calls material.color.setHex() without // re-parsing every frame. applyTheme() refreshes these whenever the // Settings UI mutates SIDEWALK_COLORS. - const _swc0 = SIDEWALK_COLORS.get(); + const _swc0 = SIDEWALK_COLORS.value; let SIDEWALK_HOVER_COLOR = new THREE.Color(_swc0.HOVER).getHex(); let SIDEWALK_SELECTED_COLOR = new THREE.Color(_swc0.SELECTED).getHex(); let SIDEWALK_DEFAULT_COLOR = new THREE.Color(_swc0.DEFAULT).getHex(); @@ -183,8 +183,8 @@ export async function startRenderLoop(canvas: HTMLCanvasElement, manifest: Manif // _refreshSidewalkTints() — repaint every sidewalk's material.color // based on the current picker.selection / picker.hover state. function _refreshSidewalkTints(): void { - const sel = picker.selection.get(); - const hov = picker.hover.get(); + const sel = picker.selection.value; + const hov = picker.hover.value; const streetPickables = world.getStreetPickables(); for (const sw of streetPickables) { if (sw.userData.origColor == null) { @@ -208,7 +208,7 @@ export async function startRenderLoop(canvas: HTMLCanvasElement, manifest: Manif // (BUILDING_FADE.*, HOVER.COMMIT_MS) are read fresh each frame and // don't need anything here. function applyTheme(): void { - const sidewalk = SIDEWALK_COLORS.get(); + const sidewalk = SIDEWALK_COLORS.value; SIDEWALK_HOVER_COLOR = new THREE.Color(sidewalk.HOVER).getHex(); SIDEWALK_SELECTED_COLOR = new THREE.Color(sidewalk.SELECTED).getHex(); @@ -219,13 +219,13 @@ export async function startRenderLoop(canvas: HTMLCanvasElement, manifest: Manif } _refreshSidewalkTints(); - const asphaltHex = new THREE.Color(ASPHALT.get().COLOR).getHex(); + const asphaltHex = new THREE.Color(ASPHALT.value.COLOR).getHex(); const asphaltMeshes = world.getAsphaltMeshes(); for (const mesh of asphaltMeshes) { mesh.material.color.setHex(asphaltHex); } - scene.background = new THREE.Color(SKY.get().COLOR); + scene.background = new THREE.Color(SKY.value.COLOR); outlineRenderer.refreshMaterials(); treeOutlineRenderer.refreshMaterials(); @@ -265,7 +265,7 @@ export async function startRenderLoop(canvas: HTMLCanvasElement, manifest: Manif // until the first manifest with media files applies. world.getAdPanels()?.refresh(); - const gemAppearance = GEM_APPEARANCE.get(); + const gemAppearance = GEM_APPEARANCE.value; const rootGemEdges = world.getRootGemEdges(); const rootGemBody = world.getRootGemBody(); const rootGem = world.getRootGem(); @@ -288,7 +288,7 @@ export async function startRenderLoop(canvas: HTMLCanvasElement, manifest: Manif // baked at construction. Rewrite it in place on Save so palette tweaks // take effect without a full applyManifest rebuild. if (rootGemBody?.geometry?.attributes.color) { - const palette = GEM_FACE_PALETTE.get(); + const palette = GEM_FACE_PALETTE.value; const paletteHexes = [ palette.FACE_1, palette.FACE_2, @@ -320,7 +320,7 @@ export async function startRenderLoop(canvas: HTMLCanvasElement, manifest: Manif colorAttr.needsUpdate = true; } if (rootGem && rootGem.userData.streetWidth != null) { - const hoverFrac = GEM_SIZING.get().HOVER_LIFT_FRAC; + const hoverFrac = GEM_SIZING.value.HOVER_LIFT_FRAC; rootGem.userData.baseY = rootGem.userData.radius + rootGem.userData.streetWidth * hoverFrac; } @@ -328,7 +328,7 @@ export async function startRenderLoop(canvas: HTMLCanvasElement, manifest: Manif // is driven per-frame by the render loop (palette cycle), so we // don't touch it here. if (rootGem && rootGem.userData.radius != null) { - const glowCfg = GEM_GLOW.get(); + const glowCfg = GEM_GLOW.value; const r = rootGem.userData.radius as number; const inner = rootGem.userData.innerGlowSprite as THREE.Sprite | null; const outer = rootGem.userData.outerGlowSprite as THREE.Sprite | null; @@ -344,7 +344,7 @@ export async function startRenderLoop(canvas: HTMLCanvasElement, manifest: Manif } } - const labelCfg = LABEL_TYPOGRAPHY.get(); + const labelCfg = LABEL_TYPOGRAPHY.value; const streetLabels = world.getStreetLabels(); for (const lg of streetLabels) { const origFrac = lg.userData.origHeightFrac; @@ -497,7 +497,7 @@ export async function startRenderLoop(canvas: HTMLCanvasElement, manifest: Manif _orientLabelsForCamera(world.getStreetLabels(), camera, labelRight); const rootGem = world.getRootGem(); if (rootGem) { - const gemAnim = GEM_ANIMATION.get(); + const gemAnim = GEM_ANIMATION.value; const t = (performance.now() - startTime) / 1000; rootGem.rotation.y = t * gemAnim.ROTATION_SPEED; // BOB_AMPLITUDE_FRAC is read live each frame so the slider @@ -508,7 +508,7 @@ export async function startRenderLoop(canvas: HTMLCanvasElement, manifest: Manif Math.sin(t * gemAnim.BOB_FREQUENCY) * (rootGem.userData.radius * gemAnim.BOB_AMPLITUDE_FRAC); // Scale-up affordance on hover so the gem reads as clickable. - const hov = picker.hover.get(); + const hov = picker.hover.value; const gemTargetScale = hov && hov.kind === NodeKind.Gem ? gemAnim.HOVER_SCALE : 1.0; const curS = rootGem.scale.x; const nextS = curS + (gemTargetScale - curS) * gemAnim.SCALE_LERP_SPEED; @@ -518,12 +518,12 @@ export async function startRenderLoop(canvas: HTMLCanvasElement, manifest: Manif // is on; otherwise fall back to the gem's EDGE_COLOR. Two halos // cycle on different phases so the gem reads with two colors at // any moment, blending as they cross. - const glowCfg = GEM_GLOW.get(); + const glowCfg = GEM_GLOW.value; const inner = rootGem.userData.innerGlowSprite as THREE.Sprite | null; const outer = rootGem.userData.outerGlowSprite as THREE.Sprite | null; if (inner || outer) { if (glowCfg.ANIMATE_COLORS) { - const palette = GEM_FACE_PALETTE.get(); + const palette = GEM_FACE_PALETTE.value; const hexes = [ palette.FACE_1, palette.FACE_2, @@ -540,7 +540,7 @@ export async function startRenderLoop(canvas: HTMLCanvasElement, manifest: Manif if (outer) _setPaletteColor((outer.material as THREE.SpriteMaterial).color, hexes, t, period, 0.5); } else { - const edge = GEM_APPEARANCE.get().EDGE_COLOR; + const edge = GEM_APPEARANCE.value.EDGE_COLOR; if (inner) (inner.material as THREE.SpriteMaterial).color.set(edge); if (outer) (outer.material as THREE.SpriteMaterial).color.set(edge); } @@ -550,7 +550,7 @@ export async function startRenderLoop(canvas: HTMLCanvasElement, manifest: Manif // BLOOM.WINDOW_EMISSION. 1.0 = no bloom from gem; higher = more. // Gated on BLOOM.ENABLED so the "flat" comparison mode skips // the HDR push entirely. - const bloomCfg = BLOOM.get(); + const bloomCfg = BLOOM.value; const gemEmission = bloomCfg.ENABLED ? bloomCfg.GEM_EMISSION : 1.0; if (gemEmission !== 1) { if (inner) (inner.material as THREE.SpriteMaterial).color.multiplyScalar(gemEmission); diff --git a/app/src/scene/system/animator.ts b/app/src/scene/system/animator.ts index 6990827a..1887c96c 100644 --- a/app/src/scene/system/animator.ts +++ b/app/src/scene/system/animator.ts @@ -108,7 +108,7 @@ export function createAnimator({ world }: { world: ReturnType; }) { - const perspective = CAMERA_PERSPECTIVE.get(); + const perspective = CAMERA_PERSPECTIVE.value; const W = canvas.clientWidth; const H = canvas.clientHeight; const camera = new THREE.PerspectiveCamera( @@ -92,7 +92,7 @@ export function createCameraRig({ perspective.FAR ); - const cameraControlsCfg = CAMERA_CONTROLS.get(); + const cameraControlsCfg = CAMERA_CONTROLS.value; const controls = new OrbitControls(camera, canvas); controls.enableDamping = true; controls.dampingFactor = cameraControlsCfg.DAMPING_FACTOR; @@ -168,7 +168,7 @@ export function createCameraRig({ // (CAMERA_PERSPECTIVE.FAR × 0.95) so the sphere never gets clipped // at the corners of small-repo viewports. const dynamicFar = controls.maxDistance * 2 + worldRadius * 2; - const skySphereExtent = CAMERA_PERSPECTIVE.get().FAR * 0.95; + const skySphereExtent = CAMERA_PERSPECTIVE.value.FAR * 0.95; camera.far = Math.max(dynamicFar, skySphereExtent); camera.updateProjectionMatrix(); @@ -327,10 +327,10 @@ export function createCameraRig({ // reset() on the SOURCE_KEY subscribe directly would snap to the // previous repo's stale initialCamPos. if (!_rebuildSubscribed) { - let _lastSourceKey = CURRENT_SOURCE_KEY.get(); + let _lastSourceKey = CURRENT_SOURCE_KEY.value; world.onChange(() => { _captureFraming(); - const cur = CURRENT_SOURCE_KEY.get(); + const cur = CURRENT_SOURCE_KEY.value; if (cur !== null && cur !== _lastSourceKey) { _lastSourceKey = cur; reset(); @@ -357,7 +357,7 @@ export function createCameraRig({ const startTarget = controls.target.clone(); const startCamPos = camera.position.clone(); const t0 = performance.now(); - const easingPower = ANIMATION_TIMING.get().EASING_POWER; + const easingPower = ANIMATION_TIMING.value.EASING_POWER; function step() { if (camAnimToken !== token) return; @@ -415,7 +415,7 @@ export function createCameraRig({ _animateCamera( p.clone(), camera.position.clone().add(delta), - ANIMATION_TIMING.get().BASE_DURATION_MS * RECENTER_RATIO + ANIMATION_TIMING.value.BASE_DURATION_MS * RECENTER_RATIO ); } @@ -477,7 +477,7 @@ export function createCameraRig({ _animateCamera( center.clone(), newCamPos, - ANIMATION_TIMING.get().BASE_DURATION_MS * durationRatio + ANIMATION_TIMING.value.BASE_DURATION_MS * durationRatio ); } diff --git a/app/src/scene/system/inputHandlers.ts b/app/src/scene/system/inputHandlers.ts index 567f0cee..dc5dcfee 100644 --- a/app/src/scene/system/inputHandlers.ts +++ b/app/src/scene/system/inputHandlers.ts @@ -155,7 +155,7 @@ export function createInputHandlers({ canvas.style.cursor = 'grab'; } - if (_sameHover(newHover, picker.hover.get())) { + if (_sameHover(newHover, picker.hover.value)) { if (_hoverCommitId) { clearTimeout(_hoverCommitId); _hoverCommitId = 0; @@ -170,8 +170,8 @@ export function createInputHandlers({ _hoverCommitId = 0; const toCommit = _hoverPending; _hoverPending = null; - if (!_sameHover(toCommit, picker.hover.get())) picker.setHover(toCommit); - }, INPUT_TIMING.get().HOVER_COMMIT_MS); + if (!_sameHover(toCommit, picker.hover.value)) picker.setHover(toCommit); + }, INPUT_TIMING.value.HOVER_COMMIT_MS); } function _handlePick(clientX: number, clientY: number): void { @@ -192,7 +192,7 @@ export function createInputHandlers({ // no-op, double-click-to-focus would race with the per-click toggle and // leave the target deselected on the dblclick frame. const next = picker.interpretHit(hit); - if (_sameHover(next, picker.selection.get())) return; + if (_sameHover(next, picker.selection.value)) return; picker.setSelection(next); } @@ -249,7 +249,7 @@ export function createInputHandlers({ const dx = ev.clientX - downX; const dy = ev.clientY - downY; const dtime = Date.now() - downTime; - const input = INPUT_TIMING.get(); + const input = INPUT_TIMING.value; const moveSq = input.CLICK_MOVE_THRESHOLD_PX * input.CLICK_MOVE_THRESHOLD_PX; if (dx * dx + dy * dy > moveSq) return; if (dtime > input.CLICK_TIME_THRESHOLD_MS) return; @@ -294,7 +294,7 @@ export function createInputHandlers({ // No manifest rebuild — reload the page for that. onResetView(); } else if (KEY_BINDINGS.FOCUS_SELECTION.keys.includes(ev.key)) { - const sel = picker.selection.get(); + const sel = picker.selection.value; if (!sel) return; if (sel.kind === NodeKind.File) { rig.focusBuilding(sel.mesh, sel.data); @@ -326,7 +326,7 @@ export function createInputHandlers({ _hoverCommitId = 0; } _hoverPending = null; - if (picker.hover.get()) picker.setHover(null); + if (picker.hover.value) picker.setHover(null); hideTooltip(); canvas.style.cursor = 'grabbing'; }; diff --git a/app/src/scene/system/picker.ts b/app/src/scene/system/picker.ts index 64532cc5..54419b66 100644 --- a/app/src/scene/system/picker.ts +++ b/app/src/scene/system/picker.ts @@ -1,17 +1,17 @@ // scene/picker.ts — owns the raycaster + the hover and selection -// state machine. State rides on nanostores atoms so consumers +// state machine. State rides on @preact/signals so consumers // (outlineRenderer, pathLineRenderer, buildingFader, coordinator) -// use the same `subscribe` / `get` idiom they already use for every +// use the same `effect` / `.value` idiom they already use for every // config store. // // Public contract: // // const picker = createPicker({ canvas, camera, world }); // -// picker.hover // atom: null | hover target -// picker.selection // atom: null | selection target -// picker.setHover(target) // updates hover atom -// picker.setSelection(target) // updates selection + derived selectionKey atoms +// picker.hover // signal: null | hover target +// picker.selection // signal: null | selection target +// picker.setHover(target) // updates hover signal +// picker.setSelection(target) // updates selection + derived selectionKey signals // picker.selectByPath(path) // tree clicks, breadcrumb segment clicks // picker.pickAt(x, y) // raycast against living meshes; returns null | hit // picker.interpretHit(hit) // null | { kind, mesh|sidewalk, file|street|dir } @@ -26,7 +26,7 @@ // // Selection persistence // --------------------- -// PICKER_SELECTION_KEY (exported, atom) holds the persistable form, a +// PICKER_SELECTION_KEY (exported, signal) holds the persistable form, a // tagged union over the same three discriminators carried by selection: // { kind: NodeKind.File, path: string } // { kind: NodeKind.Directory, path: string } @@ -39,14 +39,14 @@ // gone), so a rebuild that loses a node clears it from selection too. import * as THREE from 'three'; -import { atom } from 'nanostores'; +import { signal, effect } from '@preact/signals'; import { NodeKind } from '@/types'; import type { PickTarget, PickerWorld, PickerSelectionKey } from '@/types'; // Persisted across reloads. Exported so attachPersistence can pick it // up via the Config barrel re-export. -export const PICKER_SELECTION_KEY = atom(null); +export const PICKER_SELECTION_KEY = signal(null); export function createPicker({ canvas, @@ -57,8 +57,8 @@ export function createPicker({ camera: THREE.Camera; world: PickerWorld; }) { - const hover = atom(null); - const selection = atom(null); + const hover = signal(null); + const selection = signal(null); const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2(); @@ -97,28 +97,29 @@ export function createPicker({ // ── Selection → key derivation ──────────────────────────────────── // selection is the source of truth. Any time it changes, we recompute // PICKER_SELECTION_KEY so the persistence layer sees the new key. - // No code path writes to both atoms simultaneously. + // No code path writes to both signals simultaneously. // - // NOTE: nanostores subscribe() fires immediately with the current value. + // NOTE: effect() fires immediately with the current value. // We suppress the initial fire so we don't clobber a key that was // hydrated by attachPersistence before this picker was created. let _suspendKeyDerive = true; - selection.subscribe((sel) => { + const _disposeSelectionEffect = effect(() => { + const sel = selection.value; if (_suspendKeyDerive) return; if (!sel) { - PICKER_SELECTION_KEY.set(null); + PICKER_SELECTION_KEY.value = null; return; } if (sel.kind === NodeKind.File && sel.file?.path != null) { - PICKER_SELECTION_KEY.set({ kind: NodeKind.File, path: sel.file.path }); + PICKER_SELECTION_KEY.value = { kind: NodeKind.File, path: sel.file.path }; return; } if (sel.kind === NodeKind.Directory && sel.dir?.path != null) { - PICKER_SELECTION_KEY.set({ kind: NodeKind.Directory, path: sel.dir.path }); + PICKER_SELECTION_KEY.value = { kind: NodeKind.Directory, path: sel.dir.path }; return; } if (sel.kind === NodeKind.Commit && sel.commit?.sha) { - PICKER_SELECTION_KEY.set({ kind: NodeKind.Commit, sha: sel.commit.sha }); + PICKER_SELECTION_KEY.value = { kind: NodeKind.Commit, sha: sel.commit.sha }; return; } }); @@ -131,13 +132,13 @@ export function createPicker({ // selected node survives across rebuilds when its path still exists, // and clears cleanly when it doesn't. function _resolveKeyToSelection() { - const key = PICKER_SELECTION_KEY.get(); + const key = PICKER_SELECTION_KEY.value; _refreshPickables(); // also refresh pickables on every rebuild if (!key) { // Drop selection in case it referred to disposed meshes. _suspendKeyDerive = true; - selection.set(null); + selection.value = null; _suspendKeyDerive = false; return; } @@ -145,16 +146,16 @@ export function createPicker({ const b = world.getBuildingByPath(key.path); _suspendKeyDerive = true; if (b) { - selection.set({ + selection.value = { kind: NodeKind.File, mesh: b.mesh, data: b.building, file: b.building.file, instanceId: b.instanceId, - }); + }; } else { - selection.set(null); - PICKER_SELECTION_KEY.set(null); + selection.value = null; + PICKER_SELECTION_KEY.value = null; } _suspendKeyDerive = false; return; @@ -164,15 +165,15 @@ export function createPicker({ const st = world.getStreetByDir(key.path); _suspendKeyDerive = true; if (sw && st && st.dir) { - selection.set({ + selection.value = { kind: NodeKind.Directory, sidewalk: sw, street: st, dir: st.dir, - }); + }; } else { - selection.set(null); - PICKER_SELECTION_KEY.set(null); + selection.value = null; + PICKER_SELECTION_KEY.value = null; } _suspendKeyDerive = false; return; @@ -186,15 +187,15 @@ export function createPicker({ const hit = trees?.findTreeBySha(key.sha) ?? null; _suspendKeyDerive = true; if (hit) { - selection.set({ + selection.value = { kind: NodeKind.Commit, mesh: hit.mesh, instanceId: hit.instanceId, commit: hit.commit, - }); + }; } else { - selection.set(null); - PICKER_SELECTION_KEY.set(null); + selection.value = null; + PICKER_SELECTION_KEY.value = null; } _suspendKeyDerive = false; return; @@ -204,7 +205,7 @@ export function createPicker({ // Hover always clears on rebuild — it's transient and would point at // a stale mesh otherwise. function _clearHoverOnRebuild() { - hover.set(null); + hover.value = null; } const _unsubResolve = world.onChange(() => { @@ -217,11 +218,11 @@ export function createPicker({ // ── Public setters ───────────────────────────────────────────────── function setHover(h: PickTarget | null): void { - hover.set(h); + hover.value = h; } function setSelection(sel: PickTarget | null): void { - selection.set(sel); + selection.value = sel; } // Resolve a path string (file or directory) to a live target and set @@ -290,7 +291,7 @@ export function createPicker({ } // interpretHit(hit) — reduce a raw raycast hit to a target object of - // the same shape held by hover / selection atoms. Returns null for + // the same shape held by hover / selection signals. Returns null for // hits that aren't selectable (e.g. street labels, which don't have // userData.type populated for picking). function interpretHit(hit: THREE.Intersection | null): PickTarget | null { @@ -349,6 +350,7 @@ export function createPicker({ function dispose() { if (typeof _unsubResolve === 'function') _unsubResolve(); + _disposeSelectionEffect(); } return { diff --git a/app/src/scene/system/postFx.ts b/app/src/scene/system/postFx.ts index 4b20637a..be4df6e8 100644 --- a/app/src/scene/system/postFx.ts +++ b/app/src/scene/system/postFx.ts @@ -37,7 +37,7 @@ export function createPostFx( scene: THREE.Scene, camera: THREE.PerspectiveCamera ): PostFx { - const bloomCfg = BLOOM.get(); + const bloomCfg = BLOOM.value; // ACES tonemapping compresses HDR (>1.0) values back into display // [0,1] for the canvas. The wall colors written by the shader stay // in [0,1] so they're mostly unchanged; only the emissive windows @@ -82,7 +82,7 @@ export function createPostFx( // with refreshBuildingMaterial clamping uWindowEmissionBoost to 0 // so no shader output reaches HDR space. refresh: () => { - const cfg = BLOOM.get(); + const cfg = BLOOM.value; bloom.enabled = cfg.ENABLED; bloom.strength = cfg.STRENGTH; bloom.radius = cfg.RADIUS; diff --git a/app/src/scene/world.ts b/app/src/scene/world.ts index 68baf172..cb819487 100644 --- a/app/src/scene/world.ts +++ b/app/src/scene/world.ts @@ -254,7 +254,7 @@ function _buildWorld(layout: CityLayout) { // All visual values (street colors, sidewalk default, label fill/stroke, // gem edge color, etc.) come from the named exports of @/config. const scene = new THREE.Scene(); - scene.background = new THREE.Color(SKY.get().COLOR); + scene.background = new THREE.Color(SKY.value.COLOR); // Streets + their labels const streets = layout.streets || []; @@ -336,7 +336,7 @@ export function createWorld(_canvas: HTMLCanvasElement) { // Persistent across applyManifest calls. const scene = new THREE.Scene(); - scene.background = new THREE.Color(SKY.get().COLOR); + scene.background = new THREE.Color(SKY.value.COLOR); // Cyberpunk Valley sky — built ONCE here, lives at scene root for // the lifetime of the world. Not rebuilt per applyManifest @@ -481,14 +481,14 @@ export function createWorld(_canvas: HTMLCanvasElement) { // PATH_LINE / HOVER_PATH_LINE are live Line2 meshes, not built by buildWorld. function computeScenicConfigHash(): string { return JSON.stringify({ - sceneColors: SCENE_COLORS.get(), - asphalt: ASPHALT.get(), - sidewalkColors: SIDEWALK_COLORS.get(), - labelTypography: LABEL_TYPOGRAPHY.get(), - gemSizing: GEM_SIZING.get(), - gemFacePalette: GEM_FACE_PALETTE.get(), - gemAppearance: GEM_APPEARANCE.get(), - gemGlow: GEM_GLOW.get(), + sceneColors: SCENE_COLORS.value, + asphalt: ASPHALT.value, + sidewalkColors: SIDEWALK_COLORS.value, + labelTypography: LABEL_TYPOGRAPHY.value, + gemSizing: GEM_SIZING.value, + gemFacePalette: GEM_FACE_PALETTE.value, + gemAppearance: GEM_APPEARANCE.value, + gemGlow: GEM_GLOW.value, }); } @@ -1010,7 +1010,7 @@ export function createWorld(_canvas: HTMLCanvasElement) { // rect is inflated by HALO_WIDTH for the footprint pass). Only // expand XZ — Y stays bounded by the actual building heights so // cityHeight calc isn't inflated. - const footprintCfg = FOOTPRINT.get(); + const footprintCfg = FOOTPRINT.value; if (footprintCfg.ENABLED && footprintCfg.HALO_WIDTH > 0) { const halo = footprintCfg.HALO_WIDTH; bbox.min.x -= halo; @@ -1027,7 +1027,7 @@ export function createWorld(_canvas: HTMLCanvasElement) { rootGemEdges = cellBuilt.rootGemEdges || null; for (const child of [...cellBuilt.scene.children]) scene.add(child); - scene.background = new THREE.Color(SKY.get().COLOR); + scene.background = new THREE.Color(SKY.value.COLOR); // Remove per-building meshes that buildWorld emits — the cell // path replaces them with InstancedMesh cells. Keep streetLabels: @@ -1054,7 +1054,7 @@ export function createWorld(_canvas: HTMLCanvasElement) { // GPU upload blocks the main thread. For large repos this gap is // the difference between a snappy rebuild and a multi-hundred-ms // freeze. - const treesEnabled = TREES.get().TREES_ENABLED; + const treesEnabled = TREES.value.TREES_ENABLED; if (_trees) { _trees.dispose(); _trees = null; @@ -1123,7 +1123,7 @@ export function createWorld(_canvas: HTMLCanvasElement) { const cityHeightAtDefer = cityHeight; const foliageBbox: CityBbox = sceneBbox; - REBUILD_STATUS.set('decorating'); + REBUILD_STATUS.value = 'decorating'; // rAF lets the browser START the next frame; setTimeout(0) // then yields the task so the browser can COMPLETE the paint // before foliage work begins. @@ -1172,7 +1172,7 @@ export function createWorld(_canvas: HTMLCanvasElement) { }); } - REBUILD_STATUS.set('idle'); + REBUILD_STATUS.value = 'idle'; } } diff --git a/app/src/state/drafts.ts b/app/src/state/drafts.ts index 92291cf6..08e24255 100644 --- a/app/src/state/drafts.ts +++ b/app/src/state/drafts.ts @@ -1,26 +1,24 @@ // state/drafts.ts — in-memory draft layer between the controls panel -// and the real nanostores. Widgets read getEffective() and write +// and the real signals. Widgets read getEffective() and write // setDraft(); the Save button calls commit() to flush every draft into -// its store (which triggers the existing persist + commit-reaction -// subscriptions). Discard clears drafts without touching stores. Page +// its signal (which triggers the existing persist + commit-reaction +// effects). Discard clears drafts without touching signals. Page // reload drops drafts (in-memory only — the standard "unsaved changes" // pattern). // -// Storage shape: Map>. For atom stores -// (no setKey), key = null and the inner map has at most one entry. +// Storage shape: Map>. For scalar-valued +// signals (no sub-key), key = null and the inner map has at most one entry. import { forEachRegisteredStore, getDefault } from './persist'; -interface MapLikeStore { - get(): any; - set?(value: any): void; - setKey?(key: string, value: any): void; - subscribe(listener: (state: any) => void): () => void; +interface SignalLike { + get value(): any; + set value(v: any); } type DraftKey = string | null; -const _drafts: Map> = new Map(); +const _drafts: Map> = new Map(); const _listeners: Array<() => void> = []; function _emit(): void { @@ -50,13 +48,13 @@ function _clone(v: T): T { } } -function _committedValue(store: MapLikeStore, key: DraftKey): unknown { - const state = store.get(); +function _committedValue(store: SignalLike, key: DraftKey): unknown { + const state = store.value; if (key === null) return state; return state ? state[key] : undefined; } -export function setDraft(store: MapLikeStore, key: DraftKey, value: unknown): void { +export function setDraft(store: SignalLike, key: DraftKey, value: unknown): void { const committed = _committedValue(store, key); let perStore = _drafts.get(store); if (_equal(value, committed)) { @@ -76,15 +74,15 @@ export function setDraft(store: MapLikeStore, key: DraftKey, value: unknown): vo _emit(); } -export function getEffective(store: MapLikeStore, key: DraftKey): unknown { +export function getEffective(store: SignalLike, key: DraftKey): unknown { const perStore = _drafts.get(store); if (perStore && perStore.has(key)) return perStore.get(key); return _committedValue(store, key); } -export function stageReset(store: MapLikeStore, key: DraftKey): void { - // For atom stores: getDefault(store) returns the whole default value. - // For map stores: getDefault(store, key) returns the keyed default. +export function stageReset(store: SignalLike, key: DraftKey): void { + // For scalar signals: getDefault(store) returns the whole default value. + // For object-valued signals: getDefault(store, key) returns the keyed default. const def = key === null ? getDefault(store) : getDefault(store, key); if (def === undefined) return; setDraft(store, key, def); @@ -93,34 +91,33 @@ export function stageReset(store: MapLikeStore, key: DraftKey): void { export function stageResetAll(): void { let touched = false; forEachRegisteredStore((_name, store, defaults) => { - // Direct-write stores (e.g. SYNTAX_THEME) bypass the draft layer on - // user input — the widget writes straight to the atom for instant + // Direct-write signals (e.g. SYNTAX_THEME) bypass the draft layer on + // user input — the widget writes straight to the signal for instant // visual feedback. Reset all must do the same, otherwise it leaves // a phantom draft that the user has to Save to clear. if ((store as { _skipDrafts?: boolean })._skipDrafts) { - const s = store as MapLikeStore; - if (!_equal(s.get(), defaults) && typeof s.set === 'function') { - s.set(defaults); + const s = store as SignalLike; + if (!_equal(s.value, defaults)) { + s.value = defaults; } return; } if ( defaults && typeof defaults === 'object' && - !Array.isArray(defaults) && - typeof (store as MapLikeStore).setKey === 'function' + !Array.isArray(defaults) ) { - // Map store: stage each sub-key whose effective value differs from default. + // Object-valued signal: stage each sub-key whose effective value differs from default. for (const k in defaults) { if (!Object.hasOwn(defaults, k)) continue; - if (_equal(getEffective(store as MapLikeStore, k), defaults[k])) continue; - _stageWithoutEmit(store as MapLikeStore, k, defaults[k]); + if (_equal(getEffective(store as SignalLike, k), defaults[k])) continue; + _stageWithoutEmit(store as SignalLike, k, defaults[k]); touched = true; } } else { - // Atom store (or array-shaped atom): stage whole default if effective differs. - if (!_equal(getEffective(store as MapLikeStore, null), defaults)) { - _stageWithoutEmit(store as MapLikeStore, null, defaults); + // Scalar / array signal: stage whole default if effective differs. + if (!_equal(getEffective(store as SignalLike, null), defaults)) { + _stageWithoutEmit(store as SignalLike, null, defaults); touched = true; } } @@ -130,7 +127,7 @@ export function stageResetAll(): void { // Same write logic as setDraft but defers the _emit() call. Used by // stageResetAll so a single fan-out happens after the whole sweep. -function _stageWithoutEmit(store: MapLikeStore, key: DraftKey, value: unknown): void { +function _stageWithoutEmit(store: SignalLike, key: DraftKey, value: unknown): void { const committed = _committedValue(store, key); let perStore = _drafts.get(store); if (_equal(value, committed)) { @@ -153,9 +150,9 @@ export function commit(): void { return; } // Snapshot the entries first; clearing _drafts before the writes makes - // any synchronous store.subscribe handler that re-reads getEffective + // any synchronous signal effect that re-reads getEffective // see the freshly-committed value instead of the lingering draft. - const entries: Array<[MapLikeStore, DraftKey, unknown]> = []; + const entries: Array<[SignalLike, DraftKey, unknown]> = []; for (const [store, perStore] of _drafts) { for (const [key, value] of perStore) { entries.push([store, key, value]); @@ -164,9 +161,10 @@ export function commit(): void { _drafts.clear(); for (const [store, key, value] of entries) { if (key === null) { - if (typeof store.set === 'function') store.set(value); + store.value = value; } else { - if (typeof store.setKey === 'function') store.setKey(key, value); + // Object-valued signal: merge the key in. + store.value = { ...store.value, [key]: value }; } } _emit(); diff --git a/app/src/state/persist.ts b/app/src/state/persist.ts index d6d991ca..ff8493d5 100644 --- a/app/src/state/persist.ts +++ b/app/src/state/persist.ts @@ -15,7 +15,8 @@ // Keep this module side-effect-free until `attachPersistence()` is called — // tests + non-browser environments shouldn't touch localStorage. -import type { WritableAtom } from 'nanostores'; +import { effect, untracked } from '@preact/signals'; +import type { Signal } from '@preact/signals'; import { STORAGE_PREFIX, STORAGE_PER_SOURCE_PREFIX } from '@/constants'; import { CURRENT_SOURCE_KEY } from '@/state/runtime/sourceContext'; @@ -23,7 +24,7 @@ import { CURRENT_SOURCE_KEY } from '@/state/runtime/sourceContext'; // "reset to default" UI restores to and what the diff-vs-default check uses. // `any` here is deliberate: each store has a different value shape and // the diff/reset code is generic across all of them. Tightening to a -// `Record` would require carrying through generic +// `Record` would require carrying through generic // parameters that don't actually buy us anything at the boundary. const _DEFAULTS_BY_NAME: Record = {}; // Map from store reference → its registered name (so callers that already @@ -95,44 +96,51 @@ function _clone(v: T): T { } // Hydrate one store from localStorage if a value is persisted, then start -// streaming future changes back. Plain consts (no `subscribe`) are silently -// skipped so callers can sweep `import * as Config` blindly. +// streaming future changes back. Plain consts (no signal .value accessor) +// are silently skipped so callers can sweep `import * as Config` blindly. export function persistStore(name: string, store: any): void { if (typeof localStorage === 'undefined') return; - if (!store || typeof store.subscribe !== 'function') return; + // Only process signals (objects with a `.value` property and `.brand`). + // Non-signal exports (plain consts, functions, type re-exports) are skipped. + if (!store || typeof store !== 'object' || !('value' in store)) return; // Snapshot the original (pre-hydration) defaults. This is what reset // restores to and what the diff-vs-default check compares against. - const defaults = _clone(store.get()); + const defaults = _clone(store.value); _DEFAULTS_BY_NAME[name] = defaults; _STORE_BY_NAME[name] = store; if (_NAME_BY_STORE) _NAME_BY_STORE.set(store, name); const saved = _safeGet(name); - const initialState = store.get(); + const initialState = store.value; + // A signal is treated as a "map" (object with per-key diffs) when its value + // is a non-null, non-array plain object. Everything else (primitives, + // arrays) is treated as an "atom" (whole-value persistence). const isMap = - typeof store.setKey === 'function' && initialState && typeof initialState === 'object' && !Array.isArray(initialState); if (isMap) { - // map() — saved is a partial diff; restore each saved key. Skip keys - // that aren't in the current defaults (a previous version of the app - // may have persisted a key that's since been removed; ignoring those - // entries lets the schema evolve without piling stale data into the - // live store). + // Object-typed signal — saved is a partial diff; restore each saved key. + // Skip keys that aren't in the current defaults (a previous version of + // the app may have persisted a key that's since been removed; ignoring + // those entries lets the schema evolve without piling stale data into + // the live store). if (saved && typeof saved === 'object' && !Array.isArray(saved)) { - for (const k in saved) { - if (!Object.hasOwn(saved, k)) continue; + const merged: Record = { ...initialState }; + for (const k in saved as object) { + if (!Object.hasOwn(saved as object, k)) continue; if (!Object.hasOwn(defaults, k)) continue; - store.setKey(k, saved[k]); + merged[k] = (saved as Record)[k]; } + store.value = merged; } // On change, write the diff (only keys that exist in defaults AND // differ from them). Same skip-unknown-keys rule. - store.subscribe((state) => { - const diff = {}; + effect(() => { + const state = store.value; + const diff: Record = {}; let any = false; for (const sk in state) { if (!Object.hasOwn(state, sk)) continue; @@ -147,9 +155,10 @@ export function persistStore(name: string, store: any): void { _emitChange(); }); } else { - // atom() — single value. Saved replaces the whole thing. - if (saved !== null) store.set(saved); - store.subscribe((v) => { + // Primitive / array signal — single value. Saved replaces the whole thing. + if (saved !== null) store.value = saved; + effect(() => { + const v = store.value; if (_equal(v, defaults)) _safeRemove(name); else _safeSet(name, v); _emitChange(); @@ -168,8 +177,8 @@ export function attachPersistence(stores: Record): void { } // getDefault(store, key) -> the originally-defined default for that key. -// For map() stores: pass the key name. Returns the keyed default. -// For atom() stores: omit `key`. Returns the whole default value. +// For object-valued signals: pass the key name. Returns the keyed default. +// For scalar signals: omit `key`. Returns the whole default value. // Returns undefined if the store wasn't registered via persistStore. export function getDefault(store: any, key?: string): any { if (!_NAME_BY_STORE) return undefined; @@ -180,16 +189,16 @@ export function getDefault(store: any, key?: string): any { return d ? d[key] : undefined; } -// resetKey(store, key) — restore a single key (map) or the whole atom to -// its registered default. The store's subscribe handler installed above +// resetKey(store, key) — restore a single key (object-valued signal) or the +// whole signal to its registered default. The signal's effect installed above // then removes the localStorage entry if no keys differ anymore. export function resetKey(store: any, key?: string): void { const defaultVal = getDefault(store, key); if (defaultVal === undefined) return; - if (typeof store.setKey === 'function' && key !== undefined) { - store.setKey(key, defaultVal); + if (key !== undefined && store.value && typeof store.value === 'object' && !Array.isArray(store.value)) { + store.value = { ...store.value, [key]: defaultVal }; } else { - store.set(defaultVal); + store.value = defaultVal; } } @@ -227,29 +236,16 @@ export function onAnyChange(cb: () => void): () => void { // survive a Reset-all. // // Pushes defaults back into each store rather than just nuking localStorage: -// the store's subscribe handler then drops the localStorage entry on its own, -// AND consumers (live-poll loop, scene, controls UI) see the change live -// instead of waiting for a page reload. +// the store's effect installed above then drops the localStorage entry on +// its own, AND consumers (live-poll loop, scene, controls UI) see the change +// live instead of waiting for a page reload. export function clearPersistence(): void { for (const name in _DEFAULTS_BY_NAME) { if (!Object.hasOwn(_DEFAULTS_BY_NAME, name)) continue; const store = _STORE_BY_NAME[name]; const defaults = _DEFAULTS_BY_NAME[name]; if (!store) continue; - if ( - typeof store.setKey === 'function' && - defaults && - typeof defaults === 'object' && - !Array.isArray(defaults) - ) { - for (const k in defaults) { - if (Object.hasOwn(defaults, k)) { - store.setKey(k, _clone(defaults[k])); - } - } - } else { - store.set(_clone(defaults)); - } + store.value = _clone(defaults); } } @@ -276,68 +272,74 @@ function perSourceKey(sourceKey: string, baseName: string): string { } /** - * Wire a writable atom to per-source localStorage persistence. The atom's + * Wire a writable signal to per-source localStorage persistence. The signal's * value is stored under `cc.source..` and rehydrated * whenever CURRENT_SOURCE_KEY changes. * - * - When CURRENT_SOURCE_KEY is null, the atom is not persisted (writes - * don't reach localStorage; reads return whatever the atom currently holds). - * - On CURRENT_SOURCE_KEY change: the current atom value is saved to the - * OLD key, then the atom is set to whatever is stored under the NEW key + * - When CURRENT_SOURCE_KEY is null, the signal is not persisted (writes + * don't reach localStorage; reads return whatever the signal currently holds). + * - On CURRENT_SOURCE_KEY change: the current signal value is saved to the + * OLD key, then the signal is set to whatever is stored under the NEW key * (or `defaultValue` if absent). - * - Direct atom writes propagate to the active source key's slot. + * - Direct signal writes propagate to the active source key's slot. */ export function persistAtomPerSource( baseName: string, - store: WritableAtom, + store: Signal, defaultValue: T ): void { - let lastKey: string | null = CURRENT_SOURCE_KEY.get(); + let lastKey: string | null = CURRENT_SOURCE_KEY.value; // Hydrate at attach time if a key is already set (e.g., URL had ?src=). if (lastKey !== null) { const raw = localStorage.getItem(perSourceKey(lastKey, baseName)); if (raw !== null) { try { - store.set(JSON.parse(raw) as T); + store.value = JSON.parse(raw) as T; } catch { - store.set(defaultValue); + store.value = defaultValue; } } else { - store.set(defaultValue); + store.value = defaultValue; } } - store.subscribe((value) => { - const k = CURRENT_SOURCE_KEY.get(); + // Write effect: only tracks `store.value`. Reads the current source key + // without tracking it (untracked) so that source-key transitions don't + // trigger a premature write that would overwrite the slot we're about to + // hydrate from in the key-change effect below. + effect(() => { + const value = store.value; + const k = untracked(() => CURRENT_SOURCE_KEY.value); if (k === null) return; localStorage.setItem(perSourceKey(k, baseName), JSON.stringify(value)); }); - CURRENT_SOURCE_KEY.subscribe((nextKey) => { + effect(() => { + const nextKey = CURRENT_SOURCE_KEY.value; if (nextKey === lastKey) return; - // Save current atom to OLD slot only when transitioning between two real + // Save current signal to OLD slot only when transitioning between two real // keys. On key→null (source unloaded) we skip the write: the - // store.subscribe handler above already persisted the latest value - // whenever the atom was last mutated, so a redundant write here would + // store effect above already persisted the latest value + // whenever the signal was last mutated, so a redundant write here would // cause stale-subscriber cross-test pollution and isn't needed. if (lastKey !== null && nextKey !== null) { - localStorage.setItem(perSourceKey(lastKey, baseName), JSON.stringify(store.get())); + localStorage.setItem(perSourceKey(lastKey, baseName), JSON.stringify(store.value)); } - // Hydrate atom from NEW slot. + // Hydrate signal from NEW slot. if (nextKey !== null) { const raw = localStorage.getItem(perSourceKey(nextKey, baseName)); if (raw !== null) { try { - store.set(JSON.parse(raw) as T); + store.value = JSON.parse(raw) as T; } catch { - store.set(defaultValue); + store.value = defaultValue; } } else { - store.set(defaultValue); + store.value = defaultValue; } } else { - store.set(defaultValue); + store.value = defaultValue; } lastKey = nextKey; }); diff --git a/app/src/state/reactions.ts b/app/src/state/reactions.ts index a64ba97a..60d286e8 100644 --- a/app/src/state/reactions.ts +++ b/app/src/state/reactions.ts @@ -4,8 +4,8 @@ // // All widgets in the Controls pane write to the draft layer // (`configDrafts.setDraft`). Nothing touches the real store until Save -// calls `configDrafts.commit()`, which fires every queued `setKey` in a -// synchronous burst. Each `setKey` triggers any nanostore subscriber +// calls `configDrafts.commit()`, which fires every queued `.value =` write +// in a synchronous burst. Each write triggers any signal effect // registered below — that is the ONLY moment these reactions run. // // rebuild-required → world.applyManifest(getManifest()) for a full @@ -16,7 +16,8 @@ // Adding a new config row is a one-line entry in the appropriate set // below — the reactions pick it up automatically. -import { listenKeys } from 'nanostores'; +import { effect } from '@preact/signals'; +import type { Signal } from '@preact/signals'; import { REBUILD_STATUS, LAST_REBUILD_ERROR, LAST_UPDATED_AT } from '@/state/runtime/liveStatus'; @@ -100,18 +101,60 @@ interface CommitReactionsOpts { applyTheme: () => void; } +/** + * Helper that mimics nanostores' listenKeys() — fires `cb` only when any of + * the listed keys in the object-valued signal change value, and skips the + * initial fire. Returns a disposer. + */ +function listenKeys( + store: Signal, + keys: (keyof T)[], + cb: (value: T) => void +): () => void { + let prev: Partial = {}; + for (const k of keys) { + prev[k] = store.value[k]; + } + let armed = false; + const dispose = effect(() => { + const v = store.value; + // Read all watched keys to establish tracking. + const snapshots: Partial = {}; + for (const k of keys) { + snapshots[k] = v[k]; + } + if (!armed) { + armed = true; + // Store initial snapshot but don't fire. + prev = snapshots; + return; + } + let changed = false; + for (const k of keys) { + if (snapshots[k] !== prev[k]) { + changed = true; + break; + } + } + if (changed) { + prev = snapshots; + cb(v); + } + }); + return dispose; +} + export function attachCommitReactions({ world, applyTheme }: CommitReactionsOpts): () => void { - // nanostores `.subscribe()` fires synchronously with the current - // value when called. We wait until all subscriptions are wired - // before allowing reactions to run, so the initial fire doesn't - // trigger a wasteful rebuild. + // Signal effects fire synchronously with the current value when called. + // We wait until all subscriptions are wired before allowing reactions + // to run, so the initial fire doesn't trigger a wasteful rebuild. let armed = false; let hotIdleTimer: ReturnType | 0 = 0; async function scheduleRebuild() { if (!armed) return; - REBUILD_STATUS.set('rebuilding'); + REBUILD_STATUS.value = 'rebuilding'; try { // Config changes that hit this path always invalidate the layout // cache: the manifest didn't change but a layout-affecting config @@ -124,23 +167,23 @@ export function attachCommitReactions({ world, applyTheme }: CommitReactionsOpts if (manifest) { await world.applyManifest(manifest); } - REBUILD_STATUS.set('idle'); - LAST_REBUILD_ERROR.set(null); + REBUILD_STATUS.value = 'idle'; + LAST_REBUILD_ERROR.value = null; } catch (err) { - REBUILD_STATUS.set('error'); - LAST_REBUILD_ERROR.set(err instanceof Error ? err.message : String(err)); + REBUILD_STATUS.value = 'error'; + LAST_REBUILD_ERROR.value = err instanceof Error ? err.message : String(err); } } function refreshMaterials() { if (!armed) return; if (hotIdleTimer) clearTimeout(hotIdleTimer); - REBUILD_STATUS.set('rebuilding'); + REBUILD_STATUS.value = 'rebuilding'; try { applyTheme(); } catch (err) { - REBUILD_STATUS.set('error'); - LAST_REBUILD_ERROR.set(err instanceof Error ? err.message : String(err)); + REBUILD_STATUS.value = 'error'; + LAST_REBUILD_ERROR.value = err instanceof Error ? err.message : String(err); return; } // applyTheme is synchronous; hold the 'rebuilding' indicator on @@ -149,15 +192,15 @@ export function attachCommitReactions({ world, applyTheme }: CommitReactionsOpts // own try/catch owns the final state in that case. hotIdleTimer = setTimeout(() => { hotIdleTimer = 0; - if (REBUILD_STATUS.get() === 'rebuilding') { - REBUILD_STATUS.set('idle'); - LAST_REBUILD_ERROR.set(null); - LAST_UPDATED_AT.set(Date.now()); + if (REBUILD_STATUS.value === 'rebuilding') { + REBUILD_STATUS.value = 'idle'; + LAST_REBUILD_ERROR.value = null; + LAST_UPDATED_AT.value = Date.now(); } }, HOT_REBUILD_MIN_DWELL_MS); } - const rebuildStores = [ + const rebuildStores: Signal[] = [ BUILDING_DIMENSIONS, BUILDING_PALETTE, STREET_LAYOUT, @@ -196,7 +239,7 @@ export function attachCommitReactions({ world, applyTheme }: CommitReactionsOpts // ]; - const materialOnlyStores = [ + const materialOnlyStores: Signal[] = [ SCENE_COLORS, SIDEWALK_COLORS, ASPHALT, @@ -264,11 +307,24 @@ export function attachCommitReactions({ world, applyTheme }: CommitReactionsOpts ]; const unsubs: Array<() => void> = []; + + // Wire whole-store reactions. effect() fires immediately with current + // value; we guard with `armed` until all effects are registered. for (const store of rebuildStores) { - unsubs.push(store.subscribe(scheduleRebuild)); + unsubs.push( + effect(() => { + void store.value; // track this signal + scheduleRebuild(); + }) + ); } for (const store of materialOnlyStores) { - unsubs.push(store.subscribe(refreshMaterials)); + unsubs.push( + effect(() => { + void store.value; // track this signal + refreshMaterials(); + }) + ); } // HALO_WIDTH bakes into per-instance Matrix4 data at createCityFootprint // time, so changing it requires a full applyManifest rebuild. The other diff --git a/app/src/state/runtime/liveStatus.ts b/app/src/state/runtime/liveStatus.ts index 4fc820cc..641e4f14 100644 --- a/app/src/state/runtime/liveStatus.ts +++ b/app/src/state/runtime/liveStatus.ts @@ -13,7 +13,7 @@ // LAST_UPDATED_AT is written by the coordinator on every applied // manifest (initial paint + each successful poll that swapped state). -import { atom } from 'nanostores'; +import { signal } from '@preact/signals'; /** * State of the most recent (or current) world rebuild. @@ -26,13 +26,13 @@ import { atom } from 'nanostores'; */ export type RebuildStatus = 'idle' | 'rebuilding' | 'decorating' | 'error'; -export const REBUILD_STATUS = atom('idle'); +export const REBUILD_STATUS = signal('idle'); /** Error message from the most recent failed rebuild; null when idle/success. */ -export const LAST_REBUILD_ERROR = atom(null); +export const LAST_REBUILD_ERROR = signal(null); /** Epoch millis of the most recent manifest apply (initial or via poll). */ -export const LAST_UPDATED_AT = atom(0); +export const LAST_UPDATED_AT = signal(0); // ── Manual refresh action ──────────────────────────────────────────── // The footer's refresh button (and any future "force re-sync" UI) goes diff --git a/app/src/state/runtime/liveUpdates.ts b/app/src/state/runtime/liveUpdates.ts index a6ecb7b3..91eee192 100644 --- a/app/src/state/runtime/liveUpdates.ts +++ b/app/src/state/runtime/liveUpdates.ts @@ -13,6 +13,7 @@ // only flipped during the actual manifest fetch so the footer's // "rebuilding…" indicator only lights up when there's real work. +import { effect } from '@preact/signals'; import { LIVE_UPDATES, POLL_SECONDS_MIN, POLL_SECONDS_MAX } from '@/state/settings/index'; import { REBUILD_STATUS, LAST_REBUILD_ERROR, setRefreshManifest } from '@/state/runtime/liveStatus'; import { manifestUrl, signatureUrl, streamManifest } from '@/api/manifest'; @@ -49,7 +50,7 @@ export function setupLiveUpdates( // behaves identically. A non-2xx response or a JSON parse error // resolves to 'error' with the message captured in LAST_REBUILD_ERROR. async function refreshManifest(): Promise { - REBUILD_STATUS.set('rebuilding'); + REBUILD_STATUS.value = 'rebuilding'; try { for await (const event of streamManifest(manifestUrl())) { if (event.phase === 'error') throw new Error(event.error); @@ -66,11 +67,11 @@ export function setupLiveUpdates( await handle.world.applyManifest(m); } } - REBUILD_STATUS.set('idle'); - LAST_REBUILD_ERROR.set(null); + REBUILD_STATUS.value = 'idle'; + LAST_REBUILD_ERROR.value = null; } catch (err) { - REBUILD_STATUS.set('error'); - LAST_REBUILD_ERROR.set(err instanceof Error ? err.message : String(err)); + REBUILD_STATUS.value = 'error'; + LAST_REBUILD_ERROR.value = err instanceof Error ? err.message : String(err); } } @@ -123,7 +124,7 @@ export function setupLiveUpdates( function start(): void { stop(); - const seconds = _clampPollSeconds(LIVE_UPDATES.get().POLL_SECONDS); + const seconds = _clampPollSeconds(LIVE_UPDATES.value.POLL_SECONDS); timer = window.setInterval(tick, seconds * 1000); } function stop(): void { @@ -138,22 +139,23 @@ export function setupLiveUpdates( // trigger the same fetch+apply chain without re-implementing it. setRefreshManifest(refreshFromToggle); - // nanostores .subscribe() fires the callback synchronously with the - // current value the instant it is called. We arm the subscription - // AFTER registering it — same pattern as attachCommitReactions — so the - // initial synthetic fire is suppressed. Runtime changes (user toggles - // LIVE_UPDATES.ENABLED) happen after `armed = true` and behave normally. + // effect() fires the callback synchronously with the current value + // the instant it is called. We arm the effect AFTER registering it — + // same pattern as attachCommitReactions — so the initial synthetic fire + // is suppressed. Runtime changes (user toggles LIVE_UPDATES.ENABLED) + // happen after `armed = true` and behave normally. let _liveUpdatesArmed = false; - LIVE_UPDATES.subscribe((val) => { + effect(() => { + const val = LIVE_UPDATES.value; if (!_liveUpdatesArmed) return; if (val.ENABLED) start(); else stop(); }); _liveUpdatesArmed = true; - // Kick off the initial poll state now that the subscription is armed. - // The subscribe's initial fire was suppressed above, so we explicitly + // Kick off the initial poll state now that the effect is armed. + // The effect's initial fire was suppressed above, so we explicitly // honour the current ENABLED value here. - if (LIVE_UPDATES.get().ENABLED) start(); + if (LIVE_UPDATES.value.ENABLED) start(); return { setSignature(sig: string) { diff --git a/app/src/state/runtime/sourceContext.ts b/app/src/state/runtime/sourceContext.ts index 86d9e4f8..697ce20f 100644 --- a/app/src/state/runtime/sourceContext.ts +++ b/app/src/state/runtime/sourceContext.ts @@ -4,7 +4,7 @@ // subscribe to CURRENT_SOURCE_KEY to swap their localStorage slots when // the user picks a different source. -import { atom } from 'nanostores'; +import { signal } from '@preact/signals'; function djb2(s: string): string { let h = 5381; @@ -30,4 +30,4 @@ export function sourceKey(src: string, branch?: string): string { * (modal-open / first boot). Set by main.ts boot and on every successful * modal submit. */ -export const CURRENT_SOURCE_KEY = atom(null); +export const CURRENT_SOURCE_KEY = signal(null); diff --git a/app/src/state/settings/components/adPanels.ts b/app/src/state/settings/components/adPanels.ts index b0d94409..bb2b7b9c 100644 --- a/app/src/state/settings/components/adPanels.ts +++ b/app/src/state/settings/components/adPanels.ts @@ -5,7 +5,7 @@ // snapshots the store once per mesh). Hot-reload routes via // `rebuildStores` in app/config/hotReload.ts. -import { map } from 'nanostores'; +import { signal } from '@preact/signals'; export interface AdPanelConfig { AD_SIDE_MARGIN_FRAC: number; // 0-0.4 — horizontal margin on each side of the building's width @@ -14,7 +14,7 @@ export interface AdPanelConfig { AD_ERROR_COLOR: string; // CSS hex — shown after a permanent load/decode/upload failure (sticky) } -export const AD_PANEL = map({ +export const AD_PANEL = signal({ AD_SIDE_MARGIN_FRAC: 0.1, AD_BOTTOM_OFFSET_FLOORS: 1.0, AD_PLACEHOLDER_COLOR: '#29293d', diff --git a/app/src/state/settings/components/buildings.ts b/app/src/state/settings/components/buildings.ts index f278a656..13b98213 100644 --- a/app/src/state/settings/components/buildings.ts +++ b/app/src/state/settings/components/buildings.ts @@ -4,7 +4,7 @@ // DIMENSIONS + PALETTE changes are rebuild-required (regenerate per-building // geometry / facade textures). OUTLINE + FADE are applied on Save via applyTheme(). -import { map } from 'nanostores'; +import { signal } from '@preact/signals'; import { FadeDetail } from '@/types'; // ─── Dimensions ──────────────────────────────────────────────────────────── @@ -26,7 +26,7 @@ export interface BuildingDimensionsConfig { DISTANCE_FROM_ROAD: number; } -export const BUILDING_DIMENSIONS = map({ +export const BUILDING_DIMENSIONS = signal({ MIN_FLOORS: 2, MAX_FLOORS: 64, FLOOR_HEIGHT: 16, // scene units per floor @@ -47,7 +47,7 @@ export interface BuildingPaletteConfig { HUE_EXT_MAP: Record; } -export const BUILDING_PALETTE = map({ +export const BUILDING_PALETTE = signal({ // Saturation + lightness both key off LAST-MODIFIED (see // getBuildingColor). Newest files hit SATURATION_MAX × LIGHTNESS_MAX // for the richest, most-saturated version of the hue; oldest fall @@ -210,7 +210,7 @@ export interface BuildingOutlineConfig { SELECTED_OPACITY: number; } -export const BUILDING_OUTLINE = map({ +export const BUILDING_OUTLINE = signal({ WIDTH: 4, // shared by default + hover + selected HOVER_COLOR: '#ffffff', HOVER_OPACITY: 0.5, @@ -239,7 +239,7 @@ export interface BuildingAgingConfig { TILT_DEGREES: number; } -export const BUILDING_AGING = map({ +export const BUILDING_AGING = signal({ GRIME_ENABLED: true, GRIME_INTENSITY: 0.75, GRIME_COVERAGE: 0.55, @@ -294,7 +294,7 @@ export interface BuildingFadeConfig { LEVEL4_OUTLINE_OPACITY: number; } -export const BUILDING_FADE = map({ +export const BUILDING_FADE = signal({ // Default tier — applies to the selected/hovered building itself // and to every building when nothing is selected (idle state). DEFAULT_DETAIL: FadeDetail.Full, diff --git a/app/src/state/settings/components/facade.ts b/app/src/state/settings/components/facade.ts index f6a30345..54c56b54 100644 --- a/app/src/state/settings/components/facade.ts +++ b/app/src/state/settings/components/facade.ts @@ -18,7 +18,7 @@ // for ranges. Degenerate inputs (e.g. WINDOW_COLS_MAX=0, SLAB_HEIGHT_FRAC=1) // render visually weird but do not crash. -import { map } from 'nanostores'; +import { signal } from '@preact/signals'; export interface FacadeGeometryConfig { // SHADER-DRIVEN (refresh uniforms, no rebuild required) @@ -35,7 +35,7 @@ export interface FacadeGeometryConfig { DOOR_WIDTH_FRAC: number; // 0-1 — door width as a fraction of the building's own width } -export const FACADE_GEOMETRY = map({ +export const FACADE_GEOMETRY = signal({ SLAB_HEIGHT_FRAC: 0.05, WINDOW_WIDTH_FRAC: 0.5, WINDOW_HEIGHT_FRAC: 0.45, @@ -59,7 +59,7 @@ export interface FacadeDetailConfig { ROOF_BORDER_LIGHTNESS_DELTA: number; // -100..100 (default -15) } -export const FACADE_DETAIL = map({ +export const FACADE_DETAIL = signal({ SLAB_LIGHTNESS_DELTA: -10, DOOR_LIGHTNESS_DELTA: -50, ROOF_BORDER_LIGHTNESS_DELTA: -10, @@ -82,7 +82,7 @@ export interface WindowLightingConfig { LIT_FRESHNESS_EXPONENT: number; } -export const WINDOW_LIGHTING = map({ +export const WINDOW_LIGHTING = signal({ UNLIT_LIGHTNESS_DELTA: -4, GAP_BASE_THRESHOLD: 0.25, GAP_AGE_BONUS: 0.5, diff --git a/app/src/state/settings/components/fireflies.ts b/app/src/state/settings/components/fireflies.ts index c405347e..2747ebf2 100644 --- a/app/src/state/settings/components/fireflies.ts +++ b/app/src/state/settings/components/fireflies.ts @@ -15,7 +15,7 @@ // rings now derive from each author's firefly color (lighter variant) // and selected rings render the shared RAINBOW chase used by tree outlines. -import { map } from 'nanostores'; +import { signal } from '@preact/signals'; export interface FirefliesConfig { /** Master toggle — when false no fireflies are placed or rendered. */ @@ -44,7 +44,7 @@ export interface FirefliesConfig { ORBIT_RING_THICKNESS: number; } -export const FIREFLIES = map({ +export const FIREFLIES = signal({ FIREFLIES_ENABLED: true, ORBIT_SPEED: 0.3, BOB_AMPLITUDE: 0.5, diff --git a/app/src/state/settings/components/footprint.ts b/app/src/state/settings/components/footprint.ts index e31b251b..7301c697 100644 --- a/app/src/state/settings/components/footprint.ts +++ b/app/src/state/settings/components/footprint.ts @@ -11,7 +11,7 @@ // are rejected by the existing rbush overlap check — no extra // gradient logic required. -import { map } from 'nanostores'; +import { signal } from '@preact/signals'; export interface FootprintConfig { /** Master toggle. When false, the InstancedMesh is still built but @@ -34,7 +34,7 @@ export interface FootprintConfig { COLOR: string; } -export const FOOTPRINT = map({ +export const FOOTPRINT = signal({ ENABLED: true, HALO_WIDTH: 24, // 2.0 × 24 = 48 wu radius. Above 1.0 means the rounding extends diff --git a/app/src/state/settings/components/gem.ts b/app/src/state/settings/components/gem.ts index e30921f1..a0835e31 100644 --- a/app/src/state/settings/components/gem.ts +++ b/app/src/state/settings/components/gem.ts @@ -2,7 +2,7 @@ // (applied on Save via vertex color buffer rewrite), edge color (applied on Save via applyTheme()), // and animation tuning (applied on Save via applyTheme(); read fresh per frame). -import { map } from 'nanostores'; +import { signal } from '@preact/signals'; import { oklchToHex } from '@/scene/utils/color/colors'; // ─── Sizing + landing zone ──────────────────────────────────────────────── @@ -18,7 +18,7 @@ export interface GemSizingConfig { SIDES: string; } -export const GEM_SIZING = map({ +export const GEM_SIZING = signal({ RADIUS_AS_STREET_FRAC: 0.5, // gem radius = root street width × this MIN_RADIUS: 8, // floor for narrow root streets HOVER_LIFT_FRAC: 0.5, // gem hovers above road = radius × this @@ -61,7 +61,7 @@ const FACE_C = 0.22; const FACE_COUNT = 8; const faceHex = (i: number): string => oklchToHex(FACE_L, FACE_C, (i / FACE_COUNT) * 360); -export const GEM_FACE_PALETTE = map({ +export const GEM_FACE_PALETTE = signal({ FACE_1: faceHex(0), FACE_2: faceHex(1), FACE_3: faceHex(2), @@ -81,7 +81,7 @@ export interface GemAppearanceConfig { BODY_OPACITY: number; } -export const GEM_APPEARANCE = map({ +export const GEM_APPEARANCE = signal({ EDGE_COLOR: '#ffffff', BODY_OPACITY: 0.75, }); @@ -101,7 +101,7 @@ export interface GemGlowConfig { CYCLE_PERIOD_SECONDS: number; } -export const GEM_GLOW = map({ +export const GEM_GLOW = signal({ ENABLED: true, INNER_SCALE: 4.0, // hot core, hugging the gem OUTER_SCALE: 15.0, // atmospheric falloff, large soft disk @@ -121,7 +121,7 @@ export interface GemAnimationConfig { SCALE_LERP_SPEED: number; } -export const GEM_ANIMATION = map({ +export const GEM_ANIMATION = signal({ ROTATION_SPEED: 1.0, // radians/sec multiplier BOB_FREQUENCY: 1.0, // bob cycles/sec multiplier BOB_AMPLITUDE_FRAC: 0.5, // vertical bob distance = radius × this diff --git a/app/src/state/settings/components/island.ts b/app/src/state/settings/components/island.ts index c5bf7c68..3c037808 100644 --- a/app/src/state/settings/components/island.ts +++ b/app/src/state/settings/components/island.ts @@ -6,7 +6,7 @@ // // All fields applied on Save via applyTheme() — islandMesh.applyConfig() pulls fresh values. -import { map } from 'nanostores'; +import { signal } from '@preact/signals'; export interface IslandGeometryConfig { ENABLED: boolean; @@ -26,7 +26,7 @@ export interface IslandMaterialsConfig { HEMI_GROUND_COLOR: string; // cool "from below" tone } -export const ISLAND_GEOMETRY = map({ +export const ISLAND_GEOMETRY = signal({ ENABLED: true, SIDES: 32, IRREGULARITY: 0.12, @@ -36,7 +36,7 @@ export const ISLAND_GEOMETRY = map({ GRASS_THICKNESS: 0.025, // 2.5% of island radius — visible but subtle band }); -export const ISLAND_MATERIALS = map({ +export const ISLAND_MATERIALS = signal({ GRASS_COLOR: '#18341f', // deep forest green GRASS_SIDE_COLOR: '#214529', // brighter than GRASS_COLOR to compensate for sideways-facing hemi lighting ROCK_COLOR: '#71778e', // cool slate/granite diff --git a/app/src/state/settings/components/lighting.ts b/app/src/state/settings/components/lighting.ts index f6360ca6..c5f5fc86 100644 --- a/app/src/state/settings/components/lighting.ts +++ b/app/src/state/settings/components/lighting.ts @@ -3,7 +3,7 @@ // building fragment shader. Persisted via attachPersistence(Config) at boot // and wired through refreshBuildingMaterial() on every change. -import { map } from 'nanostores'; +import { signal } from '@preact/signals'; // Sun direction expressed in spherical coordinates rather than a raw vec3 // because users tweak "where the sun is" more naturally as @@ -32,7 +32,7 @@ export interface LightingConfig { SUN_CONTRAST: number; } -export const LIGHTING = map({ +export const LIGHTING = signal({ SUN_AZIMUTH_DEG: 51, SUN_ELEVATION_DEG: 58, AMBIENT: 0.72, diff --git a/app/src/state/settings/components/repoLabel.ts b/app/src/state/settings/components/repoLabel.ts index 50f62008..69427a63 100644 --- a/app/src/state/settings/components/repoLabel.ts +++ b/app/src/state/settings/components/repoLabel.ts @@ -27,7 +27,7 @@ // look; other colors fold the aberration into the // chosen hue. -import { map } from 'nanostores'; +import { signal } from '@preact/signals'; export interface RepoLabelConfig { ENABLED: boolean; @@ -39,7 +39,7 @@ export interface RepoLabelConfig { TEXT_COLOR: string; } -export const REPO_LABEL = map({ +export const REPO_LABEL = signal({ ENABLED: true, HEIGHT_PCT: 85, // Tuned by eye to feel like a substantial banner above the city diff --git a/app/src/state/settings/components/sky.ts b/app/src/state/settings/components/sky.ts index 69882cc0..0c54a4ff 100644 --- a/app/src/state/settings/components/sky.ts +++ b/app/src/state/settings/components/sky.ts @@ -10,13 +10,13 @@ // SKY_STARS — hashed point-star field rendered across the full // sphere (including below the horizon line). -import { map } from 'nanostores'; +import { signal } from '@preact/signals'; export interface SkyConfig { COLOR: string; } -export const SKY = map({ +export const SKY = signal({ // Near-black with a faint plum cast — reads as deep space, not pure // black, so HDR-bloomed buildings and stars retain contrast. COLOR: '#010005', @@ -27,7 +27,7 @@ export interface SkyStarsConfig { DENSITY: number; } -export const SKY_STARS = map({ +export const SKY_STARS = signal({ ENABLED: true, // 0.0075 — user-tuned default that lands ~680 stars across the // upper hemisphere. With stars now drawn across the full sphere diff --git a/app/src/state/settings/components/streets.ts b/app/src/state/settings/components/streets.ts index d7f5895f..34cdbac0 100644 --- a/app/src/state/settings/components/streets.ts +++ b/app/src/state/settings/components/streets.ts @@ -3,7 +3,7 @@ // packed (tiers + gaps). Asphalt color + sidewalk variants are applied on Save // via applyTheme(); label typography + tiers + gaps are rebuild-required. -import { atom, map } from 'nanostores'; +import { signal } from '@preact/signals'; // ─── Asphalt (the inner stripe of every street) ────────────────────────── // COLOR is applied on Save via applyTheme(). Width and length are derived: width = street @@ -16,7 +16,7 @@ export interface AsphaltConfig { WIDTH_FRAC: number; } -export const ASPHALT = map({ +export const ASPHALT = signal({ COLOR: '#313544', WIDTH_FRAC: 0.6, }); @@ -32,7 +32,7 @@ export interface SidewalkColorsConfig { SELECTED: string; } -export const SIDEWALK_COLORS = map({ +export const SIDEWALK_COLORS = signal({ DEFAULT: '#4b5163', HOVER: '#6d6e74', SELECTED: '#ffffff', @@ -54,7 +54,7 @@ export interface LabelTypographyConfig { ELEVATION: number; } -export const LABEL_TYPOGRAPHY = map({ +export const LABEL_TYPOGRAPHY = signal({ FILL: '#ffffff', STROKE: 'rgba(8, 9, 14, 0.95)', FONT_FAMILY: 'Inter, "SF Mono", sans-serif', @@ -74,7 +74,7 @@ export interface PathLineConfig { OPACITY: number; } -export const PATH_LINE = map({ +export const PATH_LINE = signal({ LINEWIDTH_PCT: 15, ELEVATION: 0.3, // Y position above ground OPACITY: 0.95, @@ -94,7 +94,7 @@ export interface HoverPathLineConfig { ELEVATION: number; } -export const HOVER_PATH_LINE = map({ +export const HOVER_PATH_LINE = signal({ COLOR: '#ffffff', OPACITY: 0.25, ELEVATION: 0.25, // sits just below PATH_LINE so the rainbow stays on top @@ -110,7 +110,7 @@ export interface StreetTier { width: number; } -export const STREET_TIERS = atom([ +export const STREET_TIERS = signal([ { min_descendants: 0, width: 32 }, { min_descendants: 4, width: 48 }, { min_descendants: 8, width: 80 }, @@ -131,7 +131,7 @@ export interface StreetLayoutConfig { PARENT_JOIN_PAD: number; } -export const STREET_LAYOUT = map({ +export const STREET_LAYOUT = signal({ CHILD_GAP: 8, ROOT_END_PAD: 8, PARENT_JOIN_PAD: 8, diff --git a/app/src/state/settings/components/trees.ts b/app/src/state/settings/components/trees.ts index 741e5db8..00c356d5 100644 --- a/app/src/state/settings/components/trees.ts +++ b/app/src/state/settings/components/trees.ts @@ -25,7 +25,7 @@ // - TRUNK: height = TRUNK_HEIGHT_FRAC × canopy height; radius = // TRUNK_RADIUS_FRAC_OF_CANOPY × canopy radius. -import { map } from 'nanostores'; +import { signal } from '@preact/signals'; export interface TreesConfig { /** Master toggle — when false no trees are placed or rendered. */ @@ -114,7 +114,7 @@ export interface TreesConfig { TREE_WIDTH_AGE_FLOOR: number; } -export const TREES = map({ +export const TREES = signal({ TREES_ENABLED: true, EDGE_INSET_PERCENT: 1, @@ -159,7 +159,7 @@ export interface TreeOutlineConfig { SELECTED_OPACITY: number; } -export const TREE_OUTLINE = map({ +export const TREE_OUTLINE = signal({ WIDTH: 1, HOVER_COLOR: '#ffffff', HOVER_OPACITY: 0.5, diff --git a/app/src/state/settings/effects/effects.ts b/app/src/state/settings/effects/effects.ts index 47cd9361..13319b52 100644 --- a/app/src/state/settings/effects/effects.ts +++ b/app/src/state/settings/effects/effects.ts @@ -3,7 +3,7 @@ // all rainbows slower") doesn't require chasing the same values through // multiple per-target stores. -import { map } from 'nanostores'; +import { signal } from '@preact/signals'; // Chasing-rainbow effect used by BOTH the selected building's neon // outline and the gem→selection neon path line. Both consumers cycle hue @@ -15,7 +15,7 @@ export interface RainbowConfig { LIGHTNESS: number; } -export const RAINBOW = map({ +export const RAINBOW = signal({ SPEED: 0.0005, // hue cycles per millisecond SATURATION: 1.0, LIGHTNESS: 0.5, @@ -63,7 +63,7 @@ export interface BloomConfig { // color tint so bright pixels in the texture push past 1.0 in linear // space → bloom picks them up. 1.0 = LDR (no bloom); 2.5 = neon // storefront. Dark image pixels stay below threshold. -export const BLOOM = map({ +export const BLOOM = signal({ ENABLED: true, STRENGTH: 0.1, RADIUS: 1.0, diff --git a/app/src/state/settings/prefs/liveUpdates.ts b/app/src/state/settings/prefs/liveUpdates.ts index 729fe6aa..5c0f5329 100644 --- a/app/src/state/settings/prefs/liveUpdates.ts +++ b/app/src/state/settings/prefs/liveUpdates.ts @@ -9,14 +9,14 @@ // can't ddos the local server, and an absurdly large one can't disable // the feature in disguise. -import { map } from 'nanostores'; +import { signal } from '@preact/signals'; export interface LiveUpdatesConfig { ENABLED: boolean; POLL_SECONDS: number; } -export const LIVE_UPDATES = map({ +export const LIVE_UPDATES = signal({ ENABLED: false, POLL_SECONDS: 10, }); diff --git a/app/src/state/settings/prefs/syntaxTheme.ts b/app/src/state/settings/prefs/syntaxTheme.ts index bd08e661..574c6fa6 100644 --- a/app/src/state/settings/prefs/syntaxTheme.ts +++ b/app/src/state/settings/prefs/syntaxTheme.ts @@ -4,7 +4,7 @@ // Persisted via attachPersistence (Config barrel) so the choice survives // sessions. -import { atom } from 'nanostores'; +import { signal } from '@preact/signals'; export interface SyntaxThemeOption { value: string; @@ -44,9 +44,9 @@ export const SYNTAX_THEME_OPTIONS: SyntaxThemeOption[] = [ // the is injected into after styles.css (higher specificity // wins when cascade order is equal and these are the same specificity). export const SYNTAX_THEME_DEFAULT = 'atom-one-dark'; -export const SYNTAX_THEME = atom(SYNTAX_THEME_DEFAULT); +export const SYNTAX_THEME = signal(SYNTAX_THEME_DEFAULT); -// Theme-picker writes directly to this atom (no draft layer — the CSS +// Theme-picker writes directly to this signal (no draft layer — the CSS // link swaps instantly). stageResetAll() reads this flag and resets // directly too, so "Reset all" doesn't leave behind a phantom draft. (SYNTAX_THEME as unknown as { _skipDrafts?: boolean })._skipDrafts = true; diff --git a/app/src/state/settings/system/animator.ts b/app/src/state/settings/system/animator.ts index 50c0fe3b..891bbf77 100644 --- a/app/src/state/settings/system/animator.ts +++ b/app/src/state/settings/system/animator.ts @@ -19,7 +19,7 @@ // UI tweaks apply immediately without restart — there's no module-level // cache of any of these. -import { map } from 'nanostores'; +import { signal } from '@preact/signals'; export interface AnimationTimingConfig { BASE_DURATION_MS: number; @@ -27,7 +27,7 @@ export interface AnimationTimingConfig { BUILDING_TRANSITION_MS: number; } -export const ANIMATION_TIMING = map({ +export const ANIMATION_TIMING = signal({ BASE_DURATION_MS: 500, EASING_POWER: 3, BUILDING_TRANSITION_MS: 375, diff --git a/app/src/state/settings/system/cameraRig.ts b/app/src/state/settings/system/cameraRig.ts index f69e449c..d5c0496c 100644 --- a/app/src/state/settings/system/cameraRig.ts +++ b/app/src/state/settings/system/cameraRig.ts @@ -11,7 +11,7 @@ // are inlined as private consts in main.ts; they're algorithm tuning, // not designer dials.) -import { map } from 'nanostores'; +import { signal } from '@preact/signals'; // ─── Camera lens ─────────────────────────────────────────────────────────── export interface CameraPerspectiveConfig { @@ -20,7 +20,7 @@ export interface CameraPerspectiveConfig { FAR: number; } -export const CAMERA_PERSPECTIVE = map({ +export const CAMERA_PERSPECTIVE = signal({ FOV: 45, // vertical field-of-view in degrees NEAR: 1, FAR: 20000, @@ -35,7 +35,7 @@ export interface CameraControlsConfig { INITIAL_DISTANCE_MULT: number; } -export const CAMERA_CONTROLS = map({ +export const CAMERA_CONTROLS = signal({ DAMPING_FACTOR: 0.08, // OrbitControls inertia (higher = snappier) MAX_POLAR_ANGLE_FRAC: 0.49, // × Math.PI; how close to vertical orbit can go MIN_DISTANCE: 30, // closest zoom (world units) @@ -64,7 +64,7 @@ export interface CameraAnimationConfig { STREET_FOCUS_ELEVATION_DEG: number; } -export const CAMERA_ANIMATION = map({ +export const CAMERA_ANIMATION = signal({ BUILDING_FOCUS_DISTANCE_MULT: 1.6, // padding multiplier on the fitted distance BUILDING_FOCUS_DISTANCE_OFFSET: 4, STREET_FOCUS_LENGTH_FRAC: 0.65, // visible street length = full × this diff --git a/app/src/state/settings/system/inputHandlers.ts b/app/src/state/settings/system/inputHandlers.ts index 3bd8aa12..a1ec2b90 100644 --- a/app/src/state/settings/system/inputHandlers.ts +++ b/app/src/state/settings/system/inputHandlers.ts @@ -3,7 +3,7 @@ // scene/system/inputHandlers.ts. Read per-event so the Settings UI's // tweaks apply immediately. -import { map } from 'nanostores'; +import { signal } from '@preact/signals'; export interface InputTimingConfig { CLICK_MOVE_THRESHOLD_PX: number; @@ -11,7 +11,7 @@ export interface InputTimingConfig { HOVER_COMMIT_MS: number; } -export const INPUT_TIMING = map({ +export const INPUT_TIMING = signal({ CLICK_MOVE_THRESHOLD_PX: 5, // pointer must move < this px to count as a click CLICK_TIME_THRESHOLD_MS: 400, // …and release within this window HOVER_COMMIT_MS: 35, // ms cursor must stay on a target before the heavy diff --git a/app/src/state/settings/system/tooltip.ts b/app/src/state/settings/system/tooltip.ts index b35203af..783fc4fc 100644 --- a/app/src/state/settings/system/tooltip.ts +++ b/app/src/state/settings/system/tooltip.ts @@ -1,14 +1,14 @@ // state/settings/system/tooltip.ts — Tooltip placement. Consumed by // views/components/tooltip.ts. -import { map } from 'nanostores'; +import { signal } from '@preact/signals'; export interface TooltipConfig { OFFSET_PX: number; VIEWPORT_MARGIN_PX: number; } -export const TOOLTIP = map({ +export const TOOLTIP = signal({ OFFSET_PX: 14, // distance from cursor VIEWPORT_MARGIN_PX: 4, // safety margin from viewport edges }); diff --git a/app/src/state/settings/world/world.ts b/app/src/state/settings/world/world.ts index d6977eb1..33ae3368 100644 --- a/app/src/state/settings/world/world.ts +++ b/app/src/state/settings/world/world.ts @@ -3,7 +3,7 @@ // visuals (island materials, sky color, etc.) live in their own // component config. -import { map } from 'nanostores'; +import { signal } from '@preact/signals'; export interface WorldConfig { /** Padding past the city's bounding box as a percentage of the city's @@ -12,7 +12,7 @@ export interface WorldConfig { GROUND_BUFFER_PERCENT: number; } -export const WORLD = map({ +export const WORLD = signal({ // 0% by default — the island polygon already has a built-in sqrt(2) × // 1/cos(π/N) expansion past the city bbox so the city is comfortably // contained without extra padding. Users dial this up if they want @@ -41,7 +41,7 @@ export interface SceneColorsConfig { // FOG_ENABLED off → uFogIntensity uniform is forced to 0 (the shader's // mix() then returns the original color unchanged). Other knobs stay // in config so flipping ENABLED back restores the haze. -export const SCENE_COLORS = map({ +export const SCENE_COLORS = signal({ FOG_ENABLED: true, FOG_COLOR: '#0f0821', FOG_INTENSITY: 0.8, diff --git a/app/tests/_helpers/cityFixtures.ts b/app/tests/_helpers/cityFixtures.ts index c22a9d83..94ac255b 100644 --- a/app/tests/_helpers/cityFixtures.ts +++ b/app/tests/_helpers/cityFixtures.ts @@ -105,7 +105,7 @@ export function mkDir(name: string, children: any[]): any { /** Resets the TREES config map to deterministic test defaults. */ export function resetTreesConfig(): void { - TREES.set({ + TREES.value = { TREES_ENABLED: true, EDGE_INSET_PERCENT: 8, TREE_DENSITY_FALLOFF: 0, @@ -128,17 +128,17 @@ export function resetTreesConfig(): void { TREE_AGE_SATURATION_MIN: 20, TREE_AGE_SATURATION_MAX: 100, TREE_WIDTH_AGE_FLOOR: 1.0, - }); + }; } /** Resets the BUILDING_DIMENSIONS config map to deterministic test defaults. */ export function resetBuildingsConfig(): void { - BUILDING_DIMENSIONS.set({ + BUILDING_DIMENSIONS.value = { MIN_FLOORS: 2, MAX_FLOORS: 96, FLOOR_HEIGHT: 16, MIN_WIDTH: 8, MAX_WIDTH: 8, DISTANCE_FROM_ROAD: 8, - }); + }; } diff --git a/app/tests/scene/cityFootprint/footprint.test.ts b/app/tests/scene/cityFootprint/footprint.test.ts index 491675da..30d9ed25 100644 --- a/app/tests/scene/cityFootprint/footprint.test.ts +++ b/app/tests/scene/cityFootprint/footprint.test.ts @@ -7,7 +7,7 @@ import { StreetAxis } from '@/types'; import type { CityLayout } from '@/types'; function resetFootprint() { - FOOTPRINT.set({ ENABLED: true, HALO_WIDTH: 32, CORNER_RADIUS: 1.25, COLOR: '#0a0b0f' }); + FOOTPRINT.value = { ENABLED: true, HALO_WIDTH: 32, CORNER_RADIUS: 1.25, COLOR: '#0a0b0f' }; } function singleBuildingLayout(): CityLayout { @@ -68,7 +68,7 @@ describe('createCityFootprint', () => { }); it('attaches a per-instance aHalfExtent attribute carrying inflated half-extents', () => { - FOOTPRINT.setKey('HALO_WIDTH', 50); + FOOTPRINT.value = { ...FOOTPRINT.value, HALO_WIDTH: 50 }; const layout: CityLayout = { buildings: [ { @@ -96,7 +96,7 @@ describe('createCityFootprint', () => { }); it('uses a ShaderMaterial whose uColor matches FOOTPRINT.COLOR', () => { - FOOTPRINT.setKey('COLOR', '#abcdef'); + FOOTPRINT.value = { ...FOOTPRINT.value, COLOR: '#abcdef' }; const fp = createCityFootprint(singleBuildingLayout()); const mesh = fp.group.children[0] as THREE.InstancedMesh; const mat = mesh.material as THREE.ShaderMaterial; @@ -109,8 +109,8 @@ describe('createCityFootprint', () => { }); it('uCornerRadius uniform is CORNER_RADIUS scaled by HALO_WIDTH', () => { - FOOTPRINT.setKey('HALO_WIDTH', 80); - FOOTPRINT.setKey('CORNER_RADIUS', 0.5); + FOOTPRINT.value = { ...FOOTPRINT.value, HALO_WIDTH: 80 }; + FOOTPRINT.value = { ...FOOTPRINT.value, CORNER_RADIUS: 0.5 }; const fp = createCityFootprint(singleBuildingLayout()); const mesh = fp.group.children[0] as THREE.InstancedMesh; const mat = mesh.material as THREE.ShaderMaterial; @@ -118,16 +118,16 @@ describe('createCityFootprint', () => { }); it('hides the group when FOOTPRINT.ENABLED is false', () => { - FOOTPRINT.setKey('ENABLED', false); + FOOTPRINT.value = { ...FOOTPRINT.value, ENABLED: false }; const fp = createCityFootprint(singleBuildingLayout()); expect(fp.group.visible).toBe(false); }); it('refresh() picks up COLOR + CORNER_RADIUS + ENABLED changes without rebuild', () => { const fp = createCityFootprint(singleBuildingLayout()); - FOOTPRINT.setKey('COLOR', '#112233'); - FOOTPRINT.setKey('CORNER_RADIUS', 0.25); // 0.25 × 32 = 8 wu - FOOTPRINT.setKey('ENABLED', false); + FOOTPRINT.value = { ...FOOTPRINT.value, COLOR: '#112233' }; + FOOTPRINT.value = { ...FOOTPRINT.value, CORNER_RADIUS: 0.25 }; // 0.25 × 32 = 8 wu + FOOTPRINT.value = { ...FOOTPRINT.value, ENABLED: false }; fp.refresh(); const mesh = fp.group.children[0] as THREE.InstancedMesh; const mat = mesh.material as THREE.ShaderMaterial; diff --git a/app/tests/scene/components/adPanels/instanced-ad-panels.test.ts b/app/tests/scene/components/adPanels/instanced-ad-panels.test.ts index 4c035e2e..8df0c431 100644 --- a/app/tests/scene/components/adPanels/instanced-ad-panels.test.ts +++ b/app/tests/scene/components/adPanels/instanced-ad-panels.test.ts @@ -268,7 +268,7 @@ describe('AdPanelTextureArray storage', () => { describe('InstancedAdPanels emission refresh', () => { it('refresh() pushes BLOOM.AD_EMISSION into uEmissionBoost uniform', () => { const ads = new InstancedAdPanels(4); - BLOOM.setKey('AD_EMISSION', 2.5); + BLOOM.value = { ...BLOOM.value, AD_EMISSION: 2.5 }; ads.refresh(); const mat = ads.mesh.material as unknown as { uniforms: { uEmissionBoost: { value: number } } }; expect(mat.uniforms.uEmissionBoost.value).toBeCloseTo(2.5); diff --git a/app/tests/scene/components/buildings/buildingColor.test.ts b/app/tests/scene/components/buildings/buildingColor.test.ts index a82b6053..7c3610e8 100644 --- a/app/tests/scene/components/buildings/buildingColor.test.ts +++ b/app/tests/scene/components/buildings/buildingColor.test.ts @@ -26,21 +26,19 @@ const TEST_LIGHT_RANGE: RangeStat = { min: 25, max: 70 }; let _origPalette: BuildingPaletteConfig | null = null; beforeEach(() => { - _origPalette = { ...BUILDING_PALETTE.get() }; - BUILDING_PALETTE.setKey('HUE_EXT_MAP', TEST_HUE_EXT_MAP); - BUILDING_PALETTE.setKey('SATURATION_MIN', TEST_SAT_RANGE.min); - BUILDING_PALETTE.setKey('SATURATION_MAX', TEST_SAT_RANGE.max); - BUILDING_PALETTE.setKey('LIGHTNESS_MIN', TEST_LIGHT_RANGE.min); - BUILDING_PALETTE.setKey('LIGHTNESS_MAX', TEST_LIGHT_RANGE.max); + _origPalette = { ...BUILDING_PALETTE.value }; + BUILDING_PALETTE.value = { + ...BUILDING_PALETTE.value, + HUE_EXT_MAP: TEST_HUE_EXT_MAP, + SATURATION_MIN: TEST_SAT_RANGE.min, + SATURATION_MAX: TEST_SAT_RANGE.max, + LIGHTNESS_MIN: TEST_LIGHT_RANGE.min, + LIGHTNESS_MAX: TEST_LIGHT_RANGE.max, + }; }); afterEach(() => { if (!_origPalette) return; - const palette = _origPalette; - (Object.keys(palette) as Array).forEach((k) => { - // setKey is per-key typed; cast value to never to satisfy the union-of-fields - // dispatch (each key has its own value type and TS can't narrow both at once). - BUILDING_PALETTE.setKey(k, palette[k] as never); - }); + BUILDING_PALETTE.value = _origPalette; }); const TEST_TREE = { diff --git a/app/tests/scene/components/labels/instanced-labels-cell.test.ts b/app/tests/scene/components/labels/instanced-labels-cell.test.ts index baf5c749..63377a20 100644 --- a/app/tests/scene/components/labels/instanced-labels-cell.test.ts +++ b/app/tests/scene/components/labels/instanced-labels-cell.test.ts @@ -185,7 +185,7 @@ describe('writeLabelToSlot', () => { pos.setFromMatrixPosition(tmpM); // y should equal b.h + LABEL_TYPOGRAPHY.get().ELEVATION (runtime config, default = 0). - const configElevation = LABEL_TYPOGRAPHY.get().ELEVATION; + const configElevation = LABEL_TYPOGRAPHY.value.ELEVATION; expect(pos.y).toBeCloseTo(b.h + configElevation); expect(pos.x).toBeCloseTo(b.x); expect(pos.z).toBeCloseTo(b.y); diff --git a/app/tests/scene/components/repoLabel/repoLabel.test.ts b/app/tests/scene/components/repoLabel/repoLabel.test.ts index 1f2209e3..3ba56c72 100644 --- a/app/tests/scene/components/repoLabel/repoLabel.test.ts +++ b/app/tests/scene/components/repoLabel/repoLabel.test.ts @@ -9,7 +9,7 @@ import { resetBuildingsConfig } from '../../../_helpers/cityFixtures'; // FLOOR_HEIGHT=16 → maxBldgH = 1536. resetBuildingsConfig pins both so // the assertions stay stable when production defaults change. function resetStore() { - REPO_LABEL.set({ + REPO_LABEL.value = { ENABLED: true, HEIGHT_PCT: 85, FONT_SIZE: 128, @@ -17,7 +17,7 @@ function resetStore() { OPACITY: 0.9, BEAM_COLOR: '#bfb3ff', TEXT_COLOR: '#ffffff', - }); + }; resetBuildingsConfig(); } @@ -62,8 +62,8 @@ describe('createRepoLabel()', () => { it('beam length tracks REPO_LABEL.HEIGHT_PCT', () => { label!.setRepoName('codecity'); // HEIGHT_PCT=50 → heightWorld = 1536 × 50/100 = 768 - REPO_LABEL.setKey('HEIGHT_PCT', 50); - REPO_LABEL.setKey('FONT_SIZE', 100); + REPO_LABEL.value = { ...REPO_LABEL.value, HEIGHT_PCT: 50 }; + REPO_LABEL.value = { ...REPO_LABEL.value, FONT_SIZE: 100 }; label!.refresh(); const beam = label!.group.children.find( (c) => ((c as THREE.Mesh).geometry as { type?: string }).type === 'CylinderGeometry' @@ -79,7 +79,7 @@ describe('createRepoLabel()', () => { it('panel scale tracks FONT_SIZE × textureAspect', () => { label!.setRepoName('codecity'); - REPO_LABEL.setKey('FONT_SIZE', 120); + REPO_LABEL.value = { ...REPO_LABEL.value, FONT_SIZE: 120 }; label!.refresh(); const panel = label!.group.children.find( (c) => ((c as THREE.Mesh).geometry as { type?: string }).type === 'PlaneGeometry' @@ -122,7 +122,7 @@ describe('createRepoLabel()', () => { it('refresh hides the group when ENABLED is false', () => { label!.setRepoName('codecity'); - REPO_LABEL.setKey('ENABLED', false); + REPO_LABEL.value = { ...REPO_LABEL.value, ENABLED: false }; label!.refresh(); expect(label!.group.visible).toBe(false); }); @@ -130,8 +130,8 @@ describe('createRepoLabel()', () => { it('setAnchor positions the group at anchor.x/z and lifts y by heightWorld + FONT_SIZE/2', () => { label!.setRepoName('codecity'); // HEIGHT_PCT=50 → heightWorld = 1536 × 50/100 = 768 - REPO_LABEL.setKey('HEIGHT_PCT', 50); - REPO_LABEL.setKey('FONT_SIZE', 80); + REPO_LABEL.value = { ...REPO_LABEL.value, HEIGHT_PCT: 50 }; + REPO_LABEL.value = { ...REPO_LABEL.value, FONT_SIZE: 80 }; label!.refresh(); label!.setAnchor(new THREE.Vector3(10, 0, 30)); expect(label!.group.position.x).toBeCloseTo(10); @@ -142,8 +142,8 @@ describe('createRepoLabel()', () => { it('HEIGHT_PCT=0 puts the panel flush with the floor (panel bottom = anchor.y)', () => { label!.setRepoName('codecity'); - REPO_LABEL.setKey('HEIGHT_PCT', 0); - REPO_LABEL.setKey('FONT_SIZE', 100); + REPO_LABEL.value = { ...REPO_LABEL.value, HEIGHT_PCT: 0 }; + REPO_LABEL.value = { ...REPO_LABEL.value, FONT_SIZE: 100 }; label!.refresh(); label!.setAnchor(new THREE.Vector3(0, 0, 0)); // Panel center = 0 + 0 + 50 = 50 → panel bottom = 0 (floor). ✓ @@ -182,8 +182,8 @@ describe('createRepoLabel()', () => { it("setGem makes the beam track the gem's live world Y (hover + bob)", () => { label!.setRepoName('codecity'); // HEIGHT_PCT=50 → heightWorld = 1536 × 50/100 = 768 - REPO_LABEL.setKey('HEIGHT_PCT', 50); - REPO_LABEL.setKey('FONT_SIZE', 100); + REPO_LABEL.value = { ...REPO_LABEL.value, HEIGHT_PCT: 50 }; + REPO_LABEL.value = { ...REPO_LABEL.value, FONT_SIZE: 100 }; label!.refresh(); // Stand-in for the gem — a THREE.Object3D with a settable position.y. // (In real use this is the gem THREE.Group; renderLoop mutates its @@ -206,8 +206,8 @@ describe('createRepoLabel()', () => { it('setGem(null) falls back to the constant inset above the anchor', () => { label!.setRepoName('codecity'); // HEIGHT_PCT=50 → heightWorld = 1536 × 50/100 = 768 - REPO_LABEL.setKey('HEIGHT_PCT', 50); - REPO_LABEL.setKey('FONT_SIZE', 60); + REPO_LABEL.value = { ...REPO_LABEL.value, HEIGHT_PCT: 50 }; + REPO_LABEL.value = { ...REPO_LABEL.value, FONT_SIZE: 60 }; label!.refresh(); const fakeGem = new THREE.Object3D(); fakeGem.position.y = 50; diff --git a/app/tests/scene/components/repoLabel/repoLabelPositioning.test.ts b/app/tests/scene/components/repoLabel/repoLabelPositioning.test.ts index d80df1c5..32e9dcf8 100644 --- a/app/tests/scene/components/repoLabel/repoLabelPositioning.test.ts +++ b/app/tests/scene/components/repoLabel/repoLabelPositioning.test.ts @@ -8,7 +8,7 @@ import { resetBuildingsConfig } from '../../../_helpers/cityFixtures'; // FLOOR_HEIGHT=16 → maxBldgH = 1536. resetBuildingsConfig pins both so // the assertions stay stable when production defaults change. function resetStore() { - REPO_LABEL.set({ + REPO_LABEL.value = { ENABLED: true, HEIGHT_PCT: 85, FONT_SIZE: 128, @@ -16,7 +16,7 @@ function resetStore() { OPACITY: 0.9, BEAM_COLOR: '#bfb3ff', TEXT_COLOR: '#ffffff', - }); + }; resetBuildingsConfig(); } @@ -50,7 +50,7 @@ describe('RepoLabel positioning', () => { }); it('HEIGHT_PCT=0 places the panel bottom flush with the floor (= anchor.y)', () => { - REPO_LABEL.setKey('HEIGHT_PCT', 0); + REPO_LABEL.value = { ...REPO_LABEL.value, HEIGHT_PCT: 0 }; label.refresh(); label.setAnchor(new THREE.Vector3(0, 0, 0)); // heightWorld = 0; panel center = 0 + 0 + 64 = 64 → panel bottom at 0 @@ -61,7 +61,7 @@ describe('RepoLabel positioning', () => { label.setAnchor(new THREE.Vector3(0, 0, 0)); expect(label.group.position.y).toBeCloseTo(1369.6); // HEIGHT_PCT=50 → heightWorld = 1536 × 50/100 = 768 - REPO_LABEL.setKey('HEIGHT_PCT', 50); + REPO_LABEL.value = { ...REPO_LABEL.value, HEIGHT_PCT: 50 }; label.refresh(); // 0 + 768 + 64 = 832 expect(label.group.position.y).toBeCloseTo(832); @@ -69,7 +69,7 @@ describe('RepoLabel positioning', () => { it('refresh() picks up new FONT_SIZE without a setAnchor call', () => { label.setAnchor(new THREE.Vector3(0, 0, 0)); - REPO_LABEL.setKey('FONT_SIZE', 200); + REPO_LABEL.value = { ...REPO_LABEL.value, FONT_SIZE: 200 }; label.refresh(); // 0 + 1305.6 + 100 = 1405.6 expect(label.group.position.y).toBeCloseTo(1405.6); diff --git a/app/tests/scene/effects/buildingFader.test.ts b/app/tests/scene/effects/buildingFader.test.ts index 72ce808e..58db0e63 100644 --- a/app/tests/scene/effects/buildingFader.test.ts +++ b/app/tests/scene/effects/buildingFader.test.ts @@ -5,16 +5,16 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as THREE from 'three'; -import { atom } from 'nanostores'; +import { signal } from '@preact/signals'; import { createBuildingFader } from '@/scene/effects/buildingFader'; import { BUILDING_FADE } from '@/state/settings/index'; import { FadeDetail, NodeKind } from '@/types'; import type { Building, DirNode, FileNode, PickTarget, Street } from '@/types'; -const _originalFade = BUILDING_FADE.get(); +const _originalFade = BUILDING_FADE.value; afterEach(() => { - BUILDING_FADE.set(_originalFade); + BUILDING_FADE.value = _originalFade; }); function makeFile(path: string): FileNode { @@ -92,8 +92,8 @@ function makeFader(opts: { } as unknown as Parameters[0]['world']; const picker = { - selection: atom(opts.selection ?? null), - hover: atom(opts.hover ?? null), + selection: signal(opts.selection ?? null), + hover: signal(opts.hover ?? null), } as unknown as Parameters[0]['picker']; const fader = createBuildingFader({ world, picker }); @@ -116,7 +116,7 @@ function makeFader(opts: { * exactly one tier without ambiguity. Detail mode is the same so * silhouette isn't a confounder; outline disabled across the board. */ function setKnownFade() { - BUILDING_FADE.set({ + BUILDING_FADE.value = { ..._originalFade, DEFAULT_DETAIL: FadeDetail.Full, DEFAULT_BODY_OPACITY: 1.0, @@ -138,7 +138,7 @@ function setKnownFade() { LEVEL4_BODY_OPACITY: 0.2, LEVEL4_OUTLINE: false, LEVEL4_OUTLINE_OPACITY: 0.0, - }); + }; } describe('buildingFader 5-tier cascade', () => { @@ -327,7 +327,7 @@ describe('buildingFader 5-tier cascade', () => { }); it('selected file honors DEFAULT config (no hardcoded constants)', () => { - BUILDING_FADE.setKey('DEFAULT_BODY_OPACITY', 0.5); + BUILDING_FADE.value = { ...BUILDING_FADE.value, DEFAULT_BODY_OPACITY: 0.5 }; const a = makeFile('src/a.ts'); const selBuilding = makeBuilding(a); diff --git a/app/tests/scene/effects/pathLineRenderer.test.ts b/app/tests/scene/effects/pathLineRenderer.test.ts index 6fa76570..13beb3bf 100644 --- a/app/tests/scene/effects/pathLineRenderer.test.ts +++ b/app/tests/scene/effects/pathLineRenderer.test.ts @@ -3,33 +3,33 @@ import { STREET_TIERS } from '@/state/settings/index'; import { computePathLinewidthPixels } from '@/scene/effects/pathLineRenderer'; // Capture the original tiers so afterEach can restore them. -const _originalTiers = STREET_TIERS.get(); +const _originalTiers = STREET_TIERS.value; afterEach(() => { - STREET_TIERS.set(_originalTiers); + STREET_TIERS.value = _originalTiers; }); describe('computePathLinewidthPixels', () => { it('multiplies smallest street tier width by pct/100', () => { - STREET_TIERS.set([ + STREET_TIERS.value = [ { min_descendants: 0, width: 10 }, { min_descendants: 4, width: 4 }, { min_descendants: 8, width: 6 }, - ]); + ]; // min width = 4; 4 * 25/100 = 1.0 expect(computePathLinewidthPixels(25)).toBeCloseTo(1.0); }); it('default 10% with smallest width 4 produces 0.4', () => { - STREET_TIERS.set([ + STREET_TIERS.value = [ { min_descendants: 0, width: 10 }, { min_descendants: 4, width: 4 }, - ]); + ]; expect(computePathLinewidthPixels(10)).toBeCloseTo(0.4); }); it('returns pct/100 as fallback when tiers array is empty', () => { - STREET_TIERS.set([]); + STREET_TIERS.value = []; // degenerate fallback: pct / 100 expect(computePathLinewidthPixels(50)).toBeCloseTo(0.5); }); diff --git a/app/tests/scene/effects/treeOutlineRenderer.test.ts b/app/tests/scene/effects/treeOutlineRenderer.test.ts index e708a678..7a37235b 100644 --- a/app/tests/scene/effects/treeOutlineRenderer.test.ts +++ b/app/tests/scene/effects/treeOutlineRenderer.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import * as THREE from 'three'; -import { atom } from 'nanostores'; +import { signal } from '@preact/signals'; import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js'; import { createTreeOutlineRenderer } from '@/scene/effects/treeOutlineRenderer'; import { TREE_OUTLINE } from '@/state/settings/components/trees'; @@ -44,8 +44,8 @@ function fakeTrees(activeSha: string, matrix: THREE.Matrix4) { } function fakePicker() { - const hover = atom(null); - const selection = atom(null); + const hover = signal(null); + const selection = signal(null); return { hover, selection }; } @@ -60,13 +60,13 @@ function commitTarget(sha: string): PickTarget { describe('treeOutlineRenderer', () => { beforeEach(() => { - TREE_OUTLINE.set({ + TREE_OUTLINE.value = { WIDTH: 3, HOVER_COLOR: '#ffffff', HOVER_OPACITY: 0.5, SELECTED_OPACITY: 1.0, - }); - RAINBOW.set({ SPEED: 0.001, SATURATION: 1, LIGHTNESS: 0.5 }); + }; + RAINBOW.value = { SPEED: 0.001, SATURATION: 1, LIGHTNESS: 0.5 }; }); it('hover outline is hidden when picker.hover is null', () => { @@ -95,7 +95,7 @@ describe('treeOutlineRenderer', () => { picker, getTrees: () => fakeTrees('a', matrix), }); - picker.hover.set(commitTarget('a')); + picker.hover.value = commitTarget('a'); expect(r.hoverOutline.visible).toBe(true); for (let i = 0; i < 16; i++) { expect(r.hoverOutline.matrix.elements[i]).toBeCloseTo(matrix.elements[i], 5); @@ -112,8 +112,8 @@ describe('treeOutlineRenderer', () => { picker, getTrees: () => fakeTrees('a', new THREE.Matrix4()), }); - picker.hover.set(commitTarget('a')); - picker.hover.set(null); + picker.hover.value = commitTarget('a'); + picker.hover.value = null; expect(r.hoverOutline.visible).toBe(false); r.dispose(); }); @@ -128,9 +128,9 @@ describe('treeOutlineRenderer', () => { getTrees: () => fakeTrees('a', new THREE.Matrix4().makeTranslation(5, 0, 0)), }); expect(r.selectedOutline.visible).toBe(false); - picker.selection.set(commitTarget('a')); + picker.selection.value = commitTarget('a'); expect(r.selectedOutline.visible).toBe(true); - picker.selection.set(null); + picker.selection.value = null; expect(r.selectedOutline.visible).toBe(false); r.dispose(); }); @@ -144,8 +144,8 @@ describe('treeOutlineRenderer', () => { picker, getTrees: () => fakeTrees('a', new THREE.Matrix4()), }); - picker.selection.set(commitTarget('a')); - picker.hover.set(commitTarget('a')); // same as selected + picker.selection.value = commitTarget('a'); + picker.hover.value = commitTarget('a'); // same as selected expect(r.selectedOutline.visible).toBe(true); expect(r.hoverOutline.visible).toBe(false); r.dispose(); @@ -160,12 +160,12 @@ describe('treeOutlineRenderer', () => { picker, getTrees: () => fakeTrees('a', new THREE.Matrix4()), }); - picker.hover.set({ + picker.hover.value = { kind: NodeKind.File, mesh: new THREE.InstancedMesh(new THREE.BufferGeometry(), new THREE.MeshBasicMaterial(), 1), data: {} as never, file: { path: 'foo' } as never, - }); + }; expect(r.hoverOutline.visible).toBe(false); r.dispose(); }); @@ -179,12 +179,12 @@ describe('treeOutlineRenderer', () => { picker, getTrees: () => fakeTrees('a', new THREE.Matrix4()), }); - TREE_OUTLINE.set({ + TREE_OUTLINE.value = { WIDTH: 7, HOVER_COLOR: '#ff00ff', HOVER_OPACITY: 0.25, SELECTED_OPACITY: 0.9, - }); + }; r.refreshMaterials(); expect((r.hoverOutline.material as { linewidth: number }).linewidth).toBe(7); expect((r.hoverOutline.material as { opacity: number }).opacity).toBeCloseTo(0.25, 5); @@ -202,7 +202,7 @@ describe('treeOutlineRenderer', () => { picker, getTrees: () => fakeTrees('a', new THREE.Matrix4()), }); - picker.selection.set(commitTarget('a')); + picker.selection.value = commitTarget('a'); // First frame: paint colors from the current time stamp. r.update(0); @@ -260,7 +260,7 @@ describe('treeOutlineRenderer', () => { }); const initialGeom = r.selectedOutline.geometry; - picker.selection.set(commitTarget('a')); + picker.selection.value = commitTarget('a'); // After selecting a d2-tier tree, the outline geometry should differ // from the initial (d0) one. diff --git a/app/tests/scene/fireflies/fireflies.test.ts b/app/tests/scene/fireflies/fireflies.test.ts index fb087db9..5287d1a1 100644 --- a/app/tests/scene/fireflies/fireflies.test.ts +++ b/app/tests/scene/fireflies/fireflies.test.ts @@ -90,20 +90,20 @@ describe('createFireflies', () => { }); it('returns an empty group when FIREFLIES_ENABLED is false', () => { - const orig = FIREFLIES.get().FIREFLIES_ENABLED; - FIREFLIES.setKey('FIREFLIES_ENABLED', false); + const orig = FIREFLIES.value.FIREFLIES_ENABLED; + FIREFLIES.value = { ...FIREFLIES.value, FIREFLIES_ENABLED: false }; try { const f = createFireflies(PLACEMENTS, COMMITS); expect(f.group.children.length).toBe(0); f.dispose(); } finally { - FIREFLIES.setKey('FIREFLIES_ENABLED', orig); + FIREFLIES.value = { ...FIREFLIES.value, FIREFLIES_ENABLED: orig }; } }); it('orbit ring is absent when ORBIT_RING_ENABLED is false', () => { - const orig = FIREFLIES.get().ORBIT_RING_ENABLED; - FIREFLIES.setKey('ORBIT_RING_ENABLED', false); + const orig = FIREFLIES.value.ORBIT_RING_ENABLED; + FIREFLIES.value = { ...FIREFLIES.value, ORBIT_RING_ENABLED: false }; try { const f = createFireflies(PLACEMENTS, COMMITS); const ringGroup = f.group.children.find((c) => c.name === 'firefly-orbit-rings'); @@ -112,7 +112,7 @@ describe('createFireflies', () => { expect(ringGroup!.children.length).toBe(0); f.dispose(); } finally { - FIREFLIES.setKey('ORBIT_RING_ENABLED', orig); + FIREFLIES.value = { ...FIREFLIES.value, ORBIT_RING_ENABLED: orig }; } }); diff --git a/app/tests/scene/fireflies/firefliesPlacement.test.ts b/app/tests/scene/fireflies/firefliesPlacement.test.ts index 9c1c48c1..88bdd58c 100644 --- a/app/tests/scene/fireflies/firefliesPlacement.test.ts +++ b/app/tests/scene/fireflies/firefliesPlacement.test.ts @@ -162,7 +162,7 @@ describe('placeFireflies', () => { { date: '2026-01-02', files: 1, sha: 'b'.repeat(40), authors: ['Solo'], subject: 'b' }, ]; const orbs = placeFireflies([placement(0, 0, 0), placement(1, 10, 0)], soloAuthor); - const scaleMax = FIREFLIES.get().SCALE_MAX; + const scaleMax = FIREFLIES.value.SCALE_MAX; expect(orbs[0].scale).toBe(scaleMax); expect(orbs[1].scale).toBe(scaleMax); }); diff --git a/app/tests/scene/island/islandMesh.test.ts b/app/tests/scene/island/islandMesh.test.ts index 82a64d17..007eef2b 100644 --- a/app/tests/scene/island/islandMesh.test.ts +++ b/app/tests/scene/island/islandMesh.test.ts @@ -6,7 +6,7 @@ import { RENDER_ORDERS } from '@/constants'; describe('createIsland', () => { beforeEach(() => { - ISLAND_GEOMETRY.set({ + ISLAND_GEOMETRY.value = { ENABLED: true, SIDES: 12, IRREGULARITY: 0.18, @@ -14,7 +14,7 @@ describe('createIsland', () => { DEPTH: 0.6, ROUNDNESS: 0.7, GRASS_THICKNESS: 0.025, - }); + }; }); it('returns a Group with island mesh', () => { @@ -44,11 +44,11 @@ describe('createIsland', () => { }); it('hidden when GEOMETRY.ENABLED=false', () => { - ISLAND_GEOMETRY.setKey('ENABLED', false); + ISLAND_GEOMETRY.value = { ...ISLAND_GEOMETRY.value, ENABLED: false }; const island = createIsland(null); expect(island.group.visible).toBe(false); island.dispose(); - ISLAND_GEOMETRY.setKey('ENABLED', true); + ISLAND_GEOMETRY.value = { ...ISLAND_GEOMETRY.value, ENABLED: true }; }); it('uses RENDER_ORDERS.VALLEY_FLOOR for the island mesh', () => { diff --git a/app/tests/scene/layout/layout.test.ts b/app/tests/scene/layout/layout.test.ts index 7ed7f341..fe22b141 100644 --- a/app/tests/scene/layout/layout.test.ts +++ b/app/tests/scene/layout/layout.test.ts @@ -40,17 +40,12 @@ const TEST_BUILDING_DIMS: Partial = { let _origBuildingDims: BuildingDimensionsConfig | null = null; beforeEach(() => { - _origBuildingDims = { ...BUILDING_DIMENSIONS.get() }; - (Object.keys(TEST_BUILDING_DIMS) as Array).forEach((k) => { - BUILDING_DIMENSIONS.setKey(k, TEST_BUILDING_DIMS[k]!); - }); + _origBuildingDims = { ...BUILDING_DIMENSIONS.value }; + BUILDING_DIMENSIONS.value = { ...BUILDING_DIMENSIONS.value, ...TEST_BUILDING_DIMS }; }); afterEach(() => { if (!_origBuildingDims) return; - const dims = _origBuildingDims; - (Object.keys(dims) as Array).forEach((k) => { - BUILDING_DIMENSIONS.setKey(k, dims[k]); - }); + BUILDING_DIMENSIONS.value = _origBuildingDims; }); const TEST_TREE = { @@ -212,7 +207,7 @@ describe('getBuildingDimensions', () => { it('huge files cap at max_floors (no runaway towers)', () => { // Without an upper cap the tallest file would dwarf the rest of the // city. Verify the cap is still enforced. - BUILDING_DIMENSIONS.setKey('MAX_FLOORS', 5); + BUILDING_DIMENSIONS.value = { ...BUILDING_DIMENSIONS.value, MAX_FLOORS: 5 }; const dim = getBuildingDimensions({ lines: 100000, size: 100000 }, { min: 1, max: 100000 }); expect(dim.floors).toBeLessThanOrEqual(5); }); diff --git a/app/tests/scene/lighting/sunDir.test.ts b/app/tests/scene/lighting/sunDir.test.ts index a82a9652..67f5b336 100644 --- a/app/tests/scene/lighting/sunDir.test.ts +++ b/app/tests/scene/lighting/sunDir.test.ts @@ -5,12 +5,12 @@ import { LIGHTING } from '@/state/settings/components/lighting'; describe('writeSunDir', () => { beforeEach(() => { - LIGHTING.set({ + LIGHTING.value = { SUN_AZIMUTH_DEG: 51, SUN_ELEVATION_DEG: 58, AMBIENT: 0.72, SUN_CONTRAST: 0.5, - }); + }; }); it('reproduces the legacy normalize(0.5, 1.0, 0.4) to within rounding', () => { @@ -24,7 +24,7 @@ describe('writeSunDir', () => { }); it('points straight up at elevation=90', () => { - LIGHTING.setKey('SUN_ELEVATION_DEG', 90); + LIGHTING.value = { ...LIGHTING.value, SUN_ELEVATION_DEG: 90 }; const out = new THREE.Vector3(); writeSunDir(out); expect(out.y).toBeCloseTo(1, 5); @@ -33,12 +33,12 @@ describe('writeSunDir', () => { }); it('azimuth=0 elevation=0 points along +Z (south)', () => { - LIGHTING.set({ + LIGHTING.value = { SUN_AZIMUTH_DEG: 0, SUN_ELEVATION_DEG: 0, AMBIENT: 0.72, SUN_CONTRAST: 0.5, - }); + }; const out = new THREE.Vector3(); writeSunDir(out); expect(out.x).toBeCloseTo(0, 5); @@ -47,12 +47,12 @@ describe('writeSunDir', () => { }); it('azimuth=90 elevation=0 points along +X (east)', () => { - LIGHTING.set({ + LIGHTING.value = { SUN_AZIMUTH_DEG: 90, SUN_ELEVATION_DEG: 0, AMBIENT: 0.72, SUN_CONTRAST: 0.5, - }); + }; const out = new THREE.Vector3(); writeSunDir(out); expect(out.x).toBeCloseTo(1, 5); @@ -62,12 +62,12 @@ describe('writeSunDir', () => { describe('sunDirFromLighting', () => { beforeEach(() => { - LIGHTING.set({ + LIGHTING.value = { SUN_AZIMUTH_DEG: 51, SUN_ELEVATION_DEG: 58, AMBIENT: 0.72, SUN_CONTRAST: 0.5, - }); + }; }); it('returns a fresh unit Vector3 matching writeSunDir output', () => { diff --git a/app/tests/scene/sky/sky.test.ts b/app/tests/scene/sky/sky.test.ts index 6269e4cd..40d5f4b9 100644 --- a/app/tests/scene/sky/sky.test.ts +++ b/app/tests/scene/sky/sky.test.ts @@ -11,13 +11,13 @@ import { SKY, SKY_STARS } from '@/state/settings/components/sky'; import { RENDER_ORDERS } from '@/constants'; function resetStores() { - SKY.set({ + SKY.value = { COLOR: '#010005', - }); - SKY_STARS.set({ + }; + SKY_STARS.value = { ENABLED: true, DENSITY: 0.0075, - }); + }; } describe('createSky()', () => { @@ -64,8 +64,8 @@ describe('createSky()', () => { }); it('refresh() pushes fresh config values into uniforms', () => { - SKY_STARS.setKey('DENSITY', 0.01); - SKY.setKey('COLOR', '#ffffff'); + SKY_STARS.value = { ...SKY_STARS.value, DENSITY: 0.01 }; + SKY.value = { ...SKY.value, COLOR: '#ffffff' }; sky.refresh(); const mat = sky.mesh.material as THREE.ShaderMaterial; expect(mat.uniforms.uStarDensity.value).toBeCloseTo(0.01); diff --git a/app/tests/scene/sky/skyConfig.test.ts b/app/tests/scene/sky/skyConfig.test.ts index 72f10500..66f11f1c 100644 --- a/app/tests/scene/sky/skyConfig.test.ts +++ b/app/tests/scene/sky/skyConfig.test.ts @@ -9,14 +9,14 @@ import { SKY, SKY_STARS } from '@/state/settings/components/sky'; describe('SKY', () => { it('exposes the expected keys with the right types', () => { - const v = SKY.get(); + const v = SKY.value; expect(v.COLOR).toMatch(/^#[0-9a-f]{6}$/i); }); }); describe('SKY_STARS', () => { it('exposes the expected keys with the right types', () => { - const v = SKY_STARS.get(); + const v = SKY_STARS.value; expect(typeof v.ENABLED).toBe('boolean'); expect(typeof v.DENSITY).toBe('number'); }); diff --git a/app/tests/scene/system/picker-commit.test.ts b/app/tests/scene/system/picker-commit.test.ts index e7be11b6..38e2527d 100644 --- a/app/tests/scene/system/picker-commit.test.ts +++ b/app/tests/scene/system/picker-commit.test.ts @@ -109,7 +109,7 @@ beforeEach(() => { canvas = document.createElement('canvas'); canvas.width = 800; canvas.height = 600; - PICKER_SELECTION_KEY.set(null); + PICKER_SELECTION_KEY.value = null; }); describe('picker: tree commit picking', () => { @@ -207,7 +207,7 @@ describe('picker: tree commit picking', () => { commit: commits[1], }); - expect(PICKER_SELECTION_KEY.get()).toEqual({ + expect(PICKER_SELECTION_KEY.value).toEqual({ kind: NodeKind.Commit, sha: commits[1].sha, }); @@ -221,10 +221,10 @@ describe('picker: tree commit picking', () => { const trees = makeFakeTrees(canopy, trunk, commits); const world = makeWorld(trees); - PICKER_SELECTION_KEY.set({ kind: NodeKind.Commit, sha: commits[1].sha }); + PICKER_SELECTION_KEY.value = { kind: NodeKind.Commit, sha: commits[1].sha }; const p = createPicker({ canvas, camera: FAKE_CAMERA, world }); - const sel = p.selection.get() as CommitTarget | null; + const sel = p.selection.value as CommitTarget | null; expect(sel).not.toBeNull(); expect(sel!.kind).toBe(NodeKind.Commit); expect(sel!.commit).toEqual(commits[1]); @@ -256,7 +256,7 @@ describe('picker: tree commit picking', () => { world.setTrees(treesB); world.triggerRebuild(); - const sel = p.selection.get() as CommitTarget | null; + const sel = p.selection.value as CommitTarget | null; expect(sel).not.toBeNull(); expect(sel!.kind).toBe(NodeKind.Commit); expect(sel!.commit).toEqual(commits[1]); @@ -304,11 +304,11 @@ describe('picker: tree commit picking', () => { const trees = makeFakeTrees(canopy, trunk, commits); const world = makeWorld(trees); - PICKER_SELECTION_KEY.set({ kind: NodeKind.Commit, sha: 'f'.repeat(40) }); + PICKER_SELECTION_KEY.value = { kind: NodeKind.Commit, sha: 'f'.repeat(40) }; const p = createPicker({ canvas, camera: FAKE_CAMERA, world }); - expect(p.selection.get()).toBeNull(); - expect(PICKER_SELECTION_KEY.get()).toBeNull(); + expect(p.selection.value).toBeNull(); + expect(PICKER_SELECTION_KEY.value).toBeNull(); p.dispose(); }); }); diff --git a/app/tests/scene/system/picker.test.ts b/app/tests/scene/system/picker.test.ts index 88faadb1..e96905df 100644 --- a/app/tests/scene/system/picker.test.ts +++ b/app/tests/scene/system/picker.test.ts @@ -136,9 +136,9 @@ beforeEach(() => { canvas = document.createElement('canvas'); canvas.width = 800; canvas.height = 600; - // Reset the module-level persistable atom between tests so a leftover + // Reset the module-level persistable signal between tests so a leftover // value from one test can't leak into the next. - PICKER_SELECTION_KEY.set(null); + PICKER_SELECTION_KEY.value = null; }); // Helper: build a fake hit object for interpretHit. Real callers pass @@ -148,11 +148,11 @@ function fakeHit(userData: Record): THREE.Intersection { - it('exposes hover, selection, selectionKey atoms + setters', () => { + it('exposes hover, selection, selectionKey signals + setters', () => { const fakeScene = makeFakeWorld([], []); const p = createPicker({ canvas, camera: FAKE_CAMERA, world: fakeScene }); - expect(typeof p.hover.get).toBe('function'); - expect(typeof p.selection.get).toBe('function'); + expect('value' in p.hover).toBe(true); + expect('value' in p.selection).toBe(true); expect(p.selectionKey).toBe(PICKER_SELECTION_KEY); expect(typeof p.setHover).toBe('function'); expect(typeof p.setSelection).toBe('function'); @@ -166,7 +166,7 @@ describe('createPicker', () => { const fakeScene = makeFakeWorld([], []); const p = createPicker({ canvas, camera: FAKE_CAMERA, world: fakeScene }); p.setSelection(makeFileTarget({ path: 'src/index.js' })); - expect(p.selectionKey.get()).toEqual({ kind: NodeKind.File, path: 'src/index.js' }); + expect(p.selectionKey.value).toEqual({ kind: NodeKind.File, path: 'src/index.js' }); p.dispose(); }); @@ -174,7 +174,7 @@ describe('createPicker', () => { const fakeScene = makeFakeWorld([], []); const p = createPicker({ canvas, camera: FAKE_CAMERA, world: fakeScene }); p.setSelection(makeDirTarget({ path: 'src/lib' })); - expect(p.selectionKey.get()).toEqual({ kind: NodeKind.Directory, path: 'src/lib' }); + expect(p.selectionKey.value).toEqual({ kind: NodeKind.Directory, path: 'src/lib' }); p.dispose(); }); @@ -183,7 +183,7 @@ describe('createPicker', () => { const p = createPicker({ canvas, camera: FAKE_CAMERA, world: fakeScene }); p.setSelection(makeFileTarget({ path: 'a.js' })); p.setSelection(null); - expect(p.selectionKey.get()).toBeNull(); + expect(p.selectionKey.value).toBeNull(); p.dispose(); }); @@ -192,12 +192,12 @@ describe('createPicker', () => { const fakeScene = makeFakeWorld([{ path: 'a.js', mesh: meshA }], []); const p = createPicker({ canvas, camera: FAKE_CAMERA, world: fakeScene }); p.selectByPath('a.js'); - const sel = p.selection.get(); + const sel = p.selection.value; expect(sel?.kind).toBe(NodeKind.File); if (sel?.kind === NodeKind.File) { expect(sel.mesh).toBe(meshA); } - expect(p.selectionKey.get()).toEqual({ kind: NodeKind.File, path: 'a.js' }); + expect(p.selectionKey.value).toEqual({ kind: NodeKind.File, path: 'a.js' }); p.dispose(); }); @@ -206,7 +206,7 @@ describe('createPicker', () => { const p = createPicker({ canvas, camera: FAKE_CAMERA, world: fakeScene }); p.selectByPath('a.js'); p.selectByPath('does-not-exist.js'); - const sel = p.selection.get(); + const sel = p.selection.value; expect(sel).not.toBeNull(); if (sel?.kind === NodeKind.File) { expect(sel.file.path).toBe('a.js'); @@ -218,14 +218,14 @@ describe('createPicker', () => { const fakeScene = makeFakeWorld([{ path: 'a.js', mesh: oldMesh }], []); const p = createPicker({ canvas, camera: FAKE_CAMERA, world: fakeScene }); p.selectByPath('a.js'); - const before = p.selection.get(); + const before = p.selection.value; if (before?.kind === NodeKind.File) expect(before.mesh).toBe(oldMesh); // Simulate a rebuild — same path, new mesh. const newMesh = { id: 'new' }; fakeScene.setSnapshot([{ path: 'a.js', mesh: newMesh }], []); - const after = p.selection.get(); + const after = p.selection.value; if (after?.kind === NodeKind.File) expect(after.mesh).toBe(newMesh); p.dispose(); }); @@ -234,12 +234,12 @@ describe('createPicker', () => { const fakeScene = makeFakeWorld([{ path: 'a.js', mesh: {} }], []); const p = createPicker({ canvas, camera: FAKE_CAMERA, world: fakeScene }); p.selectByPath('a.js'); - expect(p.selection.get()).not.toBeNull(); + expect(p.selection.value).not.toBeNull(); fakeScene.setSnapshot([], []); // path no longer exists - expect(p.selection.get()).toBeNull(); - expect(p.selectionKey.get()).toBeNull(); + expect(p.selection.value).toBeNull(); + expect(p.selectionKey.value).toBeNull(); p.dispose(); }); @@ -247,10 +247,10 @@ describe('createPicker', () => { const fakeScene = makeFakeWorld([{ path: 'a.js', mesh: {} }], []); const p = createPicker({ canvas, camera: FAKE_CAMERA, world: fakeScene }); p.setHover(makeFileTarget({ mesh: { id: 'old' } })); - expect(p.hover.get()).not.toBeNull(); + expect(p.hover.value).not.toBeNull(); fakeScene.setSnapshot([{ path: 'a.js', mesh: { id: 'new' } }], []); - expect(p.hover.get()).toBeNull(); + expect(p.hover.value).toBeNull(); p.dispose(); }); diff --git a/app/tests/scene/trees/treePlacement.test.ts b/app/tests/scene/trees/treePlacement.test.ts index af93d613..0d083e92 100644 --- a/app/tests/scene/trees/treePlacement.test.ts +++ b/app/tests/scene/trees/treePlacement.test.ts @@ -19,7 +19,7 @@ describe('placeTrees (commit-driven)', () => { }); it('returns empty when TREES_ENABLED is false', () => { - TREES.setKey('TREES_ENABLED', false); + TREES.value = { ...TREES.value, TREES_ENABLED: false }; expect( placeTrees(emptyLayout(bbox(-100, -100, 100, 100)), undefined, { commitCount: 10 }) ).toEqual([]); @@ -91,7 +91,7 @@ describe('placeTrees (commit-driven)', () => { it('rejects candidates inside the FOOTPRINT halo around a layout rect', async () => { const { FOOTPRINT } = await import('@/state/settings/components/footprint.js'); - FOOTPRINT.setKey('HALO_WIDTH', 100); + FOOTPRINT.value = { ...FOOTPRINT.value, HALO_WIDTH: 100 }; const bb = bbox(-500, -500, 500, 500); const layout: CityLayout = { @@ -116,7 +116,7 @@ describe('placeTrees (commit-driven)', () => { expect(dInf).toBeGreaterThan(110); } - FOOTPRINT.setKey('HALO_WIDTH', 32); + FOOTPRINT.value = { ...FOOTPRINT.value, HALO_WIDTH: 32 }; }); it('all placements have a defined commitIndex', () => { diff --git a/app/tests/scene/trees/treeRenderer.test.ts b/app/tests/scene/trees/treeRenderer.test.ts index c3155be7..b6099d40 100644 --- a/app/tests/scene/trees/treeRenderer.test.ts +++ b/app/tests/scene/trees/treeRenderer.test.ts @@ -19,7 +19,7 @@ import type { CommitEntry } from '@/types'; import { commits as buildCommits } from './_commitFixtures'; function resetStores() { - TREES.set({ + TREES.value = { TREES_ENABLED: true, EDGE_INSET_PERCENT: 8, TREE_DENSITY_FALLOFF: 0, @@ -42,15 +42,15 @@ function resetStores() { TREE_AGE_SATURATION_MIN: 20, TREE_AGE_SATURATION_MAX: 100, TREE_WIDTH_AGE_FLOOR: 1.0, - }); - BUILDING_DIMENSIONS.set({ + }; + BUILDING_DIMENSIONS.value = { MIN_FLOORS: 2, MAX_FLOORS: 96, FLOOR_HEIGHT: 16, MIN_WIDTH: 8, MAX_WIDTH: 8, DISTANCE_FROM_ROAD: 8, - }); + }; } function placement(x: number, y: number, seed: number, commitIndex: number): TreePlacement { @@ -164,7 +164,7 @@ describe('createTreeRenderer()', () => { }); it('honors TREES_ENABLED visibility toggle on build', () => { - TREES.setKey('TREES_ENABLED', false); + TREES.value = { ...TREES.value, TREES_ENABLED: false }; trees = createTreeRenderer([placement(0, 0, 1, 0)], makeCommits(1)); for (const m of [...canopyMeshes(trees.group), trunkMesh(trees.group)]) { expect(m.visible).toBe(false); @@ -173,12 +173,12 @@ describe('createTreeRenderer()', () => { it('refresh() flips visibility on TREES_ENABLED change', () => { trees = createTreeRenderer([placement(0, 0, 1, 0)], makeCommits(1)); - TREES.setKey('TREES_ENABLED', false); + TREES.value = { ...TREES.value, TREES_ENABLED: false }; trees.refresh(); for (const m of [...canopyMeshes(trees.group), trunkMesh(trees.group)]) { expect(m.visible).toBe(false); } - TREES.setKey('TREES_ENABLED', true); + TREES.value = { ...TREES.value, TREES_ENABLED: true }; trees.refresh(); for (const m of [...canopyMeshes(trees.group), trunkMesh(trees.group)]) { expect(m.visible).toBe(true); @@ -253,7 +253,7 @@ describe('createTreeRenderer()', () => { }); it('CANOPY_TRUNK_OVERLAP_FRAC=0 puts canopy base exactly on trunk top', () => { - TREES.setKey('CANOPY_TRUNK_OVERLAP_FRAC', 0); + TREES.value = { ...TREES.value, CANOPY_TRUNK_OVERLAP_FRAC: 0 }; trees = createTreeRenderer([placement(0, 0, 1, 0)], makeCommits(1)); const canopy = canopyMeshes(trees.group)[0]; const trunk = trunkMesh(trees.group); @@ -367,7 +367,7 @@ describe('createTreeRenderer()', () => { }); it('vertex shading strength=0 yields uniform white vertex colors', () => { - TREES.setKey('TREE_SHADING_STRENGTH', 0); + TREES.value = { ...TREES.value, TREE_SHADING_STRENGTH: 0 }; trees = createTreeRenderer([placement(0, 0, 1, 0)], makeCommits(1)); for (const m of canopyMeshes(trees.group)) { const colorAttr = m.geometry.getAttribute('color'); @@ -384,9 +384,9 @@ describe('createTreeRenderer()', () => { const meshesBefore = [...canopyMeshes(trees.group), trunkMesh(trees.group)]; const geomsBefore = meshesBefore.map((m) => m.geometry); - TREES.setKey('TREE_COLOR_BUSY_DAY', '#000000'); - TREES.setKey('TREE_COLOR_SOLO_DAY', '#ffffff'); - TREES.setKey('TREE_TRUNK_COLOR', '#ff0000'); + TREES.value = { ...TREES.value, TREE_COLOR_BUSY_DAY: '#000000' }; + TREES.value = { ...TREES.value, TREE_COLOR_SOLO_DAY: '#ffffff' }; + TREES.value = { ...TREES.value, TREE_TRUNK_COLOR: '#ff0000' }; trees.refresh(); const meshesAfter = [...canopyMeshes(trees.group), trunkMesh(trees.group)]; @@ -449,9 +449,9 @@ describe('createTreeRenderer()', () => { trees.dispose(); // Build with desat ON + extreme min (0 → fully gray). - TREES.setKey('TREE_AGE_DESAT_ENABLED', true); - TREES.setKey('TREE_AGE_SATURATION_MIN', 0); - TREES.setKey('TREE_AGE_SATURATION_MAX', 100); + TREES.value = { ...TREES.value, TREE_AGE_DESAT_ENABLED: true }; + TREES.value = { ...TREES.value, TREE_AGE_SATURATION_MIN: 0 }; + TREES.value = { ...TREES.value, TREE_AGE_SATURATION_MAX: 100 }; trees = createTreeRenderer(placements, testCommits); const onColor = instanceColor(trees.group, 0).clone(); @@ -479,9 +479,9 @@ describe('createTreeRenderer()', () => { trees.dispose(); // Now build with desat ON. - TREES.setKey('TREE_AGE_DESAT_ENABLED', true); - TREES.setKey('TREE_AGE_SATURATION_MIN', 20); - TREES.setKey('TREE_AGE_SATURATION_MAX', 100); + TREES.value = { ...TREES.value, TREE_AGE_DESAT_ENABLED: true }; + TREES.value = { ...TREES.value, TREE_AGE_SATURATION_MIN: 20 }; + TREES.value = { ...TREES.value, TREE_AGE_SATURATION_MAX: 100 }; trees = createTreeRenderer(placements, testCommits); const oldestS = instanceSaturation(trees.group, 0); @@ -502,9 +502,9 @@ describe('createTreeRenderer()', () => { trees.dispose(); // Build with desat ON, MAX=100 → factor=1.00 → no change to newest. - TREES.setKey('TREE_AGE_DESAT_ENABLED', true); - TREES.setKey('TREE_AGE_SATURATION_MIN', 20); - TREES.setKey('TREE_AGE_SATURATION_MAX', 100); + TREES.value = { ...TREES.value, TREE_AGE_DESAT_ENABLED: true }; + TREES.value = { ...TREES.value, TREE_AGE_SATURATION_MIN: 20 }; + TREES.value = { ...TREES.value, TREE_AGE_SATURATION_MAX: 100 }; trees = createTreeRenderer(placements, testCommits); const newestS = instanceSaturation(trees.group, 1); @@ -513,9 +513,9 @@ describe('createTreeRenderer()', () => { }); it('refresh() re-applies new TREE_AGE_DESAT_ENABLED config — colors differ after toggle', () => { - TREES.setKey('TREE_AGE_DESAT_ENABLED', true); - TREES.setKey('TREE_AGE_SATURATION_MIN', 20); - TREES.setKey('TREE_AGE_SATURATION_MAX', 100); + TREES.value = { ...TREES.value, TREE_AGE_DESAT_ENABLED: true }; + TREES.value = { ...TREES.value, TREE_AGE_SATURATION_MIN: 20 }; + TREES.value = { ...TREES.value, TREE_AGE_SATURATION_MAX: 100 }; const testCommits = buildCommits( { date: '2026-01-01', files: 5 }, // oldest → will have reduced saturation @@ -528,7 +528,7 @@ describe('createTreeRenderer()', () => { const sWithDesat = instanceSaturation(trees.group, 0); // Flip desat OFF and refresh. - TREES.setKey('TREE_AGE_DESAT_ENABLED', false); + TREES.value = { ...TREES.value, TREE_AGE_DESAT_ENABLED: false }; trees.refresh(); // Capture saturation of oldest tree with desat OFF. @@ -544,12 +544,12 @@ describe('createTreeRenderer()', () => { it('bakes vertex colors that respond to LIGHTING.SUN_AZIMUTH_DEG changes', () => { // Bake at the first sun direction, capture canopy shading. resetStores(); - LIGHTING.set({ + LIGHTING.value = { SUN_AZIMUTH_DEG: 51, SUN_ELEVATION_DEG: 58, AMBIENT: 0.72, SUN_CONTRAST: 0.5, - }); + }; const placements = [placement(0, 0, 1, 0)]; const commits = buildCommits({ date: '2026-01-01', files: 1 }); trees = createTreeRenderer(placements, commits); @@ -564,12 +564,12 @@ describe('createTreeRenderer()', () => { trees.dispose(); // Move the sun 90° azimuth. Vertices should have different shading. - LIGHTING.set({ + LIGHTING.value = { SUN_AZIMUTH_DEG: 141, // 51 + 90 SUN_ELEVATION_DEG: 58, AMBIENT: 0.72, SUN_CONTRAST: 0.5, - }); + }; trees = createTreeRenderer(placements, commits); const canopyInst2 = findCanopyInstance(trees.group, 0); expect(canopyInst2).not.toBeNull(); diff --git a/app/tests/scene/trees/treeRendererCommitLookup.test.ts b/app/tests/scene/trees/treeRendererCommitLookup.test.ts index 1ddeb19c..25ed14fd 100644 --- a/app/tests/scene/trees/treeRendererCommitLookup.test.ts +++ b/app/tests/scene/trees/treeRendererCommitLookup.test.ts @@ -12,7 +12,7 @@ import { BUILDING_DIMENSIONS } from '@/state/settings/components/buildings'; import type { CommitEntry } from '@/types'; function resetStores() { - TREES.set({ + TREES.value = { TREES_ENABLED: true, EDGE_INSET_PERCENT: 8, TREE_DENSITY_FALLOFF: 0, @@ -35,15 +35,15 @@ function resetStores() { TREE_AGE_SATURATION_MIN: 20, TREE_AGE_SATURATION_MAX: 100, TREE_WIDTH_AGE_FLOOR: 1.0, - }); - BUILDING_DIMENSIONS.set({ + }; + BUILDING_DIMENSIONS.value = { MIN_FLOORS: 2, MAX_FLOORS: 96, FLOOR_HEIGHT: 16, MIN_WIDTH: 8, MAX_WIDTH: 8, DISTANCE_FROM_ROAD: 8, - }); + }; } function placement(seed: number, commitIndex: number): TreePlacement { @@ -219,7 +219,7 @@ describe('Trees commit lookups', () => { // Two commits, same file count, different ages. With floor=1.0 the // attenuation is constant 1, so both trees should have the same // canopy XZ scale. - TREES.setKey('TREE_WIDTH_AGE_FLOOR', 1.0); + TREES.value = { ...TREES.value, TREE_WIDTH_AGE_FLOOR: 1.0 }; const commits = [commit(0), commit(1)]; // Force same file count so file-driven radius matches. commits[0].files = 5; @@ -247,7 +247,7 @@ describe('Trees commit lookups', () => { // the renderer assigns commits[2] (newest) min height (heightRatio=0) // → attenuation = 0.5; commits[0] (oldest) max height (heightRatio=1) // → attenuation = 1. - TREES.setKey('TREE_WIDTH_AGE_FLOOR', 0.5); + TREES.value = { ...TREES.value, TREE_WIDTH_AGE_FLOOR: 0.5 }; const commits = [commit(0), commit(1), commit(2)]; for (const c of commits) c.files = 5; const placements = [placement(0, 0), placement(1, 1), placement(2, 2)]; @@ -271,7 +271,7 @@ describe('Trees commit lookups', () => { }); it('TREE_WIDTH_AGE_FLOOR = 0.0 produces near-zero width on shortest tree', () => { - TREES.setKey('TREE_WIDTH_AGE_FLOOR', 0.0); + TREES.value = { ...TREES.value, TREE_WIDTH_AGE_FLOOR: 0.0 }; const commits = [commit(0), commit(1), commit(2)]; for (const c of commits) c.files = 5; const placements = [placement(0, 0), placement(1, 1), placement(2, 2)]; @@ -328,9 +328,9 @@ describe('Trees commit lookups', () => { // When TREE_MIN_HEIGHT == TREE_MAX_HEIGHT, the heightRatio computation // would divide by zero without a clamp. Verify the renderer constructs // and all canopy instance matrix elements stay finite. - TREES.setKey('TREE_MIN_HEIGHT', 32); - TREES.setKey('TREE_MAX_HEIGHT', 32); - TREES.setKey('TREE_WIDTH_AGE_FLOOR', 0.5); + TREES.value = { ...TREES.value, TREE_MIN_HEIGHT: 32 }; + TREES.value = { ...TREES.value, TREE_MAX_HEIGHT: 32 }; + TREES.value = { ...TREES.value, TREE_WIDTH_AGE_FLOOR: 0.5 }; const commits = [commit(0), commit(1)]; const placements = [placement(0, 0), placement(1, 1)]; const trees = createTreeRenderer(placements, commits); diff --git a/app/tests/scene/world/worldBounds.test.ts b/app/tests/scene/world/worldBounds.test.ts index 13cb35dd..06a31b8c 100644 --- a/app/tests/scene/world/worldBounds.test.ts +++ b/app/tests/scene/world/worldBounds.test.ts @@ -8,7 +8,7 @@ import { bbox } from '../../_helpers/cityFixtures'; // stable when the defaults shift again. describe('worldBounds', () => { beforeEach(() => { - WORLD.setKey('GROUND_BUFFER_PERCENT', 30); + WORLD.value = { ...WORLD.value, GROUND_BUFFER_PERCENT: 30 }; }); it('returns fallback rectangle when bbox is null', () => { @@ -67,7 +67,7 @@ describe('worldBounds', () => { }); it('GROUND_BUFFER_PERCENT slider scales the buffer linearly', () => { - WORLD.setKey('GROUND_BUFFER_PERCENT', 60); + WORLD.value = { ...WORLD.value, GROUND_BUFFER_PERCENT: 60 }; // 10000-wide. characteristic = 10000, buffer = 10000*0.60 = 6000. // halfWidth = 10000/2 + 6000 = 11000. const b = getWorldBounds(bbox(0, 0, 10000, 10000)); diff --git a/app/tests/scene/worldLayoutCache.test.ts b/app/tests/scene/worldLayoutCache.test.ts index e8057741..59d9fa6a 100644 --- a/app/tests/scene/worldLayoutCache.test.ts +++ b/app/tests/scene/worldLayoutCache.test.ts @@ -30,14 +30,14 @@ describe('configCommitReactions invalidates layout cache before applyManifest', calls = []; appliedManifests = []; detach = null; - originalChildGap = STREET_LAYOUT.get().CHILD_GAP; + originalChildGap = STREET_LAYOUT.value.CHILD_GAP; }); afterEach(() => { if (detach) detach(); detach = null; // Restore so other tests don't see a drifted CHILD_GAP. - STREET_LAYOUT.setKey('CHILD_GAP', originalChildGap); + STREET_LAYOUT.value = { ...STREET_LAYOUT.value, CHILD_GAP: originalChildGap }; }); it('calls world.invalidateLayoutCache() BEFORE world.applyManifest() on a rebuildStore commit', async () => { @@ -64,7 +64,7 @@ describe('configCommitReactions invalidates layout cache before applyManifest', // Simulate a Save commit on a rebuildStore: the user edited // STREET_LAYOUT.CHILD_GAP and clicked Save → configDrafts.commit() // fires setKey on the real store, which triggers our subscription. - STREET_LAYOUT.setKey('CHILD_GAP', originalChildGap + 1); + STREET_LAYOUT.value = { ...STREET_LAYOUT.value, CHILD_GAP: originalChildGap + 1 }; // scheduleRebuild is async; let the microtask queue drain so the // applyManifest await resolves before we assert. diff --git a/app/tests/state/drafts.test.ts b/app/tests/state/drafts.test.ts index 44bb5064..0f95440a 100644 --- a/app/tests/state/drafts.test.ts +++ b/app/tests/state/drafts.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { map, atom } from 'nanostores'; +import { signal } from '@preact/signals'; import { setDraft, getEffective, @@ -19,8 +19,8 @@ interface FooConfig { } describe('drafts', () => { - let FOO: ReturnType>; - let BAR: ReturnType>; + let FOO: ReturnType>; + let BAR: ReturnType>; beforeEach(() => { // Each test gets fresh stores + fresh draft state. Re-registering @@ -28,8 +28,8 @@ describe('drafts', () => { // taken at persistStore time, before any setKey). localStorage.clear(); _resetForTests(); - FOO = map({ COLOR: '#000000', COUNT: 1 }); - BAR = atom(10); + FOO = signal({ COLOR: '#000000', COUNT: 1 }); + BAR = signal(10); persistStore('TEST_FOO', FOO); persistStore('TEST_BAR', BAR); }); @@ -43,7 +43,7 @@ describe('drafts', () => { it('returns pending value when draft set', () => { setDraft(FOO, 'COLOR', '#ff0000'); expect(getEffective(FOO, 'COLOR')).toBe('#ff0000'); - expect(FOO.get().COLOR).toBe('#000000'); // store untouched + expect(FOO.value.COLOR).toBe('#000000'); // store untouched }); it('setDraft equal to committed value removes the draft', () => { @@ -64,7 +64,7 @@ describe('drafts', () => { it('supports atom stores with key = null', () => { setDraft(BAR, null, 42); expect(getEffective(BAR, null)).toBe(42); - expect(BAR.get()).toBe(10); + expect(BAR.value).toBe(10); }); it('treats falsy draft values as real drafts (uses Map.has, not falsy check)', () => { @@ -108,10 +108,10 @@ describe('drafts', () => { describe('stageReset', () => { it('stages the registered default into the draft', () => { - FOO.setKey('COLOR', '#ff0000'); // committed override + FOO.value = { ...FOO.value, COLOR: '#ff0000' }; // committed override stageReset(FOO, 'COLOR'); expect(getEffective(FOO, 'COLOR')).toBe('#000000'); - expect(FOO.get().COLOR).toBe('#ff0000'); // store untouched + expect(FOO.value.COLOR).toBe('#ff0000'); // store untouched expect(isDirty()).toBe(true); }); @@ -125,18 +125,17 @@ describe('drafts', () => { }); it('works on atom stores', () => { - BAR.set(99); + BAR.value = 99; stageReset(BAR, null); expect(getEffective(BAR, null)).toBe(10); - expect(BAR.get()).toBe(99); + expect(BAR.value).toBe(99); }); }); describe('stageResetAll', () => { it('stages defaults for every registered (store, key) with non-default effective value', () => { - FOO.setKey('COLOR', '#ff0000'); - FOO.setKey('COUNT', 99); - BAR.set(42); + FOO.value = { ...FOO.value, COLOR: '#ff0000', COUNT: 99 }; + BAR.value = 42; stageResetAll(); expect(getEffective(FOO, 'COLOR')).toBe('#000000'); expect(getEffective(FOO, 'COUNT')).toBe(1); @@ -144,7 +143,7 @@ describe('drafts', () => { }); it('is a no-op on the second call (idempotent)', () => { - FOO.setKey('COLOR', '#ff0000'); + FOO.value = { ...FOO.value, COLOR: '#ff0000' }; stageResetAll(); let count = 0; const unsub = subscribe(() => { @@ -159,7 +158,7 @@ describe('drafts', () => { it('skips entries where effective value already equals default', () => { // FOO is all default; only BAR is overridden. - BAR.set(42); + BAR.value = 42; stageResetAll(); // Only BAR should have been staged. // We can't directly read the draft map, but we know isDirty must @@ -168,7 +167,7 @@ describe('drafts', () => { expect(getEffective(BAR, null)).toBe(10); // Discard everything; nothing changes in committed stores. discard(); - expect(BAR.get()).toBe(42); + expect(BAR.value).toBe(42); }); }); @@ -177,14 +176,14 @@ describe('drafts', () => { setDraft(FOO, 'COLOR', '#ff0000'); setDraft(FOO, 'COUNT', 7); commit(); - expect(FOO.get()).toEqual({ COLOR: '#ff0000', COUNT: 7 }); + expect(FOO.value).toEqual({ COLOR: '#ff0000', COUNT: 7 }); expect(isDirty()).toBe(false); }); it('applies atom-store drafts via set', () => { setDraft(BAR, null, 42); commit(); - expect(BAR.get()).toBe(42); + expect(BAR.value).toBe(42); expect(isDirty()).toBe(false); }); @@ -199,18 +198,18 @@ describe('drafts', () => { let observedInsideSubscribe: unknown = null; setDraft(FOO, 'COLOR', '#ff0000'); // Install subscriber AFTER setting the draft but BEFORE commit, so it - // only fires on the commit-driven setKey call. nanostores fires .subscribe - // synchronously with the current value at subscribe time too — so capture - // only the LAST value observed. - FOO.subscribe(() => { + // only fires on the commit-driven write. Signals fire subscribers + // synchronously — capture only the LAST value observed. + const unsub = FOO.subscribe(() => { observedInsideSubscribe = getEffective(FOO, 'COLOR'); }); commit(); + unsub(); // Inside the subscriber, getEffective must reflect the committed value // — the draft must already be cleared at that point. expect(observedInsideSubscribe).toBe('#ff0000'); // And of course committed reads return it too. - expect(FOO.get().COLOR).toBe('#ff0000'); + expect(FOO.value.COLOR).toBe('#ff0000'); }); }); @@ -221,8 +220,8 @@ describe('drafts', () => { discard(); expect(getEffective(FOO, 'COLOR')).toBe('#000000'); expect(getEffective(BAR, null)).toBe(10); - expect(FOO.get().COLOR).toBe('#000000'); - expect(BAR.get()).toBe(10); + expect(FOO.value.COLOR).toBe('#000000'); + expect(BAR.value).toBe(10); expect(isDirty()).toBe(false); }); }); diff --git a/app/tests/state/persistPerSource.test.ts b/app/tests/state/persistPerSource.test.ts index ec24ec48..1b8b65a9 100644 --- a/app/tests/state/persistPerSource.test.ts +++ b/app/tests/state/persistPerSource.test.ts @@ -1,44 +1,44 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { atom } from 'nanostores'; +import { signal } from '@preact/signals'; import { persistAtomPerSource } from '@/state/persist'; import { CURRENT_SOURCE_KEY } from '@/state/runtime/sourceContext'; describe('persistAtomPerSource', () => { beforeEach(() => { localStorage.clear(); - CURRENT_SOURCE_KEY.set(null); + CURRENT_SOURCE_KEY.value = null; }); it('writes to localStorage under the active source key', () => { - const store = atom<{ path: string } | null>(null); + const store = signal<{ path: string } | null>(null); persistAtomPerSource('selection', store, null); - CURRENT_SOURCE_KEY.set('abc'); - store.set({ path: '/foo' }); + CURRENT_SOURCE_KEY.value = 'abc'; + store.value = { path: '/foo' }; expect(localStorage.getItem('cc.source.abc.selection')).toBe(JSON.stringify({ path: '/foo' })); }); it('hydrates from localStorage when source key changes', () => { - const store = atom<{ path: string } | null>(null); + const store = signal<{ path: string } | null>(null); localStorage.setItem('cc.source.xyz.selection', JSON.stringify({ path: '/bar' })); persistAtomPerSource('selection', store, null); - CURRENT_SOURCE_KEY.set('xyz'); - expect(store.get()).toEqual({ path: '/bar' }); + CURRENT_SOURCE_KEY.value = 'xyz'; + expect(store.value).toEqual({ path: '/bar' }); }); it('falls back to default when new key has no entry', () => { - const store = atom<{ path: string } | null>(null); + const store = signal<{ path: string } | null>(null); persistAtomPerSource('selection', store, null); - CURRENT_SOURCE_KEY.set('newkey'); - expect(store.get()).toBeNull(); + CURRENT_SOURCE_KEY.value = 'newkey'; + expect(store.value).toBeNull(); }); it('does nothing when source key is null', () => { - const store = atom<{ path: string } | null>(null); + const store = signal<{ path: string } | null>(null); persistAtomPerSource('selection', store, null); - store.set({ path: '/foo' }); + store.value = { path: '/foo' }; // No source key set — nothing should be in localStorage under any // cc.source.* key. const keys = Object.keys(localStorage).filter((k) => k.startsWith('cc.source.')); @@ -46,17 +46,17 @@ describe('persistAtomPerSource', () => { }); it('saves to old key, hydrates new key when CURRENT_SOURCE_KEY changes', () => { - const store = atom<{ path: string } | null>(null); + const store = signal<{ path: string } | null>(null); persistAtomPerSource('selection', store, null); - CURRENT_SOURCE_KEY.set('first'); - store.set({ path: '/A' }); + CURRENT_SOURCE_KEY.value = 'first'; + store.value = { path: '/A' }; // Pre-seed the second source's slot. localStorage.setItem('cc.source.second.selection', JSON.stringify({ path: '/B' })); - CURRENT_SOURCE_KEY.set('second'); - expect(store.get()).toEqual({ path: '/B' }); + CURRENT_SOURCE_KEY.value = 'second'; + expect(store.value).toEqual({ path: '/B' }); // First source's slot still has /A. expect(localStorage.getItem('cc.source.first.selection')).toBe(JSON.stringify({ path: '/A' })); }); diff --git a/app/tests/state/runtime/sourceContext.test.ts b/app/tests/state/runtime/sourceContext.test.ts index bc0579e3..e753b55d 100644 --- a/app/tests/state/runtime/sourceContext.test.ts +++ b/app/tests/state/runtime/sourceContext.test.ts @@ -22,12 +22,12 @@ describe('sourceKey', () => { describe('CURRENT_SOURCE_KEY', () => { it('starts as null', () => { - expect(CURRENT_SOURCE_KEY.get()).toBeNull(); + expect(CURRENT_SOURCE_KEY.value).toBeNull(); }); it('can be set and read', () => { - CURRENT_SOURCE_KEY.set('abc123'); - expect(CURRENT_SOURCE_KEY.get()).toBe('abc123'); - CURRENT_SOURCE_KEY.set(null); + CURRENT_SOURCE_KEY.value = 'abc123'; + expect(CURRENT_SOURCE_KEY.value).toBe('abc123'); + CURRENT_SOURCE_KEY.value = null; }); }); diff --git a/app/tests/state/settings/components/repoLabel.test.ts b/app/tests/state/settings/components/repoLabel.test.ts index 20682e57..94eccaef 100644 --- a/app/tests/state/settings/components/repoLabel.test.ts +++ b/app/tests/state/settings/components/repoLabel.test.ts @@ -3,7 +3,7 @@ import { REPO_LABEL } from '@/state/settings/components/repoLabel'; describe('REPO_LABEL config store', () => { beforeEach(() => { - REPO_LABEL.set({ + REPO_LABEL.value = { ENABLED: true, HEIGHT_PCT: 85, FONT_SIZE: 128, @@ -11,11 +11,11 @@ describe('REPO_LABEL config store', () => { OPACITY: 0.9, BEAM_COLOR: '#bfb3ff', TEXT_COLOR: '#ffffff', - }); + }; }); it('exposes the documented default shape', () => { - expect(REPO_LABEL.get()).toEqual({ + expect(REPO_LABEL.value).toEqual({ ENABLED: true, HEIGHT_PCT: 85, FONT_SIZE: 128, diff --git a/app/tests/state/settings/footprint.test.ts b/app/tests/state/settings/footprint.test.ts index db717a68..e2b757ff 100644 --- a/app/tests/state/settings/footprint.test.ts +++ b/app/tests/state/settings/footprint.test.ts @@ -6,7 +6,7 @@ describe('FOOTPRINT', () => { it('exposes the expected keys with the right types', () => { // Asserting shape, not specific values — tuning the production // defaults shouldn't break this test. - const v = FOOTPRINT.get(); + const v = FOOTPRINT.value; expect(typeof v.ENABLED).toBe('boolean'); expect(typeof v.HALO_WIDTH).toBe('number'); expect(typeof v.CORNER_RADIUS).toBe('number'); diff --git a/app/tests/state/settings/island.test.ts b/app/tests/state/settings/island.test.ts index 433e252b..010d1f78 100644 --- a/app/tests/state/settings/island.test.ts +++ b/app/tests/state/settings/island.test.ts @@ -5,7 +5,7 @@ describe('ISLAND config defaults', () => { it('GEOMETRY exposes the expected keys with the right types', () => { // Asserting shape, not specific values — tuning the production // defaults shouldn't break this test. - const g = ISLAND_GEOMETRY.get(); + const g = ISLAND_GEOMETRY.value; expect(typeof g.ENABLED).toBe('boolean'); expect(typeof g.SIDES).toBe('number'); expect(typeof g.IRREGULARITY).toBe('number'); @@ -16,7 +16,7 @@ describe('ISLAND config defaults', () => { }); it('MATERIALS provides grass + rock + hemispheric lighting colors', () => { - const m = ISLAND_MATERIALS.get(); + const m = ISLAND_MATERIALS.value; expect(m.GRASS_COLOR).toMatch(/^#[0-9a-f]{6}$/i); expect(m.GRASS_SIDE_COLOR).toMatch(/^#[0-9a-f]{6}$/i); expect(m.ROCK_COLOR).toMatch(/^#[0-9a-f]{6}$/i); From e4557d393a94c980f2d8631f5560f14569fc04e5 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Sat, 30 May 2026 17:47:19 -0400 Subject: [PATCH 004/201] Phase 3b: port icon.ts to Preact (LucideIcon + GemIcon components) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds LucideIcon and GemIcon Preact function components alongside backward-compat makeLucideIcon / makeGemIcon factory shims. Tests: 1985/2013 passing (28 failures all in panes/shell — pre-existing). Co-Authored-By: Claude Sonnet 4.6 --- app/src/views/components/icon.ts | 55 --------------- app/src/views/components/icon.tsx | 110 ++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 55 deletions(-) delete mode 100644 app/src/views/components/icon.ts create mode 100644 app/src/views/components/icon.tsx diff --git a/app/src/views/components/icon.ts b/app/src/views/components/icon.ts deleted file mode 100644 index e98ee517..00000000 --- a/app/src/views/components/icon.ts +++ /dev/null @@ -1,55 +0,0 @@ -// views/components/icon.ts — Tiny helpers for inline icons. -// -// makeLucideIcon — generic Lucide icon painted via CSS mask so it picks -// up currentColor (used by every monochrome glyph). -// makeGemIcon — the codecity gem (full multicolor by default; simple -// monochrome variant for inline-with-text use). -// -// Sizing comes from the parent's font-size (1em × 1em). - -import { LUCIDE_ICON_BASE_URL } from '@/constants'; - -interface IconOpts { - /** Extra class added alongside `lucide-icon`. */ - class?: string; - /** Tooltip shown on hover (sets the title attr). */ - title?: string; -} - -/** - * @param name Lucide icon basename (no .svg suffix), e.g. 'chevron-right'. - */ -export function makeLucideIcon(name: string, opts: IconOpts = {}): HTMLSpanElement { - const span = document.createElement('span'); - span.className = `lucide-icon${opts.class ? ` ${opts.class}` : ''}`; - span.setAttribute('aria-hidden', 'true'); - if (opts.title) span.title = opts.title; - const url = `url(${LUCIDE_ICON_BASE_URL}${name}.svg)`; - span.style.maskImage = url; - span.style.webkitMaskImage = url; - return span; -} - -interface GemIconOpts extends IconOpts { - /** Render the grayscale-filled variant for quieter contexts (e.g. tree - * rows) where the multicolor palette would compete with row text. Same - * octahedron geometry, just neutral fills instead of palette colors. */ - simple?: boolean; -} - -/** - * The codecity gem icon, painted as a background-image SVG so the per-face - * fills render directly. Default = full multicolor (/gem.svg, same source - * as the favicon). Simple = grayscale variant (/gem-simple.svg) for inline - * use in trees/lists. - */ -export function makeGemIcon(opts: GemIconOpts = {}): HTMLSpanElement { - const span = document.createElement('span'); - span.className = `gem-icon${opts.class ? ` ${opts.class}` : ''}`; - span.setAttribute('aria-hidden', 'true'); - if (opts.title) span.title = opts.title; - if (opts.simple) { - span.style.backgroundImage = 'url(/gem-simple.svg)'; - } - return span; -} diff --git a/app/src/views/components/icon.tsx b/app/src/views/components/icon.tsx new file mode 100644 index 00000000..fa98b1b2 --- /dev/null +++ b/app/src/views/components/icon.tsx @@ -0,0 +1,110 @@ +// views/components/icon.tsx — Tiny helpers for inline icons. +// +// makeLucideIcon — generic Lucide icon painted via CSS mask so it picks +// up currentColor (used by every monochrome glyph). +// makeGemIcon — the codecity gem (full multicolor by default; simple +// monochrome variant for inline-with-text use). +// +// Sizing comes from the parent's font-size (1em × 1em). +// +// Preact components: LucideIcon, GemIcon. +// Backward-compat factories: makeLucideIcon, makeGemIcon (still used by +// panes/shell until Phases 3c/3d port them). + +import { h } from 'preact'; +import { LUCIDE_ICON_BASE_URL } from '@/constants'; + +// ── Props interfaces ──────────────────────────────────────────────────────── + +export interface LucideIconProps { + /** Lucide icon basename (no .svg suffix), e.g. 'chevron-right'. */ + name: string; + /** Extra class added alongside `lucide-icon`. */ + class?: string; + /** Tooltip shown on hover (sets the title attr). */ + title?: string; +} + +export interface GemIconProps { + /** Extra class added alongside `gem-icon`. */ + class?: string; + /** Tooltip shown on hover (sets the title attr). */ + title?: string; + /** Render the grayscale-filled variant for quieter contexts (e.g. tree + * rows) where the multicolor palette would compete with row text. Same + * octahedron geometry, just neutral fills instead of palette colors. */ + simple?: boolean; +} + +// ── Preact components ─────────────────────────────────────────────────────── + +export function LucideIcon({ name, class: cls, title }: LucideIconProps) { + const url = `url(${LUCIDE_ICON_BASE_URL}${name}.svg)`; + return ( +