[object Object]

← back to Agent Abrams Kanban

Add interactive controls (pause/speed/spawn/click-advance) + virtual clock + README

3a5fb88bf25437ec59eb2ca43f6b189d485bd5a7 · 2026-07-25 00:41:21 -0700 · Steve

Files touched

Diff

commit 3a5fb88bf25437ec59eb2ca43f6b189d485bd5a7
Author: Steve <steve@designerwallcoverings.com>
Date:   Sat Jul 25 00:41:21 2026 -0700

    Add interactive controls (pause/speed/spawn/click-advance) + virtual clock + README
---
 README.md  | 40 ++++++++++++++++++++++++++++++
 index.html | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++------------
 2 files changed, 108 insertions(+), 16 deletions(-)

diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ca34081
--- /dev/null
+++ b/README.md
@@ -0,0 +1,40 @@
+# Agent Abrams — Task Pipeline Kanban
+
+A single self-contained `index.html` — a self-animating agent task-pipeline board.
+Task cards flow **Queued → Running → Done → Shipped** with smooth column-to-column
+slides, then recycle back to Queued so the board never empties. Pure CSS/JS, no build,
+no dependencies. Just open `index.html`.
+
+## Features
+
+- **FLIP slide animation** — cards live in natural flex flow inside scrollable columns;
+  cross-column moves animate via First-Last-Invert-Play, so they never overflow or overlap.
+- **Timed auto-advance** — each card has a randomized dwell window per stage, so work
+  flows steadily instead of clumping.
+- **Agent avatars** — colored dot with initials + agent name per card (Orion, Vega, Nova…).
+- **Live per-card timer** and an animated progress bar on Running cards.
+- **Per-column counts** that bump on change, plus header telemetry:
+  uptime, total shipped, throughput/min.
+- **Dark neon UI** — glassmorphism, animated grid backdrop, per-column accent glow.
+- Honors `prefers-reduced-motion`.
+
+## Interactive controls (top-right)
+
+| Control | Action |
+|---------|--------|
+| **+ Task** | Queue a new task immediately |
+| **1× / 2× / 4×** | Cycle simulation speed (rescales pending hops live) |
+| **⏸ Pause** | Freeze the pipeline — a virtual clock keeps timers & throughput honest |
+| **Click a card** | Manually advance it to the next column |
+
+## How it works
+
+A virtual clock (`now()`) subtracts accumulated paused time, so pausing freezes every
+timer and the throughput stat rather than letting wall-clock drift skew the numbers.
+Speed changes rescale each card's remaining time in place. Cards that reach Shipped are
+re-themed with a fresh agent + title and slid back to Queued, keeping a constant
+population without churning DOM nodes.
+
+Started from Kimi K2.5's Model Arena artifact; rebuilt the positioning engine
+(absolute-stride → FLIP-in-flow), replaced random-roll advancement with timed dwell,
+and added the control surface + telemetry.
diff --git a/index.html b/index.html
index 164cb33..e4c0a0f 100644
--- a/index.html
+++ b/index.html
@@ -53,6 +53,18 @@ body::before{
 .stats{display:flex;gap:18px;font-size:.72rem;color:var(--muted);letter-spacing:1px;text-transform:uppercase}
 .stats b{color:var(--ink);font-variant-numeric:tabular-nums}
 
+.controls{display:flex;gap:8px;align-items:center}
+.controls button{
+  font:inherit;font-size:.68rem;letter-spacing:1px;text-transform:uppercase;font-weight:700;
+  color:var(--muted);background:rgba(255,255,255,0.05);
+  border:1px solid rgba(255,255,255,0.12);border-radius:8px;
+  padding:6px 12px;cursor:pointer;transition:all .18s;
+}
+.controls button:hover{color:var(--ink);border-color:rgba(255,255,255,0.28);background:rgba(255,255,255,0.09)}
+.controls button.active{color:#06060b;background:var(--running);border-color:var(--running);box-shadow:0 0 12px rgba(0,229,255,.4)}
+.controls button.paused{color:#06060b;background:var(--shipped);border-color:var(--shipped);box-shadow:0 0 12px rgba(255,176,32,.4)}
+.card{cursor:pointer}
+
 .board{
   flex:1;min-height:0;
   display:grid;grid-template-columns:repeat(4,1fr);gap:16px;
@@ -151,6 +163,11 @@ body::before{
       <div>Shipped <b id="shipCount">0</b></div>
       <div>Throughput <b id="tput">0.0</b>/min</div>
     </div>
+    <div class="controls">
+      <button id="btnSpawn" title="Queue a new task">+ Task</button>
+      <button id="btnSpeed" title="Cycle simulation speed">1×</button>
+      <button id="btnPause" title="Pause / resume the pipeline">⏸ Pause</button>
+    </div>
   </div>
 
   <div class="board" id="board">
@@ -207,11 +224,16 @@ body::before{
 
   const rint = (a,b)=>a+Math.floor(Math.random()*(b-a+1));
   const pick = a=>a[Math.floor(Math.random()*a.length)];
-  const now  = ()=>performance.now();
+
+  // --- virtual clock: freezes on pause so timers + throughput stay honest ---
+  let paused = false, speed = 1, pausedAccum = 0, pauseStart = 0;
+  const now = ()=>performance.now() - pausedAccum - (paused ? performance.now()-pauseStart : 0);
+  // dwell scaled by sim speed
+  const dwell = stage => rint(...DWELL[stage]) / speed;
 
   let uid = 0, shippedTotal = 0;
-  const startWall = Date.now();
   const tasks = [];
+  const startV = now();
 
   function makeTask(seedStage){
     const agent = pick(AGENTS);
@@ -221,7 +243,7 @@ body::before{
       agent,
       status: seedStage || 'queued',
       createdAt: now(),
-      nextAt: now() + rint(...DWELL[seedStage||'queued']),
+      nextAt: now() + dwell(seedStage||'queued'),
       el: null,
     };
     t.el = buildEl(t);
@@ -229,6 +251,7 @@ body::before{
     bodies[t.status].appendChild(t.el);
     t.el.classList.add('enter');
     t.el.addEventListener('animationend',()=>t.el.classList.remove('enter'),{once:true});
+    t.el.addEventListener('click',()=>{ advance(t); refreshCounts(); });
     return t;
   }
 
@@ -278,7 +301,14 @@ body::before{
       moveWithFlip(t, 'queued');
     }
     // status was set inside moveWithFlip; schedule its next hop
-    t.nextAt = now() + rint(...DWELL[t.status]);
+    t.nextAt = now() + dwell(t.status);
+  }
+
+  function refreshCounts(){
+    STAGES.forEach(s=>{
+      const n = tasks.filter(t=>t.status===s).length;
+      if(counts[s].textContent != n) counts[s].textContent = n;
+    });
   }
 
   function moveWithFlip(t, toStage){
@@ -318,6 +348,7 @@ body::before{
 
   // ---- ticks ----
   function tickAdvance(){
+    if(paused) return;
     const t0 = now();
     // advance at most a couple per tick so slides don't collide visually
     let moved = 0;
@@ -326,11 +357,7 @@ body::before{
         advance(t); moved++;
       }
     }
-    // refresh column counts
-    STAGES.forEach(s=>{
-      const n = tasks.filter(t=>t.status===s).length;
-      if(counts[s].textContent != n) counts[s].textContent = n;
-    });
+    refreshCounts();
   }
 
   function tickSecond(){
@@ -342,25 +369,50 @@ body::before{
         const bar = t.el.querySelector('.bar>i');
         if(bar){
           // progress = elapsed since entering running / its dwell window (approx via nextAt)
-          const total = DWELL.running[1];
+          const total = DWELL.running[1]/speed;
           const remain = Math.max(0, t.nextAt - t0);
           const pct = Math.min(100, Math.max(4, 100*(1 - remain/total)));
           bar.style.width = pct+'%';
         }
       }
     }
-    // header stats
-    document.getElementById('uptime').textContent = fmt(Date.now()-startWall);
+    // header stats (virtual clock => frozen while paused)
+    const up = now() - startV;
+    document.getElementById('uptime').textContent = fmt(up);
     document.getElementById('shipCount').textContent = shippedTotal;
-    const mins = (Date.now()-startWall)/60000;
+    const mins = up/60000;
     document.getElementById('tput').textContent = mins>0 ? (shippedTotal/mins).toFixed(1) : '0.0';
   }
 
+  // ---- controls ----
+  const btnPause = document.getElementById('btnPause');
+  const btnSpeed = document.getElementById('btnSpeed');
+  const btnSpawn = document.getElementById('btnSpawn');
+  const SPEEDS = [1,2,4];
+
+  btnPause.addEventListener('click',()=>{
+    paused = !paused;
+    if(paused){ pauseStart = performance.now(); }
+    else { pausedAccum += performance.now()-pauseStart; }
+    btnPause.textContent = paused ? '▶ Resume' : '⏸ Pause';
+    btnPause.classList.toggle('paused', paused);
+  });
+
+  btnSpeed.addEventListener('click',()=>{
+    const i = (SPEEDS.indexOf(speed)+1) % SPEEDS.length;
+    // rescale every pending hop so the change takes effect immediately
+    const t0 = now(), prev = speed;
+    speed = SPEEDS[i];
+    tasks.forEach(t=>{ t.nextAt = t0 + (t.nextAt - t0) * (prev/speed); });
+    btnSpeed.textContent = speed+'×';
+    btnSpeed.classList.toggle('active', speed!==1);
+  });
+
+  btnSpawn.addEventListener('click',()=>{ makeTask('queued'); refreshCounts(); });
+
   // ---- seed ~10 cards spread across the pipeline ----
   const seed = ['queued','queued','queued','running','running','running','done','done','shipped','shipped'];
-  seed.forEach((s,i)=>{
-    setTimeout(()=>{ makeTask(s); STAGES.forEach(st=>counts[st].textContent = tasks.filter(t=>t.status===st).length); }, i*90);
-  });
+  seed.forEach((s,i)=>{ setTimeout(()=>{ makeTask(s); refreshCounts(); }, i*90); });
 
   setInterval(tickAdvance, 700);
   setInterval(tickSecond, 500);

← 212c8d3 Agent Abrams task-pipeline kanban: FLIP slides, timed auto-a  ·  back to Agent Abrams Kanban  ·  chore: lint (strict-eq, dedup .card rule) + refactor (cache 3dfa365 →