Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5975287d56 | ||
|
|
4dd3681eef |
@@ -23,6 +23,7 @@
|
|||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-router-dom": "^7.17.0",
|
"react-router-dom": "^7.17.0",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"three": "0.160.0",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/three": "0.160.0",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"@vitest/ui": "^4.1.8",
|
"@vitest/ui": "^4.1.8",
|
||||||
"autoprefixer": "^10.5.0",
|
"autoprefixer": "^10.5.0",
|
||||||
|
|||||||
Generated
+41
@@ -32,6 +32,9 @@ importers:
|
|||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.6.0
|
specifier: ^3.6.0
|
||||||
version: 3.6.0
|
version: 3.6.0
|
||||||
|
three:
|
||||||
|
specifier: 0.160.0
|
||||||
|
version: 0.160.0
|
||||||
zustand:
|
zustand:
|
||||||
specifier: ^5.0.14
|
specifier: ^5.0.14
|
||||||
version: 5.0.14(@types/[email protected])([email protected])
|
version: 5.0.14(@types/[email protected])([email protected])
|
||||||
@@ -57,6 +60,9 @@ importers:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: ^19.2.3
|
specifier: ^19.2.3
|
||||||
version: 19.2.3(@types/[email protected])
|
version: 19.2.3(@types/[email protected])
|
||||||
|
'@types/three':
|
||||||
|
specifier: 0.160.0
|
||||||
|
version: 0.160.0
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^6.0.1
|
specifier: ^6.0.1
|
||||||
version: 6.0.2([email protected](@types/[email protected])([email protected]))
|
version: 6.0.2([email protected](@types/[email protected])([email protected]))
|
||||||
@@ -532,6 +538,15 @@ packages:
|
|||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
|
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-jWlbUBovicUKaOYxzgkLlhkiEQJkhCVvg4W2IYD2trqD2om3VK4DGLpHH5zQHNr7RweZK/5re/4IVhbhvxbV9w==}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==}
|
||||||
|
|
||||||
'@typescript-eslint/[email protected]':
|
'@typescript-eslint/[email protected]':
|
||||||
resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==}
|
resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -914,6 +929,9 @@ packages:
|
|||||||
picomatch:
|
picomatch:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
||||||
|
|
||||||
@@ -1172,6 +1190,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||||
engines: {node: '>=8.6'}
|
engines: {node: '>=8.6'}
|
||||||
@@ -1468,6 +1489,9 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
|
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-DLU8lc0zNIPkM7rH5/e1Ks1Z8tWCGRq6g8mPowdDJpw1CFBJMU7UoJjC6PefXW7z//SSl0b2+GCw14LB+uDhng==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||||
|
|
||||||
@@ -2106,6 +2130,17 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
'@types/[email protected]': {}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@types/stats.js': 0.17.4
|
||||||
|
'@types/webxr': 0.5.24
|
||||||
|
fflate: 0.6.11
|
||||||
|
meshoptimizer: 0.18.1
|
||||||
|
|
||||||
|
'@types/[email protected]': {}
|
||||||
|
|
||||||
'@typescript-eslint/[email protected](@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected])':
|
'@typescript-eslint/[email protected](@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
@@ -2518,6 +2553,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -2729,6 +2766,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
braces: 3.0.3
|
braces: 3.0.3
|
||||||
@@ -3012,6 +3051,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
any-promise: 1.3.0
|
any-promise: 1.3.0
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 886 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 556 KiB |
@@ -18,6 +18,8 @@ vi.mock('@/lib/api', () => ({
|
|||||||
liveOnEvent = onEvent
|
liveOnEvent = onEvent
|
||||||
return closeSpy
|
return closeSpy
|
||||||
},
|
},
|
||||||
|
// OpenYourNode (rendered here) reads the runtime mode.
|
||||||
|
getMode: () => Promise.resolve({ localMode: false }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
describe('BuildFlash', () => {
|
describe('BuildFlash', () => {
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useSession } from '@/store/session'
|
import { useSession } from '@/store/session'
|
||||||
|
import { useLocalMode } from '@/lib/useLocalMode'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The browser-reachable board dashboard URL. In self-host/USB (local) mode the
|
||||||
|
* API stores the board's *container-facing* url (host.docker.internal:8080) so
|
||||||
|
* the API container can reach it — but the browser can't resolve that. From the
|
||||||
|
* browser the board's gateway is adb-forwarded to the local host on :8080, so
|
||||||
|
* open it at the current host on :8080.
|
||||||
|
*/
|
||||||
|
function boardHref(nodeUrl: string | null, localMode: boolean | null): string | null {
|
||||||
|
if (localMode) return `${window.location.protocol}//${window.location.hostname}:8080`
|
||||||
|
return nodeUrl
|
||||||
|
}
|
||||||
|
|
||||||
export interface OpenYourNodeProps {
|
export interface OpenYourNodeProps {
|
||||||
/** 'hero' is the big primary CTA used on the setup page; 'inline' is a compact
|
/** 'hero' is the big primary CTA used on the setup page; 'inline' is a compact
|
||||||
* link for reuse inside later module pages. */
|
* link for reuse inside later module pages. */
|
||||||
@@ -16,7 +29,9 @@ export interface OpenYourNodeProps {
|
|||||||
*/
|
*/
|
||||||
export function OpenYourNode({ variant = 'hero', className }: OpenYourNodeProps) {
|
export function OpenYourNode({ variant = 'hero', className }: OpenYourNodeProps) {
|
||||||
const device = useSession((s) => s.device)
|
const device = useSession((s) => s.device)
|
||||||
const canOpen = device.connected && !!device.nodeUrl
|
const localMode = useLocalMode()
|
||||||
|
const nodeHref = boardHref(device.nodeUrl, localMode)
|
||||||
|
const canOpen = device.connected && !!nodeHref
|
||||||
|
|
||||||
if (!canOpen) {
|
if (!canOpen) {
|
||||||
return (
|
return (
|
||||||
@@ -34,7 +49,7 @@ export function OpenYourNode({ variant = 'hero', className }: OpenYourNodeProps)
|
|||||||
if (variant === 'inline') {
|
if (variant === 'inline') {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={device.nodeUrl!}
|
href={nodeHref!}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -49,7 +64,7 @@ export function OpenYourNode({ variant = 'hero', className }: OpenYourNodeProps)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={device.nodeUrl!}
|
href={nodeHref!}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -65,7 +80,7 @@ export function OpenYourNode({ variant = 'hero', className }: OpenYourNodeProps)
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="font-mono text-[10px] text-muted-foreground mt-1 truncate max-w-xs">
|
<div className="font-mono text-[10px] text-muted-foreground mt-1 truncate max-w-xs">
|
||||||
{device.port ? `${device.port} · ` : ''}{device.nodeUrl}
|
{device.port ? `${device.port} · ` : ''}{nodeHref}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="w-2.5 h-2.5 rounded-full bg-teal animate-pulse shrink-0" aria-hidden />
|
<span className="w-2.5 h-2.5 rounded-full bg-teal animate-pulse shrink-0" aria-hidden />
|
||||||
|
|||||||
@@ -0,0 +1,470 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||||
|
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
|
||||||
|
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
|
||||||
|
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
|
||||||
|
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
|
||||||
|
|
||||||
|
export function TowerScene({ night, className }: { night: boolean; className?: string }) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const nightRef = useRef<boolean>(night);
|
||||||
|
|
||||||
|
// keep the ref in sync with the prop so the animate loop can lerp toward it
|
||||||
|
useEffect(() => {
|
||||||
|
nightRef.current = night;
|
||||||
|
}, [night]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
let raf = 0;
|
||||||
|
|
||||||
|
const W = () => container.clientWidth;
|
||||||
|
const H = () => container.clientHeight;
|
||||||
|
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
renderer.setSize(W(), H());
|
||||||
|
renderer.shadowMap.enabled = true;
|
||||||
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||||
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||||
|
renderer.toneMappingExposure = 1.0;
|
||||||
|
container.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(0xb7d2e8);
|
||||||
|
scene.fog = new THREE.FogExp2(0xb7d2e8, 0.0006);
|
||||||
|
|
||||||
|
const pmrem = new THREE.PMREMGenerator(renderer);
|
||||||
|
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
|
||||||
|
|
||||||
|
const camera = new THREE.PerspectiveCamera(45, W() / H(), 0.5, 3000);
|
||||||
|
camera.position.set(135, 78, 135);
|
||||||
|
|
||||||
|
const controls = new OrbitControls(camera, renderer.domElement);
|
||||||
|
controls.enableDamping = true;
|
||||||
|
controls.dampingFactor = 0.06;
|
||||||
|
controls.target.set(0, 42, 0);
|
||||||
|
controls.minDistance = 60;
|
||||||
|
controls.maxDistance = 1500;
|
||||||
|
controls.maxPolarAngle = Math.PI * 0.495;
|
||||||
|
controls.autoRotate = true;
|
||||||
|
controls.autoRotateSpeed = 0.5;
|
||||||
|
|
||||||
|
// ---- lights ----
|
||||||
|
const hemi = new THREE.HemisphereLight(0xcfe4f5, 0x36302a, 0.75);
|
||||||
|
scene.add(hemi);
|
||||||
|
const ambient = new THREE.AmbientLight(0xffffff, 0.25);
|
||||||
|
scene.add(ambient);
|
||||||
|
const sun = new THREE.DirectionalLight(0xfff3e0, 3.3);
|
||||||
|
sun.position.set(150, 200, 90);
|
||||||
|
sun.castShadow = true;
|
||||||
|
sun.shadow.mapSize.set(2048, 2048);
|
||||||
|
sun.shadow.camera.near = 10;
|
||||||
|
sun.shadow.camera.far = 600;
|
||||||
|
const sc = 200;
|
||||||
|
sun.shadow.camera.left = -sc;
|
||||||
|
sun.shadow.camera.right = sc;
|
||||||
|
sun.shadow.camera.top = sc;
|
||||||
|
sun.shadow.camera.bottom = -sc;
|
||||||
|
sun.shadow.bias = -0.0004;
|
||||||
|
scene.add(sun);
|
||||||
|
|
||||||
|
// ---- scene-wide collections (were `this.*`) ----
|
||||||
|
const winMats: THREE.MeshStandardMaterial[] = [];
|
||||||
|
const mainMats: THREE.Material[] = [];
|
||||||
|
const mainMeshes: THREE.Mesh[] = [];
|
||||||
|
const lampMats: THREE.MeshStandardMaterial[] = [];
|
||||||
|
const pointLights: THREE.PointLight[] = [];
|
||||||
|
const pools: THREE.Mesh[] = [];
|
||||||
|
const cars: { headMat: THREE.MeshStandardMaterial; tailMat: THREE.MeshStandardMaterial; poolMat: THREE.Material & { opacity: number } }[] = [];
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
const warm = { r: 255, g: 208, b: 132 };
|
||||||
|
const makeFacade = (cols: number, rows: number, litRatio: number) => {
|
||||||
|
const cell = 40, cw = cols * cell, ch = rows * cell;
|
||||||
|
const base = document.createElement('canvas'); base.width = cw; base.height = ch;
|
||||||
|
const bx = base.getContext('2d')!;
|
||||||
|
bx.fillStyle = '#191d24'; bx.fillRect(0, 0, cw, ch);
|
||||||
|
const lit = document.createElement('canvas'); lit.width = cw; lit.height = ch;
|
||||||
|
const lx = lit.getContext('2d')!; lx.fillStyle = '#000'; lx.fillRect(0, 0, cw, ch);
|
||||||
|
const mg = 5;
|
||||||
|
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
|
||||||
|
const px = c * cell + mg, py = r * cell + mg, pw = cell - 2 * mg, ph = cell - 2 * mg;
|
||||||
|
bx.fillStyle = `rgb(${118 + Math.random() * 22 | 0},${148 + Math.random() * 22 | 0},${176 + Math.random() * 26 | 0})`;
|
||||||
|
bx.fillRect(px, py, pw, ph);
|
||||||
|
if (Math.random() < litRatio) {
|
||||||
|
const b = 0.55 + Math.random() * 0.45;
|
||||||
|
lx.fillStyle = `rgba(${warm.r},${warm.g},${warm.b},${b.toFixed(3)})`;
|
||||||
|
lx.fillRect(px, py, pw, ph);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const map = new THREE.CanvasTexture(base); map.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
const emap = new THREE.CanvasTexture(lit); emap.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
return { map, emap };
|
||||||
|
};
|
||||||
|
|
||||||
|
const roofMat = () => new THREE.MeshStandardMaterial({ color: 0x14171c, metalness: 0.7, roughness: 0.5, transparent: true, opacity: 1 });
|
||||||
|
|
||||||
|
const makeBox = (w: number, h: number, d: number, cols: number, rows: number, litRatio: number, isMain: boolean) => {
|
||||||
|
const { map, emap } = makeFacade(cols, rows, litRatio);
|
||||||
|
const win = new THREE.MeshStandardMaterial({
|
||||||
|
map, emissiveMap: emap, emissive: 0xffffff, emissiveIntensity: 0,
|
||||||
|
metalness: 0.15, roughness: 0.12, envMapIntensity: 1.0, transparent: true, opacity: 1
|
||||||
|
});
|
||||||
|
const rf = roofMat();
|
||||||
|
const geo = new THREE.BoxGeometry(w, h, d);
|
||||||
|
const mesh = new THREE.Mesh(geo, [win, win, rf, rf, win, win]);
|
||||||
|
mesh.castShadow = true; mesh.receiveShadow = true;
|
||||||
|
winMats.push(win);
|
||||||
|
if (isMain) { mainMats.push(win, rf); mainMeshes.push(mesh); }
|
||||||
|
return { mesh, win, rf };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- main tower ----
|
||||||
|
const mainGroup = new THREE.Group();
|
||||||
|
const podium = makeBox(30, 8, 30, 12, 2, 0.55, true);
|
||||||
|
podium.mesh.position.y = 4; mainGroup.add(podium.mesh);
|
||||||
|
|
||||||
|
const tower = makeBox(22, 72, 22, 8, 18, 0.32, true);
|
||||||
|
tower.mesh.position.y = 44; mainGroup.add(tower.mesh);
|
||||||
|
|
||||||
|
const crown = new THREE.Mesh(new THREE.BoxGeometry(16, 6, 16),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0x1b1f26, metalness: 0.85, roughness: 0.35, transparent: true, opacity: 1 }));
|
||||||
|
crown.position.y = 83; crown.castShadow = true; mainGroup.add(crown);
|
||||||
|
mainMats.push(crown.material as THREE.Material); mainMeshes.push(crown);
|
||||||
|
|
||||||
|
const mech = new THREE.Mesh(new THREE.BoxGeometry(9, 4, 9),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0x0f1216, metalness: 0.8, roughness: 0.6, transparent: true, opacity: 1 }));
|
||||||
|
mech.position.y = 88; mech.castShadow = true; mainGroup.add(mech);
|
||||||
|
mainMats.push(mech.material as THREE.Material); mainMeshes.push(mech);
|
||||||
|
|
||||||
|
const antenna = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.5, 16, 12),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0x2a2f37, metalness: 0.9, roughness: 0.4, transparent: true, opacity: 1 }));
|
||||||
|
antenna.position.y = 98; mainGroup.add(antenna);
|
||||||
|
mainMats.push(antenna.material as THREE.Material); mainMeshes.push(antenna);
|
||||||
|
|
||||||
|
const beaconMat = new THREE.MeshStandardMaterial({ color: 0x330000, emissive: 0xff2a1a, emissiveIntensity: 0 });
|
||||||
|
const beacon = new THREE.Mesh(new THREE.SphereGeometry(0.7, 12, 12), beaconMat);
|
||||||
|
beacon.position.y = 106.5; mainGroup.add(beacon);
|
||||||
|
scene.add(mainGroup);
|
||||||
|
|
||||||
|
// wireframe overlay of main building
|
||||||
|
const wireMat = new THREE.LineBasicMaterial({ color: 0x7fe0ff, transparent: true, opacity: 0 });
|
||||||
|
const wireGroup = new THREE.Group();
|
||||||
|
mainMeshes.forEach(mesh => {
|
||||||
|
const wf = new THREE.LineSegments(new THREE.EdgesGeometry(mesh.geometry, 1), wireMat);
|
||||||
|
wf.position.copy(mesh.position); wf.rotation.copy(mesh.rotation);
|
||||||
|
wireGroup.add(wf);
|
||||||
|
});
|
||||||
|
wireGroup.visible = false;
|
||||||
|
scene.add(wireGroup);
|
||||||
|
|
||||||
|
// ---- wireframe interior: per-floor plans ----
|
||||||
|
const floorMat = new THREE.LineBasicMaterial({ color: 0x3dffa0, transparent: true, opacity: 0 });
|
||||||
|
const gridMat = new THREE.LineBasicMaterial({ color: 0x27c47e, transparent: true, opacity: 0 });
|
||||||
|
const interiorGroup = new THREE.Group();
|
||||||
|
const wallPos: number[] = [], gridPos: number[] = [];
|
||||||
|
const seg3 = (arr: number[], x1: number, y1: number, z1: number, x2: number, y2: number, z2: number) => arr.push(x1, y1, z1, x2, y2, z2);
|
||||||
|
const extrude = (segs: number[][], y0: number, h: number) => {
|
||||||
|
for (const [a, b, c, d] of segs) {
|
||||||
|
seg3(wallPos, a, y0, b, c, y0, d);
|
||||||
|
seg3(wallPos, a, y0 + h, b, c, y0 + h, d);
|
||||||
|
seg3(wallPos, a, y0, b, a, y0 + h, b);
|
||||||
|
seg3(wallPos, c, y0, d, c, y0 + h, d);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const gridPlate = (s: number, y0: number) => {
|
||||||
|
const step = 3, yy = y0 + 0.03;
|
||||||
|
for (let x = -s + step; x < s; x += step) seg3(gridPos, x, yy, -s, x, yy, s);
|
||||||
|
for (let z = -s + step; z < s; z += step) seg3(gridPos, -s, yy, z, s, yy, z);
|
||||||
|
};
|
||||||
|
const bath = (segs: number[][], cx: number, cz: number) => {
|
||||||
|
const b = 0.9;
|
||||||
|
segs.push([cx - b, cz - b, cx + b, cz - b], [cx + b, cz - b, cx + b, cz + b],
|
||||||
|
[cx + b, cz + b, cx - b, cz + b], [cx - b, cz + b, cx - b, cz - b], [cx, cz - b, cx, cz + b]);
|
||||||
|
};
|
||||||
|
const makePlan = (s: number, type: string) => {
|
||||||
|
const segs: number[][] = [], R = s - 0.7;
|
||||||
|
segs.push([-R, -R, R, -R], [R, -R, R, R], [R, R, -R, R], [-R, R, -R, -R]);
|
||||||
|
if (type === 'mech') {
|
||||||
|
const n = 6;
|
||||||
|
for (let i = 1; i < n; i++) { const p = -R + 2 * R * i / n; segs.push([p, -R, p, R], [-R, p, R, p]); }
|
||||||
|
return segs;
|
||||||
|
}
|
||||||
|
const cfg = ({ lobby: { c: false, rooms: 1, bath: 0 }, open: { c: true, rooms: 2, bath: 0 },
|
||||||
|
office: { c: true, rooms: 6, bath: 2 }, hotel: { c: true, rooms: 8, bath: 99 },
|
||||||
|
pent: { c: false, rooms: 3, bath: 1 } } as Record<string, { c: boolean; rooms: number; bath: number }>)[type];
|
||||||
|
const rc = (k: number) => -R + 2 * R * (k + 0.5) / cfg.rooms;
|
||||||
|
if (cfg.c) {
|
||||||
|
const ch = 2.0;
|
||||||
|
segs.push([-R, -ch, R, -ch], [-R, ch, R, ch]);
|
||||||
|
for (let k = 1; k < cfg.rooms; k++) { const x = -R + 2 * R * k / cfg.rooms; segs.push([x, -R, x, -ch], [x, ch, x, R]); }
|
||||||
|
for (let k = 0; k < cfg.rooms; k++) {
|
||||||
|
const useBath = cfg.bath === 99 ? true : (cfg.bath === 2 ? (k === 0 || k === cfg.rooms - 1) : false);
|
||||||
|
if (useBath) bath(segs, rc(k), R - 1.4);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let k = 1; k < cfg.rooms; k++) { const x = -R + 2 * R * k / cfg.rooms; segs.push([x, -R, x, R]); }
|
||||||
|
if (cfg.bath) bath(segs, R - 2, R - 2);
|
||||||
|
}
|
||||||
|
return segs;
|
||||||
|
};
|
||||||
|
const pattern = ['lobby', 'lobby', 'open', 'office', 'office', 'office', 'office', 'hotel', 'office', 'office',
|
||||||
|
'mech', 'office', 'office', 'office', 'office', 'hotel', 'office', 'open', 'pent', 'pent'];
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const y0 = i * 4, s = (y0 < 8 ? 15 : 11) - 0.5;
|
||||||
|
extrude(makePlan(s, pattern[i]), y0, 4);
|
||||||
|
gridPlate(s, y0);
|
||||||
|
}
|
||||||
|
const wgeo = new THREE.BufferGeometry();
|
||||||
|
wgeo.setAttribute('position', new THREE.Float32BufferAttribute(wallPos, 3));
|
||||||
|
interiorGroup.add(new THREE.LineSegments(wgeo, floorMat));
|
||||||
|
const ggeo = new THREE.BufferGeometry();
|
||||||
|
ggeo.setAttribute('position', new THREE.Float32BufferAttribute(gridPos, 3));
|
||||||
|
interiorGroup.add(new THREE.LineSegments(ggeo, gridMat));
|
||||||
|
interiorGroup.visible = false;
|
||||||
|
scene.add(interiorGroup);
|
||||||
|
|
||||||
|
// ---- procedural terrain: Turin / Po valley ----
|
||||||
|
const fract = (v: number) => v - Math.floor(v);
|
||||||
|
const hash = (x: number, z: number) => fract(Math.sin(x * 127.1 + z * 311.7) * 43758.5453);
|
||||||
|
const vnoise = (x: number, z: number) => {
|
||||||
|
const xi = Math.floor(x), zi = Math.floor(z), xf = x - xi, zf = z - zi;
|
||||||
|
const u = xf * xf * (3 - 2 * xf), v = zf * zf * (3 - 2 * zf);
|
||||||
|
const a = hash(xi, zi), b = hash(xi + 1, zi), c = hash(xi, zi + 1), d = hash(xi + 1, zi + 1);
|
||||||
|
return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
|
||||||
|
};
|
||||||
|
const fbm = (x: number, z: number) => { let f = 0, amp = 0.5, fr = 1; for (let i = 0; i < 5; i++) { f += amp * vnoise(x * fr, z * fr); fr *= 2; amp *= 0.5; } return f; };
|
||||||
|
const smooth = (a: number, b: number, x: number) => { const t = Math.min(1, Math.max(0, (x - a) / (b - a))); return t * t * (3 - 2 * t); };
|
||||||
|
// height field (planar px,py -> world x=px, z=-py)
|
||||||
|
const HT = (px: number, py: number) => {
|
||||||
|
let h = (fbm(px * 0.0016 + 10, py * 0.0016) - 0.5) * 46;
|
||||||
|
const east = Math.max(0, (px - 300) / 1500);
|
||||||
|
h += east * east * 150 * fbm(px * 0.004, py * 0.004);
|
||||||
|
const north = Math.max(0, (py - 820) / 1200);
|
||||||
|
const ridge = 1 - Math.abs(fbm(px * 0.0022 + 5, py * 0.0022) * 2 - 1);
|
||||||
|
h += Math.pow(north, 1.5) * 600 * (0.35 + 0.65 * ridge);
|
||||||
|
h *= smooth(120, 300, Math.hypot(px, py));
|
||||||
|
return h;
|
||||||
|
};
|
||||||
|
const tGeo = new THREE.PlaneGeometry(4600, 4600, 260, 260);
|
||||||
|
const tp = tGeo.attributes.position, cols: number[] = [];
|
||||||
|
const cGrass = new THREE.Color(0x5c8a39), cDry = new THREE.Color(0x83904c),
|
||||||
|
cForest = new THREE.Color(0x3a5c27), cRock = new THREE.Color(0x6f665a), cSnow = new THREE.Color(0xeef2f6);
|
||||||
|
const tc = new THREE.Color();
|
||||||
|
for (let i = 0; i < tp.count; i++) {
|
||||||
|
const x = tp.getX(i), y = tp.getY(i), h = HT(x, y);
|
||||||
|
tp.setZ(i, h);
|
||||||
|
const nz = fbm(x * 0.02, y * 0.02);
|
||||||
|
if (h < 10) tc.copy(cGrass).lerp(cDry, nz * 0.5);
|
||||||
|
else if (h < 70) tc.copy(cGrass).lerp(cForest, smooth(10, 70, h));
|
||||||
|
else if (h < 300) tc.copy(cForest).lerp(cRock, smooth(70, 300, h));
|
||||||
|
else tc.copy(cRock).lerp(cSnow, smooth(300, 430, h));
|
||||||
|
tc.offsetHSL(0, 0, (nz - 0.5) * 0.06);
|
||||||
|
cols.push(tc.r, tc.g, tc.b);
|
||||||
|
}
|
||||||
|
tGeo.setAttribute('color', new THREE.Float32BufferAttribute(cols, 3));
|
||||||
|
tGeo.rotateX(-Math.PI / 2); tGeo.computeVertexNormals();
|
||||||
|
const terrain = new THREE.Mesh(tGeo, new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 1, metalness: 0 }));
|
||||||
|
terrain.receiveShadow = true; scene.add(terrain);
|
||||||
|
|
||||||
|
// ---- Po river ----
|
||||||
|
const rpts: THREE.Vector3[] = [];
|
||||||
|
for (let k = 0; k <= 24; k++) { const py = -700 + k * 60, px = -260 + 230 * Math.sin(py * 0.004); rpts.push(new THREE.Vector3(px, 2, -py)); }
|
||||||
|
const rGeo = new THREE.TubeGeometry(new THREE.CatmullRomCurve3(rpts), 240, 27, 10, false);
|
||||||
|
const water = new THREE.Mesh(rGeo, new THREE.MeshStandardMaterial({ color: 0x2f6d97, roughness: 0.12, metalness: 0.35 }));
|
||||||
|
water.scale.y = 0.04; water.position.y = 1.3; scene.add(water);
|
||||||
|
|
||||||
|
// ---- surrounding town (Turin low blocks) ----
|
||||||
|
const dummy = new THREE.Object3D();
|
||||||
|
const townPal = [0xb08159, 0xc39a6b, 0x9a6b4a, 0xc7b393, 0xa8875f, 0x8f5f43];
|
||||||
|
const NB = 220, townI = new THREE.InstancedMesh(new THREE.BoxGeometry(1, 1, 1),
|
||||||
|
new THREE.MeshStandardMaterial({ roughness: 0.85, metalness: 0 }), NB);
|
||||||
|
let bi = 0, ba = 0;
|
||||||
|
while (bi < NB && ba < NB * 8) {
|
||||||
|
ba++;
|
||||||
|
const ang = Math.random() * 7, r = 150 + Math.random() * 640, px = Math.cos(ang) * r, py = Math.sin(ang) * r;
|
||||||
|
const h = HT(px, py); if (Math.hypot(px, py) < 140 || h > 45) continue;
|
||||||
|
const bw = 9 + Math.random() * 16, bh = 8 + Math.random() * 28, bd = 9 + Math.random() * 16;
|
||||||
|
dummy.position.set(px, h + bh / 2, -py); dummy.scale.set(bw, bh, bd); dummy.rotation.set(0, Math.random() * 7, 0);
|
||||||
|
dummy.updateMatrix(); townI.setMatrixAt(bi, dummy.matrix);
|
||||||
|
townI.setColorAt(bi, tc.setHex(townPal[bi % townPal.length]));
|
||||||
|
bi++;
|
||||||
|
}
|
||||||
|
townI.count = bi; townI.instanceMatrix.needsUpdate = true; if (townI.instanceColor) townI.instanceColor.needsUpdate = true;
|
||||||
|
scene.add(townI);
|
||||||
|
|
||||||
|
// ---- trees on hills ----
|
||||||
|
const NT = 560;
|
||||||
|
const trunkI = new THREE.InstancedMesh(new THREE.CylinderGeometry(0.6, 0.9, 6, 5),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0x4a3524, roughness: 1 }), NT);
|
||||||
|
const foliI = new THREE.InstancedMesh(new THREE.ConeGeometry(3.2, 9, 7),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0x2f5223, roughness: 1 }), NT);
|
||||||
|
let fi = 0, fa = 0;
|
||||||
|
while (fi < NT && fa < NT * 8) {
|
||||||
|
fa++;
|
||||||
|
const ang = Math.random() * 7, r = 200 + Math.random() * 1050, px = Math.cos(ang) * r, py = Math.sin(ang) * r;
|
||||||
|
const h = HT(px, py); if (Math.hypot(px, py) < 170 || h < 6 || h > 260) continue;
|
||||||
|
const s = 0.7 + Math.random() * 1.1, ry = Math.random() * 7;
|
||||||
|
dummy.rotation.set(0, ry, 0);
|
||||||
|
dummy.position.set(px, h + 3 * s, -py); dummy.scale.set(s, s, s); dummy.updateMatrix(); trunkI.setMatrixAt(fi, dummy.matrix);
|
||||||
|
dummy.position.set(px, h + (6 + 4.5) * s, -py); dummy.scale.set(s, s, s); dummy.updateMatrix(); foliI.setMatrixAt(fi, dummy.matrix);
|
||||||
|
fi++;
|
||||||
|
}
|
||||||
|
trunkI.count = foliI.count = fi;
|
||||||
|
trunkI.instanceMatrix.needsUpdate = true; foliI.instanceMatrix.needsUpdate = true;
|
||||||
|
scene.add(trunkI); scene.add(foliI);
|
||||||
|
|
||||||
|
// ---- grass tufts near base ----
|
||||||
|
const gcv = document.createElement('canvas'); gcv.width = gcv.height = 64;
|
||||||
|
const gx = gcv.getContext('2d')!;
|
||||||
|
for (let i = 0; i < 26; i++) {
|
||||||
|
const bx = 8 + Math.random() * 48;
|
||||||
|
gx.strokeStyle = `rgb(${70 + Math.random() * 40 | 0},${130 + Math.random() * 50 | 0},${50 + Math.random() * 30 | 0})`;
|
||||||
|
gx.lineWidth = 1.5 + Math.random() * 1.5; gx.beginPath(); gx.moveTo(bx, 64);
|
||||||
|
gx.quadraticCurveTo(bx + (Math.random() - 0.5) * 20, 34, bx + (Math.random() - 0.5) * 26, 6 + Math.random() * 10); gx.stroke();
|
||||||
|
}
|
||||||
|
const gTex = new THREE.CanvasTexture(gcv); gTex.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
const NG = 2400;
|
||||||
|
const tuftI = new THREE.InstancedMesh(new THREE.PlaneGeometry(5, 5),
|
||||||
|
new THREE.MeshStandardMaterial({ map: gTex, alphaTest: 0.5, side: THREE.DoubleSide, roughness: 1, color: 0x7aa34e }), NG);
|
||||||
|
let gi = 0, ga = 0;
|
||||||
|
while (gi < NG && ga < NG * 6) {
|
||||||
|
ga++;
|
||||||
|
const ang = Math.random() * 7, r = 120 + Math.random() * 460, px = Math.cos(ang) * r, py = Math.sin(ang) * r;
|
||||||
|
const h = HT(px, py); if (h > 26) continue;
|
||||||
|
dummy.position.set(px, h + 2.4, -py); dummy.scale.set(1, 1, 1); dummy.rotation.set(0, Math.random() * 7, 0);
|
||||||
|
dummy.updateMatrix(); tuftI.setMatrixAt(gi, dummy.matrix); gi++;
|
||||||
|
}
|
||||||
|
tuftI.count = gi; tuftI.instanceMatrix.needsUpdate = true; scene.add(tuftI);
|
||||||
|
const envGround: THREE.MeshStandardMaterial[] = [
|
||||||
|
terrain.material as THREE.MeshStandardMaterial,
|
||||||
|
townI.material as THREE.MeshStandardMaterial,
|
||||||
|
trunkI.material as THREE.MeshStandardMaterial,
|
||||||
|
foliI.material as THREE.MeshStandardMaterial,
|
||||||
|
tuftI.material as THREE.MeshStandardMaterial,
|
||||||
|
water.material as THREE.MeshStandardMaterial,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---- image sky domes (day / night) ----
|
||||||
|
const loader = new THREE.TextureLoader();
|
||||||
|
const skyDome = (url: string) => {
|
||||||
|
const tex = loader.load(url);
|
||||||
|
tex.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
const geo = new THREE.SphereGeometry(2600, 48, 32);
|
||||||
|
const mat = new THREE.MeshBasicMaterial({ map: tex, side: THREE.BackSide, transparent: true, opacity: 1, depthWrite: false, fog: false });
|
||||||
|
const m = new THREE.Mesh(geo, mat);
|
||||||
|
scene.add(m); return m;
|
||||||
|
};
|
||||||
|
const skyDay = skyDome('/skyscraper/sky-day.png');
|
||||||
|
const skyNight = skyDome('/skyscraper/sky-night.png');
|
||||||
|
(skyNight.material as THREE.MeshBasicMaterial).opacity = 0;
|
||||||
|
// subtle cool moonlight fill at night
|
||||||
|
const moonLight = new THREE.DirectionalLight(0x9fb8e6, 0);
|
||||||
|
moonLight.position.set(-520, 430, -720);
|
||||||
|
scene.add(moonLight);
|
||||||
|
|
||||||
|
// ---- composer / bloom ----
|
||||||
|
const composer = new EffectComposer(renderer);
|
||||||
|
composer.addPass(new RenderPass(scene, camera));
|
||||||
|
const bloom = new UnrealBloomPass(new THREE.Vector2(W(), H()), 0.18, 0.7, 0.9);
|
||||||
|
composer.addPass(bloom);
|
||||||
|
|
||||||
|
// ---- resize ----
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
const w = W(), h = H(); if (!w || !h) return;
|
||||||
|
camera.aspect = w / h; camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(w, h); composer.setSize(w, h);
|
||||||
|
});
|
||||||
|
ro.observe(container);
|
||||||
|
|
||||||
|
// ---- animation ----
|
||||||
|
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||||
|
const cDay = new THREE.Color(0xb7d2e8), cNight = new THREE.Color(0x05070d);
|
||||||
|
const fDay = new THREE.Color(0xc6dcec), fNight = new THREE.Color(0x080b14);
|
||||||
|
const clock = new THREE.Clock();
|
||||||
|
|
||||||
|
const skyDayMat = skyDay.material as THREE.MeshBasicMaterial;
|
||||||
|
const skyNightMat = skyNight.material as THREE.MeshBasicMaterial;
|
||||||
|
|
||||||
|
const applyNight = (n: number) => {
|
||||||
|
(scene.background as THREE.Color).copy(cDay).lerp(cNight, n);
|
||||||
|
const fog = scene.fog as THREE.FogExp2;
|
||||||
|
fog.color.copy(fDay).lerp(fNight, n);
|
||||||
|
fog.density = lerp(0.0006, 0.0017, n);
|
||||||
|
hemi.intensity = lerp(0.75, 0.05, n);
|
||||||
|
ambient.intensity = lerp(0.25, 0.03, n);
|
||||||
|
sun.intensity = lerp(3.3, 0.0, n);
|
||||||
|
renderer.toneMappingExposure = lerp(1.0, 1.12, n);
|
||||||
|
const wi = lerp(0.0, 1.55, n), env = lerp(1.0, 0.28, n);
|
||||||
|
winMats.forEach(m => { m.emissiveIntensity = wi; m.envMapIntensity = env; });
|
||||||
|
lampMats.forEach(m => m.emissiveIntensity = lerp(0, 2.4, n));
|
||||||
|
pointLights.forEach(l => l.intensity = lerp(0, 900, n));
|
||||||
|
pools.forEach(p => (p.material as THREE.Material & { opacity: number }).opacity = lerp(0, 0.55, n));
|
||||||
|
cars.forEach(c => {
|
||||||
|
c.headMat.emissiveIntensity = lerp(0, 3, n);
|
||||||
|
c.tailMat.emissiveIntensity = lerp(0, 2.2, n);
|
||||||
|
c.poolMat.opacity = lerp(0, 0.5, n);
|
||||||
|
});
|
||||||
|
bloom.strength = lerp(0.18, 1.0, n);
|
||||||
|
bloom.threshold = lerp(0.9, 0.0, n);
|
||||||
|
skyDayMat.opacity = 1 - n;
|
||||||
|
skyNightMat.opacity = n;
|
||||||
|
moonLight.intensity = n * 0.7;
|
||||||
|
const genv = lerp(1.0, 0.12, n);
|
||||||
|
envGround.forEach(m => { m.envMapIntensity = genv; });
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyWire = (w: number) => {
|
||||||
|
const solid = 1 - w;
|
||||||
|
mainMats.forEach(m => { (m as THREE.Material & { opacity: number }).opacity = solid; m.transparent = true; });
|
||||||
|
mainMeshes.forEach(mesh => { mesh.visible = w < 0.995; });
|
||||||
|
wireGroup.visible = w > 0.005;
|
||||||
|
wireMat.opacity = w;
|
||||||
|
interiorGroup.visible = w > 0.005;
|
||||||
|
floorMat.opacity = w * 1.0;
|
||||||
|
gridMat.opacity = w * 0.72;
|
||||||
|
};
|
||||||
|
|
||||||
|
// day/night lerp state (init to correct mode so it starts right, then slides on prop change)
|
||||||
|
let nl = night ? 1 : 0;
|
||||||
|
let wf = 0; // mode = 'solid' -> wireframe stays 0
|
||||||
|
// control state (was this._ctrl); autoRotate ON, mode solid
|
||||||
|
const ctrl = { mode: 'solid' as const, autoRotate: true };
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
raf = requestAnimationFrame(animate);
|
||||||
|
const dt = Math.min(clock.getDelta(), 0.05);
|
||||||
|
const t = clock.elapsedTime;
|
||||||
|
const k = Math.min(1, dt * 4);
|
||||||
|
nl += ((nightRef.current ? 1 : 0) - nl) * k;
|
||||||
|
wf += (((ctrl.mode as string) === 'wire' ? 1 : 0) - wf) * k;
|
||||||
|
applyNight(nl);
|
||||||
|
applyWire(wf);
|
||||||
|
beaconMat.emissiveIntensity = nl * (0.4 + 0.6 * Math.abs(Math.sin(t * 2.2)));
|
||||||
|
controls.autoRotate = ctrl.autoRotate;
|
||||||
|
controls.update();
|
||||||
|
composer.render();
|
||||||
|
};
|
||||||
|
applyNight(nl); applyWire(wf);
|
||||||
|
if (!disposed) animate();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
ro.disconnect();
|
||||||
|
controls.dispose();
|
||||||
|
renderer.dispose();
|
||||||
|
pmrem.dispose();
|
||||||
|
if (renderer.domElement.parentNode === container) {
|
||||||
|
container.removeChild(renderer.domElement);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <div ref={containerRef} className={className} style={{ width: '100%', height: '100%' }} />;
|
||||||
|
}
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
import { Outlet, Link } from 'react-router-dom'
|
import { lazy, Suspense, useState } from 'react'
|
||||||
|
import { Outlet, Link, useLocation } from 'react-router-dom'
|
||||||
import { useSession } from '@/store/session'
|
import { useSession } from '@/store/session'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import { Stepper } from './Stepper'
|
import { Stepper } from './Stepper'
|
||||||
import { CockpitRail } from './CockpitRail'
|
import { CockpitRail } from './CockpitRail'
|
||||||
|
|
||||||
function ThemeToggle() {
|
// Code-split three.js: the tower chunk only loads when the scene actually renders.
|
||||||
|
const TowerScene = lazy(() =>
|
||||||
|
import('@/components/TowerScene').then((m) => ({ default: m.TowerScene })),
|
||||||
|
)
|
||||||
|
|
||||||
|
function ThemeToggle({ collapsed }: { collapsed: boolean }) {
|
||||||
const theme = useSession((s) => s.theme)
|
const theme = useSession((s) => s.theme)
|
||||||
const setTheme = useSession((s) => s.setTheme)
|
const setTheme = useSession((s) => s.setTheme)
|
||||||
const dark = theme === 'dark'
|
const dark = theme === 'dark'
|
||||||
@@ -12,50 +19,101 @@ function ThemeToggle() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTheme(dark ? 'light' : 'dark')}
|
onClick={() => setTheme(dark ? 'light' : 'dark')}
|
||||||
aria-label={dark ? 'Switch to light theme' : 'Switch to dark theme'}
|
aria-label={dark ? 'Switch to light theme' : 'Switch to dark theme'}
|
||||||
className="rounded-[20px] border border-line px-3 py-1.5 font-mono text-[10px] tracking-[0.08em] text-ink-2 transition-colors hover:border-blue hover:text-blue-ink"
|
title={dark ? 'Light theme' : 'Dark theme'}
|
||||||
|
className={cn(
|
||||||
|
'rounded-[10px] border border-line font-mono text-[10px] tracking-[0.08em] text-ink-2 transition-colors hover:border-blue hover:text-blue-ink',
|
||||||
|
collapsed ? 'px-0 py-2' : 'px-3 py-1.5',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{dark ? '☀ LIGHT' : '☾ DARK'}
|
{collapsed ? (dark ? '☀' : '☾') : dark ? '☀ LIGHT' : '☾ DARK'}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The cockpit shell — a persistent layout wrapping every workshop phase route.
|
* The cockpit shell — a persistent layout wrapping every workshop phase route.
|
||||||
* Header + stepper + a two-pane grid (editorial content via <Outlet/> on the
|
* A collapsible left sidebar carries the brand + the phase stepper + theme; the
|
||||||
* left, the live instrument rail on the right). The rail stays mounted across
|
* center is the editorial content (<Outlet/>); the right pane is the live rail —
|
||||||
* phase navigation, so its live feed never resets.
|
* except on Team Registration, where it's the 3D tower scene that slides from
|
||||||
|
* night to day the moment the board connects.
|
||||||
*/
|
*/
|
||||||
export function CockpitLayout() {
|
export function CockpitLayout() {
|
||||||
const team = useSession((s) => s.team)
|
const team = useSession((s) => s.team)
|
||||||
|
const connected = useSession((s) => s.device.connected)
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
const { pathname } = useLocation()
|
||||||
|
|
||||||
const nodeName = team.name ? team.name.toLowerCase().replace(/\s+/g, '-') : 'crimson-node'
|
const nodeName = team.name ? team.name.toLowerCase().replace(/\s+/g, '-') : 'crimson-node'
|
||||||
|
const showTower = pathname === '/workshop'
|
||||||
|
// Night until the team is named AND the board is connected → then slide to day.
|
||||||
|
const night = !(team.name.trim().length > 0 && connected)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen">
|
||||||
<header className="flex items-center border-b border-line px-10 py-5 print:hidden">
|
{/* ── left sidebar ── */}
|
||||||
<Link to="/" className="font-mono text-xs font-semibold tracking-[0.14em]">
|
<aside
|
||||||
<span className="text-ink">APESS </span>
|
className={cn(
|
||||||
<span className="text-blue">2026</span>
|
'sticky top-0 flex h-screen shrink-0 flex-col border-r border-line bg-surface-soft transition-[width] duration-200 print:hidden',
|
||||||
<span className="text-[var(--muted)]"> · WORKSHOP</span>
|
collapsed ? 'w-[68px]' : 'w-[236px]',
|
||||||
</Link>
|
)}
|
||||||
<div className="ml-auto flex items-center gap-4">
|
>
|
||||||
<span className="font-mono text-[11px] text-[var(--muted)]">
|
<div className={cn('flex items-center border-b border-line py-5', collapsed ? 'justify-center px-2' : 'px-4')}>
|
||||||
{nodeName}
|
<Link to="/" className="font-mono text-xs font-semibold tracking-[0.14em]">
|
||||||
{team.name && <span> · {team.name}</span>}
|
{collapsed ? (
|
||||||
</span>
|
<span className="text-blue">26</span>
|
||||||
<ThemeToggle />
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-ink">APESS </span>
|
||||||
|
<span className="text-blue">2026</span>
|
||||||
|
<span className="text-[var(--muted)]"> · WORKSHOP</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="print:hidden">
|
<div className="flex-1 overflow-y-auto px-3 py-4">
|
||||||
<Stepper />
|
<Stepper collapsed={collapsed} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-auto grid w-full max-w-[1400px] items-start gap-14 px-10 pb-[90px] pt-14 lg:grid-cols-[minmax(0,1fr)_464px] print:block print:p-0">
|
<div className="flex flex-col gap-2 border-t border-line px-3 py-3">
|
||||||
<main className="min-h-[560px] w-full max-w-[660px] print:max-w-none">
|
{!collapsed && (
|
||||||
<Outlet />
|
<span className="truncate px-1 font-mono text-[10px] text-[var(--muted)]">
|
||||||
</main>
|
{nodeName}
|
||||||
<div className="print:hidden">
|
{team.name && <span> · {team.name}</span>}
|
||||||
<CockpitRail />
|
</span>
|
||||||
|
)}
|
||||||
|
<ThemeToggle collapsed={collapsed} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCollapsed((c) => !c)}
|
||||||
|
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
|
className={cn(
|
||||||
|
'rounded-[10px] border border-line font-mono text-[10px] tracking-[0.08em] text-ink-3 transition-colors hover:border-blue hover:text-blue-ink',
|
||||||
|
collapsed ? 'px-0 py-2' : 'px-3 py-1.5 text-left',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{collapsed ? '»' : '« COLLAPSE'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* ── body: content + right pane (tower on registration, else rail) ── */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="grid w-full max-w-[1440px] items-start gap-12 px-10 pb-[90px] pt-10 lg:grid-cols-[minmax(0,1fr)_620px] print:block print:p-0">
|
||||||
|
<main className="min-h-[560px] w-full max-w-[660px] print:max-w-none">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
<div className={cn('print:hidden', showTower && 'self-stretch')}>
|
||||||
|
{showTower ? (
|
||||||
|
<aside className="h-full min-h-[520px] w-full overflow-hidden rounded-[18px] border border-line bg-[#05070d] shadow-[0_20px_50px_-24px_rgba(0,0,0,0.5)]">
|
||||||
|
<Suspense fallback={<div className="h-full w-full bg-[#05070d]" />}>
|
||||||
|
<TowerScene night={night} className="h-full w-full" />
|
||||||
|
</Suspense>
|
||||||
|
</aside>
|
||||||
|
) : (
|
||||||
|
<CockpitRail />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,9 +17,12 @@ const STEPS: Step[] = [
|
|||||||
{ key: 'add', to: '/workshop/add', eyebrow: 'PHASE 5', label: 'Module 3' },
|
{ key: 'add', to: '/workshop/add', eyebrow: 'PHASE 5', label: 'Module 3' },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** The five-phase stepper nav (replaces PhaseStrip). Forward-gated: you can only
|
/**
|
||||||
* jump to a step at or before the current one (advance via the Proceed buttons). */
|
* The five-phase stepper, as a vertical sidebar list. Forward-gated: you can only
|
||||||
export function Stepper() {
|
* jump to a step at or before the current one (advance via the Proceed buttons).
|
||||||
|
* `collapsed` shows just the phase number.
|
||||||
|
*/
|
||||||
|
export function Stepper({ collapsed = false }: { collapsed?: boolean }) {
|
||||||
const { pathname } = useLocation()
|
const { pathname } = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const phases = useSession((s) => s.phases)
|
const phases = useSession((s) => s.phases)
|
||||||
@@ -30,7 +33,7 @@ export function Stepper() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="grid grid-cols-5 gap-2.5 border-b border-line px-10 py-4" data-testid="stepper">
|
<nav className="flex flex-col gap-1.5" data-testid="stepper">
|
||||||
{STEPS.map((s, i) => {
|
{STEPS.map((s, i) => {
|
||||||
const state = i < activeIndex ? 'done' : i === activeIndex ? 'active' : 'pending'
|
const state = i < activeIndex ? 'done' : i === activeIndex ? 'active' : 'pending'
|
||||||
const reachable = i <= activeIndex || phases[s.key]
|
const reachable = i <= activeIndex || phases[s.key]
|
||||||
@@ -39,36 +42,54 @@ export function Stepper() {
|
|||||||
key={s.key}
|
key={s.key}
|
||||||
type="button"
|
type="button"
|
||||||
data-state={state}
|
data-state={state}
|
||||||
|
data-phase={s.key}
|
||||||
disabled={!reachable}
|
disabled={!reachable}
|
||||||
|
title={collapsed ? `${s.eyebrow} · ${s.label}` : undefined}
|
||||||
onClick={() => reachable && navigate(s.to)}
|
onClick={() => reachable && navigate(s.to)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-lg px-3.5 py-[11px] text-left transition-colors',
|
'flex items-center gap-3 rounded-lg text-left transition-colors',
|
||||||
|
collapsed ? 'justify-center px-0 py-2.5' : 'px-3 py-2.5',
|
||||||
reachable ? 'cursor-pointer' : 'cursor-default',
|
reachable ? 'cursor-pointer' : 'cursor-default',
|
||||||
state === 'done' && 'border border-[var(--green-border-2)] bg-[var(--green-bg)]',
|
state === 'done' && 'border border-[var(--green-border-2)] bg-[var(--green-bg)]',
|
||||||
state === 'active' && 'border-[1.5px] border-blue bg-[var(--blue-soft-bg)]',
|
state === 'active' && 'border-[1.5px] border-blue bg-[var(--blue-soft-bg)]',
|
||||||
state === 'pending' && 'border border-line bg-surface',
|
state === 'pending' && 'border border-line bg-surface',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
{/* number chip */}
|
||||||
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-mono text-[10px] tracking-[0.16em]',
|
'grid h-6 w-6 shrink-0 place-items-center rounded-md font-mono text-[11px] font-semibold',
|
||||||
state === 'done' && 'text-green',
|
state === 'done' && 'text-green',
|
||||||
state === 'active' && 'text-blue-eyebrow',
|
state === 'active' && 'text-blue-ink',
|
||||||
state === 'pending' && 'text-faint',
|
state === 'pending' && 'text-[var(--muted-2)]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{s.eyebrow}
|
{i + 1}
|
||||||
</div>
|
</span>
|
||||||
<div
|
{!collapsed && (
|
||||||
className={cn(
|
<span className="min-w-0">
|
||||||
'text-[15px]',
|
<span
|
||||||
state === 'done' && 'text-green font-medium',
|
className={cn(
|
||||||
state === 'active' && 'text-blue-ink font-semibold',
|
'block font-mono text-[9px] tracking-[0.16em]',
|
||||||
state === 'pending' && 'text-[var(--muted-2)] font-medium',
|
state === 'done' && 'text-green',
|
||||||
)}
|
state === 'active' && 'text-blue-eyebrow',
|
||||||
>
|
state === 'pending' && 'text-faint',
|
||||||
{s.label}
|
)}
|
||||||
</div>
|
>
|
||||||
|
{s.eyebrow}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'block truncate text-[14px] leading-tight',
|
||||||
|
state === 'done' && 'text-green font-medium',
|
||||||
|
state === 'active' && 'text-blue-ink font-semibold',
|
||||||
|
state === 'pending' && 'text-[var(--muted-2)] font-medium',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest'
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import { MemoryRouter } from 'react-router-dom'
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
import { EnvSetup } from './EnvSetup'
|
import { EnvSetup } from './EnvSetup'
|
||||||
import { useSession } from '@/store/session'
|
import { useSession } from '@/store/session'
|
||||||
|
|
||||||
|
// EnvSetup now renders the agent chat + say-hi; stub the streaming/mode calls.
|
||||||
|
vi.mock('@/lib/api', async (orig) => ({
|
||||||
|
...(await orig<typeof import('@/lib/api')>()),
|
||||||
|
openTeamActivity: () => () => {},
|
||||||
|
getMode: () => Promise.resolve({ localMode: false }),
|
||||||
|
}))
|
||||||
|
|
||||||
function renderPage() {
|
function renderPage() {
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
@@ -12,7 +19,6 @@ function renderPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Connect a board with a live node URL — the common precondition. */
|
|
||||||
function connect(nodeUrl: string | null = 'http://192.168.1.7:8080') {
|
function connect(nodeUrl: string | null = 'http://192.168.1.7:8080') {
|
||||||
useSession.getState().setDevice({ connected: true, port: 'board · crimson-otter', uptimeS: 0, nodeUrl })
|
useSession.getState().setDevice({ connected: true, port: 'board · crimson-otter', uptimeS: 0, nodeUrl })
|
||||||
}
|
}
|
||||||
@@ -50,15 +56,16 @@ describe('EnvSetup — Meet your agent', () => {
|
|||||||
expect(screen.getByText(/claim your board first/i)).toBeInTheDocument()
|
expect(screen.getByText(/claim your board first/i)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps Proceed gated when connected but no domain is named', () => {
|
it('reveals the Your Agent section (say-hi + chat) once connected', () => {
|
||||||
connect()
|
connect()
|
||||||
renderPage()
|
renderPage()
|
||||||
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
|
expect(screen.getByTestId('your-agent')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: /say hi to your agent/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('agent-chat')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('enables Proceed once connected AND a domain is named', () => {
|
it('enables Proceed once the board is connected (domain moved to Module 1)', () => {
|
||||||
connect()
|
connect()
|
||||||
useSession.getState().setDomain('structural stress')
|
|
||||||
renderPage()
|
renderPage()
|
||||||
expect(screen.getByRole('button', { name: /proceed/i })).toBeEnabled()
|
expect(screen.getByRole('button', { name: /proceed/i })).toBeEnabled()
|
||||||
})
|
})
|
||||||
|
|||||||
+29
-20
@@ -1,24 +1,21 @@
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { OpenYourNode } from '@/components/OpenYourNode'
|
import { OpenYourNode } from '@/components/OpenYourNode'
|
||||||
import { DomainPicker } from '@/components/DomainPicker'
|
import { SayHiCard } from '@/components/SayHiCard'
|
||||||
|
import { TelegramSetup } from '@/components/TelegramSetup'
|
||||||
|
import { VoiceSetup } from '@/components/VoiceSetup'
|
||||||
|
import { AgentChat } from '@/components/AgentChat'
|
||||||
import { PanelHeading, PanelCard, ProceedButton } from '@/components/cockpit/PanelChrome'
|
import { PanelHeading, PanelCard, ProceedButton } from '@/components/cockpit/PanelChrome'
|
||||||
import { useSession } from '@/store/session'
|
import { useSession } from '@/store/session'
|
||||||
|
|
||||||
export function EnvSetup() {
|
export function EnvSetup() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const device = useSession((s) => s.device)
|
const device = useSession((s) => s.device)
|
||||||
const domain = useSession((s) => s.domain)
|
const setTried = useSession((s) => s.setTried)
|
||||||
const add = useSession((s) => s.add)
|
|
||||||
const setAddLayer = useSession((s) => s.setAddLayer)
|
|
||||||
const completePhase = useSession((s) => s.completePhase)
|
const completePhase = useSession((s) => s.completePhase)
|
||||||
|
|
||||||
const ready = device.connected && domain.trim().length > 0
|
const ready = device.connected
|
||||||
|
|
||||||
const onProceed = () => {
|
const onProceed = () => {
|
||||||
// Carry the domain into Layer 1 as a starting draft (only if untouched).
|
|
||||||
if (!add.L1.trim() && domain.trim()) {
|
|
||||||
setAddLayer('L1', `Domain: ${domain.trim()}. Events: an impact spike, a sustained sway, a stale sensor.`)
|
|
||||||
}
|
|
||||||
completePhase('setup')
|
completePhase('setup')
|
||||||
navigate('/workshop/module1')
|
navigate('/workshop/module1')
|
||||||
}
|
}
|
||||||
@@ -28,7 +25,7 @@ export function EnvSetup() {
|
|||||||
<PanelHeading
|
<PanelHeading
|
||||||
eyebrow="PHASE 2 OF 5 · ~15 MIN"
|
eyebrow="PHASE 2 OF 5 · ~15 MIN"
|
||||||
title="Meet your agent"
|
title="Meet your agent"
|
||||||
intro="Your board runs the APESS agent — a Claude-powered agent on the edge. It reasons about your domain, drives the board's own devices, and keeps working when the cloud drops. Open it to explore, then name the domain it's for."
|
intro="Your board runs the APESS agent — a Claude-powered agent on the edge. Open it to explore, say hi, set up how you reach it, then put it to work on your real board."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Open your agent */}
|
{/* Open your agent */}
|
||||||
@@ -39,17 +36,29 @@ export function EnvSetup() {
|
|||||||
</div>
|
</div>
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
|
|
||||||
{/* Pick your domain — emphasized: this seeds all five layers */}
|
{device.connected ? (
|
||||||
<PanelCard emphasized className="mt-[22px]">
|
<div className="mt-8 space-y-5" data-testid="your-agent">
|
||||||
<div className="text-[17px] font-semibold">Pick your domain</div>
|
<div>
|
||||||
<p className="mt-1.5 text-[14px] leading-[1.5] text-ink-3">
|
<h2 className="text-[22px] font-semibold tracking-[-0.01em]">Your agent</h2>
|
||||||
This one line seeds all five layers of your Agent Design Document — the domain your agent
|
<p className="mt-1 text-[15px] text-ink-2">
|
||||||
serves and the events it must notice.
|
Say hi to the agent on your board, set up how you reach it — then chat with it and watch
|
||||||
</p>
|
it use its skills on the real hardware.
|
||||||
<div className="mt-4">
|
</p>
|
||||||
<DomainPicker />
|
</div>
|
||||||
|
<SayHiCard />
|
||||||
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
|
<TelegramSetup />
|
||||||
|
<VoiceSetup />
|
||||||
|
</div>
|
||||||
|
<AgentChat onProgress={(done) => setTried(done)} />
|
||||||
</div>
|
</div>
|
||||||
</PanelCard>
|
) : (
|
||||||
|
<PanelCard className="mt-6">
|
||||||
|
<p className="text-[14px] leading-[1.5] text-ink-3">
|
||||||
|
Connect your board on the previous step to meet your agent.
|
||||||
|
</p>
|
||||||
|
</PanelCard>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-9 flex justify-end">
|
<div className="mt-9 flex justify-end">
|
||||||
<ProceedButton disabled={!ready} onClick={onProceed}>
|
<ProceedButton disabled={!ready} onClick={onProceed}>
|
||||||
|
|||||||
@@ -35,13 +35,10 @@ describe('Module1', () => {
|
|||||||
expect(screen.getByRole('heading', { name: /domain.*events/i })).toBeInTheDocument()
|
expect(screen.getByRole('heading', { name: /domain.*events/i })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows the domain carried over from the earlier screen (read-only)', () => {
|
it('lets you pick the domain here (no read-only carry)', () => {
|
||||||
useSession.getState().setDomain('air quality')
|
|
||||||
renderPage()
|
renderPage()
|
||||||
const carried = screen.getByTestId('domain-carried')
|
expect(screen.queryByTestId('domain-carried')).toBeNull()
|
||||||
expect(carried).toHaveTextContent('air quality')
|
expect(screen.getByPlaceholderText(/image measurement|structural stress|air quality/i)).toBeInTheDocument()
|
||||||
// no editable domain input here anymore
|
|
||||||
expect(screen.queryByLabelText(/your domain/i)).toBeNull()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('focuses on Layer 1 — no live board feed or actor map cards', () => {
|
it('focuses on Layer 1 — no live board feed or actor map cards', () => {
|
||||||
@@ -50,12 +47,13 @@ describe('Module1', () => {
|
|||||||
expect(screen.queryByTestId('actor-map')).toBeNull()
|
expect(screen.queryByTestId('actor-map')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('gates Proceed until the board is online and L1 is filled', async () => {
|
it('gates Proceed until the board is online, a domain is named, and L1 is filled', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
renderPage()
|
renderPage()
|
||||||
const proceed = screen.getByRole('button', { name: /proceed/i })
|
const proceed = screen.getByRole('button', { name: /proceed/i })
|
||||||
expect(proceed).toBeDisabled()
|
expect(proceed).toBeDisabled()
|
||||||
|
|
||||||
|
act(() => useSession.getState().setDomain('resonance'))
|
||||||
await user.type(screen.getByLabelText(/layer 1/i), 'structural resonance')
|
await user.type(screen.getByLabelText(/layer 1/i), 'structural resonance')
|
||||||
expect(proceed).toBeDisabled() // board not online yet
|
expect(proceed).toBeDisabled() // board not online yet
|
||||||
|
|
||||||
|
|||||||
+14
-16
@@ -1,6 +1,7 @@
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { DomainPicker } from '@/components/DomainPicker'
|
||||||
import { AddLayerForm } from '@/components/AddLayerForm'
|
import { AddLayerForm } from '@/components/AddLayerForm'
|
||||||
import { PanelHeading, ProceedButton } from '@/components/cockpit/PanelChrome'
|
import { PanelHeading, PanelCard, ProceedButton } from '@/components/cockpit/PanelChrome'
|
||||||
import { useNodeFeed } from '@/lib/useNodeFeed'
|
import { useNodeFeed } from '@/lib/useNodeFeed'
|
||||||
import { useSession } from '@/store/session'
|
import { useSession } from '@/store/session'
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ export function Module1() {
|
|||||||
const completePhase = useSession((s) => s.completePhase)
|
const completePhase = useSession((s) => s.completePhase)
|
||||||
|
|
||||||
const sensed = feed.online || feed.activity.length > 0
|
const sensed = feed.online || feed.activity.length > 0
|
||||||
const ready = sensed && l1.trim().length > 0
|
const ready = sensed && domain.trim().length > 0 && l1.trim().length > 0
|
||||||
|
|
||||||
const onProceed = () => {
|
const onProceed = () => {
|
||||||
completePhase('m1')
|
completePhase('m1')
|
||||||
@@ -25,23 +26,20 @@ export function Module1() {
|
|||||||
<PanelHeading
|
<PanelHeading
|
||||||
eyebrow="PHASE 3 OF 5 · ~75 MIN"
|
eyebrow="PHASE 3 OF 5 · ~75 MIN"
|
||||||
title="Module 1 · Domain & events"
|
title="Module 1 · Domain & events"
|
||||||
intro="Define the domain your agent is for and the events it must sense and act on — carried from what you named earlier. This is Layer 1 of your Agent Design Document."
|
intro="Name the domain your agent is for and the events it must sense and act on — this is Layer 1 of your Agent Design Document."
|
||||||
size={46}
|
size={46}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
{/* Pick your domain — the seed of all five layers */}
|
||||||
className="mt-9 rounded-xl border border-line bg-surface-soft px-5 py-4"
|
<PanelCard emphasized className="mt-9">
|
||||||
data-testid="domain-carried"
|
<div className="text-[17px] font-semibold">Pick your domain</div>
|
||||||
>
|
<p className="mt-1.5 text-[14px] leading-[1.5] text-ink-3">
|
||||||
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--muted)]">Your domain</div>
|
This one line seeds all five layers — the domain your agent serves and the events it must notice.
|
||||||
{domain.trim() ? (
|
</p>
|
||||||
<div className="mt-1.5 text-[19px] font-semibold tracking-[-0.01em]">{domain}</div>
|
<div className="mt-4">
|
||||||
) : (
|
<DomainPicker />
|
||||||
<div className="mt-1.5 text-[14px] text-ink-3">
|
</div>
|
||||||
Not set yet — name it on <span className="font-medium">Meet your agent</span>.
|
</PanelCard>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<AddLayerForm
|
<AddLayerForm
|
||||||
|
|||||||
+20
-54
@@ -1,20 +1,9 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { render, screen, act, waitFor } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import { MemoryRouter } from 'react-router-dom'
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
import { Module2 } from './Module2'
|
import { Module2 } from './Module2'
|
||||||
import { useSession } from '@/store/session'
|
import { useSession } from '@/store/session'
|
||||||
import type { WsEvent } from '@/types'
|
|
||||||
|
|
||||||
let emit: (e: WsEvent) => void = () => {}
|
|
||||||
vi.mock('@/lib/api', async (orig) => ({
|
|
||||||
...(await orig<typeof import('@/lib/api')>()),
|
|
||||||
openTeamActivity: (_teamId: string, on: (e: WsEvent) => void) => {
|
|
||||||
emit = on
|
|
||||||
return () => {}
|
|
||||||
},
|
|
||||||
sendPrompt: vi.fn().mockResolvedValue(undefined),
|
|
||||||
}))
|
|
||||||
|
|
||||||
function renderPage() {
|
function renderPage() {
|
||||||
return render(
|
return render(
|
||||||
@@ -24,13 +13,6 @@ function renderPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Click a canned prompt and let the agent reach a terminal (success) step. */
|
|
||||||
async function completePrompt(user: ReturnType<typeof userEvent.setup>, id: string) {
|
|
||||||
await user.click(screen.getByTestId(`prompt-${id}`))
|
|
||||||
act(() => emit({ type: 'node:activity', teamId: 'x', kind: 'response', label: 'Agent finished', ts: '' }))
|
|
||||||
await waitFor(() => expect(screen.getByTestId(`prompt-${id}`)).toHaveAttribute('data-state', 'done'))
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Module2', () => {
|
describe('Module2', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useSession.getState().reset()
|
useSession.getState().reset()
|
||||||
@@ -42,53 +24,37 @@ describe('Module2', () => {
|
|||||||
expect(screen.getByRole('heading', { name: /skills.*policies/i })).toBeInTheDocument()
|
expect(screen.getByRole('heading', { name: /skills.*policies/i })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('is a chat with the three canned prompts — no live feed / build & flash', () => {
|
it('shows the Layer 2 (Skills) and Layer 3 (Policies) cards — the chat moved to Meet your agent', () => {
|
||||||
renderPage()
|
renderPage()
|
||||||
expect(screen.getByTestId('agent-chat')).toBeInTheDocument()
|
|
||||||
expect(screen.getByTestId('prompt-i2c')).toBeInTheDocument()
|
|
||||||
expect(screen.getByTestId('prompt-count')).toBeInTheDocument()
|
|
||||||
expect(screen.getByTestId('prompt-scroll')).toBeInTheDocument()
|
|
||||||
expect(screen.queryByTestId('live-board-feed')).toBeNull()
|
|
||||||
expect(screen.queryByTestId('activity-log')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reveals "what\'s next" (the ADD layers) only after all three prompts succeed', async () => {
|
|
||||||
const user = userEvent.setup()
|
|
||||||
renderPage()
|
|
||||||
expect(screen.queryByTestId('whats-next')).toBeNull()
|
|
||||||
expect(screen.queryByLabelText(/layer 2/i)).toBeNull()
|
|
||||||
|
|
||||||
await completePrompt(user, 'i2c')
|
|
||||||
await completePrompt(user, 'count')
|
|
||||||
expect(screen.queryByTestId('whats-next')).toBeNull() // still one to go
|
|
||||||
await completePrompt(user, 'scroll')
|
|
||||||
|
|
||||||
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
|
|
||||||
expect(screen.getByLabelText(/layer 2/i)).toBeInTheDocument()
|
expect(screen.getByLabelText(/layer 2/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText(/layer 3/i)).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('agent-chat')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('gates Proceed until all prompts ran AND L2 + L3 are filled', async () => {
|
it('prefills Layers 2 & 3 when the three prompts were run in Meet your agent', () => {
|
||||||
const user = userEvent.setup()
|
useSession.getState().setTried(3)
|
||||||
useSession.getState().setAddLayer('L2', 'escalate on critical')
|
|
||||||
useSession.getState().setAddLayer('L3', 'drive damper on critical')
|
|
||||||
renderPage()
|
renderPage()
|
||||||
|
expect((screen.getByLabelText(/layer 2/i) as HTMLTextAreaElement).value).toMatch(/i2c_scan/i)
|
||||||
|
expect((screen.getByLabelText(/layer 3/i) as HTMLTextAreaElement).value).toMatch(/exception/i)
|
||||||
|
})
|
||||||
|
|
||||||
await completePrompt(user, 'i2c')
|
it('gates Proceed until L2 and L3 are filled', async () => {
|
||||||
await completePrompt(user, 'count')
|
const user = userEvent.setup()
|
||||||
await completePrompt(user, 'scroll')
|
renderPage()
|
||||||
|
|
||||||
const proceed = screen.getByRole('button', { name: /proceed/i })
|
const proceed = screen.getByRole('button', { name: /proceed/i })
|
||||||
|
expect(proceed).toBeDisabled()
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText(/layer 2/i), 'escalate on critical')
|
||||||
|
expect(proceed).toBeDisabled()
|
||||||
|
await user.type(screen.getByLabelText(/layer 3/i), 'drive damper on critical')
|
||||||
expect(proceed).toBeEnabled()
|
expect(proceed).toBeEnabled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('marks m2 complete on Proceed', async () => {
|
it('marks m2 complete on Proceed', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
|
useSession.getState().setAddLayer('L2', 'L2 text')
|
||||||
|
useSession.getState().setAddLayer('L3', 'L3 text')
|
||||||
renderPage()
|
renderPage()
|
||||||
await completePrompt(user, 'i2c')
|
|
||||||
await completePrompt(user, 'count')
|
|
||||||
await completePrompt(user, 'scroll')
|
|
||||||
await user.type(screen.getByLabelText(/layer 2/i), 'L2 text')
|
|
||||||
await user.type(screen.getByLabelText(/layer 3/i), 'L3 text')
|
|
||||||
await user.click(screen.getByRole('button', { name: /proceed/i }))
|
await user.click(screen.getByRole('button', { name: /proceed/i }))
|
||||||
expect(useSession.getState().phases.m2).toBe(true)
|
expect(useSession.getState().phases.m2).toBe(true)
|
||||||
})
|
})
|
||||||
|
|||||||
+25
-50
@@ -1,8 +1,7 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { AgentChat } from '@/components/AgentChat'
|
|
||||||
import { AddLayerForm } from '@/components/AddLayerForm'
|
import { AddLayerForm } from '@/components/AddLayerForm'
|
||||||
import { PanelHeading, PanelCard, ProceedButton } from '@/components/cockpit/PanelChrome'
|
import { PanelHeading, ProceedButton } from '@/components/cockpit/PanelChrome'
|
||||||
import { useSession } from '@/store/session'
|
import { useSession } from '@/store/session'
|
||||||
|
|
||||||
const L2_PREFILL =
|
const L2_PREFILL =
|
||||||
@@ -15,19 +14,18 @@ export function Module2() {
|
|||||||
const l2 = useSession((s) => s.add.L2)
|
const l2 = useSession((s) => s.add.L2)
|
||||||
const l3 = useSession((s) => s.add.L3)
|
const l3 = useSession((s) => s.add.L3)
|
||||||
const tried = useSession((s) => s.tried)
|
const tried = useSession((s) => s.tried)
|
||||||
const setTried = useSession((s) => s.setTried)
|
|
||||||
const setAddLayer = useSession((s) => s.setAddLayer)
|
const setAddLayer = useSession((s) => s.setAddLayer)
|
||||||
const completePhase = useSession((s) => s.completePhase)
|
const completePhase = useSession((s) => s.completePhase)
|
||||||
|
|
||||||
const allTried = tried >= 3
|
const ready = l2.trim().length > 0 && l3.trim().length > 0
|
||||||
const ready = allTried && l2.trim().length > 0 && l3.trim().length > 0
|
|
||||||
|
|
||||||
// On reaching 3/3, seed Layers 2 & 3 with a starting draft (only if untouched).
|
// If the team ran the three agent prompts in "Meet your agent", seed Layers 2 & 3
|
||||||
|
// with a starting draft (only if untouched).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!allTried) return
|
if (tried < 3) return
|
||||||
if (!useSession.getState().add.L2.trim()) setAddLayer('L2', L2_PREFILL)
|
if (!useSession.getState().add.L2.trim()) setAddLayer('L2', L2_PREFILL)
|
||||||
if (!useSession.getState().add.L3.trim()) setAddLayer('L3', L3_PREFILL)
|
if (!useSession.getState().add.L3.trim()) setAddLayer('L3', L3_PREFILL)
|
||||||
}, [allTried, setAddLayer])
|
}, [tried, setAddLayer])
|
||||||
|
|
||||||
const onProceed = () => {
|
const onProceed = () => {
|
||||||
completePhase('m2')
|
completePhase('m2')
|
||||||
@@ -39,53 +37,30 @@ export function Module2() {
|
|||||||
<PanelHeading
|
<PanelHeading
|
||||||
eyebrow="PHASE 4 OF 5 · ~90 MIN"
|
eyebrow="PHASE 4 OF 5 · ~90 MIN"
|
||||||
title="Module 2 · Skills & policies"
|
title="Module 2 · Skills & policies"
|
||||||
intro="Talk to your agent and watch it run real tools on your board. Each prompt makes it use a built-in skill — then capture your domain's skills and the policy that governs them."
|
intro="Capture what your agent can do and the policy that governs it — Layers 2 and 3 of your Agent Design Document."
|
||||||
size={46}
|
size={46}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-9">
|
<div className="mt-9 space-y-5">
|
||||||
<AgentChat onProgress={(done) => setTried(done)} />
|
<AddLayerForm
|
||||||
|
layer="L2"
|
||||||
|
title="ADD · Layer 2 — Skills"
|
||||||
|
description="What skills can the agent invoke to act on its domain?"
|
||||||
|
placeholder="Flash a sketch to the MCU; scroll a message; drive the damper; sample the IMU."
|
||||||
|
/>
|
||||||
|
<AddLayerForm
|
||||||
|
layer="L3"
|
||||||
|
title="ADD · Layer 3 — Policies & failure"
|
||||||
|
description="The actuation gate — and what happens when things break. A fail-safe must never quietly report 'normal'."
|
||||||
|
placeholder="Report only on exception; escalate ambiguous calls to the cloud; never suppress a stale-sensor alarm."
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{allTried ? (
|
<div className="mt-6 flex justify-end">
|
||||||
<div className="mt-8 space-y-5" data-testid="whats-next">
|
<ProceedButton disabled={!ready} onClick={onProceed}>
|
||||||
<div>
|
Proceed to Module 3 →
|
||||||
<h2 className="text-[22px] font-semibold tracking-[-0.01em]">What’s next</h2>
|
</ProceedButton>
|
||||||
<p className="mt-1 max-w-[560px] text-[15px] text-ink-2">
|
</div>
|
||||||
You just watched the agent enumerate the bus and drive the matrix with its built-in
|
|
||||||
skills. Now capture <span className="font-medium text-ink">your domain’s</span> skills and
|
|
||||||
the policy that governs them — Layers 2 and 3, drafted below.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AddLayerForm
|
|
||||||
layer="L2"
|
|
||||||
title="ADD · Layer 2 — Skills"
|
|
||||||
description="What skills can the agent invoke to act on its domain?"
|
|
||||||
placeholder="Flash a sketch to the MCU; scroll a message; drive the damper; sample the IMU."
|
|
||||||
/>
|
|
||||||
<AddLayerForm
|
|
||||||
layer="L3"
|
|
||||||
title="ADD · Layer 3 — Policies & failure"
|
|
||||||
description="The actuation gate — and what happens when things break. A fail-safe must never quietly report 'normal'."
|
|
||||||
placeholder="Report only on exception; escalate ambiguous calls to the cloud; never suppress a stale-sensor alarm."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<ProceedButton disabled={!ready} onClick={onProceed}>
|
|
||||||
Proceed to Module 3 →
|
|
||||||
</ProceedButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<PanelCard className="mt-6">
|
|
||||||
<div className="text-[15px] font-semibold text-ink-2">What’s next</div>
|
|
||||||
<p className="mt-2 text-[14px] leading-[1.5] text-ink-3">
|
|
||||||
Try all three prompts above. Once your agent has run each one successfully, we’ll
|
|
||||||
capture your domain’s skills and policies here.
|
|
||||||
</p>
|
|
||||||
</PanelCard>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,20 +104,11 @@ describe('TeamRegistration', () => {
|
|||||||
expect(useSession.getState().phases.reg).toBe(true)
|
expect(useSession.getState().phases.reg).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reveals the agent setup cards only once the board is connected', () => {
|
it('no longer shows the agent-setup cards here (moved to Meet your agent)', () => {
|
||||||
const { rerender } = renderPage()
|
renderPage()
|
||||||
expect(screen.queryByTestId('post-connect')).toBeNull()
|
|
||||||
act(() => useSession.getState().setDevice({ connected: true, port: 'board · KIT-01', uptimeS: 0 }))
|
act(() => useSession.getState().setDevice({ connected: true, port: 'board · KIT-01', uptimeS: 0 }))
|
||||||
rerender(
|
expect(screen.queryByTestId('post-connect')).toBeNull()
|
||||||
<MemoryRouter>
|
expect(screen.queryByRole('button', { name: /say hi to your agent/i })).toBeNull()
|
||||||
<TeamRegistration />
|
|
||||||
</MemoryRouter>,
|
|
||||||
)
|
|
||||||
const post = screen.getByTestId('post-connect')
|
|
||||||
expect(post).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('button', { name: /say hi to your agent/i })).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('button', { name: /set up telegram/i })).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('switch', { name: /enable voice/i })).toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('pre-fills the claim code from the ?code= URL param', () => {
|
it('pre-fills the claim code from the ?code= URL param', () => {
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { MemberFields } from '@/components/MemberFields'
|
import { MemberFields } from '@/components/MemberFields'
|
||||||
import { BoardClaim } from '@/components/BoardClaim'
|
import { BoardClaim } from '@/components/BoardClaim'
|
||||||
import { LocalBoardConnect } from '@/components/LocalBoardConnect'
|
import { LocalBoardConnect } from '@/components/LocalBoardConnect'
|
||||||
import { SayHiCard } from '@/components/SayHiCard'
|
|
||||||
import { TelegramSetup } from '@/components/TelegramSetup'
|
|
||||||
import { VoiceSetup } from '@/components/VoiceSetup'
|
|
||||||
import { PanelHeading, PanelCard, ProceedButton, FieldLabel } from '@/components/cockpit/PanelChrome'
|
import { PanelHeading, PanelCard, ProceedButton, FieldLabel } from '@/components/cockpit/PanelChrome'
|
||||||
import { useLocalMode } from '@/lib/useLocalMode'
|
import { useLocalMode } from '@/lib/useLocalMode'
|
||||||
import type { ClaimResult } from '@/lib/api'
|
import type { ClaimResult } from '@/lib/api'
|
||||||
@@ -103,24 +100,6 @@ export function TeamRegistration() {
|
|||||||
</PanelCard>
|
</PanelCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* The connection-sequencing fix: channels appear only after the board is
|
|
||||||
bound, so the first-connection moment isn't a pile-up. */}
|
|
||||||
{device.connected && (
|
|
||||||
<div className="mt-8 space-y-5" data-testid="post-connect">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-[22px] font-semibold tracking-[-0.01em]">Your agent</h2>
|
|
||||||
<p className="mt-1 text-[15px] text-ink-2">
|
|
||||||
Your board is bound — say hi to the agent on it, then set up how you reach it.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<SayHiCard />
|
|
||||||
<div className="grid gap-5 md:grid-cols-2">
|
|
||||||
<TelegramSetup />
|
|
||||||
<VoiceSetup />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-9 flex justify-end">
|
<div className="mt-9 flex justify-end">
|
||||||
<ProceedButton disabled={!ready} onClick={onProceed}>
|
<ProceedButton disabled={!ready} onClick={onProceed}>
|
||||||
Meet your agent →
|
Meet your agent →
|
||||||
|
|||||||
Reference in New Issue
Block a user