← back to Ticket System
auto-save: 2026-07-27T07:48:56 (1 files) — office.html
c4b15ed8fc2e3dbcc697257b17037749c025292e · 2026-07-27 07:48:56 -0700 · Steve Abrams
Files touched
Diff
commit c4b15ed8fc2e3dbcc697257b17037749c025292e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Jul 27 07:48:56 2026 -0700
auto-save: 2026-07-27T07:48:56 (1 files) — office.html
---
office.html | 817 +++++++++++++++++++++++++++---------------------------------
1 file changed, 368 insertions(+), 449 deletions(-)
diff --git a/office.html b/office.html
index f8d762a..9455c4f 100644
--- a/office.html
+++ b/office.html
@@ -5,43 +5,51 @@
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Fleet Office — live ticket floor</title>
<style>
- :root{ --bg:#0f1115; --panel:#141821; --line:#262a33; --dim:#8a93a5; --blue:#6db3f2; --red:#e06c75; --green:#34d399; --purple:#c9a4f2; }
+ :root{ --bg:#07080d; --panel:#10131dcc; --line:#242a3a; --dim:#8a93a5; --blue:#6db3f2; --red:#ff6b7a; --green:#3ee6a0; --purple:#c9a4f2; --amber:#ffcf6b; }
*{box-sizing:border-box}
- html,body{margin:0;height:100%;background:var(--bg);color:#e6e6e6;font:13px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;overflow:hidden}
+ html,body{margin:0;height:100%;background:#07080d;color:#eef1f7;font:13px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;overflow:hidden}
#c{position:fixed;inset:0;display:block}
.hud{position:fixed;pointer-events:none;z-index:10}
- #top{top:0;left:0;right:0;padding:12px 16px;display:flex;gap:18px;align-items:center;
- background:linear-gradient(#0f1115ee,#0f111500);letter-spacing:.05em}
- #top b{color:#fff;font-size:15px;letter-spacing:.08em}
- #top .stat{color:var(--dim)}
- #top .stat span{color:#fff;font-weight:600}
- .legend{position:fixed;top:52px;left:16px;z-index:10;background:var(--panel);border:1px solid var(--line);
- border-radius:10px;padding:10px 12px;line-height:1.9;pointer-events:auto}
- .legend .row{display:flex;align-items:center;gap:8px}
- .dot{width:11px;height:11px;border-radius:3px;display:inline-block}
+ #top{top:0;left:0;right:0;padding:14px 18px;display:flex;gap:20px;align-items:center;
+ background:linear-gradient(#07080df2,#07080d00);letter-spacing:.05em}
+ #top b{color:#fff;font-size:15px;letter-spacing:.14em;text-shadow:0 0 18px #6db3f2aa}
+ #top .stat{color:var(--dim);font-size:12px}
+ #top .stat span{color:#fff;font-weight:700}
+ .glass{background:var(--panel);border:1px solid var(--line);border-radius:12px;
+ backdrop-filter:blur(9px);-webkit-backdrop-filter:blur(9px);box-shadow:0 8px 30px #000a}
+ .legend{position:fixed;top:56px;left:18px;z-index:10;padding:11px 13px;line-height:2;pointer-events:auto}
+ .legend .row{display:flex;align-items:center;gap:9px}
+ .dot{width:11px;height:11px;border-radius:3px;display:inline-block;box-shadow:0 0 10px currentColor}
.legend small{color:var(--dim)}
- #ticker{position:fixed;bottom:0;left:0;right:0;z-index:10;background:linear-gradient(#0f111500,#0f1115ee);
- padding:14px 16px 12px;pointer-events:none}
- #ticker .lbl{color:var(--purple);letter-spacing:.14em;font-size:10px;text-transform:uppercase}
- #ticker .msg{color:#c6ccd8;margin-top:3px;max-width:70vw;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+ #ticker{position:fixed;bottom:0;left:0;right:0;z-index:10;background:linear-gradient(#07080d00,#07080df2);
+ padding:16px 18px 14px;pointer-events:none}
+ #ticker .lbl{color:var(--purple);letter-spacing:.16em;font-size:10px;text-transform:uppercase;text-shadow:0 0 12px #c9a4f288}
+ #ticker .msg{color:#dfe5f0;margin-top:4px;max-width:74vw;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:13px}
#ticker .msg b{color:#fff}
- .ctl{position:fixed;top:52px;right:16px;z-index:10;display:flex;flex-direction:column;gap:6px;pointer-events:auto}
- .ctl button{background:var(--panel);border:1px solid var(--line);color:#c6ccd8;border-radius:8px;
- padding:7px 11px;cursor:pointer;font:inherit;letter-spacing:.03em}
- .ctl button:hover{border-color:var(--blue);color:#fff}
- .ctl button.on{border-color:var(--blue);color:var(--blue)}
- #tip{position:fixed;z-index:11;background:#000d;border:1px solid var(--line);border-radius:8px;padding:8px 10px;
- max-width:340px;pointer-events:none;display:none;box-shadow:0 6px 22px #000a}
- #tip .h{color:var(--blue);font-weight:600;margin-bottom:3px}
- #tip .s{color:var(--dim);font-size:11px}
- #tip .t{color:#e6e6e6;margin-top:4px}
- #err{position:fixed;bottom:52px;left:16px;z-index:12;color:var(--red);display:none}
+ .ctl{position:fixed;top:56px;right:18px;z-index:10;display:flex;flex-direction:column;gap:7px;pointer-events:auto}
+ .ctl button{background:var(--panel);border:1px solid var(--line);color:#cdd4e2;border-radius:10px;
+ padding:8px 12px;cursor:pointer;font:inherit;letter-spacing:.03em;backdrop-filter:blur(9px);transition:.15s}
+ .ctl button:hover{border-color:var(--blue);color:#fff;box-shadow:0 0 16px #6db3f244}
+ .ctl button.on{border-color:var(--blue);color:var(--blue);box-shadow:0 0 14px #6db3f233}
+ #b-ping{border-color:#c9a4f2;color:#c9a4f2}
+ #tip{position:fixed;z-index:11;max-width:360px;pointer-events:none;display:none;padding:10px 12px}
+ #tip .h{color:var(--blue);font-weight:700;margin-bottom:3px;letter-spacing:.04em}
+ #tip .s{color:var(--dim);font-size:11px;margin-top:2px}
+ #tip .t{color:#dfe5f0;margin-top:5px;font-size:12px;line-height:1.5}
+ #err{position:fixed;bottom:56px;left:18px;z-index:12;color:var(--red);display:none;text-shadow:0 0 10px #ff6b7a66}
+ #boot{position:fixed;inset:0;z-index:20;background:#07080d;display:flex;align-items:center;justify-content:center;
+ flex-direction:column;gap:14px;transition:opacity .8s;letter-spacing:.3em;color:#6db3f2}
+ #boot .r{width:120px;height:2px;background:#1a2030;overflow:hidden;border-radius:2px}
+ #boot .r i{display:block;height:100%;width:40%;background:linear-gradient(90deg,#6db3f2,#c9a4f2);
+ animation:sweep 1s infinite;box-shadow:0 0 12px #6db3f2}
+ @keyframes sweep{0%{transform:translateX(-100%)}100%{transform:translateX(320%)}}
</style>
</head>
<body>
<canvas id="c"></canvas>
+<div id="boot"><div style="font-size:13px">ENTERING FLEET OFFICE</div><div class="r"><i></i></div></div>
<div class="hud" id="top">
- <b>FLEET OFFICE</b>
+ <b>◆ FLEET OFFICE</b>
<span class="stat">rooms <span id="s-rooms">0</span></span>
<span class="stat">agents <span id="s-agents">0</span></span>
<span class="stat">doing <span id="s-doing" style="color:var(--blue)">0</span></span>
@@ -49,21 +57,22 @@
<span class="stat">DMs <span id="s-dms" style="color:var(--purple)">0</span></span>
<span class="stat" style="margin-left:auto">↻ <span id="s-age">–</span></span>
</div>
-<div class="legend">
- <div class="row"><span class="dot" style="background:#6db3f2"></span> doing <small>active work</small></div>
- <div class="row"><span class="dot" style="background:#e06c75"></span> blocked</div>
- <div class="row"><span class="dot" style="background:#8a93a5"></span> open</div>
- <div class="row"><span class="dot" style="background:#34d399"></span> done <small>(recent)</small></div>
- <div class="row"><span class="dot" style="background:#c9a4f2"></span> DM in flight</div>
+<div class="legend glass">
+ <div class="row" style="color:#6db3f2"><span class="dot"></span> <span style="color:#eef1f7">doing</span> <small>active work</small></div>
+ <div class="row" style="color:#ff6b7a"><span class="dot"></span> <span style="color:#eef1f7">blocked</span></div>
+ <div class="row" style="color:#8a93a5"><span class="dot"></span> <span style="color:#eef1f7">open</span></div>
+ <div class="row" style="color:#3ee6a0"><span class="dot"></span> <span style="color:#eef1f7">done</span> <small>(recent)</small></div>
+ <div class="row" style="color:#c9a4f2"><span class="dot"></span> <span style="color:#eef1f7">DM in flight</span></div>
</div>
<div class="ctl">
+ <button id="b-orbit" class="on">Orbit: on</button>
<button id="b-labels" class="on">Labels: on</button>
<button id="b-done" class="on">Done rooms: on</button>
<button id="b-follow">Follow DMs: off</button>
- <button id="b-ping" title="Preview the DM packet animation between random agents (visual only — writes nothing)">Ping ✦</button>
+ <button id="b-ping" title="Preview the DM packet animation (visual only — writes nothing)">Ping ✦</button>
<button id="b-reset">Reset camera</button>
</div>
-<div id="tip"></div>
+<div id="tip" class="glass"></div>
<div id="err"></div>
<div class="hud" id="ticker"><div class="lbl">latest activity</div><div class="msg" id="ticker-msg">connecting to ticket board…</div></div>
@@ -77,469 +86,379 @@
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
+import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
+import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
+import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
+import { Reflector } from 'three/addons/objects/Reflector.js';
+import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
// ───────────────────────── config ─────────────────────────
-const LANES = ['doing', 'blocked', 'open', 'done']; // kanban lanes, left→right
-const COLOR = { doing:0x6db3f2, blocked:0xe06c75, open:0x8a93a5, done:0x34d399 };
-const ROOM = 9; // room footprint (units)
-const PITCH = 12; // room center-to-center spacing
-const LANE_W = 40; // horizontal gap between lanes
-const DONE_CAP= 14; // max done rooms to render
-const POLL_MS = 4000; // live refresh cadence
-
-// ───────────────────────── scene ─────────────────────────
-const canvas = document.getElementById('c');
-const renderer = new THREE.WebGLRenderer({ canvas, antialias:true });
-renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
-renderer.shadowMap.enabled = true;
-renderer.shadowMap.type = THREE.PCFSoftShadowMap;
-
-const scene = new THREE.Scene();
-scene.background = new THREE.Color(0x0f1115);
-scene.fog = new THREE.Fog(0x0f1115, 160, 420);
-
-const camera = new THREE.PerspectiveCamera(45, innerWidth/innerHeight, 1, 2000);
-const HOME = { pos:new THREE.Vector3(40, 215, 265), tgt:new THREE.Vector3(30, 0, 95) };
-camera.position.copy(HOME.pos);
-
-const controls = new OrbitControls(camera, canvas);
-controls.enableDamping = true;
-controls.dampingFactor = 0.08;
-controls.maxPolarAngle = Math.PI * 0.46; // keep it looking down-ish (from above)
-controls.minDistance = 30;
-controls.maxDistance = 520;
+const LANES = ['doing', 'blocked', 'open', 'done'];
+const COLOR = { doing:0x6db3f2, blocked:0xff6b7a, open:0x9aa4b8, done:0x3ee6a0 };
+const ROOM=9, PITCH=12, LANE_W=42, DONE_CAP=14, POLL_MS=4000;
+
+// ───────────────────────── renderer ─────────────────────────
+const canvas=document.getElementById('c');
+const renderer=new THREE.WebGLRenderer({ canvas, antialias:true, powerPreference:'high-performance' });
+renderer.setPixelRatio(Math.min(devicePixelRatio,2));
+renderer.toneMapping=THREE.ACESFilmicToneMapping;
+renderer.toneMappingExposure=1.05;
+renderer.shadowMap.enabled=true; renderer.shadowMap.type=THREE.PCFSoftShadowMap;
+
+const scene=new THREE.Scene();
+scene.fog=new THREE.FogExp2(0x07080d, 0.0038);
+
+const camera=new THREE.PerspectiveCamera(46, innerWidth/innerHeight, 1, 2500);
+const HOME={ pos:new THREE.Vector3(52,150,235), tgt:new THREE.Vector3(30,4,95) };
+camera.position.set(30,540,540); // fly-in start
+
+const controls=new OrbitControls(camera, canvas);
+controls.enableDamping=true; controls.dampingFactor=0.06;
+controls.maxPolarAngle=Math.PI*0.47; controls.minDistance=34; controls.maxDistance=620;
controls.target.copy(HOME.tgt);
-
-// lights
-scene.add(new THREE.HemisphereLight(0xafc4ff, 0x0a0c10, 0.9));
-const sun = new THREE.DirectionalLight(0xffffff, 1.1);
-sun.position.set(60, 160, 40); sun.castShadow = true;
-sun.shadow.mapSize.set(2048,2048);
-Object.assign(sun.shadow.camera, { left:-260, right:260, top:260, bottom:-260, near:20, far:520 });
-scene.add(sun);
-
-// ground
-const ground = new THREE.Mesh(
- new THREE.PlaneGeometry(1400, 1400),
- new THREE.MeshStandardMaterial({ color:0x11141b, roughness:1 }));
-ground.rotation.x = -Math.PI/2; ground.position.y = -0.05; ground.receiveShadow = true;
-scene.add(ground);
-// subtle grid
-const grid = new THREE.GridHelper(1400, 140, 0x1c2130, 0x161a22);
-grid.position.y = 0; scene.add(grid);
-
-// ───────────────────────── helpers ─────────────────────────
-function hashHue(s){ let h=0; for(let i=0;i<s.length;i++) h=(h*31+s.charCodeAt(i))>>>0; return h%360; }
-function agentColor(name){ const c=new THREE.Color(); c.setHSL(hashHue(name)/360, 0.55, 0.62); return c; }
-
-// canvas-texture text sprite
-function makeLabel(lines, { fg='#fff', sub='#8a93a5', w=520, scale=1 } = {}){
+controls.autoRotate=true; controls.autoRotateSpeed=0.35;
+
+// environment reflections (glossy metals) — guarded for headless
+try{ const pmrem=new THREE.PMREMGenerator(renderer);
+ scene.environment=pmrem.fromScene(new RoomEnvironment(), 0.03).texture; }catch(e){}
+
+// ───────────────────────── atmosphere ─────────────────────────
+// gradient dome
+const dome=new THREE.Mesh(new THREE.SphereGeometry(1200,32,16),
+ new THREE.ShaderMaterial({ side:THREE.BackSide, depthWrite:false, uniforms:{},
+ vertexShader:`varying vec3 vp; void main(){ vp=position; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.); }`,
+ fragmentShader:`varying vec3 vp; void main(){ float h=normalize(vp).y*0.5+0.5;
+ vec3 top=vec3(0.03,0.05,0.11), midc=vec3(0.05,0.04,0.10), bot=vec3(0.01,0.01,0.03);
+ vec3 c=mix(bot, mix(midc,top,smoothstep(0.5,1.0,h)), smoothstep(0.0,0.6,h));
+ c+=vec3(0.05,0.03,0.09)*pow(max(0.,1.-abs(h-0.46)*6.0),2.0); // horizon glow band
+ gl_FragColor=vec4(c,1.); }` }));
+scene.add(dome);
+
+// drifting dust
+const DUST=900, dpos=new Float32Array(DUST*3);
+for(let i=0;i<DUST;i++){ dpos[i*3]=(Math.random()-0.5)*640; dpos[i*3+1]=Math.random()*160+2; dpos[i*3+2]=(Math.random()-0.2)*520; }
+const dustGeo=new THREE.BufferGeometry(); dustGeo.setAttribute('position',new THREE.BufferAttribute(dpos,3));
+const dust=new THREE.Points(dustGeo, new THREE.PointsMaterial({ color:0x8fb4ff, size:0.6, transparent:true, opacity:0.5,
+ blending:THREE.AdditiveBlending, depthWrite:false }));
+scene.add(dust);
+
+// ───────────────────────── floor ─────────────────────────
+const reflector=new Reflector(new THREE.PlaneGeometry(2000,2000), {
+ textureWidth:1024, textureHeight:1024, color:0x0a0d16 });
+reflector.rotation.x=-Math.PI/2; reflector.position.y=0; scene.add(reflector);
+// tint the mirror down so it's a sheen, not a mirror
+const sheen=new THREE.Mesh(new THREE.PlaneGeometry(2000,2000),
+ new THREE.MeshStandardMaterial({ color:0x07080d, transparent:true, opacity:0.72, roughness:0.6, metalness:0.2 }));
+sheen.rotation.x=-Math.PI/2; sheen.position.y=0.01; sheen.receiveShadow=true; scene.add(sheen);
+// glowing floor grid
+const grid=new THREE.GridHelper(1600,160,0x2a3550,0x141a28);
+grid.material.transparent=true; grid.material.opacity=0.35; grid.position.y=0.02; scene.add(grid);
+
+// ───────────────────────── lights ─────────────────────────
+scene.add(new THREE.HemisphereLight(0x9fb8ff,0x05060a,0.7));
+const key=new THREE.DirectionalLight(0xffffff,1.15); key.position.set(70,170,60);
+key.castShadow=true; key.shadow.mapSize.set(2048,2048);
+Object.assign(key.shadow.camera,{ left:-300,right:300,top:300,bottom:-300,near:20,far:600 });
+scene.add(key);
+scene.add(new THREE.DirectionalLight(0x5b7bff,0.25).translateX(-80));
+
+// ───────────────────────── postprocessing ─────────────────────────
+const composer=new EffectComposer(renderer);
+composer.addPass(new RenderPass(scene,camera));
+const bloom=new UnrealBloomPass(new THREE.Vector2(innerWidth,innerHeight), 0.85, 0.7, 0.15);
+composer.addPass(bloom);
+composer.addPass(new OutputPass());
+
+// ───────────────────────── label sprites (neon) ─────────────────────────
+function makeLabel(lines,{ w=520, scale=1, glow='#6db3f2' }={}){
const pad=18, lh=34, cnv=document.createElement('canvas');
- const h = pad*2 + lines.length*lh;
- cnv.width=w; cnv.height=h;
- const g=cnv.getContext('2d');
- g.fillStyle='rgba(15,17,21,0.82)';
- roundRect(g,0,0,w,h,16); g.fill();
- g.strokeStyle='rgba(120,140,180,0.35)'; g.lineWidth=2; roundRect(g,1,1,w-2,h-2,15); g.stroke();
+ const h=pad*2+lines.length*lh; const dpr=2; cnv.width=w*dpr; cnv.height=h*dpr;
+ const g=cnv.getContext('2d'); g.scale(dpr,dpr);
+ g.fillStyle='rgba(9,11,17,0.78)'; roundRect(g,0,0,w,h,15); g.fill();
+ g.strokeStyle=glow+'88'; g.lineWidth=1.5; roundRect(g,1,1,w-2,h-2,14); g.stroke();
g.textBaseline='top';
- lines.forEach((ln,i)=>{
- g.fillStyle = ln.sub ? sub : fg;
- g.font = `${ln.sub?'400 ':'600 '}${ln.sub?22:26}px -apple-system,Segoe UI,sans-serif`;
- g.fillText(ln.t, pad, pad + i*lh);
- });
+ lines.forEach((ln,i)=>{ g.shadowColor=ln.sub?'transparent':glow; g.shadowBlur=ln.sub?0:12;
+ g.fillStyle=ln.sub?'#9aa4b8':'#ffffff';
+ g.font=`${ln.sub?'400 22px':'700 26px'} -apple-system,Segoe UI,sans-serif`;
+ g.fillText(ln.t,pad,pad+i*lh); });
const tex=new THREE.CanvasTexture(cnv); tex.anisotropy=4;
const spr=new THREE.Sprite(new THREE.SpriteMaterial({ map:tex, transparent:true, depthWrite:false }));
- spr.scale.set(w/40*scale, h/40*scale, 1);
- return spr;
+ spr.scale.set(w/40*scale, h/40*scale, 1); return spr;
}
function roundRect(g,x,y,w,h,r){ g.beginPath(); g.moveTo(x+r,y); g.arcTo(x+w,y,x+w,y+h,r);
g.arcTo(x+w,y+h,x,y+h,r); g.arcTo(x,y+h,x,y,r); g.arcTo(x,y,x+w,y,r); g.closePath(); }
-// ───────────────────────── build one room ─────────────────────────
+function hashHue(s){ let h=0; for(let i=0;i<s.length;i++) h=(h*31+s.charCodeAt(i))>>>0; return h%360; }
+function agentColor(n){ const c=new THREE.Color(); c.setHSL(hashHue(n)/360,0.7,0.6); return c; }
+const hex=n=>'#'+n.toString(16).padStart(6,'0');
+
+// ───────────────────────── room ─────────────────────────
function buildRoom(t){
- const grp = new THREE.Group();
- const col = COLOR[t.status] ?? COLOR.open;
- const floor = new THREE.Mesh(
- new THREE.BoxGeometry(ROOM, 0.4, ROOM),
- new THREE.MeshStandardMaterial({ color:col, roughness:0.85, metalness:0.05,
- emissive:col, emissiveIntensity: t.status==='doing'?0.25:(t.status==='blocked'?0.2:0.06) }));
- floor.position.y=0.2; floor.receiveShadow=true; floor.castShadow=false;
- grp.add(floor);
- // low walls
- const wallMat = new THREE.MeshStandardMaterial({ color:0x1a1f29, roughness:1,
- transparent:true, opacity:0.9 });
- const wallH=1.8, tk=0.3;
+ const grp=new THREE.Group(); const col=COLOR[t.status]??COLOR.open;
+ // floor slab
+ const floor=new THREE.Mesh(new THREE.BoxGeometry(ROOM,0.5,ROOM),
+ new THREE.MeshStandardMaterial({ color:col, roughness:0.5, metalness:0.4,
+ emissive:col, emissiveIntensity:t.status==='doing'?0.6:(t.status==='blocked'?0.5:0.18) }));
+ floor.position.y=0.25; floor.receiveShadow=true; grp.add(floor);
+ // glowing perimeter rim (additive line loop on the top edge)
+ const rimPts=[[-ROOM/2,-ROOM/2],[ROOM/2,-ROOM/2],[ROOM/2,ROOM/2],[-ROOM/2,ROOM/2],[-ROOM/2,-ROOM/2]]
+ .map(([x,z])=>new THREE.Vector3(x,0.56,z));
+ const rim=new THREE.Line(new THREE.BufferGeometry().setFromPoints(rimPts),
+ new THREE.LineBasicMaterial({ color:col, transparent:true, opacity:0.95, blending:THREE.AdditiveBlending, depthWrite:false }));
+ grp.add(rim);
+ // glass walls
+ const wallMat=new THREE.MeshPhysicalMaterial({ color:0x0d1119, roughness:0.25, metalness:0.1,
+ transmission:0.0, transparent:true, opacity:0.32, side:THREE.DoubleSide });
+ const wallH=2.0, tk=0.18;
for(const [dx,dz,ww,dd] of [[0,-ROOM/2,ROOM,tk],[0,ROOM/2,ROOM,tk],[-ROOM/2,0,tk,ROOM],[ROOM/2,0,tk,ROOM]]){
- const w=new THREE.Mesh(new THREE.BoxGeometry(ww,wallH,dd), wallMat);
- w.position.set(dx, wallH/2, dz); w.castShadow=true; grp.add(w);
+ const w=new THREE.Mesh(new THREE.BoxGeometry(ww,wallH,dd),wallMat);
+ w.position.set(dx,wallH/2,dz); grp.add(w);
}
- // status trim strip on the near wall
- const trim=new THREE.Mesh(new THREE.BoxGeometry(ROOM,0.35,0.36),
- new THREE.MeshStandardMaterial({ color:col, emissive:col, emissiveIntensity:0.8 }));
- trim.position.set(0, wallH+0.1, ROOM/2); grp.add(trim);
- // a little desk in the corner (a bit of office furniture)
+ // neon top trim on near wall
+ const trim=new THREE.Mesh(new THREE.BoxGeometry(ROOM,0.16,0.2),
+ new THREE.MeshBasicMaterial({ color:col }));
+ trim.position.set(0,wallH+0.05,ROOM/2); grp.add(trim);
+ // desk
const desk=new THREE.Mesh(new THREE.BoxGeometry(2.6,0.9,1.4),
- new THREE.MeshStandardMaterial({ color:0x2a2f3a, roughness:0.9 }));
- desk.position.set(-ROOM/2+2, 0.65, -ROOM/2+1.4); desk.castShadow=true; grp.add(desk);
-
- // floating label
- const shortTitle = (t.title||'').length>44 ? t.title.slice(0,42)+'…' : (t.title||'');
- const label = makeLabel([
- { t: t.id.replace(/^(TK-\d+).*/, '$1') + ' · ' + (t.status||'').toUpperCase() },
- { t: shortTitle, sub:true },
- { t: '@'+(t.assignee||'unassigned') + (t.project?(' ['+t.project+']'):''), sub:true },
- ], { scale:0.9 });
- label.position.set(0, 6.2, 0);
- grp.add(label);
- grp.userData = { ticket:t, floor, trim, label, baseEmis: floor.material.emissiveIntensity, flash:0 };
+ new THREE.MeshStandardMaterial({ color:0x161b26, roughness:0.7, metalness:0.3 }));
+ desk.position.set(-ROOM/2+2,0.7,-ROOM/2+1.4); desk.castShadow=true; grp.add(desk);
+ const mon=new THREE.Mesh(new THREE.BoxGeometry(1.5,0.9,0.08),
+ new THREE.MeshStandardMaterial({ color:col, emissive:col, emissiveIntensity:0.7 }));
+ mon.position.set(-ROOM/2+2,1.55,-ROOM/2+1.0); grp.add(mon);
+
+ const label=roomLabel(t);
+ label.position.set(0,6.6,0); grp.add(label);
+ grp.userData={ ticket:t, floor, rim, trim, mon, label, baseEmis:floor.material.emissiveIntensity, flash:0, col };
return grp;
}
+function roomLabel(t){
+ const st=(t.title||''); const short=st.length>44?st.slice(0,42)+'…':st;
+ return makeLabel([
+ { t:t.id.replace(/^(TK-\d+).*/, '$1')+' · '+(t.status||'').toUpperCase() },
+ { t:short, sub:true },
+ { t:'@'+(t.assignee||'unassigned')+(t.project?(' ['+t.project+']'):''), sub:true },
+ ], { scale:0.9, glow:hex(COLOR[t.status]??COLOR.open) });
+}
-// ───────────────────────── build one avatar ─────────────────────────
+// ───────────────────────── avatar ─────────────────────────
function buildAvatar(name){
- const grp=new THREE.Group();
- const col=agentColor(name);
- const body=new THREE.Mesh(new THREE.CapsuleGeometry(0.9,1.7,4,12),
- new THREE.MeshStandardMaterial({ color:col, roughness:0.6, metalness:0.1 }));
+ const grp=new THREE.Group(); const col=agentColor(name);
+ const body=new THREE.Mesh(new THREE.CapsuleGeometry(0.9,1.7,6,16),
+ new THREE.MeshStandardMaterial({ color:col, roughness:0.35, metalness:0.5,
+ emissive:col, emissiveIntensity:0.35 }));
body.position.y=1.7; body.castShadow=true; grp.add(body);
- const head=new THREE.Mesh(new THREE.SphereGeometry(0.72,16,16),
- new THREE.MeshStandardMaterial({ color:0xffe0bd, roughness:0.7 }));
+ const head=new THREE.Mesh(new THREE.SphereGeometry(0.72,20,20),
+ new THREE.MeshStandardMaterial({ color:0xffe2c4, roughness:0.6, metalness:0.1 }));
head.position.y=3.3; head.castShadow=true; grp.add(head);
- // a little "activity" ring at the feet (glows when working)
- const ring=new THREE.Mesh(new THREE.RingGeometry(1.3,1.7,24),
- new THREE.MeshBasicMaterial({ color:col, transparent:true, opacity:0.0, side:THREE.DoubleSide }));
- ring.rotation.x=-Math.PI/2; ring.position.y=0.25; grp.add(ring);
- const label=makeLabel([{t:name}], { w:Math.max(180,name.length*16+60), scale:0.55 });
- label.position.set(0,4.7,0); grp.add(label);
- grp.userData = { name, body, head, ring, label, col, target:new THREE.Vector3(),
- home:new THREE.Vector3(), phase:Math.random()*6.28, working:0, room:null };
+ const visor=new THREE.Mesh(new THREE.SphereGeometry(0.74,20,20,0,Math.PI*2,0,Math.PI*0.55),
+ new THREE.MeshStandardMaterial({ color:col, emissive:col, emissiveIntensity:0.5, transparent:true, opacity:0.5 }));
+ visor.position.y=3.35; visor.rotation.x=-0.3; grp.add(visor);
+ const ring=new THREE.Mesh(new THREE.RingGeometry(1.3,1.75,32),
+ new THREE.MeshBasicMaterial({ color:col, transparent:true, opacity:0.25, side:THREE.DoubleSide,
+ blending:THREE.AdditiveBlending, depthWrite:false }));
+ ring.rotation.x=-Math.PI/2; ring.position.y=0.12; grp.add(ring);
+ const light=new THREE.PointLight(col.getHex(),0.0,10); light.position.y=2.4; grp.add(light);
+ const label=makeLabel([{t:name}],{ w:Math.max(180,name.length*15+56), scale:0.5, glow:hex(col.getHex()) });
+ label.position.set(0,4.8,0); grp.add(label);
+ grp.userData={ name,body,head,ring,light,label,col,target:new THREE.Vector3(),phase:Math.random()*6.28,working:0,room:null };
return grp;
}
-// ───────────────────────── DM packet ─────────────────────────
+// ───────────────────────── DM comet ─────────────────────────
const packets=[];
-function spawnPacket(fromPos, toPos, broadcast){
- const col = broadcast ? 0xf0b24a : 0xc9a4f2;
- const core=new THREE.Mesh(new THREE.SphereGeometry(0.6,12,12),
- new THREE.MeshBasicMaterial({ color:col }));
- const glow=new THREE.Mesh(new THREE.SphereGeometry(1.3,12,12),
- new THREE.MeshBasicMaterial({ color:col, transparent:true, opacity:0.35 }));
- core.add(glow);
- const light=new THREE.PointLight(col, 2.2, 26); core.add(light);
- scene.add(core);
- const from=fromPos.clone(), to=toPos.clone();
- const mid=from.clone().add(to).multiplyScalar(0.5);
- mid.y += from.distanceTo(to)*0.28 + 6; // arc apex
- packets.push({ core, from, mid, to, t:0, dur:1.1 + from.distanceTo(to)/220 });
-}
-// quadratic bezier
-function qbez(a,b,c,t,out){ const u=1-t;
- out.x=u*u*a.x+2*u*t*b.x+t*t*c.x; out.y=u*u*a.y+2*u*t*b.y+t*t*c.y; out.z=u*u*a.z+2*u*t*b.z+t*t*c.z; return out; }
-
-// ───────────────────────── state ─────────────────────────
-const roomsGrp = new THREE.Group(); scene.add(roomsGrp);
-const avatarsGrp = new THREE.Group(); scene.add(avatarsGrp);
-const roomByTicket = new Map(); // ticket id -> room group
-const avatarByName = new Map(); // agent -> avatar group
-let seenMsgMids = null; // Set, null until first load (so we don't storm on boot)
-let lastActionTs = 0;
-let showLabels = true, showDone = true, followDMs = false;
-let lastFetch = 0;
-
-// last-known action time of each ticket, so avatars gravitate to their newest room
-function agentLatestRoom(tickets){
- const at = new Map(); // agent -> {ts, ticketId}
- for(const t of tickets){
- for(const a of t.actions||[]) consider(a.agent, a.ts, t.id);
- for(const c of t.comments||[]) consider(c.agent, c.ts, t.id);
- // assignee gets a weak claim on its ticket (so unassigned-but-owning shows)
- if(t.assignee) consider(t.assignee, t.updated_at, t.id, true);
- }
- function consider(agent, ts, id, weak){
- if(!agent) return;
- const cur = at.get(agent);
- const key = weak ? ts+'