Compare commits

..
2 Commits
Author SHA1 Message Date
Omar SobhandClaude Opus 4.8 5975287d56 feat(web): 3D tower scene + collapsible sidebar layout
Recast the cockpit shell: the phase stepper becomes a collapsible LEFT sidebar
(brand + vertical stepper + theme), the center is the editorial content, and the
right pane on Team Registration is a native three.js scene — the Meridian Tower
in a procedural Turin/Po-valley landscape — that slides from night to day the
moment the board connects.

- TowerScene.tsx: the .dc.html scene ported to a native React component (no
  iframe), [email protected] via npm, day/night driven by a `night` prop (lerped),
  StrictMode-safe WebGL teardown. Code-split so three.js loads only when the
  scene renders. Sky textures in public/skyscraper/.
- CockpitLayout: left sidebar + content + tower/rail right pane; tower stretches
  to the content height. Stepper is now a vertical, collapsible list.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 12:52:50 -07:00
Omar SobhandClaude Opus 4.8 4dd3681eef refactor(web): reorganize phases + fix Open-your-node URL
Move sections to where they read better:
- Phase 1 (Team registration): drop the "Your Agent" block.
- Phase 2 (Meet your agent): host the "Your Agent" section (say-hi / Telegram /
  voice) AND the agent chat under it; remove "Pick your domain".
- Module 1 (Domain & events): "Pick your domain" moves here (no more read-only
  carry) alongside the ADD Layer 1 card.
- Module 2 (Skills & policies): the chat is gone; the L2/L3 ADD cards show
  directly (still auto-prefilled if the three prompts ran in Meet your agent).

Also: OpenYourNode opens the browser-reachable board URL. In self-host/USB mode
the stored url is container-facing (host.docker.internal) which the browser
can't resolve — derive the current host on :8080 instead. Tests updated.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 12:52:50 -07:00
17 changed files with 774 additions and 242 deletions
+2
View File
@@ -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",
+41
View File
@@ -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

