;
+}
diff --git a/src/client/components/living/RoomReaction.tsx b/src/client/components/living/RoomReaction.tsx
new file mode 100644
index 0000000..51d1838
--- /dev/null
+++ b/src/client/components/living/RoomReaction.tsx
@@ -0,0 +1,119 @@
+import { useLayoutEffect, useRef, type CSSProperties, type RefObject } from "react";
+import { REACTION_DURATION_MS, REACTION_GLYPHS, isThrow, type ReactionId } from "../../../shared/room-interactions";
+import type { RoomInteraction } from "../../lib/room-interactions";
+import { identityHash } from "../../lib/identity-hash";
+import { Glyph } from "../../ui/icons";
+
+/** Props use the same small silhouette in the picker and on the couch. */
+export function ReactionIcon({ reaction }: { reaction: ReactionId }) {
+ if (!isThrow(reaction)) return {REACTION_GLYPHS[reaction]};
+ return ;
+}
+
+/** Fixed, event-seeded accents: no independent timer or mutable random stream. */
+function ReactionAccents({ reaction, seed }: { reaction: ReactionId; seed: number }) {
+ return ;
+}
+
+/** Event expiry owns lifetime. This component owns only placement and its own impact. */
+export function RoomReaction({ reaction, area, now }: {
+ reaction: RoomInteraction & { expiresAt: number }; area: RefObject; now: () => number;
+}) {
+ const flight = useRef(null);
+ const payload = reaction.payload;
+ useLayoutEffect(() => {
+ const root = area.current;
+ const element = flight.current;
+ if (!root || !element || payload.kind !== "reaction") return;
+ const pawn = (peer: string) => root.querySelector(`[data-room-peer="${CSS.escape(peer)}"] .lr-person`);
+ const sender = pawn(reaction.sender.peerId);
+ const recipient = pawn(payload.targetPeerId ?? reaction.sender.peerId);
+ if (!sender || !recipient) return;
+ let frame = 0;
+ let impact: Animation | undefined;
+ const place = () => {
+ if (now() >= reaction.expiresAt || !sender.isConnected || !recipient.isConnected) {
+ element.style.visibility = "hidden";
+ return;
+ }
+ frame = requestAnimationFrame(place);
+ if (document.hidden || !root.getClientRects().length) {
+ element.style.visibility = "hidden";
+ impact?.cancel();
+ return;
+ }
+ const rect = element.getBoundingClientRect();
+ // Transforms do not trigger ResizeObserver. Follow the actual head for
+ // this event's remaining lifetime, including hover, lean and impact.
+ const from = sender.querySelector(".lr-person-head")?.getBoundingClientRect();
+ const to = recipient.querySelector(".lr-person-head")?.getBoundingClientRect();
+ if (!from || !to) { element.style.visibility = "hidden"; return; }
+ element.style.setProperty("--from-x", `${from.x - rect.x + from.width / 2}px`);
+ element.style.setProperty("--from-y", `${from.y - rect.y + from.height / 2}px`);
+ element.style.setProperty("--to-x", `${to.x - rect.x + to.width / 2}px`);
+ element.style.setProperty("--to-y", `${to.y - rect.y + to.height / 2}px`);
+ element.style.setProperty("--throw-direction", from.x > to.x ? "-1" : "1");
+ element.style.visibility = "visible";
+ };
+ place();
+ const body = recipient.querySelector(".lr-reaction-body");
+ if (isThrow(payload.reaction) && payload.targetPeerId && body &&
+ !window.matchMedia("(prefers-reduced-motion: reduce)").matches && !root.closest("[data-comic-reduced-motion]")) {
+ const elapsed = Math.max(0, REACTION_DURATION_MS - (reaction.expiresAt - now()));
+ const delay = REACTION_DURATION_MS * .32 - elapsed;
+ if (delay > -560) {
+ const direction = sender.getBoundingClientRect().x > recipient.getBoundingClientRect().x ? -1 : 1;
+ impact = body.animate([
+ { transform: "none" },
+ { transform: payload.reaction === "tomato" ? `rotate(${direction * 6}deg) scale(1.05,.95)` : `translateX(${direction * 4}px) rotate(${direction * 8}deg)` },
+ { transform: `rotate(${-direction * 3}deg)` },
+ { transform: "none" },
+ ], { duration: 560, delay, easing: "ease-out" });
+ }
+ }
+ return () => { cancelAnimationFrame(frame); impact?.cancel(); };
+ }, [area, reaction, payload, now]);
+ if (payload.kind !== "reaction") return null;
+ const gift = Boolean(payload.targetPeerId && !isThrow(payload.reaction));
+ return
+ {
+ // CSS restarts after display:none or reduced motion. Event expiry still
+ // owns time, including a late mount when effects are shown again.
+ for (const animation of (event.target as Element).getAnimations()) {
+ if (animation instanceof CSSAnimation && animation.animationName.startsWith("lr-reaction-")) {
+ animation.currentTime = Math.min(REACTION_DURATION_MS, Math.max(0, REACTION_DURATION_MS - (reaction.expiresAt - now())));
+ }
+ }
+ }}>{gift && {reaction.sender.displayName}}
+
+
+