diff --git a/scripts/peer-assisted-benchmark.ts b/scripts/peer-assisted-benchmark.ts index 2ca771b..19f2bd4 100644 --- a/scripts/peer-assisted-benchmark.ts +++ b/scripts/peer-assisted-benchmark.ts @@ -2019,7 +2019,7 @@ async function startHost( await waitForPage( cdp, page, - "document.querySelector('.entry-actions button.entry-action')", + "document.querySelector('.lr-entry-actions button.lr-tv-big.is-action[aria-label]')", 15_000, "host controls", signal, @@ -2028,11 +2028,11 @@ async function startHost( cdp, page, `(() => { - const profiles = document.querySelectorAll('.quality-controls .segmented-control button'); + const profiles = document.querySelectorAll('.lr-tiles button.lr-tile[aria-pressed]'); const profile = profiles[${profileIndex}]; if (!(profile instanceof HTMLButtonElement)) throw new Error('Quality profile button missing'); profile.click(); - const start = document.querySelector('.entry-actions button.entry-action'); + const start = document.querySelector('.lr-entry-actions button.lr-tv-big.is-action[aria-label]'); if (!(start instanceof HTMLButtonElement)) throw new Error('Start button missing'); start.click(); return true; diff --git a/src/client/components/living/Comic.tsx b/src/client/components/living/Comic.tsx index 39914df..4fb757e 100644 --- a/src/client/components/living/Comic.tsx +++ b/src/client/components/living/Comic.tsx @@ -5,7 +5,7 @@ // constitution: state-change beats only, loops rest >=40%, max 2 movers per // panel, stamps play once and hold. SSR-safe: pure static markup, no hooks. -import type { CSSProperties, ReactNode } from "react"; +import { memo, type CSSProperties, type ReactNode } from "react"; export type ComicKind = | "waiting-for-host" @@ -1267,7 +1267,7 @@ const SCENES: Record ReactNode> = { * width min(320px, 86%). The SVG is aria-hidden: wrap it in a role="status" * container with a localized aria-label (README ยง4). */ -export function Comic({ +export const Comic = memo(function Comic({ kind, size, theme, @@ -1293,4 +1293,4 @@ export function Comic({ {SCENES[kind]({ theme: resolvedTheme })} ); -} +}); diff --git a/src/client/components/living/ComicTooltip.tsx b/src/client/components/living/ComicTooltip.tsx index 56266e3..5f39911 100644 --- a/src/client/components/living/ComicTooltip.tsx +++ b/src/client/components/living/ComicTooltip.tsx @@ -17,6 +17,7 @@ import { HintComic, isHintKind, type HintKind } from "./hints"; const LONG_PRESS_MS = 500; const TOUCH_HIDE_MS = 1500; +const COMIC_EXIT_MS = 160; // Minimum clearance the re-picked alignment keeps to each viewport edge. const EDGE_MARGIN = 8; @@ -39,10 +40,23 @@ export function ComicTooltip({ const tipRef = useRef(null); const pressTimer = useRef(null); const hideTimer = useRef(null); + const comicUnmountTimer = useRef(null); const longPressed = useRef(false); const pressPoint = useRef<{ x: number; y: number } | null>(null); + const [hoverOpen, setHoverOpen] = useState(false); + const [focusOpen, setFocusOpen] = useState(false); const [touchOpen, setTouchOpen] = useState(false); + const [comicMounted, setComicMounted] = useState(false); const [liveAlign, setLiveAlign] = useState(null); + const interactionOpen = hoverOpen || focusOpen || touchOpen; + + const mountComic = () => { + if (comicUnmountTimer.current !== null) { + window.clearTimeout(comicUnmountTimer.current); + comicUnmountTimer.current = null; + } + setComicMounted(true); + }; // Re-pick alignment from the trigger's live geometry. The configured align // wins whenever it fits; otherwise the first alignment that keeps the whole @@ -54,7 +68,7 @@ export function ComicTooltip({ const vw = window.innerWidth; if (!vw) return; // no layout (SSR/test): keep the prop alignment const rect = wrap.getBoundingClientRect(); - const measured = tipRef.current?.offsetWidth ?? 0; + const measured = comicMounted ? (tipRef.current?.offsetWidth ?? 0) : 0; const width = Math.max(0, Math.min(measured || 320, vw - EDGE_MARGIN * 2)); const center = rect.left + rect.width / 2; const boxes: Record = { @@ -82,48 +96,13 @@ export function ComicTooltip({ const pickAlignRef = useRef(pickAlign); pickAlignRef.current = pickAlign; useEffect(() => { - const wrap = wrapRef.current; - if (!wrap) return; - let frame = 0; - let listening = false; - const schedulePick = () => { - window.cancelAnimationFrame(frame); - frame = window.requestAnimationFrame(() => pickAlignRef.current()); - }; - const open = () => { - if (listening) return; - listening = true; - window.addEventListener("resize", schedulePick); - window.addEventListener("scroll", schedulePick, true); - }; - const close = () => { - if (!listening) return; - listening = false; - window.removeEventListener("resize", schedulePick); - window.removeEventListener("scroll", schedulePick, true); - }; - wrap.addEventListener("pointerenter", open); - wrap.addEventListener("pointerleave", close); - wrap.addEventListener("focusin", open); - wrap.addEventListener("focusout", close); - return () => { - window.cancelAnimationFrame(frame); - close(); - wrap.removeEventListener("pointerenter", open); - wrap.removeEventListener("pointerleave", close); - wrap.removeEventListener("focusin", open); - wrap.removeEventListener("focusout", close); - }; - }, []); - - // The long-press channel outlives the touch contact: listen while open. - useEffect(() => { - if (!touchOpen) return; + if (!interactionOpen || !comicMounted) return; let frame = 0; const schedulePick = () => { window.cancelAnimationFrame(frame); frame = window.requestAnimationFrame(() => pickAlignRef.current()); }; + schedulePick(); window.addEventListener("resize", schedulePick); window.addEventListener("scroll", schedulePick, true); return () => { @@ -131,12 +110,29 @@ export function ComicTooltip({ window.removeEventListener("resize", schedulePick); window.removeEventListener("scroll", schedulePick, true); }; - }, [touchOpen]); + }, [comicMounted, interactionOpen]); + + useEffect(() => { + if (interactionOpen || !comicMounted) return; + comicUnmountTimer.current = window.setTimeout(() => { + comicUnmountTimer.current = null; + setComicMounted(false); + }, COMIC_EXIT_MS); + return () => { + if (comicUnmountTimer.current !== null) { + window.clearTimeout(comicUnmountTimer.current); + comicUnmountTimer.current = null; + } + }; + }, [comicMounted, interactionOpen]); useEffect( () => () => { if (pressTimer.current !== null) window.clearTimeout(pressTimer.current); if (hideTimer.current !== null) window.clearTimeout(hideTimer.current); + if (comicUnmountTimer.current !== null) { + window.clearTimeout(comicUnmountTimer.current); + } }, [], ); @@ -168,8 +164,28 @@ export function ComicTooltip({ { + pickAlign(); + if (event.pointerType !== "touch") { + mountComic(); + setHoverOpen(true); + } + }} + onPointerLeave={(event) => { + if (event.pointerType !== "touch") setHoverOpen(false); + }} + onFocus={(event) => { + pickAlign(); + if ((event.target as HTMLElement).matches(":focus-visible")) { + mountComic(); + setFocusOpen(true); + } + }} + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setFocusOpen(false); + } + }} onPointerDown={(event) => { if (event.pointerType !== "touch") return; cancelPress(); @@ -177,6 +193,7 @@ export function ComicTooltip({ pressTimer.current = window.setTimeout(() => { pressTimer.current = null; longPressed.current = true; + mountComic(); pickAlign(); setTouchOpen(true); }, LONG_PRESS_MS); @@ -187,6 +204,7 @@ export function ComicTooltip({ if (longPressed.current) { if (hideTimer.current !== null) window.clearTimeout(hideTimer.current); hideTimer.current = window.setTimeout(() => { + hideTimer.current = null; longPressed.current = false; setTouchOpen(false); }, TOUCH_HIDE_MS); @@ -229,17 +247,21 @@ export function ComicTooltip({ onKeyDown={(event) => { if (event.key === "Escape") { setTouchOpen(false); + setFocusOpen(false); (document.activeElement as HTMLElement | null)?.blur?.(); + } else if ((event.target as HTMLElement).matches(":focus-visible")) { + mountComic(); + setFocusOpen(true); } }} > {children} - {isHintKind(kind) ? ( - - ) : ( - - )} + {comicMounted + ? isHintKind(kind) + ? + : + : null} ); diff --git a/src/client/components/living/RouteTree.tsx b/src/client/components/living/RouteTree.tsx index 6f5fc04..7430422 100644 --- a/src/client/components/living/RouteTree.tsx +++ b/src/client/components/living/RouteTree.tsx @@ -1,6 +1,7 @@ // The connection topology as a real tree: host (crowned) roots direct P2P // viewers and the SFU node; relay children hang off their parent viewer. // Data comes from deriveParticipantTopology. +import { memo } from "react"; import { PawnSvg, pawnColor } from "./Couch"; import { useCopy } from "../../ui/copy"; import type { LabeledViewerPresence } from "../../lib/viewer-presence"; @@ -23,7 +24,7 @@ function xForDepth(depth: number): number { return HOST_X + (depth + 1) * COLUMN_GAP; } -export function RouteTree({ +export const RouteTree = memo(function RouteTree({ hostPeerId, hostLabel, viewers, @@ -318,4 +319,4 @@ export function RouteTree({ ) : null} ); -} +}); diff --git a/src/client/components/living/hints/index.tsx b/src/client/components/living/hints/index.tsx index 867c5c8..a7a4984 100644 --- a/src/client/components/living/hints/index.tsx +++ b/src/client/components/living/hints/index.tsx @@ -6,7 +6,7 @@ // completeness at compile time. JSX-free shell: this module is also loaded // by the tsx preview harness, which uses the classic JSX runtime. -import { createElement, type CSSProperties, type ReactNode } from "react"; +import { createElement, memo, type CSSProperties, type ReactNode } from "react"; import type { ComicTheme } from "../Comic"; import { SET1_SCENES } from "./set1"; import { SET2_SCENES } from "./set2"; @@ -102,7 +102,7 @@ export function isHintKind(kind: string): kind is HintKind { } /** All hint scenes are paper idioms: they float on a paper tooltip. */ -export function HintComic({ +export const HintComic = memo(function HintComic({ kind, size, }: { @@ -125,4 +125,4 @@ export function HintComic({ }, HINT_SCENES[kind]({ theme: "paper" }), ); -} +}); diff --git a/src/client/styles.css b/src/client/styles.css index 747b1d2..4de639e 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -257,6 +257,7 @@ ul { margin: 0; padding: 0; list-style: none; } .lr-tv-overlay.is-dim { background: rgba(10, 16, 28, .62); } .lr-tv-big { + position: relative; display: grid; width: 84px; height: 84px; place-items: center; @@ -270,11 +271,21 @@ ul { margin: 0; padding: 0; list-style: none; } .lr-tv-big:active { transform: scale(.94); } .lr-tv-big.is-action { border-color: var(--action); background: var(--action); cursor: pointer; } .lr-tv-big.is-action:hover { background: var(--action-deep); } -.lr-tv-big.is-ripple { animation: lr-ripple 2s ease-out infinite; } +.lr-tv-big.is-ripple::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + box-shadow: 0 0 0 22px rgba(47, 111, 237, .45); + opacity: 0; + pointer-events: none; + transform: scale(.66); + will-change: transform, opacity; + animation: lr-ripple 2s ease-out infinite; +} @keyframes lr-ripple { - 0% { box-shadow: 0 0 0 0 rgba(47, 111, 237, .45); } - 70% { box-shadow: 0 0 0 22px rgba(47, 111, 237, 0); } - 100% { box-shadow: 0 0 0 0 rgba(47, 111, 237, 0); } + 0% { opacity: .45; transform: scale(.66); } + 70%, 100% { opacity: 0; transform: scale(1); } } .lr-tv-msg { diff --git a/tests/living-components.test.ts b/tests/living-components.test.ts index 2a6ee0a..745a801 100644 --- a/tests/living-components.test.ts +++ b/tests/living-components.test.ts @@ -3,6 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { afterEach, describe, expect, it } from "vitest"; import { Couch } from "../src/client/components/living/Couch.tsx"; +import { ComicTooltip } from "../src/client/components/living/ComicTooltip.tsx"; import { StageOverlay } from "../src/client/components/living/Stage.tsx"; import { NameTag } from "../src/client/components/living/primitives.tsx"; import { setCopy } from "../src/client/ui/copy.ts"; @@ -52,4 +53,20 @@ describe("living-room presentation", () => { expect(html).toContain(">TNT<"); expect(html).not.toContain("visually-hidden"); }); + + it("leaves hidden tooltip artwork out of the initial markup", () => { + const html = renderToStaticMarkup( + createElement( + ComicTooltip, + { + kind: "hint-share-start", + children: createElement("button", { type: "button" }, "Share"), + }, + ), + ); + + expect(html).toContain("lr-comic-tip"); + expect(html).toContain(">Share"); + expect(html).not.toContain('viewBox="0 0 320 96"'); + }); });