+2
View File
@@ -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', () => {
+19 -4
View File
@@ -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 />
+470
View File
@@ -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%' }} />;
}
+77 -19
View File
@@ -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 ── */}
<aside
className={cn(
'sticky top-0 flex h-screen shrink-0 flex-col border-r border-line bg-surface-soft transition-[width] duration-200 print:hidden',
collapsed ? 'w-[68px]' : 'w-[236px]',
)}
>
<div className={cn('flex items-center border-b border-line py-5', collapsed ? 'justify-center px-2' : 'px-4')}>
<Link to="/" className="font-mono text-xs font-semibold tracking-[0.14em]"> <Link to="/" className="font-mono text-xs font-semibold tracking-[0.14em]">
{collapsed ? (
<span className="text-blue">26</span>
) : (
<>
<span className="text-ink">APESS </span> <span className="text-ink">APESS </span>
<span className="text-blue">2026</span> <span className="text-blue">2026</span>
<span className="text-[var(--muted)]"> · WORKSHOP</span> <span className="text-[var(--muted)]"> · WORKSHOP</span>
</>
)}
</Link> </Link>
<div className="ml-auto flex items-center gap-4"> </div>
<span className="font-mono text-[11px] text-[var(--muted)]">
<div className="flex-1 overflow-y-auto px-3 py-4">
<Stepper collapsed={collapsed} />
</div>
<div className="flex flex-col gap-2 border-t border-line px-3 py-3">
{!collapsed && (
<span className="truncate px-1 font-mono text-[10px] text-[var(--muted)]">
{nodeName} {nodeName}
{team.name && <span> · {team.name}</span>} {team.name && <span> · {team.name}</span>}
</span> </span>
<ThemeToggle /> )}
<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> </div>
</header> </aside>
<div className="print:hidden"> {/* ── body: content + right pane (tower on registration, else rail) ── */}
<Stepper /> <div className="min-w-0 flex-1">
</div> <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">
<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">
<main className="min-h-[560px] w-full max-w-[660px] print:max-w-none"> <main className="min-h-[560px] w-full max-w-[660px] print:max-w-none">
<Outlet /> <Outlet />
</main> </main>
<div className="print:hidden"> <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 /> <CockpitRail />
)}
</div>
</div> </div>
</div> </div>
</div> </div>
+32 -11
View File
@@ -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 === 'active' && 'text-blue-ink',
state === 'pending' && 'text-[var(--muted-2)]',
)}
>
{i + 1}
</span>
{!collapsed && (
<span className="min-w-0">
<span
className={cn(
'block font-mono text-[9px] tracking-[0.16em]',
state === 'done' && 'text-green', state === 'done' && 'text-green',
state === 'active' && 'text-blue-eyebrow', state === 'active' && 'text-blue-eyebrow',
state === 'pending' && 'text-faint', state === 'pending' && 'text-faint',
)} )}
> >
{s.eyebrow} {s.eyebrow}
</div> </span>
<div <span
className={cn( className={cn(
'text-[15px]', 'block truncate text-[14px] leading-tight',
state === 'done' && 'text-green font-medium', state === 'done' && 'text-green font-medium',
state === 'active' && 'text-blue-ink font-semibold', state === 'active' && 'text-blue-ink font-semibold',
state === 'pending' && 'text-[var(--muted-2)] font-medium', state === 'pending' && 'text-[var(--muted-2)] font-medium',
)} )}
> >
{s.label} {s.label}
</div> </span>
</span>
)}
</button> </button>
) )
})} })}
+13 -6
View File
@@ -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()
}) })
+27 -18
View File
@@ -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
it use its skills on the real hardware.
</p> </p>
<div className="mt-4">
<DomainPicker />
</div> </div>
<SayHiCard />
<div className="grid gap-5 md:grid-cols-2">
<TelegramSetup />
<VoiceSetup />
</div>
<AgentChat onProgress={(done) => setTried(done)} />
</div>
) : (
<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> </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}>
+5 -7
View File
@@ -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
+13 -15
View File
@@ -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">
Not set yet — name it on <span className="font-medium">Meet your agent</span>.
</div>
)}
</div> </div>
</PanelCard>
<div className="mt-5"> <div className="mt-5">
<AddLayerForm <AddLayerForm
+20 -54
View File
@@ -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)
}) })
+10 -35
View File
@@ -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,25 +37,11 @@ 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)} />
</div>
{allTried ? (
<div className="mt-8 space-y-5" data-testid="whats-next">
<div>
<h2 className="text-[22px] font-semibold tracking-[-0.01em]">What&rsquo;s next</h2>
<p className="mt-1 max-w-[560px] text-[15px] text-ink-2">
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&rsquo;s</span> skills and
the policy that governs them — Layers 2 and 3, drafted below.
</p>
</div>
<AddLayerForm <AddLayerForm
layer="L2" layer="L2"
title="ADD · Layer 2 — Skills" title="ADD · Layer 2 — Skills"
@@ -70,22 +54,13 @@ export function Module2() {
description="The actuation gate — and what happens when things break. A fail-safe must never quietly report 'normal'." 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." placeholder="Report only on exception; escalate ambiguous calls to the cloud; never suppress a stale-sensor alarm."
/> />
</div>
<div className="flex justify-end"> <div className="mt-6 flex justify-end">
<ProceedButton disabled={!ready} onClick={onProceed}> <ProceedButton disabled={!ready} onClick={onProceed}>
Proceed to Module 3 → Proceed to Module 3 →
</ProceedButton> </ProceedButton>
</div> </div>
</div>
) : (
<PanelCard className="mt-6">
<div className="text-[15px] font-semibold text-ink-2">What&rsquo;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&rsquo;ll
capture your domain&rsquo;s skills and policies here.
</p>
</PanelCard>
)}
</section> </section>
) )
} }
+4 -13
View File
@@ -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', () => {
-21
View File
@@ -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 →