feat(canvas): endpoint bounce on A2A envelope send/receive (map + home) #2523

Merged
agent-reviewer merged 1 commits from feat/envelope-bounce-animation into main 2026-06-10 14:11:36 +00:00
4 changed files with 177 additions and 10 deletions
+119 -6
View File
@@ -6,7 +6,12 @@
* Uses the Web Animations API so the from/to delta can be dynamic per flight
* (a static CSS @keyframes can't translate to a runtime-computed point). */
import { useEffect, useRef } from "react";
import { FLIGHT_DURATION_MS, type A2AFlightKind } from "@/hooks/useA2AFlights";
import {
BOUNCE_DURATION_MS,
FLIGHT_DURATION_MS,
RECEIVE_BOUNCE_DELAY_MS,
type A2AFlightKind,
} from "@/hooks/useA2AFlights";
/** Stroke colour by activity kind — mirrors CommunicationOverlay's palette
* (send = cyan, receive = violet/accent, task = warm) so the two surfaces
@@ -22,6 +27,108 @@ export interface Point {
y: number;
}
/** EndpointBounce — a small bounce pulse rendered at a flight endpoint: the
* sender "flicks" the envelope on launch and the receiver "catches" it on
* landing (CTO ask, 2026-06-10 — both the canvas map and the concierge home
* render flights through this one component, so both surfaces get it).
*
* A filled dot that overshoots (scale up past 1, settle, fade) with a ring
* expanding behind it — classic squash-free bounce that works at any zoom.
* Same WAAPI degrade posture as the envelope: no Element.animate → render
* nothing visible (opacity stays 0). */
function EndpointBounce({
at,
color,
delayMs,
}: {
at: Point;
color: string;
delayMs: number;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el || typeof el.animate !== "function") return;
const dot = el.querySelector<SVGElement>("[data-bounce-dot]");
const ring = el.querySelector<SVGElement>("[data-bounce-ring]");
const anims: Animation[] = [];
if (dot && typeof (dot as unknown as HTMLElement).animate === "function") {
anims.push(
(dot as unknown as HTMLElement).animate(
[
{ transform: "scale(0.3)", opacity: 0 },
{ transform: "scale(1.45)", opacity: 0.95, offset: 0.35 }, // overshoot
{ transform: "scale(0.85)", opacity: 0.8, offset: 0.6 }, // bounce back
{ transform: "scale(1.1)", opacity: 0.5, offset: 0.8 }, // settle hop
{ transform: "scale(1)", opacity: 0 },
],
{ duration: BOUNCE_DURATION_MS, delay: delayMs, easing: "ease-out", fill: "both" },
),
);
}
if (ring && typeof (ring as unknown as HTMLElement).animate === "function") {
anims.push(
(ring as unknown as HTMLElement).animate(
[
{ transform: "scale(0.4)", opacity: 0.7 },
{ transform: "scale(1.9)", opacity: 0 },
],
{ duration: BOUNCE_DURATION_MS, delay: delayMs, easing: "ease-out", fill: "both" },
),
);
}
return () => anims.forEach((a) => a.cancel());
}, [delayMs]);
return (
<div
ref={ref}
data-testid="flight-endpoint-bounce"
aria-hidden="true"
style={{
position: "absolute",
left: at.x,
top: at.y,
width: 0,
height: 0,
pointerEvents: "none",
zIndex: 5,
}}
>
<svg
width="28"
height="28"
viewBox="0 0 28 28"
fill="none"
aria-hidden="true"
style={{ position: "absolute", left: -14, top: -14, overflow: "visible" }}
>
<circle
data-bounce-ring
cx="14"
cy="14"
r="9"
stroke={color}
strokeWidth="1.5"
fill="none"
opacity="0"
style={{ transformOrigin: "14px 14px" }}
/>
<circle
data-bounce-dot
cx="14"
cy="14"
r="4.5"
fill={color}
opacity="0"
style={{ transformOrigin: "14px 14px" }}
/>
</svg>
</div>
);
}
export function FlightEnvelope({
from,
to,
@@ -64,9 +171,14 @@ export function FlightEnvelope({
const color = KIND_COLOR[kind];
return (
<div
ref={ref}
data-testid="flight-envelope"
<>
{/* sender flick: bounce at the launch point as the envelope departs */}
<EndpointBounce at={from} color={color} delayMs={0} />
{/* receiver catch: bounce at the landing point as the envelope arrives */}
<EndpointBounce at={to} color={color} delayMs={RECEIVE_BOUNCE_DELAY_MS} />
<div
ref={ref}
data-testid="flight-envelope"
aria-hidden="true"
style={{
position: "absolute",
@@ -88,7 +200,8 @@ export function FlightEnvelope({
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
</svg>
</div>
</>
);
}
@@ -52,3 +52,37 @@ describe("FlightEnvelope", () => {
expect(getByTestId("flight-envelope")).toBeTruthy();
});
});
describe("EndpointBounce (sender flick + receiver catch)", () => {
it("renders a bounce element at BOTH endpoints (sender + receiver)", () => {
const { getAllByTestId } = render(
<FlightEnvelope from={{ x: 10, y: 20 }} to={{ x: 300, y: 400 }} kind="send" />,
);
const bounces = getAllByTestId("flight-endpoint-bounce");
expect(bounces).toHaveLength(2);
expect(bounces[0].style.left).toBe("10px");
expect(bounces[0].style.top).toBe("20px");
expect(bounces[1].style.left).toBe("300px");
expect(bounces[1].style.top).toBe("400px");
});
it("bounce dots/rings start invisible (WAAPI-unavailable degrade = nothing visible)", () => {
const { getAllByTestId } = render(
<FlightEnvelope from={{ x: 0, y: 0 }} to={{ x: 5, y: 5 }} kind="receive" />,
);
for (const el of getAllByTestId("flight-endpoint-bounce")) {
const dot = el.querySelector("[data-bounce-dot]");
const ring = el.querySelector("[data-bounce-ring]");
expect(dot?.getAttribute("opacity")).toBe("0");
expect(ring?.getAttribute("opacity")).toBe("0");
}
});
it("bounce colour matches the flight kind", () => {
const { getAllByTestId } = render(
<FlightEnvelope from={{ x: 0, y: 0 }} to={{ x: 5, y: 5 }} kind="task" />,
);
const dot = getAllByTestId("flight-endpoint-bounce")[0].querySelector("[data-bounce-dot]");
expect(dot?.getAttribute("fill")).toBe("#f5a623");
});
});
@@ -16,7 +16,12 @@ vi.mock("@/hooks/useSocketEvent", () => ({
},
}));
import { useA2AFlights, FLIGHT_DURATION_MS } from "@/hooks/useA2AFlights";
import {
useA2AFlights,
FLIGHT_DURATION_MS,
BOUNCE_DURATION_MS,
RECEIVE_BOUNCE_DELAY_MS,
} from "@/hooks/useA2AFlights";
function setReducedMotion(reduce: boolean) {
window.matchMedia = vi.fn().mockImplementation((q: string) => ({
@@ -92,13 +97,21 @@ describe("useA2AFlights", () => {
expect(result.current).toHaveLength(0);
});
it("expires a flight after the TTL", () => {
it("expires a flight after the TTL (outliving the receiver landing bounce)", () => {
vi.useFakeTimers();
const { result } = renderHook(() => useA2AFlights());
act(() => h.captured?.(a2aSend()));
expect(result.current).toHaveLength(1);
// The flight must SURVIVE past the envelope traversal — the receiver's
// landing bounce is still playing (it starts at ~0.82 of the flight and
// runs BOUNCE_DURATION_MS). Unmounting here would cut the catch mid-air.
act(() => {
vi.advanceTimersByTime(FLIGHT_DURATION_MS + 300);
vi.advanceTimersByTime(FLIGHT_DURATION_MS + 100);
});
expect(result.current).toHaveLength(1);
// ...and expire once the landing bounce has finished too.
act(() => {
vi.advanceTimersByTime(RECEIVE_BOUNCE_DELAY_MS + BOUNCE_DURATION_MS - FLIGHT_DURATION_MS + 200);
});
expect(result.current).toHaveLength(0);
});
+8 -1
View File
@@ -28,7 +28,14 @@ export interface A2AFlight {
* Web-Animations duration; the extra tail gives the fade-out room to finish
* before the element unmounts. */
export const FLIGHT_DURATION_MS = 1200;
const FLIGHT_TTL_MS = FLIGHT_DURATION_MS + 120;
// Endpoint-bounce timing (FlightEnvelope's EndpointBounce): the sender bounce
// fires at launch; the receiver bounce fires on the final approach. Living
// here (not FlightEnvelope.tsx) keeps the import direction one-way.
export const BOUNCE_DURATION_MS = 420;
export const RECEIVE_BOUNCE_DELAY_MS = Math.round(FLIGHT_DURATION_MS * 0.82);
// TTL must outlive the LAST animation on the flight — the receiver's landing
// bounce — not just the envelope traversal, or the layer unmounts mid-catch.
const FLIGHT_TTL_MS = RECEIVE_BOUNCE_DELAY_MS + BOUNCE_DURATION_MS + 120;
/** Cap concurrent envelopes so a delegation storm can't spawn unbounded DOM. */
const MAX_CONCURRENT = 12;