fix(board): stop board twitch + card overlap; KPI pulse; SVG donut center (TSK-0153/0154)

Root cause of twitch + overlap: source.onmessage ran an unconditional full
board rerender (cardsEl.innerHTML=) on EVERY change event, and the enter
animation was on a global `.card` selector so it replayed on all cards each
rerender.

1+2) SSE onmessage now parses event.type and only rerenders the board on
     type==='task' (message/ask/decision/memory/handoff/agent no longer
     trigger a board rerender; budget still refreshes — it has its own
     diff/throttle). renderBoard() rebuilt as a per-card diff-patch
     (patchColumn/cardSig/cardNode): untouched cards are kept + reordered,
     only new/changed cards are (re)built. Enter animation scoped to a
     one-shot .card-new class. min-height added to the .card transition list
     so the console-open growth is smooth. Open live consoles preserved
     (reapplyConsoles unchanged; kept nodes retain their console DOM).
3) setMetric() pulses the .metric-card (.metric-flash + @keyframes metricFlash)
     only when the value actually changes from a previously-seen value.
4) Donut center readout moved from a CSS top:66.7% div to SVG-native <text>
     in viewBox coords (x=110, value y=102, label y=120) so it scales and sits
     in the arc opening. .donut-value/.donut-label restyled with fill.
     TSK-0154 politur: .cc-line.level-bridge .cc-text color.

tsc clean; vitest 36 files / 184 tests green. Visual points are Screenshot/
manual (no board.test.ts). No console regression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-07-12 02:21:41 +02:00
parent 08667637e6
commit f9c7c2ae3c

View File

