← back to Ticket System
office clock v4: legibility pass (taller chip, halo dot, truncation, live opacity-breathe, clears room label; OVERDUE_MS const)
6c7413355d9e1425b7084eee220de2888d957eaa · 2026-07-27 15:39:01 -0700 · Steve Abrams
Round-3 loop: Contrarian gate = SHIP IT (no correctness bug/NaN/leak); adopted its OVERDUE_MS named-constant nit. UI/UX reviewer legibility fixes applied: chip 76->90px, dot 9px+halo, worker label 20/700 truncated, sub-line 17px, anchor lowered 5.2->3.6 to clear the room label, live-state sprite-opacity breathe (no repaint).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
Diff
commit 6c7413355d9e1425b7084eee220de2888d957eaa
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Jul 27 15:39:01 2026 -0700
office clock v4: legibility pass (taller chip, halo dot, truncation, live opacity-breathe, clears room label; OVERDUE_MS const)
Round-3 loop: Contrarian gate = SHIP IT (no correctness bug/NaN/leak); adopted its OVERDUE_MS named-constant nit. UI/UX reviewer legibility fixes applied: chip 76->90px, dot 9px+halo, worker label 20/700 truncated, sub-line 17px, anchor lowered 5.2->3.6 to clear the room label, live-state sprite-opacity breathe (no repaint).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
office.html | 47 +++++++++++++++++++++++++++--------------------
1 file changed, 27 insertions(+), 20 deletions(-)
diff --git a/office.html b/office.html
index 75ce98a..e841fd4 100644
--- a/office.html
+++ b/office.html
@@ -156,6 +156,7 @@ const COLOR = { doing:0x6db3f2, blocked:0xff6b7a, open:0x9aa4b8, done:0x3ee6a0
const ROOM=9, PITCH=12, LANE_W=42, DONE_CAP=14, POLL_MS=4000;
const RUN=0x34d9a0; // "running" green — the through-line for live things
const LIVE_MS=8*60*1000; // an agent that acted within 8 min counts as live/running
+const OVERDUE_MS=2*60*60*1000; // a DOING room idle this long → red "stalled" numeral
// ───────────────────────── renderer ─────────────────────────
const canvas=document.getElementById('c');
@@ -259,7 +260,7 @@ function fmtAgo(ms){ if(ms<0||!isFinite(ms)) return '—'; const s=Math.floor(ms
if(s<60) return s+'s'; const m=Math.floor(s/60); if(m<60) return m+'m';
const h=Math.floor(m/60); return h<24 ? h+'h' : Math.floor(h/24)+'d'; }
const CLK = { live:'#3ee6a0', idle:'#f5b74a', orphan:'#586074' }; // state accents
-function makeClockSprite(){ const w=360,h=76,dpr=2,cnv=document.createElement('canvas');
+function makeClockSprite(){ const w=360,h=90,dpr=2,cnv=document.createElement('canvas');
cnv.width=w*dpr; cnv.height=h*dpr; const g=cnv.getContext('2d'); g.scale(dpr,dpr);
const tex=new THREE.CanvasTexture(cnv); tex.anisotropy=4;
const spr=new THREE.Sprite(new THREE.SpriteMaterial({ map:tex, transparent:true, depthWrite:false }));
@@ -270,26 +271,29 @@ function drawClock(spr,st){
const u=spr.userData; if(key===u.last) return; u.last=key; const { g,w,h }=u;
const accent=CLK[st.state]||CLK.idle;
g.clearRect(0,0,w,h);
- g.fillStyle='rgba(9,11,17,0.84)'; roundRect(g,0,0,w,h,15); g.fill();
- g.strokeStyle=accent+(st.state==='live'?'cc':'88'); g.lineWidth=1.6; roundRect(g,1,1,w-2,h-2,14); g.stroke();
- // status dot — blinks only while live; solid otherwise
+ g.fillStyle='rgba(9,11,17,0.84)'; roundRect(g,0,0,w,h,16); g.fill();
+ g.strokeStyle=accent+(st.state==='live'?'cc':'88'); g.lineWidth=2; roundRect(g,1.5,1.5,w-3,h-3,15); g.stroke();
+ // status dot (+ halo) — blinks only while live; solid otherwise. Bigger so the
+ // blink survives the bloom pass at camera distance.
+ g.strokeStyle=accent+'55'; g.lineWidth=2; g.beginPath(); g.arc(28,h/2,13,0,Math.PI*2); g.stroke();
g.fillStyle = (st.state==='live' && st.blink) ? '#0b0d11' : accent;
- g.beginPath(); g.arc(26,h/2,7.5,0,Math.PI*2); g.fill();
- // big duration numeral (RED when overdue >2h) — the "how long in doing" signal
+ g.beginPath(); g.arc(28,h/2,9,0,Math.PI*2); g.fill();
+ // big duration numeral (RED when stalled/overdue) — the "how long in doing" signal
g.textBaseline='middle'; g.textAlign='left';
- g.shadowColor=st.overdue?'#ff6b7a':accent; g.shadowBlur=st.state==='live'?14:8;
- g.fillStyle=st.overdue?'#ff8a95':(st.state==='live'?'#8affce':(st.state==='orphan'?'#98a0b2':'#ffd98a'));
- g.font='800 32px ui-monospace,SFMono-Regular,Menlo,monospace'; g.fillText(st.dur, 46, h/2+1);
- // right column: worker/state label over "last active Xs ago" — the "who/now" signal
+ g.shadowColor=st.overdue?'#ff6b7a':accent; g.shadowBlur=st.state==='live'?16:9;
+ g.fillStyle=st.overdue?'#ff8a95':(st.state==='live'?'#8affce':(st.state==='orphan'?'#6a7282':'#e8c070'));
+ g.font='800 34px ui-monospace,SFMono-Regular,Menlo,monospace'; g.fillText(st.dur, 50, h/2+1);
+ // right column: worker/state label (large) over "last active" (readable) — the "who/now" signal
+ const wn=(st.worker||''); const w12=wn.length>12?wn.slice(0,11)+'…':wn;
g.shadowBlur=0; g.textAlign='right';
- g.font='600 17px -apple-system,Segoe UI,sans-serif'; g.fillStyle=accent;
- const top = st.state==='live' ? ('● '+(st.worker||'working'))
- : st.state==='orphan' ? 'no agent' : (st.worker?('@'+st.worker):'unassigned')+' · idle';
- g.fillText(top, w-16, h*0.34);
- g.font='500 15px -apple-system,Segoe UI,sans-serif'; g.fillStyle='#8a93a6';
+ g.font='700 20px -apple-system,Segoe UI,sans-serif'; g.fillStyle=accent;
+ const top = st.state==='live' ? ('● '+(w12||'working'))
+ : st.state==='orphan' ? 'no agent' : (w12?('@'+w12):'unassigned');
+ g.fillText(top, w-16, h*0.35);
+ g.font='500 17px -apple-system,Segoe UI,sans-serif'; g.fillStyle='#99a3b6';
const sub = st.state==='orphan' ? 'no actor yet'
- : st.state==='live' ? ('last '+st.ago+' ago') : ('last active '+st.ago+' ago');
- g.fillText(sub, w-16, h*0.66);
+ : st.state==='live' ? ('last '+st.ago+' ago') : ('idle '+st.ago);
+ g.fillText(sub, w-16, h*0.67);
u.tex.needsUpdate=true; }
function hashHue(s){ let h=0; for(let i=0;i<s.length;i++) h=(h*31+s.charCodeAt(i))>>>0; return h%360; }
@@ -338,7 +342,7 @@ function buildRoom(t){
const label=roomLabel(t);
label.position.set(0,6.6,0); grp.add(label);
// live clock chip — only shown for DOING rooms (see apply() + tick())
- const clock=makeClockSprite(); clock.position.set(0,5.2,0); clock.visible=false; grp.add(clock);
+ const clock=makeClockSprite(); clock.position.set(0,3.6,0); clock.visible=false; grp.add(clock);
grp.userData={ ticket:t, floor, rim, trim, mon, label, clock, runEdge,
baseEmis:floor.material.emissiveIntensity, flash:0, col,
workSince:+new Date(t.created_at||t.updated_at||Date.now()),
@@ -673,8 +677,11 @@ function tick(){
const state=u.orphan?'orphan':(u.liveWork?'live':'idle');
// OVERDUE alarm keys off IDLE time (stalled), not total age — a live room never reds
drawClock(u.clock,{ state, dur:fmtDur(durMs), ago:fmtAgo(idleMs),
- worker:u.worker, blink:(nowMs%1000)<500, overdue:idleMs>2*3600*1000 }); }
- u.clock.position.y=5.2+Math.sin(el*2.2+room.position.z*0.2)*0.12; }
+ worker:u.worker, blink:(nowMs%1000)<500, overdue:idleMs>OVERDUE_MS });
+ // live rooms "breathe" via sprite opacity — a distance-visible motion cue,
+ // no canvas repaint (keeps drawClock's diff-guard intact)
+ u.clock.material.opacity = u.liveWork ? 0.86+0.14*Math.sin(el*4) : 1; }
+ u.clock.position.y=3.6+Math.sin(el*2.2+room.position.z*0.2)*0.12; }
u.label.visible=showLabels; }
if(laneSigns) laneSigns.children.forEach((s,i)=>{ s.visible=showLabels; s.position.y=12+Math.sin(el*1.2+i)*0.5; });
← 8e488ba office board: dynamic live clock on DOING rooms (per-ticket
·
back to Ticket System
·
add stale-doing reaper: flags DOING tickets idle >Nh with no 1b3f83b →