← Commit history

Refresh dashboard project images and captions alongside live timeline; preserve last good view

John Lauer ·453cf46f9d · ·parent d944af0
8 files changed +91−27
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.1.84"+version = "0.1.85" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -24,7 +24,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.1.84"+version = "0.1.85" dependencies = [  "aiflow-board",  "serde",@@ -34,7 +34,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.1.84"+version = "0.1.85" dependencies = [  "roxmltree",  "serde",@@ -43,7 +43,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.1.84"+version = "0.1.85" dependencies = [  "aiflow-board",  "serde",@@ -52,7 +52,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.1.84"+version = "0.1.85" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -62,7 +62,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.1.84"+version = "0.1.85" dependencies = [  "aiflow-board",  "serde",@@ -71,7 +71,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.1.84"+version = "0.1.85" dependencies = [  "aiflow-board",  "serde",@@ -80,7 +80,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.1.84"+version = "0.1.85" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -90,7 +90,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.1.84"+version = "0.1.85" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -100,7 +100,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.1.84"+version = "0.1.85" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.1.84"+version = "0.1.85" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
bin/adom-aiflow
⋯ 1 unchanged line ⋯
docs/release-0.1.85.mdadded+3
@@ -0,0 +1,3 @@+# 0.1.85: live dashboard presentation refresh++Saved project images and captions now refresh alongside the timeline, including when the workflow state itself does not change. Partial JSON writes, disallowed image URLs and failed image loads preserve the last good view. A valid presentation without an image returns to the early-stage summary. Gate evidence and the timeline layout are unchanged.
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.1.84",+  "version": "0.1.85",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad today; Altium, Fusion and Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
tests/presentation-polling.mjsadded+29
@@ -0,0 +1,29 @@+// Exercise the dashboard's actual refresh function with deterministic fetch/image failures.+import fs from 'node:fs';+import vm from 'node:vm';+import assert from 'node:assert/strict';+const html=fs.readFileSync(new URL('../tools/timeline/index.html',import.meta.url),'utf8');+const start=html.indexOf('  let presentation = null;');+const end=html.indexOf("  $('contribute-prompt')",start);+assert(start>0 && end>start);+const elements=new Map();+const $=id=>{if(!elements.has(id))elements.set(id,{hidden:true,removeAttribute(k){delete this[k]}});return elements.get(id)};+let response='{}',status=200,imageFailure=false,loads=0,renders=0;+const ctx={URL,AbortSignal,setTimeout,clearTimeout,location:{href:'http://localhost:8893/',origin:'http://localhost:8893'},$,model:{gate:'unchanged'},render(){renders++},fetch:async()=>({ok:status===200,text:async()=>response}),Image:class{set src(url){loads++;queueMicrotask(()=>imageFailure?this.onerror():this.onload())}}};+vm.createContext(ctx);vm.runInContext(html.slice(start,end)+'\nglobalThis.refresh=refreshPresentation;',ctx);+const put=async data=>{response=JSON.stringify(data);await ctx.refresh()};+await put({image:'before.png',caption:'Before'});assert.equal($('board-caption').textContent,'Before');assert.equal(loads,1);+await ctx.refresh();assert.equal(loads,1,'unchanged metadata must not reload/flicker');+await put({image:'after.png',caption:'Current passed'});assert.equal($('board-caption').textContent,'Current passed');assert.match($('board-image').src,/after.png/);+const previous=$('board-image').src;+response='{';await ctx.refresh();assert.equal($('board-image').src,previous,'partial file preserves view');+status=404;await ctx.refresh();assert.equal($('board-image').src,previous);status=200;+await put({image:'https://tracker.invalid/p.png',caption:'Wrong'});assert.equal($('board-caption').textContent,'Current passed');+await put({image:'script.html',caption:'Wrong'});assert.equal($('board-image').src,previous);+imageFailure=true;await put({image:'missing.png',caption:'Wrong'});assert.equal($('board-image').src,previous);imageFailure=false;+await ctx.refresh();assert.match($('board-image').src,/missing.png/,'failed image is retried');+await put({title:'Requirements',summary:'Before a board exists'});assert.equal($('board-image').hidden,true);assert.equal($('board-caption').hidden,true);assert.equal($('preview-empty').hidden,false);assert.equal($('board-image').src,undefined);+await put({image:'same.png',caption:'One',imageRevision:1});const first=$('board-image').src;await put({image:'same.png',caption:'One',imageRevision:2});assert.notEqual($('board-image').src,first);+assert.equal(ctx.model.gate,'unchanged');assert(renders>=5);+assert(html.includes('await refreshPresentation();'),'poll must refresh even when timeline is unchanged');+console.log('PASS: changed image/caption, unchanged timeline, partial/missing JSON, URL policy, image failure/retry, removal, same-filename revision; gate state untouched');
tools/timeline/README.md+2
@@ -67,3 +67,5 @@ The presentation file is preserved when CLI commands refresh the timeline.  The community card links to the public MIT license, source and pull requests. Its prompt helps users contribute through their AI; follow the root skill's contribution guidance.++The optional presentation is polled along with the timeline. Changed captions and saved images appear without navigation. A missing/malformed file or failed image preserves the last good presentation; a valid object without `image` clears the image. For an overwritten image with otherwise identical metadata, change an `imageRevision` field to trigger a reload. Presentation text never changes engineering gate evidence.
tools/timeline/index.html+45−15
@@ -564,21 +564,50 @@ body.wide .stage { margin-top: 14px; }   };    let presentation = null;-  // Optional project presentation is separate from measured workflow state.-  // Only same-origin image assets; no arbitrary iframe, script, or remote tracking image.-  fetch('presentation.json', {cache: 'no-store'}).then(r => r.ok ? r.json() : null).then(p => {-    if (!p || typeof p !== 'object') return;-    presentation = p;-    if (model) render(model);-    if (typeof p.image !== 'string') return;-    const url = new URL(p.image, location.href);-    if (url.origin !== location.origin || !/\.(png|jpe?g|webp)$/i.test(url.pathname)) return;-    const img = $('board-image');-    img.onload = () => { img.hidden = false; $('preview-empty').hidden = true; $('board-caption').hidden = false; };-    img.alt = String(p.alt || 'Saved board view');-    $('board-caption').textContent = String(p.caption || 'Saved board view · not a live render');-    img.src = url.href;-  }).catch(() => {});+  let presentationRaw = null;+  let presentationGeneration = 0;+  // Optional artwork is polled independently of measured workflow state.+  // Keep the last good view across half-written JSON and failed image loads.+  async function refreshPresentation() {+    try {+      const res = await fetch('presentation.json', {cache: 'no-store', signal: AbortSignal.timeout(3000)});+      if (!res.ok) return;+      const raw = await res.text();+      if (raw === presentationRaw) return;+      const p = JSON.parse(raw);+      if (!p || typeof p !== 'object' || Array.isArray(p)) return;+      let url = null;+      if (p.image != null && p.image !== '') {+        if (typeof p.image !== 'string') return;+        url = new URL(p.image, location.href);+        if (url.origin !== location.origin || !/\.(png|jpe?g|webp)$/i.test(url.pathname)) return;+      }+      const generation = ++presentationGeneration;+      if (url) {+        // Reload an overwritten filename when its presentation metadata changes.+        url.searchParams.set('_presentation', Date.now() + '-' + generation);+        const preview = new Image();+        await new Promise((resolve, reject) => {+          const timeout = setTimeout(() => reject(new Error('preview timeout')), 3000);+          preview.onload = () => { clearTimeout(timeout); resolve(); };+          preview.onerror = () => { clearTimeout(timeout); reject(new Error('preview unavailable')); };+          preview.src = url.href;+        });+        if (generation !== presentationGeneration) return;+      }+      presentationRaw = raw;+      presentation = p;+      const img = $('board-image');+      img.hidden = !url;+      $('preview-empty').hidden = !!url;+      $('board-caption').hidden = !url;+      img.alt = String(p.alt || 'Saved board view');+      $('board-caption').textContent = url ? String(p.caption || 'Saved board view · not a live render') : '';+      if (url) img.src = url.href;+      else img.removeAttribute('src');+      if (model) render(model);+    } catch (_) { /* Preserve the last successfully loaded presentation. */ }+  }   $('contribute-prompt').addEventListener('click', async () => {     const prompt = $('contribute-prompt').title;     const feedback = $('copy-feedback');@@ -1212,6 +1241,7 @@ body.wide .stage { margin-top: 14px; }       if (!everOk) { document.body.classList.add('waiting-on'); setLive('idle'); }       else if (Date.now() - lastOkAt > 4000) setLive('stale');     } finally {+      await refreshPresentation();       setTimeout(poll, 1000);     }   }