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:
parent
08667637e6
commit
f9c7c2ae3c
@ -272,6 +272,12 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
||||
.metric-card { background: #eef1f6; border-color: #d7dce6; }
|
||||
.metric-card .metric-label { color: #5b6472; }
|
||||
.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 {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 3fr) minmax(280px, 1fr);
|
||||
@ -359,7 +365,7 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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); }
|
||||
.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.level-error .cc-text { color: #fca5a5; }
|
||||
.cc-line.level-warn .cc-text { color: #fcd34d; }
|
||||
.cc-line.level-bridge .cc-text { color: #c4b5fd; }
|
||||
.cc-empty { color: var(--muted); font-style: italic; }
|
||||
|
||||
/* ── 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); }
|
||||
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; } }
|
||||
.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); }
|
||||
@ -749,18 +758,11 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
||||
from { stroke-dashoffset: 100; }
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
.donut-center {
|
||||
grid-area: 1 / 1;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 66.7%;
|
||||
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); }
|
||||
/* Center readout is now SVG-native <text> positioned in viewBox coords
|
||||
(cx=110, cy=122), so it scales with the donut and sits vertically in the
|
||||
arc opening — replaces the old CSS percent-offset that read too high. */
|
||||
.donut-value { fill: var(--text); font: 800 28px var(--mono); letter-spacing: 0; }
|
||||
.donut-label { fill: var(--muted); font: 10px var(--mono); }
|
||||
.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-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 lastTasks = [];
|
||||
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';
|
||||
|
||||
function esc(s) {
|
||||
@ -1073,7 +1076,22 @@ ${columnSkeleton()}
|
||||
}
|
||||
function setMetric(name, value) {
|
||||
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);
|
||||
}
|
||||
function statusCounts(tasks) {
|
||||
@ -1145,6 +1163,68 @@ ${columnSkeleton()}
|
||||
function byStatus(status) {
|
||||
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) {
|
||||
lastTasks = tasks || [];
|
||||
renderMetrics(lastTasks);
|
||||
@ -1160,7 +1240,7 @@ ${columnSkeleton()}
|
||||
var count = document.querySelector('[data-count="' + k + '"]');
|
||||
if (count) count.textContent = String(list.length);
|
||||
if (!cardsEl) return;
|
||||
cardsEl.innerHTML = list.length ? list.map(taskCard).join('') : '<div class="empty">none</div>';
|
||||
patchColumn(cardsEl, list);
|
||||
});
|
||||
updateTimers();
|
||||
reapplyConsoles();
|
||||
@ -1327,11 +1407,19 @@ ${columnSkeleton()}
|
||||
refresh();
|
||||
refreshBudget();
|
||||
};
|
||||
source.onmessage = function() {
|
||||
source.onmessage = function(ev) {
|
||||
eventSourceReady = true;
|
||||
stopFallbackPoll();
|
||||
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();
|
||||
};
|
||||
source.onerror = function() {
|
||||
@ -1502,8 +1590,10 @@ ${columnSkeleton()}
|
||||
'<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="half-donut">' +
|
||||
'<svg viewBox="0 0 220 132" role="img" aria-label="' + esc(title) + '">' + paths + '</svg>' +
|
||||
'<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>' +
|
||||
'<svg viewBox="0 0 220 132" role="img" aria-label="' + esc(title) + '">' + paths +
|
||||
'<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 class="legend">' + legend + '</div>' +
|
||||
'</div>';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user