From 03b198173810ff7884f0fd0620b2a8b6aa176d99 Mon Sep 17 00:00:00 2001 From: core-devops Date: Wed, 10 Jun 2026 02:46:01 -0700 Subject: [PATCH] feat(canvas): endpoint bounce on A2A envelope send/receive (map + home) CTO ask (2026-06-10): the message envelope should come with a bounce at the sender when it launches and at the receiver when it lands - on BOTH the canvas map and the concierge homepage. FlightEnvelope now renders an EndpointBounce at each endpoint: a filled dot that overshoots (scale past 1, settle, fade) over an expanding ring - the sender "flicks" at delay 0, the receiver "catches" on the final approach (RECEIVE_BOUNCE_DELAY_MS = 0.82 x flight). One component serves both surfaces (MessageFlightLayer + MessageFlightHome), so both get it with no per-surface code. Timing constants live in useA2AFlights (one-way import direction), and FLIGHT_TTL_MS now outlives the LAST animation - the landing bounce - instead of just the envelope traversal, or the layer would unmount the catch mid-air. Same WAAPI degrade posture as the envelope: without Element.animate the bounce elements stay opacity-0 (nothing rendered visibly, no throw). Tests: both endpoints render at the right coords, invisible-by-default degrade, kind colour; TTL test re-pinned to survive-traversal + expire-after-bounce. 13/13 green; tsc error count unchanged vs main. Co-Authored-By: Claude Opus 4.8 (1M context) --- canvas/src/components/FlightEnvelope.tsx | 125 +++++++++++++++++- .../__tests__/FlightEnvelope.test.tsx | 34 +++++ .../src/hooks/__tests__/useA2AFlights.test.ts | 19 ++- canvas/src/hooks/useA2AFlights.ts | 9 +- 4 files changed, 177 insertions(+), 10 deletions(-) diff --git a/canvas/src/components/FlightEnvelope.tsx b/canvas/src/components/FlightEnvelope.tsx index ac273b61c..39b54408d 100644 --- a/canvas/src/components/FlightEnvelope.tsx +++ b/canvas/src/components/FlightEnvelope.tsx @@ -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(null); + + useEffect(() => { + const el = ref.current; + if (!el || typeof el.animate !== "function") return; + const dot = el.querySelector("[data-bounce-dot]"); + const ring = el.querySelector("[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 ( + + ); +} + export function FlightEnvelope({ from, to, @@ -64,9 +171,14 @@ export function FlightEnvelope({ const color = KIND_COLOR[kind]; return ( -
+ {/* sender flick: bounce at the launch point as the envelope departs */} + + {/* receiver catch: bounce at the landing point as the envelope arrives */} + + + +
+ ); } diff --git a/canvas/src/components/__tests__/FlightEnvelope.test.tsx b/canvas/src/components/__tests__/FlightEnvelope.test.tsx index f0a1bde55..183cfabe0 100644 --- a/canvas/src/components/__tests__/FlightEnvelope.test.tsx +++ b/canvas/src/components/__tests__/FlightEnvelope.test.tsx @@ -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( + , + ); + 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( + , + ); + 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( + , + ); + const dot = getAllByTestId("flight-endpoint-bounce")[0].querySelector("[data-bounce-dot]"); + expect(dot?.getAttribute("fill")).toBe("#f5a623"); + }); +}); diff --git a/canvas/src/hooks/__tests__/useA2AFlights.test.ts b/canvas/src/hooks/__tests__/useA2AFlights.test.ts index 1c2dfeba1..1732b6fe7 100644 --- a/canvas/src/hooks/__tests__/useA2AFlights.test.ts +++ b/canvas/src/hooks/__tests__/useA2AFlights.test.ts @@ -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); }); diff --git a/canvas/src/hooks/useA2AFlights.ts b/canvas/src/hooks/useA2AFlights.ts index e7f91ab81..54ef9a4ff 100644 --- a/canvas/src/hooks/useA2AFlights.ts +++ b/canvas/src/hooks/useA2AFlights.ts @@ -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; -- 2.52.0