feat(canvas): endpoint bounce on A2A envelope send/receive (map + home) #2523
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user