Merge #a9165a5e: 3D hardware models, NIP-99 discovery, and private phon…
Demo images / Build & push demo images (push) Failing after 41s

3D hardware models, NIP-99 discovery, and private phone goal

nostr:nevent1qqs2j9j6tc3kpsdp3jhwty3wt8yy3un7r93e4d4smjpza4nvt033ctgpz3mhxue69uhhyetvv9ujumn8d96zuer9wcq28amm

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Adds refined 3D hardware illustrations, OpenWrt and private-phone marketplace discovery, and the Setup goal for a non-surveillance phone. Production queries enabled Archipelago relays for NIP-99 kind-30402 listings; development mock cards are used only when DEV is enabled. Tested with production build plus Chromium, Firefox, and WebKit desktop/mobile browser checks. File-browser goal completion now opens the native Cloud files view.
This commit is contained in:
archipelago
2026-10-09 14:59:38 -04:00
52 changed files with 1249 additions and 4509 deletions
+93
View File
@@ -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.
+2
View File
@@ -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;
+6
View File
@@ -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',
+65
View File
@@ -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",
+2
View File
@@ -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",
@@ -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.
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 96 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="795.27 277.13 409.46 1319.35"><defs><style>.cls-1{fill:#353535;}.cls-2{fill:#1e1e1d;}.cls-3{fill:#b1a368;}.cls-10,.cls-11,.cls-4,.cls-6,.cls-8,.cls-9{fill:none;}.cls-4,.cls-6{stroke:#050606;}.cls-10,.cls-11,.cls-4,.cls-6,.cls-8{stroke-miterlimit:10;}.cls-4{stroke-width:2.41px;}.cls-5{fill:#30c2db;}.cls-6{stroke-width:3.91px;}.cls-7{fill:#dcf0f2;}.cls-10,.cls-11,.cls-8{stroke:#dcf0f2;}.cls-8{stroke-width:1.81px;}.cls-9{stroke:#17afbf;stroke-linecap:round;stroke-linejoin:round;stroke-width:7.23px;}.cls-10{stroke-width:1.78px;}.cls-11{stroke-width:1.81px;}</style></defs><g id="Layer_7" data-name="Layer 7"><path class="cls-1" fill="#353535" d="M915.62,278.34h22.61a35,35,0,0,1,35,35V715.74a0,0,0,0,1,0,0H880.6a0,0,0,0,1,0,0V313.36A35,35,0,0,1,915.62,278.34Z"></path><rect class="cls-2" fill="#1e1e1d" x="880.6" y="340.15" width="92.65" height="7.54"></rect><rect class="cls-2" fill="#1e1e1d" x="880.6" y="356.68" width="92.65" height="7.54"></rect><rect class="cls-3" fill="#b1a368" x="885.8" y="844.3" width="84.14" height="19.02"></rect><rect class="cls-3" fill="#b1a368" x="880.6" y="819.07" width="92.65" height="25.23"></rect><rect class="cls-3" fill="#b1a368" x="885.8" y="790.65" width="84.14" height="28.41"></rect><rect class="cls-3" fill="#b1a368" x="880.6" y="723.02" width="92.65" height="67.63"></rect><rect class="cls-3" fill="#b1a368" x="885.8" y="715.74" width="84.14" height="7.28"></rect><rect class="cls-4" stroke-width="2.41px" x="885.8" y="844.3" width="84.14" height="19.02"></rect><rect class="cls-4" stroke-width="2.41px" x="880.6" y="819.07" width="92.65" height="25.23"></rect><rect class="cls-4" stroke-width="2.41px" x="885.8" y="790.65" width="84.14" height="28.41"></rect><rect class="cls-4" stroke-width="2.41px" x="880.6" y="723.02" width="92.65" height="67.63"></rect><rect class="cls-4" stroke-width="2.41px" x="885.8" y="715.74" width="84.14" height="7.28"></rect><path class="cls-4" stroke-width="2.41px" d="M915.62,278.34h22.61a35,35,0,0,1,35,35V715.74a0,0,0,0,1,0,0H880.6a0,0,0,0,1,0,0V313.36A35,35,0,0,1,915.62,278.34Z"></path><rect class="cls-4" stroke-width="2.41px" x="880.6" y="340.15" width="92.65" height="7.54"></rect><rect class="cls-4" stroke-width="2.41px" x="880.6" y="356.68" width="92.65" height="7.54"></rect><rect class="cls-5" fill="#30c2db" x="796.48" y="856.3" width="407.05" height="738.98" rx="47.74"></rect><rect class="cls-1" fill="#353535" x="900.05" y="973.19" width="202.03" height="354.65" rx="16.4"></rect><rect class="cls-6" stroke-width="3.91px" x="900.05" y="973.19" width="202.03" height="354.65" rx="16.4"></rect><rect class="cls-7" fill="#dcf0f2" x="871.51" y="890.41" width="55.42" height="31.12" rx="15.56"></rect><rect class="cls-7" fill="#dcf0f2" x="1070.16" y="890.41" width="55.42" height="31.12" rx="15.56"></rect><rect class="cls-4" stroke-width="2.41px" x="871.51" y="890.41" width="55.42" height="31.12" rx="15.56"></rect><rect class="cls-4" stroke-width="2.41px" x="1070.16" y="890.41" width="55.42" height="31.12" rx="15.56"></rect><circle class="cls-8" stroke-width="1.81px" cx="841.7" cy="1537.01" r="16.25"></circle><circle class="cls-8" stroke-width="1.81px" cx="841.7" cy="913.26" r="16.25"></circle><circle class="cls-8" stroke-width="1.81px" cx="1157.32" cy="913.26" r="16.25"></circle><circle class="cls-8" stroke-width="1.81px" cx="1157.32" cy="1504.51" r="16.25"></circle><line class="cls-9" stroke="#17afbf" stroke-width="7.23px" stroke-linecap="round" stroke-linejoin="round" x1="942.51" y1="1592.42" x2="942.51" y2="1381.55"></line><line class="cls-9" stroke="#17afbf" stroke-width="7.23px" stroke-linecap="round" stroke-linejoin="round" x1="966.52" y1="1592.42" x2="966.52" y2="1381.55"></line><line class="cls-9" stroke="#17afbf" stroke-width="7.23px" stroke-linecap="round" stroke-linejoin="round" x1="990.57" y1="1592.42" x2="990.57" y2="1381.55"></line><line class="cls-9" stroke="#17afbf" stroke-width="7.23px" stroke-linecap="round" stroke-linejoin="round" x1="1014.59" y1="1592.42" x2="1014.59" y2="1381.55"></line><line class="cls-9" stroke="#17afbf" stroke-width="7.23px" stroke-linecap="round" stroke-linejoin="round" x1="1038.63" y1="1592.42" x2="1038.63" y2="1381.55"></line><line class="cls-9" stroke="#17afbf" stroke-width="7.23px" stroke-linecap="round" stroke-linejoin="round" x1="1062.65" y1="1592.42" x2="1062.65" y2="1381.55"></line><rect class="cls-4" stroke-width="2.41px" x="796.48" y="856.3" width="407.05" height="738.98" rx="47.74"></rect><path class="cls-10" stroke-width="1.78px" d="M1040.1,947.74H960.65A13.93,13.93,0,0,1,947,936.64l-10.23-49.2a13.93,13.93,0,0,1,13.64-16.77h97.72a13.93,13.93,0,0,1,13.75,16.18l-8,49.2A13.94,13.94,0,0,1,1040.1,947.74Z"></path><rect class="cls-11" stroke-width="1.81px" x="816.35" y="870.67" width="365.51" height="703.12" rx="32.37"></rect><rect class="cls-11" stroke-width="1.81px" x="888.77" y="963.84" width="223.2" height="374.66" rx="25.21"></rect></g></svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="404.68 390.65 1217.15 959.26"><defs><style>.cls-1{fill:#dfeaf7;}.cls-2{fill:#17907f;}.cls-3{fill:#2b2b2b;}.cls-4,.cls-5{fill:none;stroke:#050606;stroke-miterlimit:10;}.cls-4{stroke-width:2.25px;}.cls-5{stroke-width:4px;}.cls-6{fill:#050606;}</style></defs><g id="Layer_5" data-name="Layer 5"><path class="cls-1" fill="#dfeaf7" d="M1517.73,392.65h0a102.1,102.1,0,0,0-102.1,102.1V770.37A40.62,40.62,0,0,1,1375,811H455.16a48.49,48.49,0,0,0-48.48,48.48v126a11.85,11.85,0,0,0,3.46,8.37l15.34,15.34a11.81,11.81,0,0,1,3.47,8.37v137.16a11.81,11.81,0,0,1-3.47,8.37l-15.34,15.34a11.85,11.85,0,0,0-3.46,8.37v112.67a48.49,48.49,0,0,0,48.48,48.49H1571.34a48.51,48.51,0,0,0,48.49-48.5V494.75A102.1,102.1,0,0,0,1517.73,392.65Zm-110.61,815V954a33.14,33.14,0,0,1,66.27,0v253.65a33.14,33.14,0,0,1-66.27,0Z"></path><path class="cls-2" fill="#17907f" d="M1516,439.16c-30.23.91-53.92,26.54-53.92,56.79V770.37A87.11,87.11,0,0,1,1375,857.48H732.31A27.51,27.51,0,0,0,704.8,885v388.93a27.51,27.51,0,0,0,27.51,27.51h828.38a12.7,12.7,0,0,0,12.65-12.65v-794A55.69,55.69,0,0,0,1516,439.16Zm-108.9,768.47V954a33.14,33.14,0,0,1,66.27,0v253.65a33.14,33.14,0,0,1-66.27,0Z"></path><rect class="cls-3" fill="#2b2b2b" x="787.14" y="943.38" width="429.45" height="224.42"></rect><path class="cls-1" fill="#dfeaf7" d="M1478.6,915.35A54.23,54.23,0,0,0,1386,953.69v254.23a54.23,54.23,0,1,0,108.45,0V953.69A54,54,0,0,0,1478.6,915.35Zm-5.21,292.28a33.14,33.14,0,0,1-66.27,0V954a33.14,33.14,0,0,1,66.27,0Z"></path></g><g id="Layer_2" data-name="Layer 2"><path class="cls-4" stroke-width="2.25px" d="M1573.34,494.75v794a12.68,12.68,0,0,1-12.65,12.65H732.31a27.51,27.51,0,0,1-27.51-27.51V885a27.51,27.51,0,0,1,27.51-27.51H1375a87.11,87.11,0,0,0,87.11-87.11V496c0-30.25,23.69-55.88,53.92-56.79A55.69,55.69,0,0,1,1573.34,494.75Z"></path><path class="cls-5" stroke-width="4px" d="M410.14,1178.39,425.49,1163a11.78,11.78,0,0,0,3.46-8.35V1017.5a11.8,11.8,0,0,0-3.46-8.35l-15.35-15.36a11.77,11.77,0,0,1-3.46-8.35v-126A48.47,48.47,0,0,1,455.16,811H1375a40.63,40.63,0,0,0,40.63-40.63V494.75a102.1,102.1,0,0,1,102.1-102.1h0a102.1,102.1,0,0,1,102.1,102.1v804.66a48.51,48.51,0,0,1-48.49,48.5H455.16a48.48,48.48,0,0,1-48.48-48.49V1186.74A11.78,11.78,0,0,1,410.14,1178.39Z"></path><rect class="cls-4" stroke-width="2.25px" x="1407.12" y="920.85" width="66.26" height="319.9" rx="33.13"></rect><rect class="cls-4" stroke-width="2.25px" x="1386.03" y="899.46" width="108.46" height="362.69" rx="54.23"></rect><path class="cls-6" fill="#050606" d="M639.76,1070.55a2.91,2.91,0,0,1-2.91-2.91v-30.53a5.42,5.42,0,0,0-1.6-3.86l-32.44-32.44a11.86,11.86,0,0,1-3.5-8.44V901a12.52,12.52,0,0,0-12.51-12.51H483.92a12.7,12.7,0,0,0-12.68,12.69v76.78a24.13,24.13,0,0,0,7.11,17.18l14.33,14.33a24.13,24.13,0,0,0,17.18,7.11h50.75a11.86,11.86,0,0,1,8.44,3.5l24.26,24.26a12.47,12.47,0,0,1,3.68,8.88v14.46a2.91,2.91,0,1,1-5.81,0v-14.46a6.72,6.72,0,0,0-2-4.77l-24.26-24.26a6.09,6.09,0,0,0-4.33-1.8H509.86a29.91,29.91,0,0,1-21.29-8.81l-14.33-14.33a29.87,29.87,0,0,1-8.81-21.29V901.14a18.51,18.51,0,0,1,18.49-18.5H586.8A18.34,18.34,0,0,1,605.12,901v91.41a6.09,6.09,0,0,0,1.8,4.33l32.44,32.44a11.19,11.19,0,0,1,3.3,8v30.53A2.9,2.9,0,0,1,639.76,1070.55Z"></path><path class="cls-6" fill="#050606" d="M586.8,1289.46H483.92a18.51,18.51,0,0,1-18.49-18.5v-76.78a29.87,29.87,0,0,1,8.81-21.29l14.33-14.34a29.91,29.91,0,0,1,21.29-8.81h50.75a6.09,6.09,0,0,0,4.33-1.8l24.26-24.25a6.74,6.74,0,0,0,2-4.78v-14.46a2.91,2.91,0,0,1,5.81,0v14.46a12.51,12.51,0,0,1-3.68,8.89l-24.26,24.25a11.86,11.86,0,0,1-8.44,3.5H509.86a24.17,24.17,0,0,0-17.18,7.11L478.35,1177a24.09,24.09,0,0,0-7.11,17.18V1271a12.71,12.71,0,0,0,12.68,12.69H586.8a12.53,12.53,0,0,0,12.51-12.52v-91.4a11.83,11.83,0,0,1,3.5-8.44l32.44-32.44a5.46,5.46,0,0,0,1.6-3.87v-30.53a2.91,2.91,0,0,1,5.81,0V1135a11.19,11.19,0,0,1-3.3,8l-32.44,32.45a6.06,6.06,0,0,0-1.8,4.33v91.4A18.35,18.35,0,0,1,586.8,1289.46Z"></path><rect class="cls-4" stroke-width="2.25px" x="787.14" y="943.38" width="429.45" height="224.42"></rect></g></svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 37 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 46 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 40 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.4 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.2 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 137 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 84 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 32 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 34 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 26 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.6 KiB

@@ -1,502 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 293.56501 470.885"
version="1.1"
id="svg207"
sodipodi:docname="buyer benmeshtastic 2-03.svg"
inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
width="293.565"
height="470.88501"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview207"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="0.96807266"
inkscape:cx="186.96944"
inkscape:cy="370.32344"
inkscape:window-width="1728"
inkscape:window-height="1056"
inkscape:window-x="0"
inkscape:window-y="33"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_2" />
<defs
id="defs1">
<style
id="style1">.cls-1{fill:#474746;}.cls-2{fill:#3a3a3a;}.cls-3{fill:#202121;}.cls-4{fill:#dbdcdd;}.cls-5{fill:#cbcbcc;}.cls-11,.cls-16,.cls-18,.cls-6{fill:none;}.cls-11,.cls-16,.cls-6{stroke:#1d1d1b;stroke-linecap:round;stroke-linejoin:round;}.cls-6{stroke-width:0.95px;}.cls-7{fill:#e5e2e5;}.cls-8{fill:#444e85;}.cls-21,.cls-22,.cls-9{fill:#1d1d1b;}.cls-10{fill:#d1b066;}.cls-11{stroke-width:1.3px;}.cls-12{fill:#d8b258;}.cls-13{fill:#b28e45;}.cls-14{fill:#a6a9af;}.cls-15{fill:#d1c854;}.cls-16{stroke-width:1.01px;}.cls-17{fill:#fff;}.cls-18{stroke:#fff;stroke-miterlimit:10;}.cls-19{fill:#ba9c61;}.cls-20{fill:#715c34;}.cls-21{font-size:13.29px;}.cls-21,.cls-22{font-family:LucidaConsole, Lucida Console;}.cls-22{font-size:8.44px;}</style>
</defs>
<g
id="Layer_2"
data-name="Layer 2"
transform="translate(-106.20269,-157.045)">
<path
class="cls-1" fill="#474746"
d="m 396.14,293.93 v 311.36 a 22,22 0 0 1 -22,22 H 128.65 a 22,22 0 0 1 -22,-22 v -225.7 a 22,22 0 0 1 22,-22 h 69.14 a 22,22 0 0 0 22,-22 v -41.6 a 22,22 0 0 1 22,-22 h 132.32 a 22,22 0 0 1 22.03,21.94 z"
id="path1" />
<path
class="cls-2" fill="#3a3a3a"
d="m 358.92,384 a 23.67,23.67 0 0 1 23.64,23.64 v 176.6 a 23.67,23.67 0 0 1 -23.64,23.64 H 147.31 A 23.67,23.67 0 0 1 123.67,584.24 V 407.61 A 23.67,23.67 0 0 1 147.31,384 h 211.61 m 0,-5 H 147.31 a 28.64,28.64 0 0 0 -28.64,28.64 v 176.6 a 28.65,28.65 0 0 0 28.64,28.64 h 211.61 a 28.64,28.64 0 0 0 28.64,-28.64 V 407.61 A 28.64,28.64 0 0 0 358.92,379 Z"
id="path2" />
<rect
class="cls-2" fill="#3a3a3a"
x="237.61"
y="284.09"
width="139.52"
height="79.300003"
rx="8.3900003"
id="rect2" />
<rect
class="cls-3" fill="#202121"
x="244.67999"
y="289.66"
width="125.32"
height="67.370003"
rx="2"
id="rect3" />
<path
class="cls-2" fill="#3a3a3a"
d="M 151.55,352.34 H 194 a 3.83,3.83 0 0 1 3.83,3.83 v 1.39 h -50.11 v -1.39 a 3.83,3.83 0 0 1 3.83,-3.83 z"
id="path3" />
<path
class="cls-1" fill="#474746"
d="m 240,272 a 3,3 0 0 1 3,-2.69 h 24.48 a 3,3 0 0 1 3,2.61 L 240.25,272"
id="path4" />
<path
class="cls-4" fill="#dbdcdd"
d="m 353.11,172.61 a 54.23,54.23 0 0 0 -32.38,-10.67 h -49 a 54.49,54.49 0 0 0 -54.49,54.48 V 282 h -5.21 v -70 a 54.49,54.49 0 0 1 54.49,-54.48 h 49 a 54.24,54.24 0 0 1 37.59,15.09 z"
id="path5" />
<path
class="cls-5" fill="#cbcbcc"
d="m 370,212 v 51.84 h -21.49 l -9.24,-46.17 V 209 A 19.5,19.5 0 0 0 319.79,189.52 H 260.87 A 19.48,19.48 0 0 0 241.39,209 v 60.74 c -1.23,0.56 -0.86,2.13 -0.86,2.13 l -6.91,1.75 c -5.66,1.43 -7.63,4.78 -9.44,7 -1.81,2.22 -2,1.41 -2,1.41 h -4.89 v -65.61 a 54.49,54.49 0 0 1 54.49,-54.48 h 49 a 54.23,54.23 0 0 1 32.38,10.67 v 0 A 54.3,54.3 0 0 1 370,212 Z"
id="path6" />
<path
class="cls-6" stroke-width="0.95px"
d="m 396.42,323.74 h 1.21 a 1.46,1.46 0 0 1 1.46,1.46 v 2.17 a 1.46,1.46 0 0 1 -1.46,1.46 h -1.21 z"
id="path7" />
<path
class="cls-2" fill="#3a3a3a"
d="M 329.33,586.79 H 198 A 24.51,24.51 0 0 1 173.52,562.31 V 429 A 24.5,24.5 0 0 1 198,404.51 H 329.33 A 24.49,24.49 0 0 1 353.8,429 v 133.31 a 24.5,24.5 0 0 1 -24.47,24.48 z m -128.81,-27 H 326.8 V 431.51 H 200.52 Z"
id="path8" />
<rect
class="cls-7" fill="#e5e2e5"
x="187.02"
y="418.01001"
width="153.28"
height="155.27"
rx="10.97"
id="rect8" />
<path
class="cls-3" fill="#202121"
d="m 166.62,386.56 a 12.85,12.85 0 1 0 12.86,12.85 12.85,12.85 0 0 0 -12.86,-12.85 z m 0,21 a 8.15,8.15 0 1 1 8.15,-8.15 8.14,8.14 0 0 1 -8.15,8.15 z"
id="path9" />
<path
class="cls-2" fill="#3a3a3a"
d="m 174.77,399.41 a 8.15,8.15 0 1 1 -8.15,-8.15 8.15,8.15 0 0 1 8.15,8.15 z"
id="path10" />
<path
class="cls-3" fill="#202121"
d="m 359.9,386.56 a 12.85,12.85 0 1 0 12.86,12.85 12.85,12.85 0 0 0 -12.86,-12.85 z m 0,21 a 8.15,8.15 0 1 1 8.15,-8.15 8.14,8.14 0 0 1 -8.15,8.15 z"
id="path11" />
<path
class="cls-2" fill="#3a3a3a"
d="m 368.05,399.41 a 8.15,8.15 0 1 1 -8.15,-8.15 8.15,8.15 0 0 1 8.15,8.15 z"
id="path12" />
<path
class="cls-3" fill="#202121"
d="m 359.9,579.56 a 12.86,12.86 0 1 0 12.86,12.85 12.85,12.85 0 0 0 -12.86,-12.85 z m 0,21 a 8.15,8.15 0 1 1 8.15,-8.15 8.14,8.14 0 0 1 -8.15,8.15 z"
id="path13" />
<path
class="cls-2" fill="#3a3a3a"
d="m 368.05,592.41 a 8.15,8.15 0 1 1 -8.15,-8.14 8.15,8.15 0 0 1 8.15,8.14 z"
id="path14" />
<path
class="cls-3" fill="#202121"
d="m 166.62,579.56 a 12.86,12.86 0 1 0 12.86,12.85 12.86,12.86 0 0 0 -12.86,-12.85 z m 0,21 a 8.15,8.15 0 1 1 8.15,-8.15 8.14,8.14 0 0 1 -8.15,8.15 z"
id="path15" />
<path
class="cls-2" fill="#3a3a3a"
d="m 174.77,592.41 a 8.15,8.15 0 1 1 -8.15,-8.14 8.15,8.15 0 0 1 8.15,8.14 z"
id="path16" />
<path
class="cls-8" fill="#444e85"
d="m 253.12,315.37 h 5.46 v 15.27 h 8.51 v 5 h -14 z"
id="path17" />
<path
class="cls-8" fill="#444e85"
d="m 269.79,315.37 h 5.48 v 20.25 h -5.48 z"
id="path18" />
<path
class="cls-8" fill="#444e85"
d="m 279.18,315.37 h 5.46 v 15.27 h 8.52 v 5 h -14 z"
id="path19" />
<path
class="cls-8" fill="#444e85"
d="m 292,315.37 h 6 l 3.56,6.84 3.56,-6.84 h 6 l -6.87,11.77 v 8.48 h -5.47 v -8.48 z"
id="path20" />
<path
class="cls-8" fill="#444e85"
d="m 320.33,328.29 v -4.21 h 8.44 v 8.63 a 16.91,16.91 0 0 1 -4.28,2.58 13,13 0 0 1 -4.42,0.68 9.6,9.6 0 0 1 -5.13,-1.23 8,8 0 0 1 -3.07,-3.66 13.45,13.45 0 0 1 -1.09,-5.58 13.15,13.15 0 0 1 1.19,-5.77 8.3,8.3 0 0 1 3.5,-3.72 10,10 0 0 1 4.83,-1 11.86,11.86 0 0 1 4.38,0.61 5.69,5.69 0 0 1 2.41,1.88 8.8,8.8 0 0 1 1.44,3.24 l -5.27,1.08 a 3.24,3.24 0 0 0 -1.1,-1.76 3.15,3.15 0 0 0 -2,-0.6 3.36,3.36 0 0 0 -2.86,1.43 7.54,7.54 0 0 0 -1.07,4.52 c 0,2.19 0.36,3.76 1.08,4.7 a 3.57,3.57 0 0 0 3,1.41 5.11,5.11 0 0 0 1.75,-0.31 9.16,9.16 0 0 0 1.91,-1 v -1.91 z"
id="path21" />
<path
class="cls-8" fill="#444e85"
d="m 331.32,325.51 c 0,-3.3 0.81,-5.88 2.41,-7.72 a 8.46,8.46 0 0 1 6.72,-2.76 8.55,8.55 0 0 1 6.79,2.71 q 2.38,2.72 2.39,7.61 a 14.13,14.13 0 0 1 -1,5.82 8.19,8.19 0 0 1 -3,3.54 8.93,8.93 0 0 1 -4.91,1.26 10,10 0 0 1 -4.94,-1.09 8,8 0 0 1 -3.18,-3.45 12.89,12.89 0 0 1 -1.28,-5.92 z m 5.46,0 a 7.49,7.49 0 0 0 1,4.41 3.44,3.44 0 0 0 5.43,0 q 1,-1.31 1,-4.71 a 6.89,6.89 0 0 0 -1,-4.17 3.25,3.25 0 0 0 -2.73,-1.32 3.13,3.13 0 0 0 -2.65,1.34 7.6,7.6 0 0 0 -1.05,4.48 z"
id="path22" />
<path
class="cls-8" fill="#444e85"
d="m 356,322.64 v -4.46 h 1.89 a 3.22,3.22 0 0 1 1,0.12 1,1 0 0 1 0.52,0.43 1.3,1.3 0 0 1 0.2,0.7 1.18,1.18 0 0 1 -0.3,0.83 1.44,1.44 0 0 1 -0.88,0.42 2,2 0 0 1 0.48,0.37 5,5 0 0 1 0.51,0.72 l 0.55,0.87 h -1.08 l -0.65,-1 a 8,8 0 0 0 -0.47,-0.65 0.82,0.82 0 0 0 -0.28,-0.19 1.54,1.54 0 0 0 -0.45,-0.05 h -0.18 v 1.86 z m 0.9,-2.57 h 0.66 a 3.91,3.91 0 0 0 0.81,-0.05 0.51,0.51 0 0 0 0.26,-0.19 0.59,0.59 0 0 0 0.09,-0.34 0.52,0.52 0 0 0 -0.12,-0.36 0.62,0.62 0 0 0 -0.34,-0.18 h -1.36 z"
id="path23" />
<path
class="cls-8" fill="#444e85"
d="m 357.66,326.28 a 5.71,5.71 0 1 1 5.71,-5.71 5.71,5.71 0 0 1 -5.71,5.71 z m 0,-10.12 a 4.42,4.42 0 1 0 4.41,4.41 4.41,4.41 0 0 0 -4.41,-4.41 z"
id="path24" />
<path
class="cls-9" fill="#1d1d1b"
d="m 135.34,431.15 h 25.89 v 5.73 h -21.49 v 14.25 h -4.4 z"
id="path25" />
<path
class="cls-9" fill="#1d1d1b"
d="m 135.34,455 h 26.1 v 5.73 h -26.1 z"
id="path26" />
<path
class="cls-9" fill="#1d1d1b"
d="m 135.34,466.39 h 25.89 v 5.73 h -21.49 v 14.25 h -4.4 z"
id="path27" />
<path
class="cls-9" fill="#1d1d1b"
d="m 135.34,494.27 h 11 l 15.11,-10.4 v 6.72 l -10.32,6.68 10.32,6.55 v 6.6 L 146.29,500 h -10.95 z"
id="path28" />
<path
class="cls-9" fill="#1d1d1b"
d="m 144.94,526.48 h 4.4 v 12.35 h -10.4 a 15.78,15.78 0 0 1 -2.82,-5.22 20.31,20.31 0 0 1 -1.22,-6.92 16.61,16.61 0 0 1 1.72,-7.77 11.34,11.34 0 0 1 4.91,-5 14.83,14.83 0 0 1 6.95,-1.67 14,14 0 0 1 7.25,1.86 12.12,12.12 0 0 1 4.86,5.44 15.74,15.74 0 0 1 1.3,6.8 q 0,5.28 -2,8.26 a 9.16,9.16 0 0 1 -5.64,3.82 l -1,-5.69 a 5.51,5.51 0 0 0 3,-2.26 7.25,7.25 0 0 0 1.11,-4.13 8.14,8.14 0 0 0 -2.19,-6 q -2.19,-2.21 -6.49,-2.21 c -3.1,0 -5.43,0.75 -7,2.24 a 7.82,7.82 0 0 0 -2.33,5.89 10.72,10.72 0 0 0 0.65,3.61 12.93,12.93 0 0 0 1.58,3.11 h 3.31 z"
id="path29" />
<path
class="cls-9" fill="#1d1d1b"
d="m 148.23,543 a 15.54,15.54 0 0 1 6.7,1.3 12.53,12.53 0 0 1 3.58,2.64 11.41,11.41 0 0 1 2.35,3.67 17,17 0 0 1 1,6.12 q 0,6.27 -3.58,10 -3.58,3.73 -9.95,3.77 -6.33,0 -9.89,-3.74 -3.56,-3.74 -3.57,-10 0,-6.33 3.55,-10.07 3.55,-3.74 9.81,-3.69 z m 0.18,5.91 c -2.95,0 -5.2,0.74 -6.72,2.23 a 8.12,8.12 0 0 0 0,11.28 q 2.28,2.19 6.81,2.19 4.53,0 6.7,-2.14 a 8.43,8.43 0 0 0 0,-11.39 q -2.28,-2.22 -6.79,-2.22 z"
id="path30" />
<path
class="cls-1" fill="#474746"
d="m 384.27,274.39 v -3.8 a 6.7,6.7 0 0 0 -6.71,-6.71 h -73.09 a 6.7,6.7 0 0 0 -6.71,6.71 v 1.31 c 0,0 74.61,0 76.35,0 0.32,0 6.89,-0.2 10.16,2.49 z"
id="path67" />
<path
class="cls-15" fill="#d1c854"
d="m 223.32,282 a 21.84,21.84 0 0 0 -3.5,11.91 v 41.6 a 21.88,21.88 0 0 1 -3,11 L 204.7,339.3 a 8,8 0 0 1 -3.9,-6.86 V 290 a 8,8 0 0 1 8,-8 z"
id="path68" />
<path
class="cls-2" fill="#3a3a3a"
d="m 374.11,275.9 a 18,18 0 0 1 18,18 v 311.39 a 18.05,18.05 0 0 1 -18,18 H 128.65 a 18.05,18.05 0 0 1 -18,-18 v -225.7 a 18,18 0 0 1 18,-18 h 69.14 a 26.05,26.05 0 0 0 26,-26 v -41.6 a 18,18 0 0 1 18,-18 h 132.32 m 0,-4 H 241.85 a 22,22 0 0 0 -22,22 v 41.6 a 22,22 0 0 1 -22,22 h -69.2 a 22,22 0 0 0 -22,22 v 225.7 a 22,22 0 0 0 22,22 h 245.46 a 22,22 0 0 0 22,-22 V 293.93 a 22,22 0 0 0 -22,-22 z"
id="path69" />
</g>
<g
id="Layer_7"
data-name="Layer 7"
transform="translate(-106,-157.045)">
<path
class="cls-11" stroke-width="1.3px"
d="m 396.14,293.93 v 311.35 a 22,22 0 0 1 -22,22 H 128.65 a 22,22 0 0 1 -22,-22 V 379.59 a 22,22 0 0 1 22,-22 h 69.14 a 22,22 0 0 0 22,-22 v -41.66 a 22,22 0 0 1 22,-22 h 132.32 a 22,22 0 0 1 22.03,22 z"
id="path70" />
<rect
class="cls-11" stroke-width="1.3px"
x="118.67"
y="378.97"
width="268.88"
height="233.91"
rx="28.639999"
id="rect70" />
<rect
class="cls-11" stroke-width="1.3px"
x="237.61"
y="284.09"
width="139.52"
height="79.300003"
rx="8.3900003"
id="rect71" />
<rect
class="cls-11" stroke-width="1.3px"
x="244.67999"
y="289.66"
width="125.32"
height="67.370003"
rx="2"
id="rect72" />
<path
class="cls-11" stroke-width="1.3px"
d="M 151.55,352.34 H 194 a 3.83,3.83 0 0 1 3.83,3.83 v 1.39 h -50.11 v -1.39 a 3.83,3.83 0 0 1 3.83,-3.83 z"
id="path72" />
<path
class="cls-16" stroke-width="1.01px"
d="m 240,272 a 3,3 0 0 1 3,-2.69 h 24.48 a 3,3 0 0 1 3,2.61"
id="path73" />
<rect
class="cls-10" fill="#d1b066"
x="322.39001"
y="253.42999"
width="37.77"
height="10.45"
id="rect73" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,250.81 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.63 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path74" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,248.28 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.63 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path75" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,245.75 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path76" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,243.22 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
id="path77" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,240.69 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
id="path78" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,238.17 -1,0.64 a 0.67,0.67 0 0 0 0,1.15 l 1,0.64 26.39,0.09 1,-0.63 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path79" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,235.64 -1,0.64 a 0.67,0.67 0 0 0 0,1.15 l 1,0.64 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path80" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,233.11 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.63 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path81" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,230.58 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path82" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,228.05 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
id="path83" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,225.52 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
id="path84" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,223 -1,0.64 a 0.67,0.67 0 0 0 0,1.15 l 1,0.64 26.39,0.09 1,-0.63 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path85" />
<rect
class="cls-10" fill="#d1b066"
x="328.20001"
y="217.71001"
width="26.27"
height="4.9099998"
id="rect85" />
<rect
class="cls-11" stroke-width="1.3px"
x="322.63"
y="253.42999"
width="18.77"
height="10.45"
id="rect86" />
<rect
class="cls-11" stroke-width="1.3px"
x="341.39999"
y="253.42999"
width="18.77"
height="10.45"
id="rect87" />
<rect
class="cls-6" stroke-width="0.95px"
x="328.20001"
y="217.71001"
width="26.27"
height="5.29"
id="rect88" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,250.81 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.63 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path88" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,248.28 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.63 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path89" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,245.75 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path90" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,243.22 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
id="path91" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,240.69 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
id="path92" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,238.17 -1,0.64 a 0.67,0.67 0 0 0 0,1.15 l 1,0.64 26.39,0.09 1,-0.63 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path93" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,235.64 -1,0.64 a 0.67,0.67 0 0 0 0,1.15 l 1,0.64 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path94" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,233.11 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.63 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path95" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,230.58 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path96" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,228.05 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
id="path97" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,225.52 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
id="path98" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,223 -1,0.64 a 0.67,0.67 0 0 0 0,1.15 l 1,0.64 26.39,0.09 1,-0.63 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path99" />
<path
class="cls-11" stroke-width="1.3px"
d="m 384.27,274.39 v 0 -3.79 a 6.7,6.7 0 0 0 -6.71,-6.71 h -73.09 a 6.7,6.7 0 0 0 -6.71,6.71 v 1.31"
id="path100" />
<path
class="cls-11" stroke-width="1.3px"
d="m 223.32,282 h -14.53 a 8,8 0 0 0 -8,8 v 42.43 a 8,8 0 0 0 3.9,6.86 l 12.16,7.25"
id="path101" />
<path
class="cls-6" stroke-width="0.95px"
d="m 212.06,282 v -70 a 54.49,54.49 0 0 1 54.49,-54.48 h 49 A 54.48,54.48 0 0 1 370,212 v 51.84"
id="path102" />
<path
class="cls-6" stroke-width="0.95px"
d="M 241.39,269.77 V 209 a 19.48,19.48 0 0 1 19.48,-19.48 h 58.92 A 19.48,19.48 0 0 1 339.27,209 v 8.68"
id="path103" />
<path
class="cls-6" stroke-width="0.95px"
d="m 396.42,323.74 h 1.21 a 1.46,1.46 0 0 1 1.46,1.46 v 2.17 a 1.46,1.46 0 0 1 -1.46,1.46 h -1.21 z"
id="path104" />
<rect
class="cls-6" stroke-width="0.95px"
x="187.02"
y="418.01001"
width="153.28"
height="155.27"
rx="10.97"
id="rect104" />
<circle
class="cls-6" stroke-width="0.95px"
cx="166.62"
cy="399.41"
r="8.1499996"
id="circle140" />
<circle
class="cls-6" stroke-width="0.95px"
cx="166.62"
cy="399.41"
r="12.85"
id="circle141" />
<circle
class="cls-6" stroke-width="0.95px"
cx="359.89999"
cy="399.41"
r="8.1499996"
id="circle142" />
<circle
class="cls-6" stroke-width="0.95px"
cx="359.89999"
cy="399.41"
r="12.85"
id="circle143" />
<circle
class="cls-6" stroke-width="0.95px"
cx="359.89999"
cy="592.40997"
r="8.1499996"
id="circle144" />
<circle
class="cls-6" stroke-width="0.95px"
cx="359.89999"
cy="592.40997"
r="12.85"
id="circle145" />
<circle
class="cls-6" stroke-width="0.95px"
cx="166.62"
cy="592.40997"
r="8.1499996"
id="circle146" />
<circle
class="cls-6" stroke-width="0.95px"
cx="166.62"
cy="592.40997"
r="12.85"
id="circle147" />
<path
class="cls-8" fill="#444e85"
d="m 253.12,315.37 h 5.46 v 15.27 h 8.51 v 5 h -14 z"
id="path147" />
<path
class="cls-8" fill="#444e85"
d="m 269.79,315.37 h 5.48 v 20.25 h -5.48 z"
id="path148" />
<path
class="cls-8" fill="#444e85"
d="m 279.18,315.37 h 5.46 v 15.27 h 8.52 v 5 h -14 z"
id="path149" />
<path
class="cls-8" fill="#444e85"
d="m 292,315.37 h 6 l 3.56,6.84 3.56,-6.84 h 6 l -6.87,11.77 v 8.48 h -5.47 v -8.48 z"
id="path150" />
<path
class="cls-8" fill="#444e85"
d="m 320.33,328.29 v -4.21 h 8.44 v 8.63 a 16.91,16.91 0 0 1 -4.28,2.58 13,13 0 0 1 -4.42,0.68 9.6,9.6 0 0 1 -5.13,-1.23 8,8 0 0 1 -3.07,-3.66 13.45,13.45 0 0 1 -1.09,-5.58 13.15,13.15 0 0 1 1.19,-5.77 8.3,8.3 0 0 1 3.5,-3.72 10,10 0 0 1 4.83,-1 11.86,11.86 0 0 1 4.38,0.61 5.69,5.69 0 0 1 2.41,1.88 8.8,8.8 0 0 1 1.44,3.24 l -5.27,1.08 a 3.24,3.24 0 0 0 -1.1,-1.76 3.15,3.15 0 0 0 -2,-0.6 3.36,3.36 0 0 0 -2.86,1.43 7.54,7.54 0 0 0 -1.07,4.52 c 0,2.19 0.36,3.76 1.08,4.7 a 3.57,3.57 0 0 0 3,1.41 5.11,5.11 0 0 0 1.75,-0.31 9.16,9.16 0 0 0 1.91,-1 v -1.91 z"
id="path151" />
<path
class="cls-8" fill="#444e85"
d="m 331.32,325.51 c 0,-3.3 0.81,-5.88 2.41,-7.72 a 8.46,8.46 0 0 1 6.72,-2.76 8.55,8.55 0 0 1 6.79,2.71 q 2.38,2.72 2.39,7.61 a 14.13,14.13 0 0 1 -1,5.82 8.19,8.19 0 0 1 -3,3.54 8.93,8.93 0 0 1 -4.91,1.26 10,10 0 0 1 -4.94,-1.09 8,8 0 0 1 -3.18,-3.45 12.89,12.89 0 0 1 -1.28,-5.92 z m 5.46,0 a 7.49,7.49 0 0 0 1,4.41 3.44,3.44 0 0 0 5.43,0 q 1,-1.31 1,-4.71 a 6.89,6.89 0 0 0 -1,-4.17 3.25,3.25 0 0 0 -2.73,-1.32 3.13,3.13 0 0 0 -2.65,1.34 7.6,7.6 0 0 0 -1.05,4.48 z"
id="path152" />
<path
class="cls-8" fill="#444e85"
d="m 356,322.64 v -4.46 h 1.89 a 3.22,3.22 0 0 1 1,0.12 1,1 0 0 1 0.52,0.43 1.3,1.3 0 0 1 0.2,0.7 1.18,1.18 0 0 1 -0.3,0.83 1.44,1.44 0 0 1 -0.88,0.42 2,2 0 0 1 0.48,0.37 5,5 0 0 1 0.51,0.72 l 0.55,0.87 h -1.08 l -0.65,-1 a 8,8 0 0 0 -0.47,-0.65 0.82,0.82 0 0 0 -0.28,-0.19 1.54,1.54 0 0 0 -0.45,-0.05 h -0.18 v 1.86 z m 0.9,-2.57 h 0.66 a 3.91,3.91 0 0 0 0.81,-0.05 0.51,0.51 0 0 0 0.26,-0.19 0.59,0.59 0 0 0 0.09,-0.34 0.52,0.52 0 0 0 -0.12,-0.36 0.62,0.62 0 0 0 -0.34,-0.18 h -1.36 z"
id="path153" />
<path
class="cls-8" fill="#444e85"
d="m 357.66,326.28 a 5.71,5.71 0 1 1 5.71,-5.71 5.71,5.71 0 0 1 -5.71,5.71 z m 0,-10.12 a 4.42,4.42 0 1 0 4.41,4.41 4.41,4.41 0 0 0 -4.41,-4.41 z"
id="path154" />
<path
class="cls-3" fill="#202121"
d="m 135.34,431.15 h 25.89 v 5.73 h -21.49 v 14.25 h -4.4 z"
id="path155" />
<path
class="cls-3" fill="#202121"
d="m 135.34,455 h 26.1 v 5.73 h -26.1 z"
id="path156" />
<path
class="cls-3" fill="#202121"
d="m 135.34,466.39 h 25.89 v 5.73 h -21.49 v 14.25 h -4.4 z"
id="path157" />
<path
class="cls-3" fill="#202121"
d="m 135.34,494.27 h 11 l 15.11,-10.4 v 6.72 l -10.32,6.68 10.32,6.55 v 6.6 L 146.29,500 h -10.95 z"
id="path158" />
<path
class="cls-3" fill="#202121"
d="m 144.94,526.48 h 4.4 v 12.35 h -10.4 a 15.78,15.78 0 0 1 -2.82,-5.22 20.31,20.31 0 0 1 -1.22,-6.92 16.61,16.61 0 0 1 1.72,-7.77 11.34,11.34 0 0 1 4.91,-5 14.83,14.83 0 0 1 6.95,-1.67 14,14 0 0 1 7.25,1.86 12.12,12.12 0 0 1 4.86,5.44 15.74,15.74 0 0 1 1.3,6.8 q 0,5.28 -2,8.26 a 9.16,9.16 0 0 1 -5.64,3.82 l -1,-5.69 a 5.51,5.51 0 0 0 3,-2.26 7.25,7.25 0 0 0 1.11,-4.13 8.14,8.14 0 0 0 -2.19,-6 q -2.19,-2.21 -6.49,-2.21 c -3.1,0 -5.43,0.75 -7,2.24 a 7.82,7.82 0 0 0 -2.33,5.89 10.72,10.72 0 0 0 0.65,3.61 12.93,12.93 0 0 0 1.58,3.11 h 3.31 z"
id="path159" />
<path
class="cls-3" fill="#202121"
d="m 148.23,543 a 15.54,15.54 0 0 1 6.7,1.3 12.53,12.53 0 0 1 3.58,2.64 11.41,11.41 0 0 1 2.35,3.67 17,17 0 0 1 1,6.12 q 0,6.27 -3.58,10 -3.58,3.73 -9.95,3.77 -6.33,0 -9.89,-3.74 -3.56,-3.74 -3.57,-10 0,-6.33 3.55,-10.07 3.55,-3.74 9.81,-3.69 z m 0.18,5.91 c -2.95,0 -5.2,0.74 -6.72,2.23 a 8.12,8.12 0 0 0 0,11.28 q 2.28,2.19 6.81,2.19 4.53,0 6.7,-2.14 a 8.43,8.43 0 0 0 0,-11.39 q -2.28,-2.22 -6.79,-2.22 z"
id="path160" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 78 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 123 KiB

@@ -1,109 +0,0 @@
<svg xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" version="1.1" id="svg105" sodipodi:docname="thinknode_m1.svg" inkscape:version="1.4 (e7c3feb1, 2024-10-09)" viewBox="397.31 77.24 361 863.17">
<sodipodi:namedview id="namedview105" pagecolor="#ffffff" bordercolor="#000000" borderopacity="0.25" inkscape:showpageshadow="2" inkscape:pageopacity="0.0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" inkscape:zoom="4.066786" inkscape:cx="564.94244" inkscape:cy="741.49463" inkscape:window-width="1472" inkscape:window-height="890" inkscape:window-x="0" inkscape:window-y="38" inkscape:window-maximized="1" inkscape:current-layer="Layer_3"/>
<defs id="defs1">
<style id="style1">.cls-1{fill:#353535;}.cls-2{fill:#262626;}.cls-3{fill:#cccccb;}.cls-4{fill:#2b2b2b;}.cls-5{fill:#f05043;}.cls-6{fill:#3d3d3d;}.cls-7{fill:#231f20;}.cls-8{fill:none;stroke:#000;stroke-miterlimit:10;}</style>
</defs>
<g id="Layer_3" data-name="Layer 3">
<path class="cls-1" fill="#353535" d="M720.82,449.91h11.45a19.68,19.68,0,0,1,19.67,19.68V905.12a28.48,28.48,0,0,1-28.47,28.48H425.72A27.77,27.77,0,0,1,397.81,906V470.82a21,21,0,0,1,21.13-20.91h23.74" id="path1"/>
<rect class="cls-2" fill="#262626" x="447.12" y="523.83" width="266.09" height="266.09" rx="22.7" id="rect1"/>
<rect class="cls-1" fill="#353535" x="465.51" y="542.22" width="229.3" height="229.3" rx="12.91" id="rect2"/>
<rect class="cls-3" fill="#cccccb" x="476.07" y="552.78" width="208.17" height="208.17" rx="7.83" id="rect3"/>
<path class="cls-1" fill="#353535" d="M507.38,77.74H472.16a7,7,0,0,0-7,7V359.93L452.2,396.26v39.91H561V396.26l-13.3-36V84.15a6.41,6.41,0,0,0-6.41-6.41Z" id="path3"/>
<rect class="cls-2" fill="#262626" x="454.25" y="436.17" width="104.38" height="3.65" id="rect4"/>
<polygon class="cls-1" fill="#353535" points="442.68 449.91 448.16 440.69 562.98 440.69 570.51 449.91 442.68 449.91" id="polygon4"/>
<rect class="cls-1" fill="#353535" x="604.37" y="355.96" width="105.26" height="60.65" rx="4.8" id="rect5"/>
<path class="cls-2" fill="#262626" d="M611.2,356v-5.48a3.13,3.13,0,0,1,3.13-3.13h86.35a3.13,3.13,0,0,1,3.13,3.13V356Z" id="path5"/>
<rect class="cls-2" fill="#262626" x="611.07" y="416.61" width="92.74" height="23.22" id="rect6"/>
<polygon class="cls-1" fill="#353535" points="592.99 449.91 598.47 440.69 713.42 440.69 720.82 449.91 592.99 449.91" id="polygon6"/>
<rect class="cls-2" fill="#262626" x="751.94" y="555.13" width="5.87" height="47.48" id="rect7"/>
<path class="cls-2" fill="#262626" d="M751.94,683.87h2.72a3.15,3.15,0,0,1,3.15,3.15v49.17a3.15,3.15,0,0,1-3.15,3.15h-2.72a0,0,0,0,1,0,0V683.87A0,0,0,0,1,751.94,683.87Z" id="path7"/>
<path class="cls-2" fill="#262626" d="M751.94,781.43h2.72a3.15,3.15,0,0,1,3.15,3.15v49.17a3.15,3.15,0,0,1-3.15,3.15h-2.72a0,0,0,0,1,0,0V781.43A0,0,0,0,1,751.94,781.43Z" id="path8"/>
<path class="cls-4" fill="#2b2b2b" d="M425.72,933.6l17.46-41.05a15.2,15.2,0,0,1,14-9.25H702.88A15.19,15.19,0,0,1,717,892.9l15.52,39.22" id="path9"/>
<rect class="cls-2" fill="#262626" x="505.03" y="841.57" width="147.52" height="24.65" rx="12.33" id="rect9"/>
<circle class="cls-5" fill="#f05043" cx="518.72" cy="853.89" r="5.48" id="circle9"/>
<circle class="cls-1" fill="#353535" cx="640.14" cy="853.89" r="5.48" id="circle10"/>
<circle class="cls-1" fill="#353535" cx="541.83" cy="853.89" r="5.48" id="circle11"/>
<circle class="cls-1" fill="#353535" cx="567.67" cy="853.89" r="5.48" id="circle12"/>
<circle class="cls-1" fill="#353535" cx="593.51" cy="853.89" r="5.48" id="circle13"/>
<circle class="cls-1" fill="#353535" cx="616.82" cy="853.89" r="5.48" id="circle14"/>
<path class="cls-4" fill="#2b2b2b" d="M428.2,933.6v4.1a2.21,2.21,0,0,0,2.22,2.21h11.22a2.21,2.21,0,0,0,2.21-2.21v-4.1" id="path14"/>
<path class="cls-4" fill="#2b2b2b" d="M713.2,933.6v4.1a2.21,2.21,0,0,0,2.22,2.21h11.22a2.21,2.21,0,0,0,2.21-2.21v-4.1" id="path15"/>
<path class="cls-6" fill="#3d3d3d" d="M494.46,449.91v5.59a12.22,12.22,0,0,0,1.05,4.95l8.6,19.42a9.43,9.43,0,0,0,8.62,5.61h3.61a9.43,9.43,0,0,0,9.43-9.43V449.91" id="path16"/>
<path class="cls-6" fill="#3d3d3d" d="M672.56,449.91v5.59a12.22,12.22,0,0,1-1,4.95l-8.6,19.42a9.43,9.43,0,0,1-8.62,5.61h-3.61a9.43,9.43,0,0,1-9.43-9.43V449.91" id="path17"/>
<path class="cls-6" fill="#3d3d3d" d="M532.42,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,532.42,449.91Z" id="path18"/>
<path class="cls-6" fill="#3d3d3d" d="M559.81,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,559.81,449.91Z" id="path19"/>
<path class="cls-6" fill="#3d3d3d" d="M587.2,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,587.2,449.91Z" id="path20"/>
<path class="cls-6" fill="#3d3d3d" d="M613.81,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,613.81,449.91Z" id="path21"/>
<path class="cls-1" fill="#353535" d="M477,924.32h-3V903.09h-7.46v-2.65h17.9v2.65H477Z" id="path51"/>
<path class="cls-1" fill="#353535" d="M490.59,906.36c0,.43,0,.86,0,1.31s-.07.85-.12,1.2h.2a5.17,5.17,0,0,1,1.44-1.54,7.08,7.08,0,0,1,1.94-.92,7.81,7.81,0,0,1,2.21-.31,8.61,8.61,0,0,1,3.63.68,4.62,4.62,0,0,1,2.19,2.13,8.22,8.22,0,0,1,.73,3.74v11.67h-2.9V912.85a4.72,4.72,0,0,0-1-3.24,3.92,3.92,0,0,0-3.05-1.07,5.65,5.65,0,0,0-3.14.75,4.07,4.07,0,0,0-1.62,2.21,11.17,11.17,0,0,0-.49,3.56v9.26h-2.94V898.91h2.94Z" id="path52"/>
<path class="cls-1" fill="#353535" d="M509.82,899.67a1.73,1.73,0,0,1,1.19.46,2.17,2.17,0,0,1,0,2.82,1.74,1.74,0,0,1-1.19.47,1.77,1.77,0,0,1-1.24-.47,2.24,2.24,0,0,1,0-2.82A1.76,1.76,0,0,1,509.82,899.67Zm1.44,6.73v17.92h-2.94V906.4Z" id="path53"/>
<path class="cls-1" fill="#353535" d="M525.58,906.06a6.8,6.8,0,0,1,4.85,1.56c1.09,1,1.63,2.71,1.63,5v11.67h-2.91V912.85a4.67,4.67,0,0,0-1-3.24,3.88,3.88,0,0,0-3-1.07c-2,0-3.36.56-4.11,1.67a8.54,8.54,0,0,0-1.14,4.82v9.29H517V906.4h2.37l.44,2.44h.16a5.68,5.68,0,0,1,1.49-1.56,6.41,6.41,0,0,1,2-.92A8.13,8.13,0,0,1,525.58,906.06Z" id="path54"/>
<path class="cls-1" fill="#353535" d="M540.53,912.18c0,.36,0,.83,0,1.41s-.07,1.08-.09,1.5h.14l.6-.77.82-1c.28-.34.52-.63.72-.85l5.72-6.05h3.44l-7.26,7.66,7.76,10.26h-3.54L542.57,916l-2,1.78v6.58h-2.91V898.91h2.91Z" id="path55"/>
<path class="cls-1" fill="#353535" d="M574.81,924.32H571.3l-12.78-19.83h-.13c0,.51.08,1.12.11,1.82s.07,1.45.1,2.24,0,1.61,0,2.43v13.34h-2.77V900.44h3.48l12.74,19.77h.13c0-.36-.05-.89-.08-1.6s-.07-1.5-.1-2.35,0-1.62,0-2.34V900.44h2.81Z" id="path56"/>
<path class="cls-1" fill="#353535" d="M596.48,915.33a12.32,12.32,0,0,1-.58,4,8.32,8.32,0,0,1-1.67,2.93,7,7,0,0,1-2.65,1.82,9.31,9.31,0,0,1-3.46.62,8.63,8.63,0,0,1-3.28-.62,7.29,7.29,0,0,1-2.61-1.82,8.57,8.57,0,0,1-1.72-2.93,11.76,11.76,0,0,1-.62-4,11.43,11.43,0,0,1,1-5,7.12,7.12,0,0,1,2.87-3.14,8.76,8.76,0,0,1,4.45-1.09,8.4,8.4,0,0,1,4.3,1.09,7.45,7.45,0,0,1,2.91,3.14A11,11,0,0,1,596.48,915.33Zm-13.54,0a10.93,10.93,0,0,0,.55,3.66,4.82,4.82,0,0,0,1.72,2.39,5.69,5.69,0,0,0,5.95,0,4.78,4.78,0,0,0,1.73-2.39,10.93,10.93,0,0,0,.55-3.66,10.36,10.36,0,0,0-.57-3.65,4.84,4.84,0,0,0-1.72-2.32,5,5,0,0,0-3-.82,4.5,4.5,0,0,0-4,1.8A8.79,8.79,0,0,0,582.94,915.33Z" id="path57"/>
<path class="cls-1" fill="#353535" d="M607.49,924.66a6.67,6.67,0,0,1-5.35-2.33c-1.34-1.54-2-3.86-2-6.94s.67-5.4,2-7a6.74,6.74,0,0,1,5.37-2.36,7.71,7.71,0,0,1,2.44.35,6.37,6.37,0,0,1,1.81,1,6.58,6.58,0,0,1,1.3,1.34h.2c0-.29-.06-.72-.11-1.29s-.09-1-.09-1.36v-7.15H616v25.41h-2.38l-.43-2.4h-.14a6.51,6.51,0,0,1-1.3,1.38,5.9,5.9,0,0,1-1.82,1A7.4,7.4,0,0,1,607.49,924.66Zm.46-2.44c1.9,0,3.23-.52,4-1.56a7.84,7.84,0,0,0,1.16-4.7v-.53a9.84,9.84,0,0,0-1.11-5.14c-.73-1.19-2.09-1.79-4.08-1.79a4,4,0,0,0-3.56,1.89,9.46,9.46,0,0,0-1.19,5.07,9,9,0,0,0,1.19,5A4,4,0,0,0,608,922.22Z" id="path58"/>
<path class="cls-1" fill="#353535" d="M628.62,906.06a7.53,7.53,0,0,1,4,1,6.62,6.62,0,0,1,2.54,2.82,9.69,9.69,0,0,1,.89,4.27v1.77H623.74a6.75,6.75,0,0,0,1.56,4.63,5.42,5.42,0,0,0,4.16,1.59,12.58,12.58,0,0,0,3-.32,16.78,16.78,0,0,0,2.72-.92v2.58a13.84,13.84,0,0,1-2.71.89,16.15,16.15,0,0,1-3.17.28,9.46,9.46,0,0,1-4.5-1,7.15,7.15,0,0,1-3-3.09,10.61,10.61,0,0,1-1.09-5,12,12,0,0,1,1-5,7.33,7.33,0,0,1,6.94-4.38Zm0,2.41a4.26,4.26,0,0,0-3.33,1.36,6.34,6.34,0,0,0-1.45,3.76h9.13a7.05,7.05,0,0,0-.47-2.68,3.94,3.94,0,0,0-1.42-1.79A4.33,4.33,0,0,0,628.59,908.47Z" id="path59"/>
<path class="cls-1" fill="#353535" d="M639.36,913h8.1v2.74h-8.1Z" id="path60"/>
<path class="cls-1" fill="#353535" d="M662.87,924.32,655,903.39h-.13c0,.44.08,1,.12,1.7s.06,1.45.08,2.26,0,1.65,0,2.49v14.48h-2.77V900.44h4.45L664.15,920h.13l7.49-19.57h4.42v23.88h-3V909.64c0-.78,0-1.55,0-2.32s.06-1.5.1-2.18.08-1.25.1-1.72h-.13l-8,20.9Z" id="path61"/>
<path class="cls-1" fill="#353535" d="M688.16,924.32V909.41c0-.63,0-1.3,0-2s0-1.42.07-2.1,0-1.27,0-1.76c-.35.38-.66.69-.92.92s-.6.54-1,.92l-2.41,2-1.57-2,6.26-4.89H691v23.88Z" id="path62"/>
<path class="cls-1" fill="#353535" d="M502.55,894.19l-2.22-2.37a14.1,14.1,0,0,1,18.94-.33l-2.13,2.44a10.9,10.9,0,0,0-7.16-2.69A10.78,10.78,0,0,0,502.55,894.19Z" id="path63"/>
<path class="cls-1" fill="#353535" d="M506.38,897.85l-2.4-2.18a8.08,8.08,0,0,1,11.61-.39l-2.24,2.33a4.85,4.85,0,0,0-7,.24Z" id="path64"/>
</g>
<g id="Layer_2" data-name="Layer 2">
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M472.55,77.74h68.09a7.43,7.43,0,0,1,7.43,7.43V360.26a0,0,0,0,1,0,0h-83a0,0,0,0,1,0,0V85.17A7.43,7.43,0,0,1,472.55,77.74Z" id="path65"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="465.12" y1="123.91" x2="548.07" y2="123.91" id="line65"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="465.12" y1="149.74" x2="548.07" y2="149.74" id="line66"/>
<polyline class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" points="465.12 360.26 452.2 396.26 452.2 436.17 560.99 436.17 560.99 396.26 548.07 360.26" id="polyline66"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="452.2" y1="396.26" x2="560.98" y2="396.26" id="line67"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M449.69,440.17H562a3.26,3.26,0,0,1,2.56,1.55l5.93,8.19H442.68l4-7.49A3.65,3.65,0,0,1,449.69,440.17Z" id="path67"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M600,440.17H712.33a3.24,3.24,0,0,1,2.56,1.55l5.93,8.19H593l4-7.49A3.65,3.65,0,0,1,600,440.17Z" id="path68"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="454.45" y1="436.17" x2="454.45" y2="439.83" id="line68"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="558.64" y1="436.17" x2="558.64" y2="439.83" id="line69"/>
<rect class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x="604.37" y="355.96" width="105.26" height="60.65" rx="4.87" id="rect69"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="611.07" y1="416.61" x2="611.07" y2="439.83" id="line70"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="703.81" y1="416.61" x2="703.81" y2="439.83" id="line71"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M614.2,347.35h86.48a3.13,3.13,0,0,1,3.13,3.13V356a0,0,0,0,1,0,0H611.07a0,0,0,0,1,0,0v-5.48A3.13,3.13,0,0,1,614.2,347.35Z" id="path71"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="570.51" y1="449.91" x2="592.99" y2="449.91" id="line72"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M720.82,449.91h11.45a19.68,19.68,0,0,1,19.67,19.68V905.12a28.48,28.48,0,0,1-28.47,28.48H425.72A27.77,27.77,0,0,1,397.81,906V470.82a21,21,0,0,1,21.13-20.91h23.74" id="path72"/>
<rect class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x="447.12" y="523.83" width="266.09" height="266.09" rx="22.7" id="rect72"/>
<rect class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x="465.51" y="542.22" width="229.3" height="229.3" rx="12.91" id="rect73"/>
<rect class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x="476.07" y="552.78" width="208.17" height="208.17" rx="7.83" id="rect74"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M494.46,449.91v5.59a12.22,12.22,0,0,0,1.05,4.95l8.6,19.42a9.43,9.43,0,0,0,8.62,5.61h3.61a9.43,9.43,0,0,0,9.43-9.43V449.91" id="path74"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M672.56,449.91v5.59a12.22,12.22,0,0,1-1,4.95l-8.6,19.42a9.43,9.43,0,0,1-8.62,5.61h-3.61a9.43,9.43,0,0,1-9.43-9.43V449.91" id="path75"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M532.42,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,532.42,449.91Z" id="path76"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M559.81,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,559.81,449.91Z" id="path77"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M587.2,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,587.2,449.91Z" id="path78"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M613.81,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,613.81,449.91Z" id="path79"/>
<rect class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x="751.94" y="555.13" width="5.87" height="47.48" id="rect79"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M751.94,683.87h2.72a3.15,3.15,0,0,1,3.15,3.15v49.17a3.15,3.15,0,0,1-3.15,3.15h-2.72a0,0,0,0,1,0,0V683.87A0,0,0,0,1,751.94,683.87Z" id="path80"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M751.94,781.43h2.72a3.15,3.15,0,0,1,3.15,3.15v49.17a3.15,3.15,0,0,1-3.15,3.15h-2.72a0,0,0,0,1,0,0V781.43A0,0,0,0,1,751.94,781.43Z" id="path81"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M425.72,933.6l17.46-41.05a15.2,15.2,0,0,1,14-9.25H702.88A15.19,15.19,0,0,1,717,892.9l15.52,39.22" id="path82"/>
<rect class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x="505.03" y="841.57" width="147.52" height="24.65" rx="12.33" id="rect82"/>
<circle class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" cx="518.72" cy="853.89" r="5.48" id="circle82"/>
<circle class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" cx="640.14" cy="853.89" r="5.48" id="circle83"/>
<circle class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" cx="541.83" cy="853.89" r="5.48" id="circle84"/>
<circle class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" cx="567.67" cy="853.89" r="5.48" id="circle85"/>
<circle class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" cx="593.51" cy="853.89" r="5.48" id="circle86"/>
<circle class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" cx="616.82" cy="853.89" r="5.48" id="circle87"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="430.68" y1="572.74" x2="430.68" y2="602.61" id="line87"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="424.42" y1="595.43" x2="424.42" y2="578.11" id="line88"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="438.21" y1="595.43" x2="438.21" y2="578.11" id="line89"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="430.68" y1="644.74" x2="430.68" y2="674.61" id="line90"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="424.42" y1="667.43" x2="424.42" y2="650.11" id="line91"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="438.21" y1="667.43" x2="438.21" y2="650.11" id="line92"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="430.68" y1="716.74" x2="430.68" y2="746.61" id="line93"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="424.42" y1="739.43" x2="424.42" y2="722.11" id="line94"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="438.21" y1="739.43" x2="438.21" y2="722.11" id="line95"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="730.03" y1="572.74" x2="730.03" y2="602.61" id="line96"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="723.77" y1="595.43" x2="723.77" y2="578.11" id="line97"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="737.56" y1="595.43" x2="737.56" y2="578.11" id="line98"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="730.03" y1="644.74" x2="730.03" y2="674.61" id="line99"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="723.77" y1="667.43" x2="723.77" y2="650.11" id="line100"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="737.56" y1="667.43" x2="737.56" y2="650.11" id="line101"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="730.03" y1="716.74" x2="730.03" y2="746.61" id="line102"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="723.77" y1="739.43" x2="723.77" y2="722.11" id="line103"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="737.56" y1="739.43" x2="737.56" y2="722.11" id="line104"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M428.2,933.6v4.1a2.21,2.21,0,0,0,2.22,2.21h11.22a2.21,2.21,0,0,0,2.21-2.21v-4.1" id="path104"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M713.2,933.6v4.1a2.21,2.21,0,0,0,2.22,2.21h11.22a2.21,2.21,0,0,0,2.21-2.21v-4.1" id="path105"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 35 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 30 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.9 KiB

@@ -1,264 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 233.57587 210"
fill="none"
version="1.1"
id="svg22"
sodipodi:docname="unknown-new.svg"
inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
width="233.57587"
height="210"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview22"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="2.6448247"
inkscape:cx="116.45384"
inkscape:cy="98.116143"
inkscape:window-width="1728"
inkscape:window-height="1056"
inkscape:window-x="0"
inkscape:window-y="33"
inkscape:window-maximized="1"
inkscape:current-layer="svg22" />
<!-- Antenna -->
<!-- Radio body - main housing with gradient -->
<defs
id="defs6">
<linearGradient
id="bodyGrad"
x1="29.580399"
y1="34.651325"
x2="207.06279"
y2="212.13371"
gradientTransform="matrix(0.84515425,0,0,1.183216,16.289544,-41)"
gradientUnits="userSpaceOnUse">
<stop
offset="0%"
style="stop-color:#22222a"
id="stop2" />
<stop
offset="100%"
style="stop-color:#16161b"
id="stop3" />
</linearGradient>
<linearGradient
id="screenGrad"
x1="36.829224"
y1="105.71089"
x2="36.829224"
y2="201.38222"
gradientTransform="matrix(1.1811273,0,0,0.84664878,16.289544,-41)"
gradientUnits="userSpaceOnUse">
<stop
offset="0%"
style="stop-color:#0c1a12"
id="stop4" />
<stop
offset="100%"
style="stop-color:#061008"
id="stop5" />
</linearGradient>
<filter
id="glow"
x="-0.8"
y="-0.8"
width="2.6"
height="2.6">
<feGaussianBlur
stdDeviation="2"
result="coloredBlur"
id="feGaussianBlur5" />
<feMerge
id="feMerge6">
<feMergeNode
in="coloredBlur"
id="feMergeNode5" />
<feMergeNode
in="SourceGraphic"
id="feMergeNode6" />
</feMerge>
</filter>
</defs>
<rect
x="46.289543"
y="5"
width="140"
height="200"
rx="14"
fill="url(#bodyGrad)"
stroke="#67ea94"
stroke-width="10"
stroke-linecap="round"
stroke-linejoin="round"
id="rect6"
style="fill:url(#bodyGrad)" />
<!-- Inner bezel -->
<rect
x="54.289543"
y="13"
width="124"
height="184"
rx="10"
fill="none"
stroke="#2a2a32"
stroke-width="1"
id="rect7" />
<!-- Top speaker grille -->
<rect
x="71.289543"
y="21"
width="90"
height="20"
rx="3"
fill="#0a0a0f"
id="rect8" />
<g
stroke="#333340"
stroke-width="1"
id="g10"
transform="translate(16.289544,-41)">
<line
x1="62"
y1="68"
x2="138"
y2="68"
id="line8" />
<line
x1="62"
y1="72"
x2="138"
y2="72"
id="line9" />
<line
x1="62"
y1="76"
x2="138"
y2="76"
id="line10" />
</g>
<!-- Screen area with glow effect -->
<rect
x="60.289543"
y="49"
width="112"
height="80"
rx="6"
fill="url(#screenGrad)"
stroke="#67ea94"
stroke-width="1"
opacity="0.8"
id="rect10"
style="fill:url(#screenGrad)" />
<!-- Meshtastic logo centered in screen - using actual logo paths -->
<g
transform="matrix(0.95,0,0,0.95,69.289544,64)"
filter="url(#glow)"
id="g14">
<g
transform="matrix(0.802386,0,0,0.460028,-421.748,-122.127)"
id="g13">
<g
transform="matrix(0.579082,0,0,1.01004,460.975,-39.6867)"
id="g11">
<path
d="m 250.908,330.267 -57.782,84.738 -12.188,-8.311 63.864,-93.657 c 1.372,-2.013 3.651,-3.218 6.087,-3.221 2.437,-0.002 4.717,1.199 6.093,3.21 l 64.012,93.51 -12.173,8.333 z"
fill="#67ea94"
id="path10" />
</g>
<g
transform="matrix(0.582378,0,0,1.01579,485.019,-211.182)"
id="g12">
<path
d="m 87.642,581.398 67.115,-98.421 -12.119,-8.264 -67.115,98.421 z"
fill="#67ea94"
id="path11" />
</g>
</g>
</g>
<!-- Question mark with glow -->
<!-- Control area background -->
<rect
x="60.289543"
y="137"
width="112"
height="60"
rx="6"
fill="#0f0f14"
id="rect14" />
<!-- D-pad style navigation -->
<g
transform="translate(88.289544,167)"
id="g17">
<circle
cx="0"
cy="0"
r="22"
fill="#1a1a20"
stroke="#2a2a32"
stroke-width="1"
id="circle14" />
<path
d="M -6,-16 H 6 V -6 H -6 Z"
fill="#2a2a32"
rx="1"
id="path14" />
<path
d="M -6,6 H 6 V 16 H -6 Z"
fill="#2a2a32"
rx="1"
id="path15" />
<path
d="M -16,-6 H -6 V 6 h -10 z"
fill="#2a2a32"
rx="1"
id="path16" />
<path
d="M 6,-6 H 16 V 6 H 6 Z"
fill="#2a2a32"
rx="1"
id="path17" />
<circle
cx="0"
cy="0"
r="5"
fill="#3a3a42"
id="circle17" />
</g>
<!-- Action button (glowing green) -->
<circle
cx="144.28955"
cy="167"
r="14"
fill="#1a2a1f"
stroke="#67ea94"
stroke-width="2"
id="circle18" />
<circle
cx="144.28955"
cy="167"
r="8"
fill="#67ea94"
filter="url(#glow)"
id="circle19" />
<!-- Status LED -->
<circle
cx="170.26186"
cy="25.547974"
r="3"
fill="#67ea94"
opacity="0.8"
filter="url(#glow)"
id="circle20" />
<!-- Side buttons -->
<!-- USB port indication at bottom -->
</svg>

Before

Width:  |  Height:  |  Size: 6.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+202
View File
@@ -0,0 +1,202 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { createDeviceScene } from '@/graphics/deviceScene'
import { deviceSpecs } from '@/graphics/deviceModels'
const props = withDefaults(defineProps<{
model: string
label: string
fallback?: string
fillViewport?: boolean
glassContainer?: boolean
controls?: boolean
/** Development-only gallery controls. Never rendered in production builds. */
devBrowse?: boolean
}>(), { fallback: '', fillViewport: false, glassContainer: false, controls: true, devBrowse: false })
const host = ref<HTMLElement>()
const frame = ref<HTMLElement>()
const ready = ref(false)
const failed = ref(false)
const paused = ref(false)
const reducedMotion = ref(false)
const currentModel = ref(props.model)
const devModelIds = Object.keys(deviceSpecs)
const devBrowseEnabled = import.meta.env.DEV && props.devBrowse
const dragging = ref(false)
const remainingHeight = ref('360px')
let view: ReturnType<typeof createDeviceScene> | undefined
let observer: IntersectionObserver | undefined
let resizeObserver: ResizeObserver | undefined
let motion: MediaQueryList | undefined
let visible = false
let mounted = false
let generation = 0
let raf = 0
let previous = 0
let lastDraw = 0
let pointer: number | undefined
let lastX = 0
let lastY = 0
function devModelLabel(id: string) { return deviceSpecs[id]?.name ?? id }
function browseModel(offset: number) {
if (!devBrowseEnabled || !view) return
const index = devModelIds.indexOf(currentModel.value)
const next = devModelIds[(index + offset + devModelIds.length) % devModelIds.length]!
currentModel.value = next
view.setModel(next)
view.render()
}
function stop() { cancelAnimationFrame(raf); raf = 0; previous = 0; lastDraw = 0 }
function canSpin() { return view && visible && !document.hidden && !paused.value && !motion?.matches && !dragging.value }
function animate(now: number) {
raf = 0
if (!canSpin()) { previous = 0; return }
// Cap to 30fps; complete a turn in about a minute, independent of refresh rate.
if (now - lastDraw >= 32) {
view!.rotation.y += previous ? Math.min((now - previous) / 1000, .1) * .105 : 0
previous = now; lastDraw = now; view!.render()
}
raf = requestAnimationFrame(animate)
}
function sync() { reducedMotion.value = !!motion?.matches; stop(); if (canSpin()) raf = requestAnimationFrame(animate) }
function layout() {
if (props.fillViewport && frame.value) {
// Use the layout position, not its changing scrolled screen position.
let scroll = 0
for (let el = frame.value.parentElement; el; el = el.parentElement) scroll += el.scrollTop
const top = frame.value.getBoundingClientRect().top + scroll
// Keep a visible page gutter below the showcase; the frame sits inside a
// glass card whose bottom margin must remain visible in the viewport.
remainingHeight.value = `${Math.max(260, window.innerHeight - top - 88)}px`
}
view?.resize()
}
function disposeScene() {
stop(); ready.value = false
view?.canvas.removeEventListener('webglcontextlost', contextLost)
view?.dispose(); view = undefined
}
function contextLost(event: Event) {
event.preventDefault(); failed.value = true; disposeScene()
}
async function init() {
if (!mounted || !visible || view || failed.value) return
const token = ++generation
try {
const { createDeviceScene } = await import('@/graphics/deviceScene')
if (!mounted || token !== generation || !host.value) return
view = createDeviceScene(host.value, props.model)
view.canvas.addEventListener('webglcontextlost', contextLost)
ready.value = true; layout(); sync()
} catch {
if (token === generation) { failed.value = true; disposeScene() }
}
}
function down(event: PointerEvent) {
if (!view || (event.pointerType === 'mouse' && event.button !== 0) || pointer !== undefined) return
pointer = event.pointerId; lastX = event.clientX; lastY = event.clientY
dragging.value = true; stop(); host.value?.setPointerCapture(pointer)
}
function move(event: PointerEvent) {
if (pointer !== event.pointerId || !view) return
view.rotation.y += (event.clientX - lastX) * .012
// Touch preserves vertical page scrolling; mouse can also tilt the model.
if (event.pointerType === 'mouse') view.rotation.x = Math.max(-.65, Math.min(.65, view.rotation.x + (event.clientY - lastY) * .008))
lastX = event.clientX; lastY = event.clientY; view.render()
}
function up() { pointer = undefined; dragging.value = false; sync() }
function key(event: KeyboardEvent) {
if (!view || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return
event.preventDefault(); paused.value = true; stop()
if (event.key === 'Home') view.rotation.set(props.model === 'beryl-ax' ? .27 : -.08, -.4, 0)
else if (event.key === 'End') view.rotation.set(props.model === 'beryl-ax' ? .27 : -.08, Math.PI - .4, 0)
else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') view.rotation.y += event.key === 'ArrowLeft' ? -.2 : .2
else view.rotation.x = Math.max(-.65, Math.min(.65, view.rotation.x + (event.key === 'ArrowUp' ? -.12 : .12)))
view.render()
}
watch(paused, sync)
watch(() => props.model, () => {
generation++; up()
currentModel.value = props.model
if (view) {
try { view.setModel(props.model) } catch { failed.value = true; disposeScene() }
} else { failed.value = false; void init() }
})
onMounted(() => {
mounted = true
if (!window.matchMedia || !window.IntersectionObserver || !window.ResizeObserver) {
failed.value = true
return
}
motion = window.matchMedia('(prefers-reduced-motion: reduce)')
motion.addEventListener('change', sync)
document.addEventListener('visibilitychange', sync)
window.addEventListener('resize', layout)
resizeObserver = new ResizeObserver(layout)
if (host.value) resizeObserver.observe(host.value)
if (frame.value?.parentElement) resizeObserver.observe(frame.value.parentElement)
observer = new IntersectionObserver(entries => {
visible = entries.some(entry => entry.isIntersecting)
if (visible) void init()
sync()
})
if (frame.value) observer.observe(frame.value)
layout()
})
onBeforeUnmount(() => {
mounted = false; generation++; observer?.disconnect(); resizeObserver?.disconnect()
motion?.removeEventListener('change', sync)
document.removeEventListener('visibilitychange', sync); window.removeEventListener('resize', layout)
disposeScene()
})
</script>
<template>
<figure ref="frame" class="device-model" :class="{ 'device-model--viewport': fillViewport, 'device-model--glass': glassContainer }"
:style="{ '--device-height': remainingHeight }" :data-model="model" :data-ready="ready">
<div ref="host" class="device-model__stage" :class="{ 'is-dragging': dragging }"
:tabindex="ready ? 0 : -1" role="group" :aria-label="`${label}. Drag or use arrow keys to rotate. Home shows the front; End shows the back.`"
@pointerdown="down" @pointermove="move" @pointerup="up" @pointercancel="up" @lostpointercapture="up" @keydown="key">
<img v-if="!ready && fallback" :src="fallback" :alt="label" class="device-model__fallback" />
<div v-else-if="!ready" class="device-model__placeholder" role="status" aria-label="Loading 3D illustration">
<span class="device-model__loader" aria-hidden="true"><i></i><i></i><i></i></span>
</div>
</div>
<figcaption v-if="controls || devBrowseEnabled" class="device-model__caption">
<span>{{ failed ? label : (devBrowseEnabled ? devModelLabel(currentModel) : 'Drag to explore') }}</span>
<template v-if="devBrowseEnabled">
<button type="button" aria-label="Previous 3D model" @click="browseModel(-1)">Back</button>
<span class="device-model__counter">{{ devModelIds.indexOf(currentModel) + 1 }} / {{ devModelIds.length }}</span>
<button type="button" aria-label="Next 3D model" @click="browseModel(1)">Next</button>
</template>
<button v-if="ready && !reducedMotion" type="button" :aria-label="paused ? 'Resume automatic rotation' : 'Pause automatic rotation'"
:aria-pressed="paused" @click="paused = !paused">{{ paused ? 'Rotate' : 'Pause' }}</button>
</figcaption>
</figure>
</template>
<style scoped>
.device-model { position: relative; width: 100%; height: 240px; margin: 0; isolation: isolate; }
.device-model--glass { padding: 16px 18px 12px; border: 1px solid rgba(255,255,255,.13); border-radius: 14px; background: linear-gradient(135deg, rgba(255,255,255,.075), rgba(255,255,255,.025)); box-shadow: inset 0 1px 0 rgba(255,255,255,.045), 0 18px 55px rgba(0,0,0,.14); }
.device-model--glass::after { content: ''; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; background: linear-gradient(120deg, rgba(255,255,255,.1), transparent 34%, transparent 72%, rgba(255,255,255,.035)); opacity: .45; }
.device-model::before { content: ''; position: absolute; inset: 14% 9%; background: radial-gradient(ellipse, rgba(110,167,180,.09), transparent 68%); pointer-events: none; z-index: -1; }
.device-model__stage { position: absolute; inset: 0 0 28px; cursor: grab; touch-action: pan-y; border-radius: 16px; }
.device-model__stage.is-dragging { cursor: grabbing; }
.device-model__stage:focus-visible { outline: 1px solid rgba(255,255,255,.5); outline-offset: 2px; }
.device-model__stage :deep(canvas) { display: block; width: 100%; height: 100%; }
.device-model__fallback { width: 100%; height: 100%; object-fit: contain; padding: 22px; }
.device-model__placeholder { height: 100%; display: grid; place-items: center; }
.device-model__loader { display: flex; align-items: center; gap: 8px; opacity: .7; }
.device-model__loader i { display: block; width: 7px; height: 7px; border-radius: 999px; background: rgba(140,228,213,.75); animation: device-model-pulse 1.1s ease-in-out infinite; }
.device-model__loader i:nth-child(2) { animation-delay: .14s; }
.device-model__loader i:nth-child(3) { animation-delay: .28s; }
@keyframes device-model-pulse { 0%, 60%, 100% { transform: scale(.65); opacity: .35; } 30% { transform: scale(1); opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .device-model__loader i { animation: none; opacity: .65; } }
.device-model__caption { position: absolute; inset: auto 0 0; display: flex; justify-content: center; align-items: center; gap: 14px; color: rgba(255,255,255,.4); font-size: 11px; }
.device-model__caption button { padding: 4px 9px; color: rgba(255,255,255,.65); border: 1px solid rgba(255,255,255,.12); border-radius: 20px; background: transparent; cursor: pointer; }
.device-model__counter { color: rgba(255,255,255,.3); font-variant-numeric: tabular-nums; }
.device-model--viewport { height: 290px; }
@media (min-width: 1024px) { .device-model--viewport { height: var(--device-height); min-height: 260px; } }
</style>
+1
View File
@@ -94,6 +94,7 @@ function goalIcon(icon: string): string {
lightning: '⚡',
identity: '🔑',
backup: '💾',
phone: '📱',
}
return icons[icon] || '📦'
}
@@ -0,0 +1,233 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'
import { rpcClient } from '@/api/rpc-client'
const props = withDefaults(defineProps<{ expanded?: boolean; kind?: 'router' | 'phone' }>(), { expanded: false, kind: 'router' })
const emit = defineEmits<{ open: []; close: [] }>()
interface HardwareListing {
id: string
pubkey: string
title: string
summary: string
content: string
location: string
status: string
image: string
price?: { amount: string; currency: string; frequency?: string }
relay: string
}
const relayUrls = ref<string[]>([])
const DEV_ROUTER_LISTINGS: HardwareListing[] = [
{ id: 'dev-beryl-ax', pubkey: 'archipelago-demo-seller', title: 'GL.iNet Beryl AX (GL-MT3000)', summary: 'Wi‑Fi 6 travel router with OpenWrt, WireGuard and mesh-ready firmware.', content: '', location: 'London, UK', status: 'active', image: '/assets/img/router-mockups/beryl-ax.webp', price: { amount: '89', currency: 'EUR' }, relay: 'development mock' },
{ id: 'dev-flint-2', pubkey: 'openwrt-demo-seller', title: 'GL.iNet Flint 2', summary: 'Dual 2.5G Ethernet OpenWrt router for a home gateway or node controller.', content: '', location: 'Berlin, DE', status: 'active', image: '/assets/img/router-mockups/flint-2.webp', price: { amount: '159', currency: 'EUR' }, relay: 'development mock' },
{ id: 'dev-nano-pi', pubkey: 'community-demo-seller', title: 'NanoPi R5S OpenWrt gateway', summary: 'Compact RK3568 gateway with three Ethernet ports and community support.', content: '', location: 'Amsterdam, NL', status: 'active', image: '/assets/img/router-mockups/nanopi-r5s.webp', price: { amount: '74', currency: 'EUR' }, relay: 'development mock' },
]
const DEV_PHONE_LISTINGS: HardwareListing[] = [
{ id: 'dev-pixel-9', pubkey: 'privacy-demo-seller', title: 'Pixel 9 · GrapheneOS ready', summary: 'Unlocked Pixel with a long update runway and a fresh install path.', content: '', location: 'London, UK', status: 'active', image: '/assets/img/phone-mockups/pixel-9.webp', price: { amount: '499', currency: 'EUR' }, relay: 'development mock' },
{ id: 'dev-pixel-9a', pubkey: 'mobile-demo-seller', title: 'Pixel 9a · unlocked', summary: 'Compact everyday Pixel. Check the bootloader can be unlocked before buying.', content: '', location: 'Berlin, DE', status: 'active', image: '/assets/img/phone-mockups/pixel-9a.webp', price: { amount: '339', currency: 'EUR' }, relay: 'development mock' },
{ id: 'dev-pixel-10', pubkey: 'phone-demo-seller', title: 'Pixel 10 · GrapheneOS ready', summary: 'A newer Pixel for private, independent mobile computing.', content: '', location: 'Amsterdam, NL', status: 'active', image: '/assets/img/phone-mockups/pixel-10.webp', price: { amount: '699', currency: 'EUR' }, relay: 'development mock' },
]
const devListings = computed(() => props.kind === 'phone' ? DEV_PHONE_LISTINGS : DEV_ROUTER_LISTINGS)
const topicTags = computed(() => props.kind === 'phone' ? ['pixel'] : ['openwrt', 'router', 'gl-inet', 'beryl', 'flint'])
const open = ref(false)
const carousel = ref<HTMLElement>()
const listings = ref<HardwareListing[]>([])
const loading = ref(false)
const error = ref('')
let requestId = 0
let sockets: WebSocket[] = []
function tag(event: { tags?: string[][] }, name: string): string {
return event.tags?.find(item => item[0] === name)?.[1]?.trim() ?? ''
}
function imageTag(event: { tags?: string[][] }): string {
return event.tags?.find(item => item[0] === 'image')?.[1]?.trim() ?? ''
}
function matchesHardware(item: { title: string; summary: string; content: string; tags?: string[][] }) {
const tags = item.tags?.flat().join(' ') ?? ''
const text = `${item.title} ${item.summary} ${item.content} ${tags}`
return props.kind === 'phone'
? /\bpixel\b/i.test(text)
: /openwrt|gl[- .]?inet|beryl|flint|nanopi|gl-mt3000|gl-mt6000/i.test(text)
}
function parse(event: {
id?: string; pubkey?: string; content?: string; tags?: string[][]
}, relay: string): HardwareListing | null {
if (!event.id || !event.pubkey || !matchesHardware({ title: tag(event, 'title'), summary: tag(event, 'summary'), content: event.content ?? '', tags: event.tags })) return null
const price = event.tags?.find(item => item[0] === 'price')
return {
id: event.id,
pubkey: event.pubkey,
title: tag(event, 'title') || (props.kind === 'phone' ? 'Pixel phone' : 'OpenWrt router'),
summary: tag(event, 'summary') || (event.content ?? '').split('\n')[0]?.slice(0, 150) || 'Hardware listing',
content: event.content ?? '',
location: tag(event, 'location'),
status: tag(event, 'status') || 'active',
image: imageTag(event),
price: price?.[1] && price[2] ? { amount: price[1], currency: price[2], frequency: price[3] } : undefined,
relay,
}
}
function closeSockets() {
sockets.forEach(socket => socket.close())
sockets = []
}
async function search() {
const run = ++requestId
closeSockets(); loading.value = true; error.value = ''
try {
const configured = await rpcClient.call<{ relays?: Array<{ url?: string; enabled?: boolean }> }>({ method: 'nostr.list-relays', params: {} })
relayUrls.value = (configured.relays ?? [])
.filter(relay => relay.enabled !== false && typeof relay.url === 'string' && /^wss?:\/\//.test(relay.url))
.map(relay => relay.url!.replace(/\/$/, ''))
} catch {
relayUrls.value = []
}
if (run !== requestId) return
if (!relayUrls.value.length) {
if (import.meta.env.DEV) {
listings.value = devListings.value
loading.value = false
return
}
loading.value = false
error.value = 'No Archipelago Nostr relays are configured. Add a relay in Settings → Nostr Relays and try again.'
return
}
const found = new Map<string, HardwareListing>()
await Promise.all(relayUrls.value.map(relay => new Promise<void>(resolve => {
let settled = false
const finish = () => { if (!settled) { settled = true; resolve() } }
let socket: WebSocket
try { socket = new WebSocket(relay) } catch { finish(); return }
sockets.push(socket)
const timer = window.setTimeout(() => { socket.close(); finish() }, 8000)
socket.addEventListener('open', () => {
socket.send(JSON.stringify(['REQ', `archy-${props.kind}-${run}`, {
kinds: [30402],
'#t': topicTags.value,
limit: 100,
}, {
kinds: [30402],
limit: 200,
}]))
})
socket.addEventListener('message', message => {
try {
const packet = JSON.parse(String(message.data)) as [string, string, { id?: string; pubkey?: string; content?: string; tags?: string[][] }]
if (packet[0] === 'EVENT') {
const item = parse(packet[2], relay)
if (item && item.status !== 'sold') found.set(item.id, item)
}
if (packet[0] === 'EOSE') { window.clearTimeout(timer); socket.close(); finish() }
} catch { /* Ignore malformed relay frames. */ }
})
socket.addEventListener('error', () => { window.clearTimeout(timer); finish() })
socket.addEventListener('close', () => { window.clearTimeout(timer); finish() })
})))
if (run !== requestId) return
listings.value = found.size ? [...found.values()] : import.meta.env.DEV ? devListings.value : []
loading.value = false
if (!listings.value.length) error.value = `No active ${props.kind === 'phone' ? 'Pixel phone' : 'OpenWrt router'} listings found on your configured relays.`
}
function openShop() {
open.value = true
if (import.meta.env.DEV) listings.value = devListings.value
emit('open')
void search()
}
function closeShop() { requestId++; open.value = false; closeSockets(); emit('close') }
function moveCarousel(direction: number) {
const list = carousel.value
const card = list?.querySelector<HTMLElement>('.router-marketplace__card')
if (list && card) list.scrollBy({ left: direction * (card.offsetWidth + 16), behavior: 'smooth' })
}
function formatPrice(item: HardwareListing) {
if (!item.price) return 'Price on request'
return `${item.price.amount} ${item.price.currency.toUpperCase()}${item.price.frequency ? ` / ${item.price.frequency}` : ''}`
}
onBeforeUnmount(() => { requestId++; closeSockets() })
</script>
<template>
<section class="router-marketplace lg:mt-0" :class="{ 'router-marketplace--expanded': props.expanded }" aria-labelledby="router-marketplace-title">
<div class="glass-card p-5 md:p-6">
<template v-if="!open">
<p class="text-xs uppercase tracking-[.18em] text-white/40">{{ props.kind === 'phone' ? 'Private mobile' : 'OpenWrt gateway' }}</p>
<h2 id="router-marketplace-title" class="text-base font-semibold text-white mt-1">{{ props.kind === 'phone' ? 'Ready for a phone that answers to you?' : 'Not got an OpenWrt router?' }}</h2>
<p class="text-sm text-white/55 mt-2 max-w-2xl">{{ 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.' }}</p>
<button type="button" class="glass-button router-marketplace__cta mt-4" @click="openShop">
{{ props.kind === 'phone' ? 'Buy phone' : 'Buy router' }}
<span aria-hidden="true">→</span>
</button>
</template>
<template v-else>
<div class="flex flex-wrap items-start justify-between gap-3 mb-2">
<div>
<h2 id="router-marketplace-title" class="text-base font-semibold text-white">{{ props.kind === 'phone' ? 'Pixel phones for sale' : 'OpenWrt routers for sale' }}</h2>
<p class="text-xs text-white/50 mt-1">{{ listings[0]?.relay === 'development mock' ? 'Development preview · conceptual images and sample prices' : 'Live NIP-99 listings from Archipelago relays.' }}</p>
</div>
<div class="flex items-center gap-2">
<button type="button" class="router-marketplace__nav" :aria-label="`Previous ${props.kind} listing`" @click="moveCarousel(-1)">‹</button>
<button type="button" class="router-marketplace__nav" :aria-label="`Next ${props.kind} listing`" @click="moveCarousel(1)">›</button>
<button type="button" class="text-xs text-white/45 hover:text-white ml-2" @click="closeShop">Close</button>
</div>
</div>
<div v-if="loading && !listings.length" class="sr-only" aria-live="polite">Loading {{ props.kind }} listings</div>
<div v-else-if="error && !listings.length" class="text-sm text-white/50 py-5 text-center">{{ error }}</div>
<div v-else ref="carousel" class="router-marketplace__results" tabindex="0" :aria-label="`${props.kind === 'phone' ? 'Phone' : 'Router'} listings carousel`">
<article v-for="item in listings" :key="item.id" class="router-marketplace__card">
<img v-if="item.image" :src="item.image" :alt="item.title" class="router-marketplace__image" loading="lazy" />
<div v-else class="router-marketplace__image router-marketplace__image--empty" aria-hidden="true">⌁</div>
<div class="router-marketplace__details">
<h3 class="text-sm font-medium text-white truncate">{{ item.title }}</h3>
<p class="text-xs text-white/55 mt-1 line-clamp-1">{{ item.summary }}</p>
<div class="flex flex-wrap gap-x-3 gap-y-1 mt-3 text-[11px] text-white/40">
<span v-if="item.location">{{ item.location }}</span>
<span>seller {{ item.pubkey.slice(0, 8) }}…</span>
</div>
<div class="router-marketplace__footer">
<strong class="router-marketplace__price">{{ formatPrice(item) }}</strong>
<div class="router-marketplace__actions">
<button type="button" class="router-marketplace__action" disabled title="Listing details are next">View</button>
<button type="button" class="router-marketplace__action router-marketplace__action--buy" disabled title="Checkout is the next step">Buy</button>
</div>
</div>
</div>
</article>
</div>
</template>
</div>
</section>
</template>
<style scoped>
.router-marketplace { margin-top: 16px; }
.router-marketplace--expanded { grid-column: 1 / -1; width: 100%; min-width: 0; height: 100%; min-height: 0; margin-top: 0; }
.router-marketplace--expanded > .glass-card { height: 100%; min-height: 0; display: flex; flex-direction: column; padding: 14px; }
.router-marketplace--expanded .router-marketplace__results { flex: 1 1 0; min-height: 0; }
.router-marketplace__cta { display: inline-flex; align-items: center; gap: 10px; }
.router-marketplace__nav { display: grid; place-items: center; width: 26px; height: 26px; border: 1px solid rgba(255,255,255,.14); border-radius: 50%; color: rgba(255,255,255,.72); font-size: 18px; line-height: 1; }
.router-marketplace__nav:hover { background: rgba(255,255,255,.09); }
@media (max-width: 1023px) {
.router-marketplace__cta { width: 100%; justify-content: center; }
/* The dashboard's fixed mobile Back bar must not cover card actions. */
.router-marketplace--expanded > .glass-card { padding-bottom: 64px; }
}
.router-marketplace__results { display: flex; align-items: stretch; gap: 16px; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scroll-snap-type: x mandatory; scrollbar-width: none; mask-image: linear-gradient(to right, #000 0%, #000 calc(100% - 52px), transparent 100%); }
.router-marketplace__results::-webkit-scrollbar { display: none; }
.router-marketplace__card { display: flex; flex: 0 0 88%; scroll-snap-align: start; flex-direction: column; gap: 12px; min-width: 0; min-height: 0; padding: 12px; border: 1px solid rgba(255,255,255,.09); border-radius: 11px; background: rgba(255,255,255,.035); }
.router-marketplace__details { display: flex; flex: 0 0 auto; min-width: 0; flex-direction: column; }
.router-marketplace__footer { margin-top: auto; padding-top: 8px; }
.router-marketplace__price { display: block; color: rgba(180,244,235,.9); font-size: 18px; }
.router-marketplace__actions { display: flex; gap: 8px; margin-top: 8px; }
.router-marketplace__action { flex: 1; padding: 8px 12px; border: 1px solid rgba(255,255,255,.14); border-radius: 8px; color: rgba(255,255,255,.55); font-size: 12px; }
.router-marketplace__action--buy { border-color: rgba(118,230,191,.35); color: rgba(180,244,235,.9); }
@media (min-width: 768px) {
.router-marketplace__card { flex-basis: min(360px, 44%); }
}
.router-marketplace__image { width: 100%; height: 0; min-height: 0; flex: 1 1 0; object-fit: contain; border-radius: 8px; background: linear-gradient(135deg, rgba(112,214,205,.18), rgba(255,255,255,.04)); }
.router-marketplace__image--empty { display: grid; place-items: center; color: rgba(121,205,215,.6); font-size: 34px; }
</style>
@@ -8,25 +8,11 @@
>
<!-- Step 1: detection graphic + what's currently on the radio -->
<div v-if="step === 1" class="text-center">
<div class="mx-auto my-2 w-40 h-40 relative">
<!-- pulsing signal waves behind the board image -->
<svg viewBox="0 0 160 160" class="absolute inset-0 w-full h-full" aria-hidden="true">
<g class="mesh-detect-waves" stroke="rgba(251,146,60,0.5)" fill="none" stroke-width="2.5" stroke-linecap="round">
<path d="M130 44 a30 30 0 0 1 0 28" />
<path d="M138 36 a42 42 0 0 1 0 44" />
<path d="M146 28 a54 54 0 0 1 0 60" />
</g>
</svg>
<!-- actual board image (vendored from the Meshtastic web flasher) -->
<img
:src="deviceImage.image"
:alt="deviceImage.label"
class="relative w-full h-full object-contain drop-shadow-[0_8px_24px_rgba(251,146,60,0.25)]"
@error="imageFailed = true"
v-if="!imageFailed"
/>
<div v-else class="relative w-full h-full flex items-center justify-center text-5xl">📡</div>
</div>
<DeviceModel
:model="deviceImage.model"
:label="deviceImage.label"
dev-browse
/>
<p class="text-white text-base font-medium">{{ deviceImage.label }}</p>
<p class="text-white/60 text-xs mt-1">
{{ 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
}
</script>
<style scoped>
.mesh-detect-waves path {
animation: mesh-wave-pulse 2.2s ease-out infinite;
opacity: 0;
}
.mesh-detect-waves path:nth-child(2) { animation-delay: 0.35s; }
.mesh-detect-waves path:nth-child(3) { animation-delay: 0.7s; }
@keyframes mesh-wave-pulse {
0% { opacity: 0; }
25% { opacity: 1; }
100% { opacity: 0; }
}
</style>
+7
View File
@@ -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',
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

+406
View File
@@ -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<string, DeviceSpec> = {
'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<string, THREE.BufferGeometry>()
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<string, THREE.Mesh[]>()
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())
},
}
}
+58
View File
@@ -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()
},
}
}
+5
View File
@@ -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',
+35 -38
View File
@@ -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<string, { image: string; label: string; exact: boolean }> = {
const VIDPID_MATCHES: Record<string, { model: string; label: string; exact: boolean }> = {
// 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) {
+5 -2
View File
@@ -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<string, { label: string; route?: string; launchAppId?: string }> = {
const GOAL_COMPLETION_CTA: Record<string, { label: string; route?: string | { path: string; query?: Record<string, string> }; 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' },
+16 -7
View File
@@ -163,13 +163,22 @@
</div>
</template>
</div>
<router-link
to="/dashboard/server/firewall-tunnels"
class="glass-button mt-4 w-full gap-2 rounded-lg text-sm font-medium"
>
Firewalls &amp; tunnels
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
</router-link>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-4">
<router-link
to="/dashboard/server/firewall-tunnels"
class="glass-button w-full gap-2 rounded-lg text-sm font-medium"
>
Firewalls &amp; tunnels
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
</router-link>
<router-link
to="/dashboard/server/openwrt"
class="glass-button w-full gap-2 rounded-lg text-sm font-medium"
>
OpenWrt Gateway
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
</router-link>
</div>
</div>
<FipsNetworkCard />
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { nextTick, ref } from 'vue'
import { useRouter } from 'vue-router'
import BackButton from '@/components/BackButton.vue'
import DeviceModel from '@/components/DeviceModel.vue'
import HardwareMarketplace from '@/components/HardwareMarketplace.vue'
const router = useRouter()
const shopOpen = ref(false)
const showcase = ref<HTMLElement>()
const showcaseHeight = ref<string>()
function openShop() {
const height = showcase.value?.getBoundingClientRect().height
if (height) showcaseHeight.value = `${height}px`
shopOpen.value = true
}
async function closeShop() {
shopOpen.value = false
await nextTick()
showcaseHeight.value = undefined
}
</script>
<template>
<div class="pb-12">
<div class="flex items-center gap-3 mb-6">
<BackButton desktop-margin="mb-0" @click="router.push({ path: '/dashboard', query: { tab: 'setup' } })" />
<h1 class="text-lg font-semibold text-white">Get a non surveillance phone</h1>
</div>
<p class="text-sm text-white/75 max-w-2xl">Your phone should answer to you. Choose a supported Pixel, install GrapheneOS, and keep verified boot on.</p>
<div ref="showcase" class="glass-card p-6 overflow-hidden phone-showcase" :style="showcaseHeight ? { height: showcaseHeight } : undefined">
<div class="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_minmax(280px,360px)] gap-6 items-start phone-showcase__grid">
<DeviceModel v-if="!shopOpen" model="pixel-graphene" label="Black Pixel 9 Pro with the GrapheneOS boot screen" fill-viewport :controls="false" />
<HardwareMarketplace kind="phone" :expanded="shopOpen" @open="openShop" @close="closeShop" />
</div>
</div>
</div>
</template>
<style scoped>
.phone-showcase { margin: 24px 0 48px; }
.phone-showcase__grid { height: 100%; min-height: 0; grid-template-rows: minmax(0, 1fr); }
@media (min-width: 1024px) {
.phone-showcase__grid > :deep(.router-marketplace) { align-self: end; margin-top: 0; }
}
</style>
+44 -3
View File
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
import BackButton from '@/components/BackButton.vue'
import DeviceModel from '@/components/DeviceModel.vue'
import HardwareMarketplace from '@/components/HardwareMarketplace.vue'
const router = useRouter()
function goBack() {
@@ -113,6 +115,19 @@ const host = ref('')
const sshUser = ref('root')
const sshPassword = ref('')
const showConnectForm = ref(false)
const routerShopOpen = ref(false)
const routerCard = ref<HTMLElement>()
const routerCardHeight = ref<string>()
function openRouterShop() {
const height = routerCard.value?.getBoundingClientRect().height
if (height) routerCardHeight.value = `${height}px`
routerShopOpen.value = true
}
async function closeRouterShop() {
routerShopOpen.value = false
await nextTick()
routerCardHeight.value = undefined
}
const connecting = ref(false)
const connectedParams = ref<Record<string, string> | null>(null)
@@ -456,7 +471,7 @@ onMounted(() => {
</script>
<template>
<div class="pb-6">
<div class="pb-12">
<!-- mb-0 overrides the pill's default mb-4, which pushed it above the
title's centerline in this inline header row. -->
<div class="flex items-center gap-3 mb-6">
@@ -465,7 +480,8 @@ onMounted(() => {
</div>
<!-- Connect form -->
<div v-if="showConnectForm" class="glass-card p-6 mb-6">
<template v-if="showConnectForm">
<div class="glass-card p-6 mb-6">
<h2 class="text-sm font-semibold text-white/80 mb-4">Connect to Router</h2>
<div class="space-y-3">
<div>
@@ -530,6 +546,16 @@ onMounted(() => {
<p v-if="error" class="mt-3 text-xs text-red-400">{{ error }}</p>
</div>
<div ref="routerCard" class="glass-card p-6 mb-6 overflow-hidden router-model-card" :style="routerCardHeight ? { height: routerCardHeight } : undefined">
<div class="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_minmax(280px,360px)] gap-6 items-start">
<DeviceModel v-if="!routerShopOpen" model="beryl-ax" label="Beryl AX (GL-MT3000) OpenWrt router" fill-viewport :controls="false" />
<HardwareMarketplace :expanded="routerShopOpen" @open="openRouterShop" @close="closeRouterShop" />
</div>
</div>
<div class="h-16" aria-hidden="true"></div>
</template>
<!-- Loading skeleton -->
<template v-else-if="loading">
<div v-for="i in 3" :key="i" class="glass-card p-6 mb-4 animate-pulse">
@@ -1004,5 +1030,20 @@ onMounted(() => {
</div>
</template>
</div>
</template>
<style scoped>
.router-model-card {
margin: 24px 0 48px;
}
.router-model-card > .grid { height: 100%; min-height: 0; grid-template-rows: minmax(0, 1fr); }
@media (min-width: 1024px) {
.router-model-card > .grid > :deep(.router-marketplace) {
align-self: end;
margin-top: 0;
}
}
</style>