diff --git a/docs/device-models.md b/docs/device-models.md
new file mode 100644
index 00000000..15199b1c
--- /dev/null
+++ b/docs/device-models.md
@@ -0,0 +1,93 @@
+# Interactive hardware illustrations
+
+The radio detection modal and unconfigured OpenWrt gateway use original
+procedural Three.js models. The renderer follows the studio lighting,
+rounded geometry, material finishes and instancing approach used for the
+computer illustrations in `archipelago-website-deck`.
+
+## Coverage
+
+`neode-ui/src/graphics/deviceModels.ts` contains 25 radio illustrations,
+including the generic fallback and three optional enclosure variants, plus
+the Beryl AX (GL-MT3000) and a black Pixel 9 Pro. All existing `meshDeviceImages.ts` detection results
+resolve to a procedural model key. USB chip identification remains a
+best-effort visual match; it does not select firmware or claim exact hardware.
+
+The radios include modeled rear faces, enclosure panels or populated boards,
+and appropriate battery holders, connectors and antennas. These are stylized
+recognition illustrations, not CAD models. Fine component placement and rear
+details are representative; not every board revision or enclosure has been
+verified against a rear photograph. The Beryl AX is the example router from
+`docs/openwrt-gateway-setup.md`, not an assertion about the connected router.
+
+Reference material:
+
+- Beryl AX official front/rear/side hardware drawing:
+ https://static.gl-inet.com/docs/router/en/4/user_guide/gl-mt3000/hardware_info/mt3000_interface.jpg
+- Beryl AX guide:
+ https://docs.gl-inet.com/router/en/4/user_guide/gl-mt3000/
+- T-Echo manufacturer board and enclosure reference:
+ https://github.com/Xinyuan-LilyGO/T-Echo
+ https://raw.githubusercontent.com/Xinyuan-LilyGO/T-Echo/main/image/T-ECHO.jpg
+- T-Deck manufacturer documentation:
+ https://wiki.lilygo.cc/products/t-deck-series/t-deck/
+
+Manufacturer photographs are references only; none are copied into the app.
+Geometry, grain and device labels are generated locally without CDN assets.
+The black Pixel 9 Pro illustration uses an unmodified, locally bundled GrapheneOS
+boot logo mask from its framework source. Its rounded outline, glass panels,
+camera island, layered lenses, side buttons and ports are procedural geometry.
+The display shows a static phase of the OS boot animation, distinct from Google's
+bootloader notice. Source and attribution: `neode-ui/src/graphics/assets/ATTRIBUTION.md`.
+
+## Interaction and lifecycle
+
+`DeviceModel.vue` loads the renderer on intersection. It turns once per minute,
+capped at 30 rendered frames per second. Drag horizontally to rotate; a mouse
+can also tilt vertically. Touch preserves vertical page scrolling. Arrow keys
+rotate/tilt, Home shows the front and End shows the back. Keyboard interaction
+pauses automatic turning. The Pause/Rotate button controls autoplay.
+
+Reduced motion disables autoplay and hides its control while preserving manual
+rotation. Hidden tabs and offscreen models stop scheduling frames. Model changes
+reuse the renderer/environment and dispose the previous geometry, textures and
+materials. Unmount releases the renderer and context. Unsupported browser APIs,
+unavailable WebGL, import failures or lost contexts leave a static fallback.
+
+The router figure uses the remaining viewport height below the connection form
+on desktop (minimum 260px); mobile layouts get a fixed 290px illustration. Small desktop
+windows can scroll normally rather than collapsing the model to zero height.
+
+## Hardware discovery
+
+The router gateway and private-phone Setup goal share `HardwareMarketplace.vue`.
+Buy router / Buy phone replaces the model with a horizontal product carousel,
+preserving the outer container's height. Discovery queries enabled relays from
+`nostr.list-relays` for NIP-99 kind-30402 events. Phone discovery uses the `pixel`
+keyword. Development builds show clearly
+labeled sample listings when no matching live listings are found. Checkout and
+the product View/Buy actions remain unimplemented.
+
+Browser scenarios and reports stay outside this repository, under
+`/home/yaya/.local/share/browser-check/projects/archy-devices/`.
+
+## Validation (2026-10-09)
+
+- Production typecheck/build passes; the lazy renderer chunk is 136 KB gzipped.
+- Existing gateway/cache tests (10) and radio setup tests (2) pass.
+- Chromium 153, Firefox 155 and WebKit 26.6 pass at desktop 1440×900 and
+ phone 390×844: all 26 models, front/back rotation, drag, autoplay/pause,
+ reduced motion, zero offscreen/idle draws, model replacement, unmount/remount
+ and context-loss fallback. Reports: `/tmp/archy-models-final/`.
+- Actual gateway and detection-modal components pass the same six-case matrix
+ with mocked RPC data. Reports: `/tmp/archy-device-pages-matrix/`.
+- Delayed renderer loading, storage denial, reload and unavailable WebGL pass
+ the same matrix. Reports: `/tmp/archy-device-fallbacks/`.
+- These are Linux browser tests with phone viewport emulation, not physical
+ iPhone/Android GPU or touch-scroll certification. No live router was changed.
+
+Branch: `feat/3d-mesh-router`, based on ngit main
+`5812f53c7c05027848a1007f818e9d33117b62e0`. The ngit helper could read relay
+state but failed Git server authentication; main's published kind-30618 ref
+was checked on relay.ngit.dev and that exact commit fetched from the existing
+Git mirror. No remote proposal, merge or deployment was performed.
diff --git a/neode-ui/THIRD-PARTY-LICENSES.md b/neode-ui/THIRD-PARTY-LICENSES.md
index b3a19c50..43b5780e 100644
--- a/neode-ui/THIRD-PARTY-LICENSES.md
+++ b/neode-ui/THIRD-PARTY-LICENSES.md
@@ -33,6 +33,8 @@ planned (see docs/LICENSE-COMPLIANCE-AUDIT.md).
## Vendored
+- `src/graphics/assets/grapheneos-boot-mask.png` — GrapheneOS boot artwork,
+ Apache-2.0; see the adjacent ATTRIBUTION.md for the pinned source and credits.
- `public/assets/icon/` — see ATTRIBUTION.md in that directory
(game-icons.net CC BY 3.0; pixelarticons MIT).
- `public/assets/img/mesh-devices/` — Meshtastic project artwork, GPL-3.0;
diff --git a/neode-ui/mock-backend.js b/neode-ui/mock-backend.js
index 642cea6b..a7baaa57 100755
--- a/neode-ui/mock-backend.js
+++ b/neode-ui/mock-backend.js
@@ -4470,6 +4470,12 @@ app.post('/rpc/v1', (req, res) => {
// ── OpenWRT / TollGate gateway (demo: a thoroughly-used gateway) ──
case 'openwrt.get-status': {
+ // Keep the local preview in the unconnected state so the router model
+ // is immediately visible. Set MOCK_OPENWRT_CONNECTED=1 to preview the
+ // connected dashboard instead.
+ if (!process.env.MOCK_OPENWRT_CONNECTED) {
+ return res.json({ error: { code: -32004, message: 'No router configured' } })
+ }
return res.json({
result: {
host: '192.168.8.1',
diff --git a/neode-ui/package-lock.json b/neode-ui/package-lock.json
index 1c660047..20e20a7a 100644
--- a/neode-ui/package-lock.json
+++ b/neode-ui/package-lock.json
@@ -22,6 +22,7 @@
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"qrloop": "^1.4.1",
+ "three": "^0.185.0",
"vue": "^3.5.24",
"vue-i18n": "^11.3.0",
"vue-router": "^4.6.3"
@@ -32,6 +33,7 @@
"@types/leaflet": "^1.9.21",
"@types/node": "^24.10.0",
"@types/qrcode": "^1.5.6",
+ "@types/three": "^0.185.0",
"@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-vue": "^6.0.1",
"@vitest/coverage-v8": "^5.0.3",
@@ -1825,6 +1827,13 @@
"node": ">=18"
}
},
+ "node_modules/@dimforge/rapier3d-compat": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
+ "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
@@ -3753,6 +3762,13 @@
"node": ">=12"
}
},
+ "node_modules/@tweenjs/tween.js": {
+ "version": "23.1.3",
+ "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
+ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -4121,12 +4137,41 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/stats.js": {
+ "version": "0.17.4",
+ "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
+ "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/three": {
+ "version": "0.185.0",
+ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.0.tgz",
+ "integrity": "sha512-O2Uy8Cj4Nonr8dWUUbifMdPe8B0Mq7EdOHb89S4+kjUw/KhbjTZrUuYlrQ1bpUKG+EP9QJnN7qNxbHGlGoLHMA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@dimforge/rapier3d-compat": "~0.12.0",
+ "@tweenjs/tween.js": "~23.1.3",
+ "@types/stats.js": "*",
+ "@types/webxr": ">=0.5.17",
+ "fflate": "~0.8.2",
+ "meshoptimizer": "~1.1.1"
+ }
+ },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
+ "node_modules/@types/webxr": {
+ "version": "0.5.24",
+ "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
+ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@vite-pwa/assets-generator": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@vite-pwa/assets-generator/-/assets-generator-1.0.4.tgz",
@@ -6948,6 +6993,13 @@
"reusify": "^1.0.4"
}
},
+ "node_modules/fflate": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
+ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/filelist": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
@@ -8465,6 +8517,13 @@
"node": ">= 8"
}
},
+ "node_modules/meshoptimizer": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
+ "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
@@ -10823,6 +10882,12 @@
"node": ">=0.8"
}
},
+ "node_modules/three": {
+ "version": "0.185.0",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.185.0.tgz",
+ "integrity": "sha512-+yRrcRO2iZa8uzvNNl0d7cL4huhgKgBvVJ0njcTe8xFqZ6DMAFZdCKDP91SEAuj25bNAj7k1QQdf+srZywVK6w==",
+ "license": "MIT"
+ },
"node_modules/tinybench": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.1.tgz",
diff --git a/neode-ui/package.json b/neode-ui/package.json
index 370180a7..260a4dec 100644
--- a/neode-ui/package.json
+++ b/neode-ui/package.json
@@ -39,6 +39,7 @@
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"qrloop": "^1.4.1",
+ "three": "^0.185.0",
"vue": "^3.5.24",
"vue-i18n": "^11.3.0",
"vue-router": "^4.6.3"
@@ -49,6 +50,7 @@
"@types/leaflet": "^1.9.21",
"@types/node": "^24.10.0",
"@types/qrcode": "^1.5.6",
+ "@types/three": "^0.185.0",
"@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-vue": "^6.0.1",
"@vitest/coverage-v8": "^5.0.3",
diff --git a/neode-ui/public/assets/img/mesh-devices/ATTRIBUTION.md b/neode-ui/public/assets/img/mesh-devices/ATTRIBUTION.md
deleted file mode 100644
index 456b7ffd..00000000
--- a/neode-ui/public/assets/img/mesh-devices/ATTRIBUTION.md
+++ /dev/null
@@ -1,13 +0,0 @@
-# Device Artwork Attribution
-
-The device illustrations in this directory are from the
-**Meshtastic® project** — https://meshtastic.org
-(https://github.com/meshtastic), © Meshtastic contributors,
-licensed under GPL-3.0.
-
-Meshtastic® is a registered trademark of Meshtastic LLC, used here solely to
-identify compatible hardware. No endorsement by the Meshtastic project is
-implied.
-
-Any modifications to these images made for Archipelago are likewise available
-under GPL-3.0 as part of this public repository.
diff --git a/neode-ui/public/assets/img/mesh-devices/diy.svg b/neode-ui/public/assets/img/mesh-devices/diy.svg
deleted file mode 100644
index 81d0da13..00000000
--- a/neode-ui/public/assets/img/mesh-devices/diy.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/neode-ui/public/assets/img/mesh-devices/heltec-mesh-node-t114-case.svg b/neode-ui/public/assets/img/mesh-devices/heltec-mesh-node-t114-case.svg
deleted file mode 100644
index dc84fd1b..00000000
--- a/neode-ui/public/assets/img/mesh-devices/heltec-mesh-node-t114-case.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/neode-ui/public/assets/img/mesh-devices/heltec-mesh-node-t114.svg b/neode-ui/public/assets/img/mesh-devices/heltec-mesh-node-t114.svg
deleted file mode 100644
index fc44238c..00000000
--- a/neode-ui/public/assets/img/mesh-devices/heltec-mesh-node-t114.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/neode-ui/public/assets/img/mesh-devices/heltec-v3-case.svg b/neode-ui/public/assets/img/mesh-devices/heltec-v3-case.svg
deleted file mode 100644
index 86879653..00000000
--- a/neode-ui/public/assets/img/mesh-devices/heltec-v3-case.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/neode-ui/public/assets/img/mesh-devices/heltec-v3.svg b/neode-ui/public/assets/img/mesh-devices/heltec-v3.svg
deleted file mode 100644
index 759dbe90..00000000
--- a/neode-ui/public/assets/img/mesh-devices/heltec-v3.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/neode-ui/public/assets/img/mesh-devices/heltec-wsl-v3.svg b/neode-ui/public/assets/img/mesh-devices/heltec-wsl-v3.svg
deleted file mode 100644
index c61b676a..00000000
--- a/neode-ui/public/assets/img/mesh-devices/heltec-wsl-v3.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/neode-ui/public/assets/img/mesh-devices/heltec_v4.svg b/neode-ui/public/assets/img/mesh-devices/heltec_v4.svg
deleted file mode 100644
index e7cb8943..00000000
--- a/neode-ui/public/assets/img/mesh-devices/heltec_v4.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/neode-ui/public/assets/img/mesh-devices/nano-g2-ultra.svg b/neode-ui/public/assets/img/mesh-devices/nano-g2-ultra.svg
deleted file mode 100644
index 5ad5c5bf..00000000
--- a/neode-ui/public/assets/img/mesh-devices/nano-g2-ultra.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/neode-ui/public/assets/img/mesh-devices/rak-wismeshtap.svg b/neode-ui/public/assets/img/mesh-devices/rak-wismeshtap.svg
deleted file mode 100644
index 1085736a..00000000
--- a/neode-ui/public/assets/img/mesh-devices/rak-wismeshtap.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/neode-ui/public/assets/img/mesh-devices/rak4631.svg b/neode-ui/public/assets/img/mesh-devices/rak4631.svg
deleted file mode 100644
index 0eaea9b6..00000000
--- a/neode-ui/public/assets/img/mesh-devices/rak4631.svg
+++ /dev/null
@@ -1,3514 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/neode-ui/public/assets/img/mesh-devices/rak4631_case.svg b/neode-ui/public/assets/img/mesh-devices/rak4631_case.svg
deleted file mode 100644
index 4ffa23c9..00000000
--- a/neode-ui/public/assets/img/mesh-devices/rak4631_case.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/neode-ui/public/assets/img/mesh-devices/rpipicow.svg b/neode-ui/public/assets/img/mesh-devices/rpipicow.svg
deleted file mode 100644
index 519648ba..00000000
--- a/neode-ui/public/assets/img/mesh-devices/rpipicow.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/neode-ui/public/assets/img/mesh-devices/seeed-xiao-s3.svg b/neode-ui/public/assets/img/mesh-devices/seeed-xiao-s3.svg
deleted file mode 100644
index 768093b2..00000000
--- a/neode-ui/public/assets/img/mesh-devices/seeed-xiao-s3.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/neode-ui/public/assets/img/mesh-devices/station-g2.svg b/neode-ui/public/assets/img/mesh-devices/station-g2.svg
deleted file mode 100644
index 158d456a..00000000
--- a/neode-ui/public/assets/img/mesh-devices/station-g2.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/neode-ui/public/assets/img/mesh-devices/t-deck.svg b/neode-ui/public/assets/img/mesh-devices/t-deck.svg
deleted file mode 100644
index c13a445c..00000000
--- a/neode-ui/public/assets/img/mesh-devices/t-deck.svg
+++ /dev/null
@@ -1 +0,0 @@
-Q W E R T Y I U P O A S D F G H K J L alt Z X C V B M N
\ No newline at end of file
diff --git a/neode-ui/public/assets/img/mesh-devices/t-echo.svg b/neode-ui/public/assets/img/mesh-devices/t-echo.svg
deleted file mode 100644
index 006f77f5..00000000
--- a/neode-ui/public/assets/img/mesh-devices/t-echo.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/neode-ui/public/assets/img/mesh-devices/t-echo_plus.svg b/neode-ui/public/assets/img/mesh-devices/t-echo_plus.svg
deleted file mode 100644
index ebc76c89..00000000
--- a/neode-ui/public/assets/img/mesh-devices/t-echo_plus.svg
+++ /dev/null
@@ -1,502 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/neode-ui/public/assets/img/mesh-devices/tbeam-s3-core.svg b/neode-ui/public/assets/img/mesh-devices/tbeam-s3-core.svg
deleted file mode 100644
index ed304d99..00000000
--- a/neode-ui/public/assets/img/mesh-devices/tbeam-s3-core.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/neode-ui/public/assets/img/mesh-devices/tbeam.svg b/neode-ui/public/assets/img/mesh-devices/tbeam.svg
deleted file mode 100644
index 6119e7da..00000000
--- a/neode-ui/public/assets/img/mesh-devices/tbeam.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/neode-ui/public/assets/img/mesh-devices/thinknode_m1.svg b/neode-ui/public/assets/img/mesh-devices/thinknode_m1.svg
deleted file mode 100644
index d4f95045..00000000
--- a/neode-ui/public/assets/img/mesh-devices/thinknode_m1.svg
+++ /dev/null
@@ -1,109 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/neode-ui/public/assets/img/mesh-devices/tlora-t3s3-v1.svg b/neode-ui/public/assets/img/mesh-devices/tlora-t3s3-v1.svg
deleted file mode 100644
index fb8297d3..00000000
--- a/neode-ui/public/assets/img/mesh-devices/tlora-t3s3-v1.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/neode-ui/public/assets/img/mesh-devices/tlora-v2-1-1_6.svg b/neode-ui/public/assets/img/mesh-devices/tlora-v2-1-1_6.svg
deleted file mode 100644
index 53064569..00000000
--- a/neode-ui/public/assets/img/mesh-devices/tlora-v2-1-1_6.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/neode-ui/public/assets/img/mesh-devices/tracker-t1000-e.svg b/neode-ui/public/assets/img/mesh-devices/tracker-t1000-e.svg
deleted file mode 100644
index 1d54ac53..00000000
--- a/neode-ui/public/assets/img/mesh-devices/tracker-t1000-e.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/neode-ui/public/assets/img/mesh-devices/unknown-new.svg b/neode-ui/public/assets/img/mesh-devices/unknown-new.svg
deleted file mode 100644
index daece9e5..00000000
--- a/neode-ui/public/assets/img/mesh-devices/unknown-new.svg
+++ /dev/null
@@ -1,264 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/neode-ui/public/assets/img/mesh-devices/wio-tracker-wm1110.svg b/neode-ui/public/assets/img/mesh-devices/wio-tracker-wm1110.svg
deleted file mode 100644
index 14b8f528..00000000
--- a/neode-ui/public/assets/img/mesh-devices/wio-tracker-wm1110.svg
+++ /dev/null
@@ -1 +0,0 @@
-LoRa WI FI LED RESET GNSS BLE
\ No newline at end of file
diff --git a/neode-ui/public/assets/img/phone-mockups/pixel-10.webp b/neode-ui/public/assets/img/phone-mockups/pixel-10.webp
new file mode 100644
index 00000000..0f3ba1ff
Binary files /dev/null and b/neode-ui/public/assets/img/phone-mockups/pixel-10.webp differ
diff --git a/neode-ui/public/assets/img/phone-mockups/pixel-9.webp b/neode-ui/public/assets/img/phone-mockups/pixel-9.webp
new file mode 100644
index 00000000..7689d967
Binary files /dev/null and b/neode-ui/public/assets/img/phone-mockups/pixel-9.webp differ
diff --git a/neode-ui/public/assets/img/phone-mockups/pixel-9a.webp b/neode-ui/public/assets/img/phone-mockups/pixel-9a.webp
new file mode 100644
index 00000000..40726152
Binary files /dev/null and b/neode-ui/public/assets/img/phone-mockups/pixel-9a.webp differ
diff --git a/neode-ui/public/assets/img/router-mockups/beryl-ax.webp b/neode-ui/public/assets/img/router-mockups/beryl-ax.webp
new file mode 100644
index 00000000..547c03ce
Binary files /dev/null and b/neode-ui/public/assets/img/router-mockups/beryl-ax.webp differ
diff --git a/neode-ui/public/assets/img/router-mockups/flint-2.webp b/neode-ui/public/assets/img/router-mockups/flint-2.webp
new file mode 100644
index 00000000..f367c48e
Binary files /dev/null and b/neode-ui/public/assets/img/router-mockups/flint-2.webp differ
diff --git a/neode-ui/public/assets/img/router-mockups/nanopi-r5s.webp b/neode-ui/public/assets/img/router-mockups/nanopi-r5s.webp
new file mode 100644
index 00000000..f747c258
Binary files /dev/null and b/neode-ui/public/assets/img/router-mockups/nanopi-r5s.webp differ
diff --git a/neode-ui/src/components/DeviceModel.vue b/neode-ui/src/components/DeviceModel.vue
new file mode 100644
index 00000000..4a29ad86
--- /dev/null
+++ b/neode-ui/src/components/DeviceModel.vue
@@ -0,0 +1,202 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ failed ? label : (devBrowseEnabled ? devModelLabel(currentModel) : 'Drag to explore') }}
+
+ Back
+ {{ devModelIds.indexOf(currentModel) + 1 }} / {{ devModelIds.length }}
+ Next
+
+ {{ paused ? 'Rotate' : 'Pause' }}
+
+
+
+
+
diff --git a/neode-ui/src/components/EasyHome.vue b/neode-ui/src/components/EasyHome.vue
index 3fad4a04..739c3cb4 100644
--- a/neode-ui/src/components/EasyHome.vue
+++ b/neode-ui/src/components/EasyHome.vue
@@ -94,6 +94,7 @@ function goalIcon(icon: string): string {
lightning: '⚡',
identity: '🔑',
backup: '💾',
+ phone: '📱',
}
return icons[icon] || '📦'
}
diff --git a/neode-ui/src/components/HardwareMarketplace.vue b/neode-ui/src/components/HardwareMarketplace.vue
new file mode 100644
index 00000000..f63d48c4
--- /dev/null
+++ b/neode-ui/src/components/HardwareMarketplace.vue
@@ -0,0 +1,233 @@
+
+
+
+
+
+
+ {{ props.kind === 'phone' ? 'Private mobile' : 'OpenWrt gateway' }}
+ {{ props.kind === 'phone' ? 'Ready for a phone that answers to you?' : 'Not got an OpenWrt router?' }}
+ {{ props.kind === 'phone' ? 'Choose an unlockable Pixel. Install GrapheneOS. Lock the bootloader for verified boot.' : 'Browse community-published router listings from Nostr marketplaces. We’ll add Archipelago checkout after discovery is in place.' }}
+
+ {{ props.kind === 'phone' ? 'Buy phone' : 'Buy router' }}
+ →
+
+
+
+
+
+
{{ props.kind === 'phone' ? 'Pixel phones for sale' : 'OpenWrt routers for sale' }}
+
{{ listings[0]?.relay === 'development mock' ? 'Development preview · conceptual images and sample prices' : 'Live NIP-99 listings from Archipelago relays.' }}
+
+
+ ‹
+ ›
+ Close
+
+
+ Loading {{ props.kind }} listings
+ {{ error }}
+
+
+
+ ⌁
+
+
{{ item.title }}
+
{{ item.summary }}
+
+ {{ item.location }}
+ seller {{ item.pubkey.slice(0, 8) }}…
+
+
+
+
+
+
+
+
+
+
+
diff --git a/neode-ui/src/components/mesh/MeshDeviceSetupModal.vue b/neode-ui/src/components/mesh/MeshDeviceSetupModal.vue
index a1ad2bf5..84260b44 100644
--- a/neode-ui/src/components/mesh/MeshDeviceSetupModal.vue
+++ b/neode-ui/src/components/mesh/MeshDeviceSetupModal.vue
@@ -8,25 +8,11 @@
>
-
-
-
-
-
-
-
-
-
-
-
-
📡
-
+
{{ deviceImage.label }}
{{ deviceImage.exact ? 'Detected' : 'Detected LoRa radio' }} on
@@ -306,6 +292,7 @@ import { useMeshStore, type MeshDeviceProbe, type MeshConfigureParams, type Flas
import { useAppStore } from '@/stores/app'
import { LORA_REGIONS, regionByCode, suggestRegionFromLatLon, meshcorePlanFor, RNODE_REGION_PLANS } from '@/utils/loraRegions'
import { resolveMeshDeviceImage } from '@/utils/meshDeviceImages'
+import DeviceModel from '@/components/DeviceModel.vue'
const mesh = useMeshStore()
const appStore = useAppStore()
@@ -344,7 +331,6 @@ function stopProbeProgress(done = false) {
const devicePath = computed(() => mesh.flashFlowPath ?? mesh.undismissedDetectedDevices[0] ?? '')
const show = computed(() => !!devicePath.value)
-const imageFailed = ref(false)
const deviceImage = computed(() =>
resolveMeshDeviceImage(
mesh.status?.detected_device_info?.find(d => d.path === devicePath.value)
@@ -417,7 +403,6 @@ watch([show, devicePath], async ([visible]) => {
probeError.value = ''
probing.value = false
stopProbeProgress()
- imageFailed.value = false
openFlashStep()
const t = (mesh.status?.device_type ?? '').toLowerCase()
if (t === 'meshcore' || t === 'meshtastic' || t === 'reticulum') {
@@ -427,7 +412,6 @@ watch([show, devicePath], async ([visible]) => {
}
step.value = 1
error.value = ''
- imageFailed.value = false
form.value.region = suggestedRegion.value?.code ?? mesh.status?.lora_region ?? ''
form.value.name = mesh.status?.self_advert_name ?? appStore.serverName ?? ''
form.value.channel = mesh.status?.channel_name || 'archipelago'
@@ -629,17 +613,3 @@ function closeFlashStep() {
step.value = 1
}
-
-
diff --git a/neode-ui/src/data/goals.ts b/neode-ui/src/data/goals.ts
index 77ece715..5f87698f 100644
--- a/neode-ui/src/data/goals.ts
+++ b/neode-ui/src/data/goals.ts
@@ -67,6 +67,13 @@ export const GOALS: GoalDefinition[] = [
route: '/dashboard/setup/external-access', estimatedTime: 'Guided setup', difficulty: 'beginner',
steps: [{ id: 'external-access', title: 'Configure access', description: 'Choose and verify each connection.', action: 'configure', isAutomatic: false }],
},
+ {
+ id: 'non-surveillance-phone', title: 'Get a non surveillance phone',
+ subtitle: 'Choose an unlockable Pixel and take control with GrapheneOS',
+ icon: 'phone', category: 'identity', requiredApps: [],
+ route: '/dashboard/setup/non-surveillance-phone', estimatedTime: 'Browse phones', difficulty: 'beginner',
+ steps: [{ id: 'choose-private-phone', title: 'Choose a compatible Pixel', description: 'Check that the bootloader can be unlocked, then install GrapheneOS and lock it again for verified boot.', action: 'info', isAutomatic: false }],
+ },
{
id: 'publish-website', title: 'Publish a website',
subtitle: 'Create a website on your node and choose where to publish it',
diff --git a/neode-ui/src/graphics/assets/ATTRIBUTION.md b/neode-ui/src/graphics/assets/ATTRIBUTION.md
new file mode 100644
index 00000000..86e07187
--- /dev/null
+++ b/neode-ui/src/graphics/assets/ATTRIBUTION.md
@@ -0,0 +1,15 @@
+# GrapheneOS boot artwork
+
+`grapheneos-boot-mask.png` is the unmodified boot logo mask from GrapheneOS's
+Android framework, commit `0f505137b78efaee3058434e88974e2a06e0917c`:
+
+https://github.com/GrapheneOS/platform_frameworks_base/blob/0f505137b78efaee3058434e88974e2a06e0917c/core/res/assets/images/android-logo-mask.png
+
+The source commit credits Daniel Micay and smdyv. The framework is distributed
+under Apache-2.0; the license text is also shipped at
+`public/assets/fonts/Open_Sans/LICENSE.txt` in this frontend.
+
+The phone displays a static white phase of the actual GrapheneOS OS boot logo.
+It does not reproduce Google's separate bootloader / yellow alternate-OS notice.
+GrapheneOS branding identifies the illustrated operating system; it does not
+imply endorsement. The phone geometry and materials are original procedural art.
diff --git a/neode-ui/src/graphics/assets/grapheneos-boot-mask.png b/neode-ui/src/graphics/assets/grapheneos-boot-mask.png
new file mode 100644
index 00000000..fb26e928
Binary files /dev/null and b/neode-ui/src/graphics/assets/grapheneos-boot-mask.png differ
diff --git a/neode-ui/src/graphics/deviceModels.ts b/neode-ui/src/graphics/deviceModels.ts
new file mode 100644
index 00000000..ae935ef8
--- /dev/null
+++ b/neode-ui/src/graphics/deviceModels.ts
@@ -0,0 +1,406 @@
+import * as THREE from 'three'
+import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js'
+import grapheneBootMaskUrl from './assets/grapheneos-boot-mask.png'
+
+// Original, procedural display models. Dimensions/layouts follow the existing
+// device illustrations and manufacturer references listed in device-models.md.
+// These are recognition aids, not mechanical CAD or revision-specific pinouts.
+type Kind = 'board' | 'echo' | 'deck' | 'card' | 'case' | 'router' | 'phone'
+interface DeviceSpec {
+ kind: Kind; name: string; w: number; h: number; d: number; color: number
+ screen?: 'oled' | 'paper'; antenna?: boolean; battery?: boolean; pins?: number
+}
+export const deviceSpecs: Record = {
+ 'heltec-v3': { kind: 'board', name: 'HELTEC V3', w: 2.9, h: 1.25, d: .07, color: 0xe8e7df, screen: 'oled', pins: 18 },
+ 'heltec_v4': { kind: 'board', name: 'HELTEC V4', w: 2.85, h: 1.2, d: .07, color: 0x181d20, screen: 'oled', pins: 18 },
+ 'heltec-wsl-v3': { kind: 'board', name: 'HELTEC WSL', w: 2.85, h: 1, d: .07, color: 0x182225, pins: 18 },
+ 'heltec-mesh-node-t114': { kind: 'board', name: 'HELTEC T114', w: 2.65, h: 1.25, d: .08, color: 0xe6e3dc, screen: 'oled' },
+ 'tlora-t3s3-v1': { kind: 'board', name: 'LILYGO T3-S3', w: 2.8, h: 1.25, d: .07, color: 0x1b2024, screen: 'oled', antenna: true, pins: 16 },
+ 'tlora-v2-1-1_6': { kind: 'board', name: 'LILYGO T-LoRa', w: 2.8, h: 1.2, d: .07, color: 0x25272a, screen: 'oled', antenna: true, pins: 16 },
+ 'tbeam': { kind: 'board', name: 'LILYGO T-Beam', w: 3.7, h: 1.2, d: .08, color: 0x242a2d, screen: 'oled', antenna: true, battery: true, pins: 16 },
+ 'tbeam-s3-core': { kind: 'board', name: 'T-Beam S3', w: 3.35, h: 1.05, d: .08, color: 0x22272a, screen: 'oled', antenna: true, battery: true },
+ 'rak4631': { kind: 'board', name: 'RAK4631', w: 1.4, h: 2.8, d: .08, color: 0x202629, pins: 12 },
+ 'seeed-xiao-s3': { kind: 'board', name: 'XIAO ESP32S3', w: 1.25, h: 1.5, d: .08, color: 0x152c28, pins: 7 },
+ 'wio-tracker-wm1110': { kind: 'board', name: 'Wio WM1110', w: 1.85, h: 2.7, d: .08, color: 0xb32130, pins: 12 },
+ 'rpipicow': { kind: 'board', name: 'Raspberry Pi Pico', w: 3.1, h: 1.2, d: .07, color: 0x16754d, pins: 20 },
+ 'diy': { kind: 'board', name: 'RNode', w: 2.8, h: 1.3, d: .07, color: 0x24272b, pins: 19 },
+ 't-deck': { kind: 'deck', name: 'LILYGO T-Deck', w: 2, h: 3.2, d: .28, color: 0x25262b, screen: 'oled' },
+ 't-echo': { kind: 'echo', name: 'LILYGO', w: 1.7, h: 1.9, d: .5, color: 0xe3e4dc, screen: 'paper', antenna: true },
+ 't-echo_plus': { kind: 'echo', name: 'LILYGO', w: 1.9, h: 2.15, d: .65, color: 0x343839, screen: 'paper', antenna: true },
+ 'tracker-t1000-e': { kind: 'card', name: 'SenseCAP T1000-E', w: 1.8, h: 2.9, d: .17, color: 0xc6c9c5 },
+ 'rak-wismeshtap': { kind: 'case', name: 'WisMesh Tap', w: 2.2, h: 1.7, d: .52, color: 0x33363a, screen: 'oled', antenna: true },
+ 'station-g2': { kind: 'case', name: 'Station G2', w: 1.7, h: 2.85, d: .65, color: 0x32363b, screen: 'oled' },
+ 'nano-g2-ultra': { kind: 'case', name: 'Nano G2 Ultra', w: 1.7, h: 2.8, d: .5, color: 0x36383d, screen: 'oled' },
+ 'thinknode_m1': { kind: 'case', name: 'ThinkNode M1', w: 1.7, h: 2.3, d: .65, color: 0x252c2c, screen: 'paper', antenna: true },
+ 'heltec-v3-case': { kind: 'case', name: 'HELTEC V3', w: 2.8, h: 1.25, d: .48, color: 0x13857e, screen: 'oled', antenna: true },
+ 'heltec-mesh-node-t114-case': { kind: 'case', name: 'HELTEC T114', w: 1.25, h: 2.4, d: .5, color: 0x10a9c4, screen: 'oled', antenna: true },
+ 'rak4631_case': { kind: 'case', name: 'RAK WisBlock', w: 1.4, h: 2.3, d: .55, color: 0xd5dad5, screen: 'oled', antenna: true },
+ 'unknown-new': { kind: 'case', name: 'LoRa mesh radio', w: 1.8, h: 2.6, d: .5, color: 0x182623, screen: 'oled' },
+ 'beryl-ax': { kind: 'router', name: 'Beryl AX · GL-MT3000', w: 3, h: .8, d: 2.05, color: 0x171b20 },
+ 'pixel-graphene': { kind: 'phone', name: 'Pixel 9 Pro · GrapheneOS', w: 1.8, h: 3.82, d: .213, color: 0x08090b },
+}
+
+export function createDeviceModel(id: string, invalidate: () => void = () => {}) {
+ const spec = deviceSpecs[id] ?? deviceSpecs['unknown-new']!
+ const root = new THREE.Group()
+ const geometries = new Map()
+ const materials: THREE.Material[] = []
+ const textures: THREE.Texture[] = []
+ let disposed = false
+ const material = (color: number, roughness = .5, metalness = .1) => {
+ const m = new THREE.MeshStandardMaterial({ color, roughness, metalness })
+ materials.push(m); return m
+ }
+ const body = material(spec.color, .43)
+ const black = material(0x10151a, .58)
+ const dark = material(0x03070a, .72)
+ const silver = material(0x9ca9af, .3, .86)
+ const gold = material(0xc9a454, .3, .82)
+ const white = material(0xdce2de, .66)
+ const pcb = material(0x193a30, .6)
+ const glass = material(0x091923, .17, .26)
+ const accent = material(0x76e6bf, .35)
+ accent.emissive.set(0x1b704a); accent.emissiveIntensity = .5
+ const grainCanvas = document.createElement('canvas'); grainCanvas.width = grainCanvas.height = 64
+ const grainContext = grainCanvas.getContext('2d')!
+ const pixels = grainContext.createImageData(64, 64)
+ let seed = 173
+ for (let i = 0; i < pixels.data.length; i += 4) {
+ seed = (seed * 1664525 + 1013904223) >>> 0
+ const value = 200 + seed % 45; pixels.data.set([value, value, value, 255], i)
+ }
+ grainContext.putImageData(pixels, 0, 0)
+ const grain = new THREE.CanvasTexture(grainCanvas)
+ grain.wrapS = grain.wrapT = THREE.RepeatWrapping; grain.repeat.set(7, 7); textures.push(grain)
+ body.roughnessMap = grain; body.bumpMap = grain; body.bumpScale = .0018
+ const geometry = (key: string, build: () => THREE.BufferGeometry) => {
+ if (!geometries.has(key)) geometries.set(key, build())
+ return geometries.get(key)!
+ }
+ function box(w: number, h: number, d: number, x: number, y: number, z: number, m = body, radius = .025, parent = root) {
+ const r = Math.min(radius, w / 3, h / 3, d / 3)
+ const g = geometry(`b${w},${h},${d},${r}`, () => new RoundedBoxGeometry(w, h, d, 2, r))
+ const mesh = new THREE.Mesh(g, m); mesh.position.set(x, y, z); parent.add(mesh); return mesh
+ }
+ function cylinder(r: number, length: number, x: number, y: number, z: number, m = silver, parent = root) {
+ const g = geometry(`c${r},${length}`, () => new THREE.CylinderGeometry(r, r, length, 20))
+ const mesh = new THREE.Mesh(g, m); mesh.position.set(x, y, z); parent.add(mesh); return mesh
+ }
+ function disk(r: number, x: number, y: number, z: number, m = silver) {
+ const mesh = cylinder(r, .018, x, y, z, m); mesh.rotation.x = Math.PI / 2; return mesh
+ }
+ function label(text: string, w: number, h: number, x: number, y: number, z: number, back = false, color = '#dbe4e3') {
+ const canvas = document.createElement('canvas'); canvas.width = 512; canvas.height = 96
+ const ctx = canvas.getContext('2d')!
+ ctx.fillStyle = color; ctx.font = '500 40px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'
+ ctx.fillText(text, 256, 48, 500)
+ const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; textures.push(texture)
+ const m = new THREE.MeshBasicMaterial({ map: texture, transparent: true, depthWrite: false }); materials.push(m)
+ const mesh = new THREE.Mesh(geometry(`p${w},${h}`, () => new THREE.PlaneGeometry(w, h)), m)
+ mesh.position.set(x, y, z); if (back) mesh.rotation.y = Math.PI; root.add(mesh); return mesh
+ }
+ function screws(w: number, h: number, z: number) {
+ for (const x of [-w / 2 + .12, w / 2 - .12]) for (const y of [-h / 2 + .12, h / 2 - .12]) {
+ disk(.055, x, y, z, silver)
+ box(.058, .012, .012, x, y, z + Math.sign(z) * .013, dark, .001)
+ }
+ }
+ function antenna(x: number, y: number, length = 1.3) {
+ cylinder(.085, .2, x, y + .1, 0, gold)
+ cylinder(.1, .18, x, y + .24, 0, black)
+ cylinder(.068, length, x, y + .3 + length / 2, 0, black)
+ for (let i = 0; i < 3; i++) cylinder(.074, .02, x, y + length + .1 + i * .05, 0, body)
+ }
+ function screen(w: number, h: number, x: number, y: number, z: number, paper = false) {
+ box(w + .12, h + .12, .065, x, y, z, black)
+ box(w, h, .02, x, y, z + .038, paper ? white : glass, .015)
+ const ink = paper ? '#263834' : '#8ce4d5'
+ label('MESH', w * .65, h * .28, x, y + h * .14, z + .052, false, ink)
+ for (let i = 0; i < 4; i++) box(.028, .045 + .035 * i, .008, x + w * .3 + i * .045, y - h * .32, z + .053, paper ? black : accent, .002)
+ label('LoRa', w * .48, h * .18, x - w * .08, y - h * .2, z + .054, false, ink)
+ }
+ function usb(x: number, y: number, z: number, back = false) {
+ box(.28, .115, .14, x, y, z, silver)
+ box(.22, .063, .015, x, y, z + (back ? -.077 : .077), dark)
+ box(.15, .015, .018, x, y, z + (back ? -.084 : .084), black)
+ }
+ const { w, h, d } = spec
+ if (spec.kind === 'router') {
+ // Beryl AX: two hinged paddles, rear USB-C / WAN / LAN / vertical USB-A.
+ box(w, h, d, 0, 0, 0, body, .25)
+ for (const z of [-1.023, 1.023]) box(2.56, .013, .008, 0, -.12, z, black, .002)
+ label('GL.iNet', .62, .14, 0, .423, .08, false, '#d5e9ed').rotation.x = -Math.PI / 2
+ box(.22, .032, .022, 0, -.115, d / 2 + .012, white)
+ for (const side of [-1, 1]) {
+ const hinge = cylinder(.16, .12, side * 1.57, -.04, .36, body); hinge.rotation.z = Math.PI / 2
+ const paddle = new THREE.Group(); paddle.position.set(side * 1.62, 0, .36); paddle.rotation.z = side * -.1; root.add(paddle)
+ box(.27, 1.93, .19, 0, .86, 0, body, .085, paddle)
+ box(.18, 1.65, .015, 0, .88, .102, body, .005, paddle)
+ for (let i = 0; i < 24; i++) box(.014, .37, .028, side * 1.497, -.035, -.85 + i * .069, dark, .003)
+ for (const z of [-.72, .72]) {
+ cylinder(.14, .08, side * 1.12, -.43, z, black)
+ for (let i = 0; i < 14; i++) box(.038, .012, .45, side * (.35 + i * .055), -.405, z * .6, dark, .002)
+ }
+ }
+ box(2.46, .55, .022, 0, -.015, -1.031, body, .008)
+ usb(1, .04, -1.035, true)
+ label('POWER', .3, .075, 1, .2, -1.057, true)
+ for (const [x, text] of [[.38, '2.5G WAN'], [-.27, 'LAN']] as const) {
+ box(.51, .4, .03, x, -.025, -1.055, silver)
+ box(.45, .32, .03, x, -.025, -1.074, dark)
+ box(.22, .06, .04, x, -.19, -1.08, dark)
+ for (let i = 0; i < 8; i++) box(.019, .08, .014, x - .15 + i * .043, .062, -1.095, gold, .001)
+ label(text, .53, .075, x, .24, -1.06, true)
+ }
+ box(.22, .4, .035, -.93, -.04, -1.06, silver)
+ box(.145, .32, .02, -.93, -.04, -1.086, dark)
+ box(.038, .26, .022, -.92, -.04, -1.1, material(0x287ecc))
+ label('USB 3.0', .42, .075, -.93, .24, -1.06, true)
+ box(.018, .085, .18, -1.508, .14, -.5, black)
+ box(.02, .05, .05, -1.51, .14, -.77, dark)
+ const underside = label('GL-MT3000 / BERYL AX', 1.25, .15, 0, -.413, 0)
+ underside.rotation.x = Math.PI / 2
+ } else if (spec.kind === 'phone') {
+ // Rounded outlines are independent of thickness: a thin RoundedBox clamps
+ // the corner radius to its depth and makes a handset look square.
+ function outline(width: number, height: number, radius: number) {
+ const x = -width / 2, y = -height / 2, r = radius
+ const shape = new THREE.Shape()
+ shape.moveTo(x + r, y); shape.lineTo(x + width - r, y)
+ shape.quadraticCurveTo(x + width, y, x + width, y + r)
+ shape.lineTo(x + width, y + height - r)
+ shape.quadraticCurveTo(x + width, y + height, x + width - r, y + height)
+ shape.lineTo(x + r, y + height)
+ shape.quadraticCurveTo(x, y + height, x, y + height - r)
+ shape.lineTo(x, y + r); shape.quadraticCurveTo(x, y, x + r, y)
+ return shape
+ }
+ function panel(width: number, height: number, depth: number, radius: number, z: number, m: THREE.Material, x = 0, y = 0) {
+ const bevel = Math.min(.012, depth / 4)
+ const g = geometry(`phone${width},${height},${depth},${radius}`, () => {
+ const shape = outline(width - bevel * 2, height - bevel * 2, radius - bevel)
+ const result = new THREE.ExtrudeGeometry(shape, { depth: depth - bevel * 2, bevelEnabled: true, bevelThickness: bevel, bevelSize: bevel, bevelSegments: 3, steps: 1, curveSegments: 12 })
+ result.translate(0, 0, -depth / 2 + bevel)
+ return result
+ })
+ const mesh = new THREE.Mesh(g, m); mesh.position.set(x, y, z); root.add(mesh); return mesh
+ }
+ const frame = material(0x101113, .32, .72)
+ const edge = material(0x24262a, .27, .8)
+ const rear = material(0x08090b, .32, .12)
+ const lensRing = material(0x303238, .25, .78)
+ const lens = material(0x080d16, .12, .3)
+ const lensCoating = material(0x172a38, .16, .45)
+ const seal = material(0x030405, .85)
+ panel(w, h, d, .265, 0, frame)
+ panel(w - .024, h - .024, .022, .255, -d / 2, edge)
+ panel(w - .048, h - .048, .018, .245, -d / 2 - .013, rear)
+ panel(w - .02, h - .02, .02, .255, d / 2, seal)
+ panel(w - .044, h - .044, .012, .244, d / 2 + .011, material(0x050609, .2, .08))
+ const oled = new THREE.MeshBasicMaterial({ color: 0x000000, toneMapped: false }); materials.push(oled)
+ panel(w - .108, h - .108, .004, .216, d / 2 + .02, oled)
+
+ // Unmodified GrapheneOS boot mask, centered at its native Pixel 9 Pro
+ // screen ratio (448 / 1280). Static white phase of the OS boot animation.
+ const logoW = (w - .108) * 448 / 1280, logoH = logoW * 605 / 448
+ const logoGeometry = geometry('graphene-boot-logo', () => new THREE.PlaneGeometry(logoW, logoH))
+ const bootCanvas = document.createElement('canvas'); bootCanvas.width = 448; bootCanvas.height = 605
+ const boot = bootCanvas.getContext('2d')!
+ boot.fillStyle = '#000'; boot.fillRect(0, 0, 448, 605)
+ const bootTexture = new THREE.CanvasTexture(bootCanvas)
+ bootTexture.colorSpace = THREE.SRGBColorSpace; textures.push(bootTexture)
+ new THREE.ImageLoader().load(grapheneBootMaskUrl, image => {
+ if (disposed) return
+ boot.fillStyle = '#f3f3f3'; boot.fillRect(0, 0, 448, 605)
+ boot.drawImage(image, 0, 0); bootTexture.needsUpdate = true; invalidate()
+ })
+ const logoMaterial = new THREE.MeshBasicMaterial({ map: bootTexture, toneMapped: false }); materials.push(logoMaterial)
+ const logo = new THREE.Mesh(logoGeometry, logoMaterial); logo.position.z = d / 2 + .026; root.add(logo)
+ disk(.031, 0, h / 2 - .145, d / 2 + .028, seal)
+ disk(.017, 0, h / 2 - .145, d / 2 + .039, lens)
+ panel(.32, .014, .006, .007, d / 2 + .02, seal, 0, h / 2 - .044)
+
+ // Pixel 9 Pro's raised camera island, glass pill, three layered optics,
+ // flash and temperature sensor; all face outward on the rear.
+ const cameraY = h / 2 - .68
+ panel(1.57, .59, .105, .285, -d / 2 - .058, frame, 0, cameraY)
+ panel(1.51, .53, .018, .255, -d / 2 - .117, edge, 0, cameraY)
+ panel(1.14, .402, .012, .196, -d / 2 - .13, seal, .125, cameraY)
+ for (const x of [-.245, .125, .49]) {
+ disk(.154, x, cameraY, -d / 2 - .142, lensRing)
+ disk(.13, x, cameraY, -d / 2 - .153, dark)
+ disk(.103, x, cameraY, -d / 2 - .164, lens)
+ disk(.064, x, cameraY, -d / 2 - .175, lensCoating)
+ disk(.039, x, cameraY, -d / 2 - .186, dark)
+ disk(.018, x - .029, cameraY + .038, -d / 2 - .197, material(0x46556c, .18, .3))
+ }
+ disk(.069, -.582, cameraY + .11, -d / 2 - .139, material(0xc8c4ae, .5))
+ disk(.033, -.582, cameraY - .11, -d / 2 - .139, lens)
+ disk(.009, -.45, cameraY - .12, -d / 2 - .14, seal)
+ // Black buttons, antenna seams, bottom USB-C opening and speaker holes.
+ box(.038, .28, .09, w / 2 + .012, .74, 0, frame, .012)
+ box(.038, .45, .09, w / 2 + .012, .19, 0, frame, .012)
+ for (const side of [-1, 1]) for (const y of [-1.35, 1.36]) box(.008, .027, d - .035, side * (w / 2 + .001), y, 0, seal, .002)
+ const port = panel(.245, .071, .009, .032, 0, edge)
+ port.rotation.x = Math.PI / 2; port.position.set(0, -h / 2 - .002, 0)
+ const portHole = panel(.213, .047, .01, .021, 0, seal)
+ portHole.rotation.x = Math.PI / 2; portHole.position.set(0, -h / 2 - .008, 0)
+ box(.145, .008, .012, 0, -h / 2 - .015, 0, frame, .003)
+ for (let i = 0; i < 5; i++) {
+ const hole = disk(.016, 0, 0, 0, seal)
+ hole.rotation.x = 0; hole.position.set(.34 + i * .067, -h / 2 - .003, 0)
+ }
+ const mic = disk(.015, 0, 0, 0, seal); mic.rotation.x = 0; mic.position.set(-.4, -h / 2 - .003, 0)
+ } else if (spec.kind === 'board') {
+ box(w, h, d, 0, 0, 0, body, .06)
+ // Plated through holes, copper traces, solder joints and populated reverse.
+ const horizontal = w > h; const count = spec.pins ?? 10
+ for (const side of [-1, 1]) for (let i = 0; i < count; i++) {
+ const a = ((i + .5) / count - .5) * ((horizontal ? w : h) - .28)
+ const x = horizontal ? a : side * (w / 2 - .08)
+ const y = horizontal ? side * (h / 2 - .08) : a
+ for (const face of [-1, 1]) {
+ disk(.037, x, y, face * (d / 2 + .008), gold)
+ disk(.018, x, y, face * (d / 2 + .021), dark)
+ }
+ }
+ const heltec = id.startsWith('heltec')
+ const shieldX = spec.screen ? w * .28 : w * .05
+ if (!heltec || !spec.screen) {
+ box(w * .25, h * .43, .11, shieldX, .05, .095, silver)
+ label(spec.name.split(' ')[0]!, w * .23, .08, shieldX, .06, .158, false, '#354047')
+ } else {
+ box(w * .32, h * .43, .11, shieldX, .05, -.12, silver)
+ label('ESP32 · LoRa', w * .29, .08, shieldX, .06, -.18, true, '#354047')
+ }
+ for (let i = 0; i < 12; i++) {
+ const x = -w * .38 + (i % 6) * w * .14, y = -h * .28 + Math.floor(i / 6) * h * .56
+ box(.095, .043, .035, x, y, .065, i % 3 ? black : silver, .003)
+ box(.15, .023, .008, x, y, -.05, gold, .002)
+ box(.022, .18, .008, x, y + .07, -.047, pcb, .001)
+ }
+ box(w * .28, h * .34, .055, -w * .15, 0, -.08, black)
+ label(spec.name, w * .65, .12, 0, -h * .31, -.114, true)
+ // SMD passives with metal end caps, short traces and silkscreen outlines.
+ for (const face of [-1, 1]) for (let i = 0; i < 26; i++) {
+ const x = -w * .4 + (i % 13) * w * .065, y = (i < 13 ? -.33 : .33) * h
+ box(.065, .029, .021, x, y, face * .061, i % 4 ? black : material(0x8b7856), .002)
+ for (const end of [-1, 1]) box(.013, .032, .024, x + end * .032, y, face * .063, silver, .001)
+ box(.09, .007, .004, x, y + .03, face * .043, white, .001)
+ }
+ if (spec.screen) screen(w * (heltec ? .52 : .36), h * .51, w * (heltec ? .15 : -.15), 0, .145)
+ else {
+ box(w * .23, h * .28, .055, -w * .26, .03, .1, black)
+ for (let i = 0; i < 6; i++) box(.028, h * .32, .015, -w * .36 + i * .04, .03, .085, silver, .001)
+ }
+ // USB connector faces outward at the short edge.
+ const port = new THREE.Group(); root.add(port)
+ box(.29, .17, .14, 0, 0, 0, silver, .02, port)
+ box(.22, .11, .015, 0, 0, .078, dark, .018, port)
+ if (horizontal) { port.position.set((heltec ? -1 : 1) * (w / 2 + .025), 0, .12); port.rotation.y = (heltec ? -1 : 1) * Math.PI / 2 }
+ else { port.position.set(0, h / 2 + .025, .12); port.rotation.x = -Math.PI / 2 }
+ disk(.055, -w * .4, .12, .076, gold)
+ box(.105, .105, .055, -w * .4, -.14, .09, silver)
+ box(.052, .052, .024, -w * .4, -.14, .13, black)
+ if (spec.antenna) antenna(w * .27, h / 2, 1.25)
+ if (spec.battery) {
+ box(w * .82, .85, .5, 0, 0, -.31, black, .05)
+ const cell = cylinder(.34, w * .7, 0, 0, -.52, material(0x8461a5)); cell.rotation.z = Math.PI / 2
+ for (const x of [-w * .36, w * .36]) { const end = cylinder(.29, .05, x, 0, -.52); end.rotation.z = Math.PI / 2 }
+ }
+ if (id === 'wio-tracker-wm1110') for (const x of [-.66, .66]) for (let i = 0; i < 4; i++) box(.21, .2, .15, x, -.87 + i * .52, .1, white)
+ if (id === 'rak4631') {
+ box(1.08, .96, .035, 0, .45, .18, material(0x187b65))
+ box(.86, .73, .04, 0, .45, .22, silver)
+ label('RAK', .52, .19, 0, .56, .246, false, '#126d95')
+ label('4631', .45, .1, 0, .33, .247, false, '#354047')
+ for (const x of [-.43, 0, .43]) box(.21, .52, .06, x, -.74, .12, black)
+ }
+ } else {
+ box(w, h, d, 0, 0, 0, body, .17)
+ // A recessed rear panel preserves the rounded enclosure silhouette.
+ box(w * .88, h * .91, .025, 0, 0, -d / 2 - .012, body, .008)
+ screws(w * .94, h * .95, -d / 2 - .031)
+ label(spec.name, w * .66, .11, 0, -.2, -d / 2 - .045, true)
+ label('LoRa · wireless', w * .52, .07, 0, -.34, -d / 2 - .045, true)
+ if (spec.kind === 'deck') {
+ screen(w * .79, h * .38, 0, h * .19, d / 2 + .015)
+ const keys = ['QWERTYUIOP', 'ASDFGHJKL', 'ZXCVBNM']
+ keys.forEach((row, r) => [...row].forEach((key, i) => {
+ const x = (i - (row.length - 1) / 2) * .165, y = -.55 - r * .19
+ box(.146, .15, .07, x, y, d / 2 + .03, black, .02)
+ label(key, .065, .06, x, y, d / 2 + .069)
+ }))
+ box(.63, .13, .065, 0, -1.12, d / 2 + .03, black)
+ disk(.11, 0, -.28, d / 2 + .04, silver)
+ disk(.072, 0, -.28, d / 2 + .056, white)
+ label('LILYGO', .48, .09, 0, 1.47, d / 2 + .02)
+ usb(0, -h / 2 + .05, -.015)
+ // Exposed rear PCB on the original T-Deck development board.
+ box(w * .85, h * .83, .045, 0, 0, -d / 2 - .05, pcb)
+ box(.7, .65, .1, .35, .7, -d / 2 - .11, silver)
+ label('ESP32-S3', .55, .08, .35, .7, -d / 2 - .165, true, '#354047')
+ box(1.35, 1.1, .06, 0, -.65, -d / 2 - .09, black)
+ for (let i = 0; i < 8; i++) box(.07, .12, .04, -.65 + i * .18, .04, -d / 2 - .09, gold)
+ } else if (spec.kind === 'echo') {
+ box(w * .49, .42, d * .85, w * .25, h / 2 + .12, 0, body, .08)
+ label('LILYGO', w * .37, .13, w * .25, h / 2 + .14, d * .43, false, '#67b6d8')
+ screen(w * .57, h * .57, 0, -.05, d / 2 + .017, true)
+ screws(w, h, d / 2 + .024)
+ antenna(w * .25, h / 2 + .33, 1.15)
+ for (const y of [-.3, 0, .3]) box(.05, .14, .17, -w / 2 - .02, y, 0, white)
+ usb(0, -h / 2 + .1, -d / 2, true)
+ if (id === 't-echo_plus') {
+ const loop = new THREE.Mesh(geometry('loop', () => new THREE.TorusGeometry(.31, .065, 10, 28, Math.PI)), silver)
+ loop.position.set(-.25, h / 2 + .22, -.08); root.add(loop)
+ }
+ } else if (spec.kind === 'card') {
+ disk(.31, 0, -.3, d / 2 + .015, gold)
+ disk(.24, 0, -.3, d / 2 + .029, body)
+ disk(.06, 0, .92, d / 2 + .018, dark)
+ label('SenseCAP', 1.03, .17, 0, .35, d / 2 + .021, false, '#59645f')
+ box(.46, .085, .025, 0, h / 2 - .12, d / 2 + .008, dark)
+ for (let i = 0; i < 4; i++) disk(.045, -.22 + i * .145, -.95, -d / 2 - .047, gold)
+ } else {
+ if (spec.screen) screen(w * .6, Math.min(h * .3, .7), 0, h * -.04, d / 2 + .025, spec.screen === 'paper')
+ label(spec.name, w * .72, .13, 0, -h * .35, d / 2 + .025)
+ screws(w, h, d / 2 + .016)
+ for (let i = 0; i < 7; i++) box(.045, h * .16, .013, (i - 3) * w * .08, h * .3, d / 2 + .012, dark, .004)
+ usb(0, -h / 2 + .09, -d / 2, true)
+ if (spec.antenna) antenna(w * .25, h / 2, 1.05)
+ if (id === 'unknown-new') {
+ for (const x of [-.48, .48]) disk(.12, x, -.85, d / 2 + .02, accent)
+ }
+ }
+ }
+ // Batch repeated opaque hardware. Every device owns/disposes its resources.
+ function batch(parent: THREE.Object3D) {
+ const groups = new Map()
+ for (const child of [...parent.children]) {
+ if (child.children.length) batch(child)
+ if (!(child instanceof THREE.Mesh) || Array.isArray(child.material) || child.material.transparent) continue
+ const key = child.geometry.uuid + child.material.uuid
+ const list = groups.get(key) ?? []; list.push(child); groups.set(key, list)
+ }
+ for (const meshes of groups.values()) if (meshes.length > 1) {
+ const first = meshes[0]!
+ const instance = new THREE.InstancedMesh(first.geometry, first.material, meshes.length)
+ meshes.forEach((mesh, i) => { mesh.updateMatrix(); instance.setMatrixAt(i, mesh.matrix); parent.remove(mesh) })
+ parent.add(instance)
+ }
+ }
+ batch(root)
+ const bounds = new THREE.Box3().setFromObject(root)
+ root.position.sub(bounds.getCenter(new THREE.Vector3()))
+ const pivot = new THREE.Group(); pivot.add(root)
+ const radius = bounds.getBoundingSphere(new THREE.Sphere()).radius
+ root.traverse(object => { object.updateMatrix(); object.matrixAutoUpdate = false })
+ return {
+ pivot, radius, name: spec.name,
+ dispose() {
+ disposed = true
+ root.traverse(object => { if (object instanceof THREE.InstancedMesh) object.dispose() })
+ geometries.forEach(g => g.dispose()); materials.forEach(m => m.dispose()); textures.forEach(t => t.dispose())
+ },
+ }
+}
diff --git a/neode-ui/src/graphics/deviceScene.ts b/neode-ui/src/graphics/deviceScene.ts
new file mode 100644
index 00000000..6731bd93
--- /dev/null
+++ b/neode-ui/src/graphics/deviceScene.ts
@@ -0,0 +1,58 @@
+import * as THREE from 'three'
+import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'
+import { createDeviceModel } from './deviceModels'
+
+export function createDeviceScene(host: HTMLElement, id: string) {
+ const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true, powerPreference: 'low-power' })
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.75))
+ renderer.setClearColor(0x000000, 0)
+ renderer.outputColorSpace = THREE.SRGBColorSpace
+ renderer.toneMapping = THREE.ACESFilmicToneMapping
+ renderer.toneMappingExposure = .94
+ renderer.domElement.setAttribute('aria-hidden', 'true')
+ const scene = new THREE.Scene()
+ const camera = new THREE.PerspectiveCamera(33, 1, .1, 100)
+ let disposed = false
+ let model = createDeviceModel(id, render)
+ scene.add(model.pivot)
+ model.pivot.rotation.set(id === 'beryl-ax' ? .27 : -.08, -.4, 0)
+ const studio = new RoomEnvironment()
+ const pmrem = new THREE.PMREMGenerator(renderer)
+ const environment = pmrem.fromScene(studio, .04, .1, 100, { size: 128 })
+ scene.environment = environment.texture; scene.environmentIntensity = 1.05
+ studio.dispose(); pmrem.dispose()
+ scene.add(new THREE.HemisphereLight(0xe4eeff, 0x354052, 2.25))
+ for (const [color, intensity, x, y, z] of [
+ [0xffffff, 1.15, 3, 5, 6], [0x91c6e8, .85, -4, 1, -3], [0xe8c5ac, .65, 4, 0, -4],
+ ]) {
+ const light = new THREE.DirectionalLight(color, intensity)
+ light.position.set(x!, y!, z!); scene.add(light)
+ }
+ host.append(renderer.domElement)
+ function render() { if (!disposed) renderer.render(scene, camera) }
+ function resize() {
+ if (disposed) return
+ const { width, height } = host.getBoundingClientRect()
+ if (!width || !height) return
+ renderer.setSize(width, height, false)
+ camera.aspect = width / height
+ const halfFov = Math.atan(Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) * Math.min(1, camera.aspect))
+ camera.position.set(0, 0, model.radius / Math.sin(halfFov) * 1.09)
+ camera.lookAt(0, 0, 0); camera.updateProjectionMatrix(); render()
+ }
+ resize()
+ return {
+ canvas: renderer.domElement, get rotation() { return model.pivot.rotation }, render, resize,
+ setModel(next: string) {
+ const replacement = createDeviceModel(next, render)
+ scene.remove(model.pivot); model.dispose(); model = replacement
+ model.pivot.rotation.set(next === 'beryl-ax' ? .27 : -.08, -.4, 0)
+ scene.add(model.pivot); resize()
+ },
+ dispose() {
+ if (disposed) return
+ disposed = true; model.dispose(); environment.dispose(); renderer.dispose()
+ renderer.forceContextLoss(); renderer.domElement.remove()
+ },
+ }
+}
diff --git a/neode-ui/src/router/index.ts b/neode-ui/src/router/index.ts
index c21c63fe..aa3edf57 100644
--- a/neode-ui/src/router/index.ts
+++ b/neode-ui/src/router/index.ts
@@ -255,6 +255,11 @@ const router = createRouter({
name: 'publish-website',
component: () => import('@/views/publishing/PublishingSetup.vue'),
},
+ {
+ path: 'setup/non-surveillance-phone',
+ name: 'non-surveillance-phone',
+ component: () => import('@/views/goals/NonSurveillancePhone.vue'),
+ },
{
path: 'goals/:goalId',
name: 'goal-detail',
diff --git a/neode-ui/src/utils/meshDeviceImages.ts b/neode-ui/src/utils/meshDeviceImages.ts
index c3b371c6..3359f7b2 100644
--- a/neode-ui/src/utils/meshDeviceImages.ts
+++ b/neode-ui/src/utils/meshDeviceImages.ts
@@ -1,7 +1,6 @@
/**
- * Best-effort mapping from a detected serial port's USB identity to a board
- * image (SVGs vendored from the Meshtastic web-flasher, GPL-3.0 —
- * github.com/meshtastic/web-flasher, public/img/devices/).
+ * Best-effort mapping from a detected serial port's USB identity to an
+ * original procedural Three.js model.
*
* Native-USB boards (T-Deck, RAK4631, T1000-E…) report their name in the USB
* product string, so those match precisely. UART-bridge boards only identify
@@ -18,58 +17,56 @@ export interface DetectedDeviceInfo {
}
export interface MeshDeviceImage {
- /** Path under public/ */
- image: string
+ /** Procedural model key in graphics/deviceModels.ts. */
+ model: string
/** Human name shown in the modal */
label: string
/** True when the match is exact (product string), false for chip-level guesses */
exact: boolean
}
-const IMG = '/assets/img/mesh-devices'
-
/** product-string keyword → image (checked in order, case-insensitive) */
-const PRODUCT_MATCHES: Array<{ match: RegExp; image: string; label: string }> = [
- { match: /t-?deck/i, image: `${IMG}/t-deck.svg`, label: 'LILYGO T-Deck' },
- { match: /t-?echo\s*plus/i, image: `${IMG}/t-echo_plus.svg`, label: 'LILYGO T-Echo Plus' },
- { match: /t-?echo/i, image: `${IMG}/t-echo.svg`, label: 'LILYGO T-Echo' },
- { match: /t-?beam\s*s3/i, image: `${IMG}/tbeam-s3-core.svg`, label: 'LILYGO T-Beam S3' },
- { match: /t-?beam/i, image: `${IMG}/tbeam.svg`, label: 'LILYGO T-Beam' },
- { match: /t3.?s3/i, image: `${IMG}/tlora-t3s3-v1.svg`, label: 'LILYGO T3-S3' },
- { match: /t-?lora|tlora/i, image: `${IMG}/tlora-v2-1-1_6.svg`, label: 'LILYGO T-LoRa' },
- { match: /rak.?4631|wisblock/i, image: `${IMG}/rak4631.svg`, label: 'RAK WisBlock 4631' },
- { match: /wismesh|wistap/i, image: `${IMG}/rak-wismeshtap.svg`, label: 'RAK WisMesh Tap' },
- { match: /t1000|tracker.?t1000/i, image: `${IMG}/tracker-t1000-e.svg`, label: 'Seeed Card Tracker T1000-E' },
- { match: /xiao/i, image: `${IMG}/seeed-xiao-s3.svg`, label: 'Seeed XIAO S3' },
- { match: /wio.?tracker|wm1110/i, image: `${IMG}/wio-tracker-wm1110.svg`, label: 'Seeed Wio Tracker' },
- { match: /station\s*g2/i, image: `${IMG}/station-g2.svg`, label: 'B&Q Station G2' },
- { match: /nano\s*g2/i, image: `${IMG}/nano-g2-ultra.svg`, label: 'B&Q Nano G2 Ultra' },
- { match: /t114/i, image: `${IMG}/heltec-mesh-node-t114.svg`, label: 'Heltec Mesh Node T114' },
- { match: /wireless\s*stick|wsl/i, image: `${IMG}/heltec-wsl-v3.svg`, label: 'Heltec Wireless Stick Lite V3' },
- { match: /heltec.*v4/i, image: `${IMG}/heltec_v4.svg`, label: 'Heltec V4' },
- { match: /heltec/i, image: `${IMG}/heltec-v3.svg`, label: 'Heltec LoRa 32 V3' },
- { match: /thinknode/i, image: `${IMG}/thinknode_m1.svg`, label: 'Elecrow ThinkNode' },
- { match: /pico/i, image: `${IMG}/rpipicow.svg`, label: 'Raspberry Pi Pico' },
- { match: /rnode/i, image: `${IMG}/diy.svg`, label: 'RNode' },
+const PRODUCT_MATCHES: Array<{ match: RegExp; model: string; label: string }> = [
+ { match: /t-?deck/i, model: 't-deck', label: 'LILYGO T-Deck' },
+ { match: /t-?echo\s*plus/i, model: 't-echo_plus', label: 'LILYGO T-Echo Plus' },
+ { match: /t-?echo/i, model: 't-echo', label: 'LILYGO T-Echo' },
+ { match: /t-?beam\s*s3/i, model: 'tbeam-s3-core', label: 'LILYGO T-Beam S3' },
+ { match: /t-?beam/i, model: 'tbeam', label: 'LILYGO T-Beam' },
+ { match: /t3.?s3/i, model: 'tlora-t3s3-v1', label: 'LILYGO T3-S3' },
+ { match: /t-?lora|tlora/i, model: 'tlora-v2-1-1_6', label: 'LILYGO T-LoRa' },
+ { match: /rak.?4631|wisblock/i, model: 'rak4631', label: 'RAK WisBlock 4631' },
+ { match: /wismesh|wistap/i, model: 'rak-wismeshtap', label: 'RAK WisMesh Tap' },
+ { match: /t1000|tracker.?t1000/i, model: 'tracker-t1000-e', label: 'Seeed Card Tracker T1000-E' },
+ { match: /xiao/i, model: 'seeed-xiao-s3', label: 'Seeed XIAO S3' },
+ { match: /wio.?tracker|wm1110/i, model: 'wio-tracker-wm1110', label: 'Seeed Wio Tracker' },
+ { match: /station\s*g2/i, model: 'station-g2', label: 'B&Q Station G2' },
+ { match: /nano\s*g2/i, model: 'nano-g2-ultra', label: 'B&Q Nano G2 Ultra' },
+ { match: /t114/i, model: 'heltec-mesh-node-t114', label: 'Heltec Mesh Node T114' },
+ { match: /wireless\s*stick|wsl/i, model: 'heltec-wsl-v3', label: 'Heltec Wireless Stick Lite V3' },
+ { match: /heltec.*v4/i, model: 'heltec_v4', label: 'Heltec V4' },
+ { match: /heltec/i, model: 'heltec-v3', label: 'Heltec LoRa 32 V3' },
+ { match: /thinknode/i, model: 'thinknode_m1', label: 'Elecrow ThinkNode' },
+ { match: /pico/i, model: 'rpipicow', label: 'Raspberry Pi Pico' },
+ { match: /rnode/i, model: 'diy', label: 'RNode' },
]
/** vid:pid → most-likely board (bridge chips = chip-level guess) */
-const VIDPID_MATCHES: Record = {
+const VIDPID_MATCHES: Record = {
// Silicon Labs CP210x — Heltec V3 family ships this bridge
- '10c4:ea60': { image: `${IMG}/heltec-v3.svg`, label: 'LoRa radio (CP2102 serial)', exact: false },
+ '10c4:ea60': { model: 'heltec-v3', label: 'LoRa radio (CP2102 serial)', exact: false },
// WCH CH340 — most T-Beam / T-LoRa boards
- '1a86:7523': { image: `${IMG}/tbeam.svg`, label: 'LoRa radio (CH340 serial)', exact: false },
- '1a86:55d4': { image: `${IMG}/tbeam.svg`, label: 'LoRa radio (CH9102 serial)', exact: false },
+ '1a86:7523': { model: 'tbeam', label: 'LoRa radio (CH340 serial)', exact: false },
+ '1a86:55d4': { model: 'tbeam', label: 'LoRa radio (CH9102 serial)', exact: false },
// Espressif native USB (S3 boards: T3-S3, T-Deck without product str)
- '303a:1001': { image: `${IMG}/tlora-t3s3-v1.svg`, label: 'ESP32-S3 LoRa board', exact: false },
+ '303a:1001': { model: 'tlora-t3s3-v1', label: 'ESP32-S3 LoRa board', exact: false },
// RAK4631 nRF52 native USB
- '239a:8029': { image: `${IMG}/rak4631.svg`, label: 'RAK WisBlock 4631', exact: true },
+ '239a:8029': { model: 'rak4631', label: 'RAK WisBlock 4631', exact: true },
// Raspberry Pi (Pico W)
- '2e8a:0005': { image: `${IMG}/rpipicow.svg`, label: 'Raspberry Pi Pico', exact: false },
+ '2e8a:0005': { model: 'rpipicow', label: 'Raspberry Pi Pico', exact: false },
}
const FALLBACK: MeshDeviceImage = {
- image: `${IMG}/unknown-new.svg`,
+ model: 'unknown-new',
label: 'LoRa mesh radio',
exact: false,
}
@@ -79,7 +76,7 @@ export function resolveMeshDeviceImage(info: DetectedDeviceInfo | undefined): Me
const product = `${info.manufacturer ?? ''} ${info.product ?? ''}`.trim()
if (product) {
for (const m of PRODUCT_MATCHES) {
- if (m.match.test(product)) return { image: m.image, label: m.label, exact: true }
+ if (m.match.test(product)) return { model: m.model, label: m.label, exact: true }
}
}
if (info.vid && info.pid) {
diff --git a/neode-ui/src/views/GoalDetail.vue b/neode-ui/src/views/GoalDetail.vue
index f9fa0115..0b31f777 100644
--- a/neode-ui/src/views/GoalDetail.vue
+++ b/neode-ui/src/views/GoalDetail.vue
@@ -280,13 +280,16 @@ function stepIconUrl(step: GoalStep): string | undefined {
* generic services list. `launchAppId` opens via the app launcher (iframe apps
* overlay on top of the current screen; X-Frame-Options apps open a tab).
*/
-const GOAL_COMPLETION_CTA: Record = {
+const GOAL_COMPLETION_CTA: Record }; launchAppId?: string }> = {
'instant-business-site': { label: 'Open Just Works', launchAppId: 'justworks' },
'open-a-shop': { label: 'Go to my shop (BTCPay)', launchAppId: 'btcpay-server' },
'accept-payments': { label: 'Go to Lightning (LND)', route: '/dashboard/apps/lnd' },
'run-lightning-node': { label: 'View my channels', route: '/dashboard/apps/lnd/channels' },
'setup-fedimint': { label: 'Open Fedimint', launchAppId: 'fedimint' },
- 'file-browser': { label: 'Open File Browser', launchAppId: 'filebrowser' },
+ // Keep the completion action inside Archipelago's native Cloud browser. The
+ // old CTA launched the standalone File Browser app, which lost the Cloud
+ // navigation context and bypassed the original in-app file browser view.
+ 'file-browser': { label: 'Open my cloud files', route: { path: '/dashboard/cloud', query: { tab: 'mine' } } },
'store-files': { label: 'Open my cloud (Nextcloud)', launchAppId: 'nextcloud' },
'create-identity': { label: 'Go to my identity', route: '/dashboard/web5' },
'back-up-everything': { label: 'Go to backups', route: '/dashboard/settings' },
diff --git a/neode-ui/src/views/Server.vue b/neode-ui/src/views/Server.vue
index ea582ebb..27debe9e 100644
--- a/neode-ui/src/views/Server.vue
+++ b/neode-ui/src/views/Server.vue
@@ -163,13 +163,22 @@
-