diff --git a/packages/review/app/src/animation/animation-block.browser.test.ts b/packages/review/app/src/animation/animation-block.browser.test.ts new file mode 100644 index 000000000..ce3eb7509 --- /dev/null +++ b/packages/review/app/src/animation/animation-block.browser.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { schemeOf } from "./animation-block"; + +describe("schemeOf", () => { + it("takes the host's own light or dark palette", () => { + expect(schemeOf("dark")).toBe("dark"); + expect(schemeOf("light")).toBe("light"); + expect(schemeOf("normal")).toBe("light"); + }); + + it("follows the reader's preference where the host names both", () => { + const dark = matchMedia("(prefers-color-scheme: dark)").matches; + + expect(schemeOf("light dark")).toBe(dark ? "dark" : "light"); + expect(schemeOf("dark light")).toBe(dark ? "dark" : "light"); + }); +}); diff --git a/packages/review/app/src/animation/animation-block.tsx b/packages/review/app/src/animation/animation-block.tsx index d7596a2ba..5b4df5829 100644 --- a/packages/review/app/src/animation/animation-block.tsx +++ b/packages/review/app/src/animation/animation-block.tsx @@ -3,9 +3,15 @@ import { CodePeekCard } from "@canvas/CodePeek"; import { useReviewDebugSettings } from "@canvas/debug-settings"; import { DiagramHeader } from "@canvas/diagram-header"; import { diagramStyles } from "@canvas/diagram-styles"; -import { useDiagramTourShell } from "@canvas/diagram-tour"; +import { DiagramTourOverlay, useDiagramTourShell } from "@canvas/diagram-tour"; +import { + useDrawQueueControls, + useMotionPhase, +} from "@canvas/draw-queue-provider"; import { drawStyles } from "@canvas/draw-styles"; -import { useOptionalReviewPanel } from "@canvas/review-panel"; +import { countLabel } from "@canvas/format"; +import { useReviewSession } from "@canvas/host/review-session"; +import { useReviewPanel, useReviewPanelStore } from "@canvas/review-panel"; import type { SourcePeekAnchor } from "@canvas/review-panel-model"; import { fontSize, @@ -18,10 +24,14 @@ import { shellStyles } from "@canvas/shell-styles"; import { withClass } from "@canvas/stylex-props"; import { themeClass, themeStyles } from "@canvas/theme-styles"; import { tokens } from "@canvas/tokens.stylex"; -import { Button, IconButton } from "@canvas/ui/button"; +import { captureUiEvent } from "@canvas/ui-telemetry"; +import { Button } from "@canvas/ui/button"; +import { focusStyles } from "@canvas/ui/focus"; import { SegmentedControl } from "@canvas/ui/segmented"; import { textStyles } from "@canvas/ui/text"; import { useTooltip } from "@canvas/ui/tooltip"; +import { useDiagramSelection } from "@canvas/use-diagram-selection"; +import { useWhiteboardSettings } from "@canvas/whiteboard-settings"; import { type Block, anchorSelection, @@ -31,10 +41,11 @@ import { import { STAGE_KIT_CSS, STAGE_THEME } from "@review/animation-stage-kit"; import * as stylex from "@stylexjs/stylex"; import { - type KeyboardEvent, type ReactNode, + type RefObject, useCallback, useEffect, + useEffectEvent, useLayoutEffect, useMemo, useRef, @@ -42,11 +53,22 @@ import { } from "react"; import { createPortal } from "react-dom"; +import { + animationTour, + frameAnchorId, + frameContent, + markerAnchorId, +} from "./animation-tour"; +import { useClipPlayer } from "./clip-player"; +import { useFramePlayer } from "./frame-player"; import { useReportAnimationRun } from "./report-run"; +import { StepStrip } from "./stage-controls"; +import type { StagePlayer } from "./stage-player"; import { type HostMessage, STAGE_BOOT, type SlotLayout, + type StageColorScheme, type StageInit, type StageMessage, stageMessageSchema, @@ -57,8 +79,6 @@ import { SCALE } from "./stage-scale"; // Read on demand: a Worker may lack import.meta.url. const stageUrl = () => new URL("./stage.html", import.meta.url).href; -const DEFAULT_DURATION = 3000; - let animeSource: Promise | undefined; // anime.js's source, for the stage: its own chunk, fetched by the first @@ -108,20 +128,34 @@ function readTheme(element: Element): Pick { computed.getPropertyValue(property).trim(), ]), ), - colorScheme: computed.colorScheme.includes("dark") ? "dark" : "light", + colorScheme: schemeOf(computed.colorScheme), }; } +// The host's own light or dark palette, or the reader's where it follows the +// system: "light dark" names both, and would read as dark on a light page. +export function schemeOf(colorScheme: string): StageColorScheme { + const named = colorScheme.split(/\s+/); + + if (named.includes("light") && named.includes("dark")) + return matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + + return named.includes("dark") ? "dark" : "light"; +} + const reducedMotion = () => matchMedia("(prefers-reduced-motion: reduce)").matches; -/** Blocks a stage may show with whiteboard.block(): any but animations, - * which would nest stages. */ +/** Blocks a stage may show with whiteboard.block(): any but animations and + * clips, which would nest stages. */ function embeddableBlocks(document: Block[]): Map { const found = new Map(); const visit = (block: Block) => { - if (block.id && block.type !== "animation") found.set(block.id, block); + if (block.id && block.type !== "animation" && block.type !== "clip") + found.set(block.id, block); if ("children" in block) block.children.forEach(visit); }; @@ -131,170 +165,220 @@ function embeddableBlocks(document: Block[]): Map { return found; } +type StageBlockProps = BlockProps<"animation" | "clip">; + +/** The channel to the stage showing: set when it boots. */ +function useStagePort() { + const portRef = useRef(null); + + const send = useCallback((message: HostMessage) => { + portRef.current?.postMessage(message); + }, []); + + return { portRef, send }; +} + +type StagePort = ReturnType; + /** - * An agent's HTML animation, run in a sandboxed stage. Whiteboard pieces the - * stage asks for (code peeks, other blocks) are drawn here, over the - * placeholders the stage lays out, so they are the real components. + * An agent's HTML animation or clip, run in a sandboxed stage. Whiteboard + * pieces the stage asks for (code peeks, other blocks) are drawn here, over + * the placeholders the stage lays out, so they are the real components. An + * animation's reader steps through frames; a clip's plays one timeline. */ -export function AnimationBlock({ +export function AnimationBlock(props: StageBlockProps) { + return props.node.type === "clip" ? ( + + ) : ( + + ); +} + +function FrameStage(props: BlockProps<"animation">) { + const port = useStagePort(); + + return ( + + ); +} + +function ClipStage(props: BlockProps<"clip">) { + const port = useStagePort(); + + return ( + + ); +} + +function StageBlock({ node, data, children, -}: BlockProps<"animation">) { + port: { portRef, send }, + player, +}: StageBlockProps & { port: StagePort; player: StagePlayer }) { const { theme } = useReviewDebugSettings(); - const openPeek = useOptionalReviewPanel((state) => state.openPeek); - const figureRef = useRef(null); + const panelStore = useReviewPanelStore(); + const session = useReviewSession(); + const { diagramFullscreenOnClick } = useWhiteboardSettings(); const frameRef = useRef(null); - const portRef = useRef(null); - const [index, setIndex] = useState(0); - const [sequenceKeys, setSequenceKeys] = useState(null); - const sequenceRef = useRef(null); - const [playing, setPlaying] = useState(false); - const [settled, setSettled] = useState(-1); const [contentHeight, setContentHeight] = useState(120); const [slots, setSlots] = useState([]); const [error, setError] = useState(null); + const clip = node.type === "clip"; + const noun = clip ? "clip" : "animation"; const reportRun = useReportAnimationRun(data.snapshot.reviewId, node.id); - // The inline figure's height while it is out fullscreen, held by a - // placeholder so the document keeps its place. - const [expanded, setExpanded] = useState(null); - const expandRef = useRef(null); + // A block from the agent's stream paints its first frame in when the board + // gets to it, and holds the draw queue until its stage says it is done. + const motion = useMotionPhase(node.id); + const motionRef = useRef(motion); + motionRef.current = motion; + const { painted } = useDrawQueueControls(); + + // The stage showing now: whether it boots unseen, has run its scripts, was + // told to paint, and reported an error. + const paintRef = useRef({ + wanted: false, + ready: false, + sent: false, + failed: false, + }); + + // A no-op unless the queue is painting this block. + const endPaint = useCallback(() => { + if (node.id) painted(node.id); + }, [node.id, painted]); + + // Fullscreen without a tour. Either way, a placeholder holds the inline + // figure's height so the document keeps its place. + const [expanded, setExpanded] = useState(false); + const [held, setHeld] = useState(0); + const openRef = useRef(null); const closeRef = useRef(null); - const indexRef = useRef(index); - indexRef.current = index; const embeddable = useMemo( () => embeddableBlocks(data.snapshot.document), [data.snapshot.document], ); - const frames = node.frames; + const { stops, activeIndex } = player; + const stop = stops[activeIndex]; + const anchorId = clip ? markerAnchorId : frameAnchorId; - const activeFrames = useMemo(() => { - if (!sequenceKeys) return frames; - const byKey = new Map(frames.map((item) => [item.key, item])); + // A stop per frame of the path showing, or per marker, when they show code. + const tour = useMemo( + () => + animationTour( + { id: node.id, title: node.title }, + data.anchors, + stops, + anchorId, + ), + [node.id, node.title, data.anchors, stops, anchorId], + ); - return sequenceKeys.flatMap((key) => { - const item = byKey.get(key); + const frameAnchor = tour && stop ? anchorId(node.id, stop.key) : null; - return item ? [item] : []; - }); - }, [frames, sequenceKeys]); + // Unlike a diagram's, the tour stays open on a stop it no longer has: a + // scenario switch moves it to the frame showing instead. + const overlay = useReviewPanel((state) => + tour && state.overlayTour?.tourId === node.id ? state.overlayTour : null, + ); - const last = activeFrames.length - 1; - const frame = frames[Math.min(index, frames.length - 1)]!; + const tourOpen = overlay !== null; - const activeIndex = Math.max( - 0, - activeFrames.findIndex((item) => item.key === frame.key), + // Set while the column shows this tour. + const { anchor: inlineAnchor, figureRef } = useDiagramSelection( + tour, + tourOpen || expanded, ); - const steps = activeFrames.every((item) => item.label) ? activeFrames : null; - // A new stage for each edit of what it runs; frame changes reuse it. const stageKey = useMemo( () => JSON.stringify([ node.html, node.height, - node.frames, + node.type === "clip" ? node.markers : node.frames, node.code, [...embeddable.keys()], ]), - [node.html, node.height, node.frames, node.code, embeddable], + [node, embeddable], ); - const send = useCallback((message: HostMessage) => { - portRef.current?.postMessage(message); - }, []); + // Paint once the stage is ready and the queue has reached this block, or + // has given up on it: the content must appear either way. + const paintStage = () => { + const stage = paintRef.current; + const phase = motionRef.current; - const go = useCallback( - (target: number, instant: boolean) => { - const keys = sequenceRef.current; - - const path = keys - ? keys.flatMap((key) => { - const item = frames.find((candidate) => candidate.key === key); - - return item ? [item] : []; - }) - : frames; - - const currentKey = frames[indexRef.current]?.key; - const currentPosition = path.findIndex((item) => item.key === currentKey); - const position = Math.max(0, Math.min(path.length - 1, target)); - const next = frames.findIndex((item) => item.key === path[position]?.key); - - if (next < 0 || next === indexRef.current) return; - setIndex(next); - send({ - type: "frame", - index: next, - instant, - direction: - currentPosition < 0 || position === currentPosition - ? "none" - : position > currentPosition - ? "forward" - : "backward", - adjacent: currentPosition >= 0 && position === currentPosition + 1, - }); - }, - [frames, send], - ); + if (phase === "painting" && stage.failed) endPaint(); - const reveal = useCallback( - (name: string) => { - const anchor = data.anchors.get(animationCodeId(node.id, name)); + if (!stage.wanted || !stage.ready || stage.sent || phase === "queued") + return; + stage.sent = true; + send({ type: "paint" }); + }; - if (!anchor || !openPeek) return; - openPeek({ - kind: "peek", - anchor, - content: { kind: "source", source: anchor.peek }, - }); - }, - [data.anchors, node.id, openPeek], - ); + const onPhase = useEffectEvent(paintStage); - // A stage script's setSequence() and the scenario picker both land here. - const selectSequence = (keys: string[]) => { - const unique = new Set(keys); + useEffect(() => onPhase(), [motion]); - const valid = - unique.size === keys.length && - keys.every((key) => frames.some((frame) => frame.key === key)); + const openTour = () => { + if (!tour || !frameAnchor) return; - if (!valid) return; + if (!tourOpen) { + captureUiEvent(session, "tour_started", { steps: tour.stops.length }); - if ( - sequenceRef.current?.length === keys.length && - sequenceRef.current.every((key, index) => key === keys[index]) - ) - return; + if (!expanded) setHeld(figureRef.current?.offsetHeight ?? 0); + } - sequenceRef.current = keys; - setSequenceKeys(keys); - setPlaying(false); - setTimeUp(-1); - const currentKey = frames[indexRef.current]?.key; + setExpanded(false); + panelStore + .getState() + .openOverlayTour({ tourId: node.id, kind: "animation" }, frameAnchor); + }; - const fallback = frames.findIndex((frame) => frame.key === keys[0]); + // A drawn element's code opens beside the board with the tour, at the + // frame showing, or with the tour fullscreen. + const reveal = (name: string) => { + const anchor = data.anchors.get(animationCodeId(node.id, name)); - const next = keys.includes(currentKey) ? indexRef.current : fallback; + if (!anchor) return; + captureUiEvent(session, "peek_opened", { via: "diagram" }); + const { openPeek, moveOverlayTour, active } = panelStore.getState(); - if (next !== indexRef.current) setIndex(next); + const tourStop = tour?.stops.find((item) => item.anchor.id === frameAnchor); - // Refresh even a preserved key: lifecycle ranges, held scene changes, - // and its timeline can differ when active order changes. - send({ - type: "frame", - index: next, - instant: true, - direction: "none", - adjacent: false, + if (!tour || !tourStop || !stop) + return openPeek({ + kind: "peek", + anchor, + content: { kind: "source", source: anchor.peek }, + }); + + if (tourOpen) return moveOverlayTour(tourStop.anchor.id, { reveal: true }); + + if (expanded || diagramFullscreenOnClick) return openTour(); + + openPeek({ + kind: "peek", + anchor: tourStop.anchor, + content: frameContent(node.id, data.anchors, stop, name), + tour: { + definition: tour, + revealRequest: (active?.tour?.revealRequest ?? 0) + 1, + }, }); }; @@ -308,43 +392,31 @@ export function AnimationBlock({ case "slots": setSlots(message.slots); break; - case "settled": - setSettled(message.index); - break; - case "goto": { - const keys = sequenceRef.current; - const path = keys ?? frames.map((item) => item.key); - const position = path.indexOf(message.key); - go(position, true); - break; - } - - case "step": { - const keys = sequenceRef.current; - const path = keys ?? frames.map((item) => item.key); - const currentKey = frames[indexRef.current]?.key; - go(path.indexOf(currentKey) + message.delta, false); - break; - } - - case "sequence": - selectSequence(message.keys); - break; - case "reveal": reveal(message.name); break; case "error": setError(message.message); reportRun(message.message); + // A broken stage must not hold the queue. + paintRef.current.failed = true; + endPaint(); break; case "ready": reportRun(null); + paintRef.current.ready = true; + paintStage(); + break; + case "painted": + endPaint(); break; + default: + player.receive(message); } }; const bootedRef = useRef(null); + const bootPlayer = useEffectEvent(() => player.boot()); // Hand the stage its runtime and a private port when its page first loads. // A later load in the same frame is a page the stage navigated to, which @@ -359,7 +431,8 @@ export function AnimationBlock({ if (bootedRef.current === frameRef.current) { portRef.current = null; setSlots([]); - setError("The animation left its stage."); + setError(`The ${noun} left its stage.`); + endPaint(); return; } @@ -373,24 +446,24 @@ export function AnimationBlock({ if (message.success) onMessage.current(message.data); }; - // A new stage starts in the scenario the reader had picked, or the first. - const sequence = - ( - node.scenarios?.find((scenario) => - sameKeys(scenario.frames, sequenceRef.current), - ) ?? node.scenarios?.[0] - )?.frames ?? null; - - sequenceRef.current = sequence; - setSequenceKeys(sequence); - - if (sequence && !sequence.includes(frames[indexRef.current]!.key)) { - indexRef.current = frames.findIndex((item) => item.key === sequence[0]); - setIndex(indexRef.current); - } - + const kind = bootPlayer(); setSlots([]); setError(null); + + // Unseen until painted only while the queue is drawing (or about to draw) + // this block. A stage replaced mid-paint starts over. + const paint = + (motionRef.current === "painting" || motionRef.current === "queued") && + !reducedMotion(); + + paintRef.current = { + wanted: paint, + ready: false, + sent: false, + failed: false, + }; + + if (!paint) endPaint(); target.postMessage( { type: STAGE_BOOT, runtime: stageRuntime.toString() }, "*", @@ -399,24 +472,17 @@ export function AnimationBlock({ const init: Omit = { type: "init", + ...kind, html: node.html, kit: STAGE_KIT_CSS, autoSize: node.height === "auto", - frames: frames.map(({ key, title, caption, data, code }) => ({ - key, - title, - caption, - data, - code, - })), code: Object.keys(node.code), codeTitles: codeTitles(node, data.anchors), blocks: [...embeddable.keys()], ...readTheme(figure), scale: SCALE, reducedMotion: reducedMotion(), - frame: indexRef.current, - ...(sequence && { sequence }), + paint, }; Promise.all([loadAnime(), loadFonts()]).then( @@ -426,11 +492,14 @@ export function AnimationBlock({ libraries: [anime], fonts, } satisfies HostMessage), - () => setError("The animation libraries or fonts could not be loaded."), + () => { + setError(`The ${noun} libraries or fonts could not be loaded.`); + endPaint(); + }, ); - }, [node.html, node.code, node.height, node.scenarios, frames, embeddable]); + }, [node, noun, embeddable, endPaint, portRef]); - useEffect(() => () => portRef.current?.close(), []); + useEffect(() => () => portRef.current?.close(), [portRef]); // Read after React commits the matching theme class. Listening to the host // event directly can race that commit and export a mixed palette (new neutral @@ -440,55 +509,107 @@ export function AnimationBlock({ send({ type: "theme", ...readTheme(figureRef.current) }); }, [theme, send]); - // Play: hold each active-sequence frame for its duration and until its motion ends. - const [timeUp, setTimeUp] = useState(-1); + const { closeOverlayTour, moveOverlayTour } = panelStore.getState(); + const collapse = useCallback(() => setExpanded(false), []); + const close = tourOpen ? closeOverlayTour : collapse; - useEffect(() => { - if (!playing) return; + const { portalTarget } = useDiagramTourShell(tourOpen || expanded, close); - const timer = setTimeout( - () => setTimeUp(index), - frame.duration ?? DEFAULT_DURATION, - ); + const fullscreen = (tourOpen || expanded) && portalTarget !== null; - return () => clearTimeout(timer); - }, [playing, index, frame.duration]); + // Focus follows the figure in and back out to the control that opened it. + const wasFullscreen = useRef(false); useEffect(() => { - if (!playing || timeUp !== index || settled !== index) return; + if (fullscreen) closeRef.current?.focus(); + else if (wasFullscreen.current) openRef.current?.focus(); + + wasFullscreen.current = fullscreen; + }, [fullscreen]); - if (activeIndex >= last) setPlaying(false); - else go(activeIndex + 1, false); - }, [playing, timeUp, settled, index, activeIndex, last, go]); + // The tour's panel picked a stop (its Next, a click, its scroll): show + // that frame, moving to it when it is the next one. A stop the tour no + // longer has, after a scenario switch, gives way to the frame showing. + const followPanel = useEffectEvent(() => { + const anchor = panelStore.getState().overlayTour?.anchor; + + if (!tour || !frameAnchor || !anchor) return; + const position = tour.stops.findIndex((item) => item.anchor.id === anchor); + + if (position < 0) moveOverlayTour(frameAnchor, { reveal: true }); + else if (position !== activeIndex) player.pick(position, "tour"); + }); + + const overlayAnchor = overlay?.anchor; - // An edit can drop frames or a sequence selection from under the current one. useEffect(() => { - if (!activeFrames.some((item) => item.key === frame.key)) go(0, true); - }, [activeFrames, frame.key, go]); + if (overlayAnchor) followPanel(); + }, [overlayAnchor, tour]); - const collapse = useCallback(() => setExpanded(null), []); + // The column's tour picked a stop (its Next, a click, its scroll): show + // that frame, as the overlay's panel does. + const followColumn = useEffectEvent(() => { + if (!tour || fullscreen || !inlineAnchor) return; - const { portalTarget } = useDiagramTourShell(expanded !== null, collapse); + const position = tour.stops.findIndex( + (item) => item.anchor.id === inlineAnchor, + ); - // Focus follows the figure in and back out to the control that opened it. - const wasExpanded = useRef(false); + if (position >= 0 && position !== activeIndex) + player.pick(position, "tour"); + }); useEffect(() => { - if (expanded !== null) closeRef.current?.focus(); - else if (wasExpanded.current) expandRef.current?.focus(); + if (inlineAnchor) followColumn(); + }, [inlineAnchor, tour]); - wasExpanded.current = expanded !== null; - }, [expanded]); + // The frame changed some other way (the controls, playback, the stage, a + // scenario): the open tour follows it, fullscreen or in the column. Each + // side writes only what differs, so neither echoes the other. + const followFrame = useEffectEvent(() => { + const { overlayTour, active, openPeek } = panelStore.getState(); - const fullscreen = expanded !== null && portalTarget !== null; + if (!tour || !frameAnchor) return; - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "ArrowRight") go(activeIndex + 1, false); - else if (event.key === "ArrowLeft") go(activeIndex - 1, false); - else return; + if (overlayTour?.tourId === node.id) { + if (overlayTour.anchor !== frameAnchor) + moveOverlayTour(frameAnchor, { reveal: true }); - event.preventDefault(); - }; + return; + } + + const tourStop = tour.stops.find((item) => item.anchor.id === frameAnchor); + + if ( + !inlineAnchor || + fullscreen || + !tourStop || + (active?.anchor?.id === frameAnchor && active.tour?.definition === tour) + ) + return; + openPeek({ + kind: "peek", + anchor: tourStop.anchor, + content: tourStop.content, + tour: { + definition: tour, + revealRequest: (active?.tour?.revealRequest ?? 0) + 1, + }, + }); + }); + + // Not on mount: a restored tour's stop leads the frame there. + const followed = useRef({ frameAnchor, tour }); + + useEffect(() => { + if ( + followed.current.frameAnchor === frameAnchor && + followed.current.tour === tour + ) + return; + followed.current = { frameAnchor, tour }; + followFrame(); + }, [frameAnchor, tour]); const figure = (
- fullscreen - ? collapse() - : setExpanded(figureRef.current?.offsetHeight ?? 0) - } - > - {fullscreen ? "Close" : "Expand"} - + fullscreen ? ( + + ) : tour ? ( + + ) : ( + + ) } /> {node.scenarios && ( @@ -533,23 +671,12 @@ export function AnimationBlock({ - sameKeys(scenario.frames, sequenceKeys), - )?.key - } + value={player.scenario} options={node.scenarios.map(({ key, label }) => ({ value: key, label, }))} - onChange={(key) => { - const keys = node.scenarios!.find( - (scenario) => scenario.key === key, - )!.frames; - - send({ type: "sequence", keys }); - selectSequence(keys); - }} + onChange={player.selectScenario} /> )} @@ -598,85 +725,22 @@ export function AnimationBlock({ - {steps && ( -
- {steps.map((item, position) => ( - - ))} -
+ {player.afterStage} + {player.strip && ( + player.pick(position, "controls")} + onKeyDown={player.onKeyDown} + /> )} -
- go(activeIndex - 1, false)} - > - - - = last ? "Replay" : "Play"} - onClick={() => { - if (playing) return setPlaying(false); - - if (activeIndex >= last) go(0, true); - - setPlaying(true); - }} - > - {playing ? ( - - ) : ( - - )} - - = last} - onClick={() => go(activeIndex + 1, false)} - > - - -
- {frame.title} - {frame.caption && ( - {frame.caption} - )} -
- {!steps && ( -
- {activeFrames.map((item, position) => ( - - go(position, Math.abs(position - activeIndex) > 1) - } - /> - ))} -
- )} +
+ {player.controls}
{error && (

- The animation reported an error: {error} + The {noun} reported an error: {error}

)}
@@ -684,11 +748,32 @@ export function AnimationBlock({ if (!fullscreen) return figure; + const placeholder =