@ -272,6 +272,12 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
.metric-card { background: #eef1f6; border-color: #d7dce6; } .metric-card { background: #eef1f6; border-color: #d7dce6; }
.metric-card .metric-label { color: #5b6472; } .metric-card .metric-label { color: #5b6472; }
.metric-card .metric-value { color: #0b1220; } .metric-card .metric-value { color: #0b1220; }
/* Subtle background pulse when a KPI value actually changes (setMetric). */
.metric-card.metric-flash { animation: metricFlash 900ms ease; }
@keyframes metricFlash {
0% { background: #dbe4f5; box-shadow: 0 0 0 2px rgba(88, 166, 255, .35); }
100% { background: #eef1f6; box-shadow: 0 0 0 0 rgba(88, 166, 255, 0); }
}
.workbench { .workbench {
display: grid; display: grid;
grid-template-columns: minmax(0, 3fr) minmax(280px, 1fr); grid-template-columns: minmax(0, 3fr) minmax(280px, 1fr);
@ -359,7 +365,7 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
user-select: none; user-select: none;
transition: border-color 180ms ease, background 180ms ease, transform 180ms ease, box-shadow 180ms ease; transition: border-color 180ms ease, background 180ms ease, transform 180ms ease, box-shadow 180ms ease, min-height 200ms ease;
} }
.card:hover { border-color: rgba(88, 166, 255, .55); background: #223047; transform: translateY(-1px); box-shadow: 0 12px 28px rgba(2, 6, 18, .24); } .card:hover { border-color: rgba(88, 166, 255, .55); background: #223047; transform: translateY(-1px); box-shadow: 0 12px 28px rgba(2, 6, 18, .24); }
.column[data-column="open"] .card { border-left-color: var(--open); } .column[data-column="open"] .card { border-left-color: var(--open); }
@ -643,6 +649,7 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
.cc-line .cc-agent { color: var(--accent); font-weight: 600; flex: 0 0 auto; } .cc-line .cc-agent { color: var(--accent); font-weight: 600; flex: 0 0 auto; }
.cc-line.level-error .cc-text { color: #fca5a5; } .cc-line.level-error .cc-text { color: #fca5a5; }
.cc-line.level-warn .cc-text { color: #fcd34d; } .cc-line.level-warn .cc-text { color: #fcd34d; }
.cc-line.level-bridge .cc-text { color: #c4b5fd; }
.cc-empty { color: var(--muted); font-style: italic; } .cc-empty { color: var(--muted); font-style: italic; }
/* ── Drag & drop ────────────────────────────────────────────────────── */ /* ── Drag & drop ────────────────────────────────────────────────────── */
@ -653,7 +660,9 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
0% { border-color: var(--green); box-shadow: 0 0 0 2px rgba(34,197,94,.4); } 0% { border-color: var(--green); box-shadow: 0 0 0 2px rgba(34,197,94,.4); }
100% { border-color: var(--border); box-shadow: none; } 100% { border-color: var(--border); box-shadow: none; }
} }
.card { animation: cardEnter 240ms cubic-bezier(.2,.7,.2,1); } /* Enter animation only on genuinely new card nodes NOT every rerender
(a global .card selector replayed it on all cards visible overlap). */
.card-new { animation: cardEnter 240ms cubic-bezier(.2,.7,.2,1); }
@keyframes cardEnter { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } @keyframes cardEnter { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.column.drop-active { border-color: var(--accent); background: #1a2436; box-shadow: 0 0 0 1px rgba(88,166,255,.4) inset; } .column.drop-active { border-color: var(--accent); background: #1a2436; box-shadow: 0 0 0 1px rgba(88,166,255,.4) inset; }
.column.drop-active .col-head { color: var(--accent); } .column.drop-active .col-head { color: var(--accent); }
@ -749,18 +758,11 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
from { stroke-dashoffset: 100; } from { stroke-dashoffset: 100; }
to { stroke-dashoffset: 0; } to { stroke-dashoffset: 0; }
} }
.donut-center { /* Center readout is now SVG-native <text> positioned in viewBox coords
grid-area: 1 / 1; (cx=110, cy=122), so it scales with the donut and sits vertically in the
position: absolute; arc opening replaces the old CSS percent-offset that read too high. */
left: 50%; .donut-value { fill: var(--text); font: 800 28px var(--mono); letter-spacing: 0; }
top: 66.7%; .donut-label { fill: var(--muted); font: 10px var(--mono); }
width: min(180px, 82%);
transform: translate(-50%, -50%);
text-align: center;
pointer-events: none;
}
.donut-value { display: block; font: 800 28px/1 var(--mono); color: var(--text); letter-spacing: 0; }
.donut-label { display: block; margin-top: 3px; color: var(--muted); font: 10px/1.2 var(--mono); }
.legend { display: grid; gap: 6px; margin-top: 6px; } .legend { display: grid; gap: 6px; margin-top: 6px; }
.legend-row { display: grid; grid-template-columns: 10px minmax(0, 1fr) auto; align-items: center; gap: 7px; font: 11px/1.25 var(--mono); color: var(--muted); } .legend-row { display: grid; grid-template-columns: 10px minmax(0, 1fr) auto; align-items: center; gap: 7px; font: 11px/1.25 var(--mono); color: var(--muted); }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; background: currentColor; box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 16%, transparent); } .legend-dot { width: 10px; height: 10px; border-radius: 50%; background: currentColor; box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 16%, transparent); }
@ -978,6 +980,7 @@ ${columnSkeleton()}
var PROJECT_NAME = ${initialProjectName}; var PROJECT_NAME = ${initialProjectName};
var lastTasks = []; var lastTasks = [];
var openConsoles = {}; // taskId -> true when its in-card console is expanded var openConsoles = {}; // taskId -> true when its in-card console is expanded
var metricValues = {}; // KPI name -> last rendered value (drives change-pulse)
var CONN_CACHE_KEY = 'agenthub-connection-state'; var CONN_CACHE_KEY = 'agenthub-connection-state';
function esc(s) { function esc(s) {
@ -1073,7 +1076,22 @@ ${columnSkeleton()}
} }
function setMetric(name, value) { function setMetric(name, value) {
var el = document.querySelector('[data-metric="' + name + '"]'); var el = document.querySelector('[data-metric="' + name + '"]');
if (el) el.textContent = String(value); var next = String(value);
if (el) {
// Pulse only when the value truly changed from a previously-seen value —
// never on first paint (no entry yet), so the KPIs don't all flash on load.
var changed = Object.prototype.hasOwnProperty.call(metricValues, name) && metricValues[name] !== next;
el.textContent = next;
if (changed) {
var card = el.closest('.metric-card');
if (card) {
card.classList.remove('metric-flash');
void card.offsetWidth; // restart the animation if it was mid-flight
card.classList.add('metric-flash');
}
}
}
metricValues[name] = next;
if (window.__setMetricIcon) window.__setMetricIcon(name, value); if (window.__setMetricIcon) window.__setMetricIcon(name, value);
} }
function statusCounts(tasks) { function statusCounts(tasks) {
@ -1145,6 +1163,68 @@ ${columnSkeleton()}
function byStatus(status) { function byStatus(status) {
return COLUMNS.indexOf(status) >= 0 ? status : 'open'; return COLUMNS.indexOf(status) >= 0 ? status : 'open';
} }
// Signature of everything that changes a card's rendered CONTENT — but NOT
// its console open/closed state (that lives in the DOM + openConsoles and
// must survive a patch). Identical signature ⇒ the card node is left alone.
function cardSig(t) {
return [byStatus(t.status), t.title || '', t.assignedTo || '', t.reviewer || '', t.role || '', t.createdAt || '', t.updatedAt || ''].join('\\u0001');
}
function cardNode(t, sig, isNew) {
var tmp = document.createElement('div');
tmp.innerHTML = taskCard(t);
var node = tmp.firstElementChild;
if (!node) return null;
node.setAttribute('data-sig', sig);
if (isNew) {
node.classList.add('card-new');
// One-shot marker: drop it once the enter animation has played, so a
// later reorder (insertBefore) can never replay the animation.
setTimeout(function() { node.classList.remove('card-new'); }, 300);
}
return node;
}
// Reconcile ONE column's DOM against its desired task list WITHOUT a full
// innerHTML= replace (which re-created every node on every event → board
// twitch + card overlap from the global enter animation). Only genuinely
// new/changed cards are (re)built; untouched cards are kept and merely
// reordered, so open live consoles + hover/animation state survive.
function patchColumn(cardsEl, list) {
if (!list.length) {
if (cardsEl.children.length !== 1 || !cardsEl.querySelector('.empty')) {
cardsEl.innerHTML = '<div class="empty">none</div>';
}
return;
}
var placeholder = cardsEl.querySelector('.empty');
if (placeholder) placeholder.remove();
var existing = {};
cardsEl.querySelectorAll('.card[data-id]').forEach(function(node) {
existing[node.getAttribute('data-id')] = node;
});
var seen = {};
var prev = null;
list.forEach(function(t) {
seen[t.id] = true;
var sig = cardSig(t);
var old = existing[t.id];
var node;
if (old && old.getAttribute('data-sig') === sig) {
node = old; // unchanged — keep node + its console
} else {
node = cardNode(t, sig, !old); // new-to-this-column ⇒ enter animation
if (old) old.remove(); // drop the stale version
if (!node) return;
}
// Place node immediately after the previous card so DOM order == list.
var anchor = prev ? prev.nextSibling : cardsEl.firstChild;
if (node !== anchor) cardsEl.insertBefore(node, anchor);
prev = node;
});
// Remove cards that left this column (moved elsewhere or were deleted).
Object.keys(existing).forEach(function(id) {
if (!seen[id]) existing[id].remove();
});
}
function renderBoard(tasks) { function renderBoard(tasks) {
lastTasks = tasks || []; lastTasks = tasks || [];
renderMetrics(lastTasks); renderMetrics(lastTasks);
@ -1160,7 +1240,7 @@ ${columnSkeleton()}
var count = document.querySelector('[data-count="' + k + '"]'); var count = document.querySelector('[data-count="' + k + '"]');
if (count) count.textContent = String(list.length); if (count) count.textContent = String(list.length);
if (!cardsEl) return; if (!cardsEl) return;
cardsEl.innerHTML = list.length ? list.map(taskCard).join('') : '<div class="empty">none</div>'; patchColumn(cardsEl, list);
}); });
updateTimers(); updateTimers();
reapplyConsoles(); reapplyConsoles();
@ -1327,11 +1407,19 @@ ${columnSkeleton()}
refresh(); refresh();
refreshBudget(); refreshBudget();
}; };
source.onmessage = function() { source.onmessage = function(ev) {
eventSourceReady = true; eventSourceReady = true;
stopFallbackPoll(); stopFallbackPoll();
setConn('ok', 'connected'); setConn('ok', 'connected');
refresh(); // Only task lifecycle changes affect the board. message / ask / decision /
// memory / handoff / agent-presence events must NOT trigger a board
// rerender — the old unconditional refresh() on every change was the root
// cause of the board twitch + card overlap. The budget panel has its own
// diff + throttle (shouldRenderDonut / FLIP bars), so it can refresh on
// any change without churn.
var type = '';
try { type = (JSON.parse(ev && ev.data) || {}).type || ''; } catch (_) {}
if (type === 'task') refresh();
refreshBudget(); refreshBudget();
}; };
source.onerror = function() { source.onerror = function() {
@ -1502,8 +1590,10 @@ ${columnSkeleton()}
'<h3 class="donut-title">' + esc(title) + '</h3>' + '<h3 class="donut-title">' + esc(title) + '</h3>' +
'<div class="donut-tabs"><button type="button" data-donut-tab="tokens" class="' + (donutMetric === 'tokens' ? 'active' : '') + '">Tokens</button><button type="button" data-donut-tab="cost" class="' + (donutMetric === 'cost' ? 'active' : '') + '">Kosten</button></div>' + '<div class="donut-tabs"><button type="button" data-donut-tab="tokens" class="' + (donutMetric === 'tokens' ? 'active' : '') + '">Tokens</button><button type="button" data-donut-tab="cost" class="' + (donutMetric === 'cost' ? 'active' : '') + '">Kosten</button></div>' +
'<div class="half-donut">' + '<div class="half-donut">' +
'<svg viewBox="0 0 220 132" role="img" aria-label="' + esc(title) + '">' + paths + '</svg>' + '<svg viewBox="0 0 220 132" role="img" aria-label="' + esc(title) + '">' + paths +
'<div class="donut-center"><span class="donut-value" data-count-to="' + esc(centerValue) + '" data-count-format="' + (formatValue === fmtEur ? 'eur' : 'tokens') + '">0</span><span class="donut-label">' + esc(centerLabel) + '</span></div>' + '<text class="donut-value" x="110" y="102" text-anchor="middle" dominant-baseline="central" data-count-to="' + esc(centerValue) + '" data-count-format="' + (formatValue === fmtEur ? 'eur' : 'tokens') + '">0</text>' +
'<text class="donut-label" x="110" y="120" text-anchor="middle" dominant-baseline="central">' + esc(centerLabel) + '</text>' +
'</svg>' +
'</div>' + '</div>' +
'<div class="legend">' + legend + '</div>' + '<div class="legend">' + legend + '</div>' +
'</div>'; '</div>';