diff --git a/src/server/team.ts b/src/server/team.ts index aeb1a3b..2fbb6e9 100644 --- a/src/server/team.ts +++ b/src/server/team.ts @@ -234,11 +234,16 @@ export function renderTeamHtml(cwd: string): string { .node.active::before { opacity: 1; animation: borderSweep 2.6s linear infinite; } @keyframes borderSweep { 0% { background-position: -50% 0; } 100% { background-position: 150% 0; } } .node.reviewing { border-color: rgba(210,153,34,.55); box-shadow: 0 0 0 1px rgba(210,153,34,.45), 0 0 20px rgba(210,153,34,.12); } - /* Gentle, non-jerky glow fades for pulse-arrival and task completion. */ - .node.arrive { animation: arriveGlow 700ms ease; } - @keyframes arriveGlow { 0% { box-shadow: 0 0 0 1px rgba(34,197,94,.55), 0 0 18px rgba(34,197,94,.28); } 100% { box-shadow: 0 0 0 1px rgba(34,197,94,0), 0 0 0 rgba(34,197,94,0); } } - .node.done-flash { animation: doneFade 900ms ease; } - @keyframes doneFade { 0% { box-shadow: 0 0 0 1px rgba(34,197,94,.5); } 45% { box-shadow: 0 0 0 1px rgba(34,197,94,.4), 0 0 22px rgba(34,197,94,.22); } 100% { box-shadow: 0 0 0 1px rgba(34,197,94,0); } } + /* Arrival + completion glows live on a separate ::after overlay (opacity only) + so they never fight the node's own box-shadow transition — no twitch. */ + .node::after { + content: ''; position: absolute; inset: 0; border-radius: 14px; pointer-events: none; + box-shadow: 0 0 0 1px rgba(34,197,94,.55), 0 0 22px rgba(34,197,94,.25); + opacity: 0; + } + .node.arrive::after { animation: glowInOut 700ms ease; } + .node.done-flash::after { animation: glowInOut 950ms ease; } + @keyframes glowInOut { 0% { opacity: 0; } 38% { opacity: 1; } 100% { opacity: 0; } } .edge-base { fill: none; stroke: var(--border); stroke-width: 1.5; animation: edgeBreath 4.5s ease-in-out infinite; } @keyframes edgeBreath { 0%,100% { opacity: .35; } 50% { opacity: .62; } } @@ -321,7 +326,7 @@ export function renderTeamHtml(cwd: string): string { }); } - function firePulseEdge(nodeId, dir) { + function firePulseEdge(nodeId, dir, doFlash) { var e = edgeMap[nodeId]; var svg = document.getElementById('edges'); if (!e || !svg) return; var p = document.createElementNS(NS, 'path'); @@ -333,7 +338,9 @@ export function renderTeamHtml(cwd: string): string { p.style.strokeDashoffset = String(len + 20); var done = function() { try { p.remove(); } catch (_) {} - flash(dir === 'up' ? nodeById(parentOf[nodeId]) : nodeById(nodeId)); + // Only the final destination lights up — waypoints (e.g. CEO on a + // peer-to-peer handoff) are just passed through, not "involved". + if (doFlash) flash(dir === 'up' ? nodeById(parentOf[nodeId]) : nodeById(nodeId)); }; if (p.animate) { p.animate([{ strokeDashoffset: len + 20 }, { strokeDashoffset: 0 }], { duration: EDGE_DUR, easing: 'cubic-bezier(.4,0,.5,1)' }).onfinish = done; } else setTimeout(done, EDGE_DUR); @@ -344,7 +351,7 @@ export function renderTeamHtml(cwd: string): string { var chain = []; var cur = nodeId; while (cur && edgeMap[cur]) { chain.push(cur); cur = parentOf[cur]; } // node → topChild if (dir === 'down') chain.reverse(); // topChild → node - chain.forEach(function(id, i) { setTimeout(function() { firePulseEdge(id, dir); }, i * SEG); }); + chain.forEach(function(id, i) { setTimeout(function() { firePulseEdge(id, dir, i === chain.length - 1); }, i * SEG); }); } function pulseToAgent(agent, dir) { document.querySelectorAll('.node[data-agent="' + (agent||'').replace(/"/g,'') + '"]').forEach(function(n) { @@ -478,7 +485,7 @@ export function renderTeamHtml(cwd: string): string { var seq = []; for (var u = 0; u < ai; u++) seq.push({ nodeId: A[u], dir: 'up' }); // from → LCA for (var d = Bidx[lca] - 1; d >= 0; d--) seq.push({ nodeId: B[d], dir: 'down' }); // LCA → to - seq.forEach(function(step, k) { setTimeout(function(){ firePulseEdge(step.nodeId, step.dir); }, k * SEG); }); + seq.forEach(function(step, k) { setTimeout(function(){ firePulseEdge(step.nodeId, step.dir, k === seq.length - 1); }, k * SEG); }); return Math.max(EDGE_DUR, seq.length * SEG + EDGE_DUR); }