authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-05 00:55:35-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 00:55:35-07:00
logeced382b451abc35137e7047b010c6ae5f6e9353
tree4ac319bafd030bc3bd8de3127821f36296e4df22
parentcd92a7e7ae68a9b0c01938e1f69c2044b6c455ac
signature Signed by PGP key B5690EEEBB952194

fix(web): keep chat media at a stable size while it loads (#9938)

Co-authored-by: Claude Code <noreply@anthropic.com>

3 files changed, 307 insertions(+), 97 deletions(-)

apps/web/src/components/ChatMarkdown.tsx+242-80
......@@ -52,6 +52,7 @@ import React, {
5252 Children,
5353 Suspense,
5454 type CSSProperties,
55 type ComponentProps,
5556 type ClipboardEvent as ReactClipboardEvent,
5657 type KeyboardEvent as ReactKeyboardEvent,
5758 type MouseEvent as ReactMouseEvent,
......@@ -357,6 +358,55 @@ type MarkdownImageHastNode = {
357358 children?: MarkdownImageHastNode[];
358359};
359360
361function meaningfulHastChildren(node: MarkdownImageHastNode): MarkdownImageHastNode[] {
362 return (node.children ?? []).filter(
363 (child) => !(child.type === "text" && (child as { value?: string }).value?.trim() === ""),
364 );
365}
366
367/**
368 * An image that is the only content of its block (optionally wrapped in a
369 * link) is almost always a screenshot or figure, so it gets a reserved slot
370 * while it loads. Images mixed with text or other images — badge rows, icons
371 * in a sentence — stay inline at their natural size, since a placeholder taller
372 * than the image would move the page more than the image itself does.
373 */
374/** Containers whose sole child image reads as a figure rather than part of a sentence. */
375const STANDALONE_IMAGE_BLOCKS = new Set([
376 "p",
377 "div",
378 "li",
379 "td",
380 "th",
381 "figure",
382 "center",
383 "blockquote",
384]);
385
386function soleImageDescendant(node: MarkdownImageHastNode): MarkdownImageHastNode | undefined {
387 const children = meaningfulHastChildren(node);
388 if (children.length !== 1) return undefined;
389 const only = children[0];
390 if (only?.type !== "element") return undefined;
391 if (only.tagName === "img") return only;
392 // A link, emphasis, or similar inline wrapper around the image still counts
393 // as long as nothing else shares the block.
394 return only.tagName === "a" || only.tagName === "strong" || only.tagName === "em"
395 ? soleImageDescendant(only)
396 : undefined;
397}
398
399function markStandaloneImages(node: MarkdownImageHastNode) {
400 // A raw `<img>` on its own line reaches the root without a paragraph.
401 if (node.type === "root" || (node.tagName && STANDALONE_IMAGE_BLOCKS.has(node.tagName))) {
402 const image = soleImageDescendant(node);
403 if (image) image.properties = { ...image.properties, dataStandalone: true };
404 }
405 node.children?.forEach((child) => {
406 if (child.type === "element") markStandaloneImages(child);
407 });
408}
409
360410/** Carries authored image source metadata through the sanitizer to the image renderer. */
361411function rehypePreserveImageSourceMeta() {
362412 return (tree: MarkdownImageHastNode) => {
......@@ -374,6 +424,7 @@ function rehypePreserveImageSourceMeta() {
374424 };
375425
376426 visit(tree);
427 markStandaloneImages(tree);
377428 };
378429}
379430
......@@ -386,7 +437,12 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
386437 blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"],
387438 div: [...(defaultSchema.attributes?.div ?? []), ...CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES],
388439 a: [...(defaultSchema.attributes?.a ?? []), "dataPullRequestAutolink"],
389 img: [...(defaultSchema.attributes?.img ?? []), "dataLocalSrc", "dataMarkdownTitle"],
440 img: [
441 ...(defaultSchema.attributes?.img ?? []),
442 "dataLocalSrc",
443 "dataMarkdownTitle",
444 "dataStandalone",
445 ],
390446 },
391447 protocols: {
392448 ...defaultSchema.protocols,
......@@ -1200,7 +1256,6 @@ function authoredImageSizeStyle(
12001256}
12011257
12021258const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn(
1203 CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME,
12041259 CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME,
12051260 CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME,
12061261);
......@@ -1242,13 +1297,26 @@ function expandableMarkdownImageProps(
12421297 };
12431298}
12441299
1300function ChatMarkdownMediaUnavailableLabel(props: {
1301 readonly alt: string;
1302 readonly kind?: "image" | "video" | undefined;
1303}) {
1304 const label = props.kind === "video" ? "Video unavailable" : "Image unavailable";
1305 return (
1306 <span className="inline-flex items-center gap-1.5">
1307 <TriangleAlertIcon aria-hidden className="size-3.5 shrink-0" />
1308 {props.alt.length > 0 ? `${label} · ${props.alt}` : label}
1309 </span>
1310 );
1311}
1312
1313/** Inline chip for an image that sits in a line of text or can never load. */
12451314function ChatMarkdownImageFallback(props: {
12461315 readonly alt: string;
12471316 readonly copyMarkdown?: string | undefined;
12481317 readonly kind?: "image" | "video";
1249 readonly actionsSource?: MediaActionSource;
1318 readonly actionsSource?: MediaActionSource | undefined;
12501319}) {
1251 const label = props.kind === "video" ? "Video unavailable" : "Image unavailable";
12521320 const content = (
12531321 <span
12541322 data-markdown-copy={props.copyMarkdown}
......@@ -1257,10 +1325,7 @@ function ChatMarkdownImageFallback(props: {
12571325 "rounded-md border border-border/40 bg-muted/40 px-2 py-1 text-xs text-muted-foreground",
12581326 )}
12591327 >
1260 <span className="inline-flex items-center gap-1.5">
1261 <TriangleAlertIcon aria-hidden className="size-3.5 shrink-0" />
1262 {props.alt.length > 0 ? `${label} · ${props.alt}` : label}
1263 </span>
1328 <ChatMarkdownMediaUnavailableLabel alt={props.alt} kind={props.kind} />
12641329 </span>
12651330 );
12661331 return props.actionsSource ? (
......@@ -1270,6 +1335,144 @@ function ChatMarkdownImageFallback(props: {
12701335 );
12711336}
12721337
1338const CHAT_MARKDOWN_IMAGE_FRAME_CLASS_NAME = cn(
1339 "aspect-video w-full overflow-hidden bg-muted/60",
1340 CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME,
1341 CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME,
1342);
1343
1344/**
1345 * A standalone image holds a 16:9 slot (or its authored size) until it has
1346 * decoded, and keeps that slot if it fails, so a timeline row moves at most
1347 * once: when the natural size arrives. A bare `<img>` is zero height until
1348 * then. Once decoded the image renders bare again so its box, hit area, and
1349 * alignment are exactly the image's own. Inline images (badges, icons in a
1350 * sentence) skip the slot: a placeholder taller than the image would move the
1351 * page more than the image does.
1352 *
1353 * Callers key this on the file's identity, not its URL: a re-signed URL for
1354 * the same file keeps the decoded image on screen while the new bytes arrive,
1355 * and a different file starts from the slot again.
1356 */
1357function ChatMarkdownImage(props: {
1358 /** Null while the URL is being resolved; the last decoded image stays up. */
1359 readonly src: string | null;
1360 readonly sourceFailed?: boolean | undefined;
1361 readonly alt: string;
1362 readonly copyMarkdown: string | undefined;
1363 readonly standalone: boolean;
1364 readonly className?: string | undefined;
1365 readonly style?: CSSProperties | undefined;
1366 /** Sanitized authored attributes (`id`, `align`, …) that fragment links and layout rely on. */
1367 readonly imageProps?:
1368 | Omit<ComponentProps<"img">, "src" | "alt" | "className" | "style">
1369 | undefined;
1370 readonly actionsSource: MediaActionSource;
1371 readonly originalUrl?: string | undefined;
1372 readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
1373}) {
1374 const [loadedSrc, setLoadedSrc] = useState<string | null>(null);
1375 const [failedSrc, setFailedSrc] = useState<string | null>(null);
1376 const src = props.src ?? loadedSrc;
1377 const failed = props.sourceFailed === true || (src !== null && failedSrc === src);
1378 // A failure forgets the decoded image so the next URL loads behind the slot.
1379 const settled = src !== null && !failed && (!props.standalone || loadedSrc !== null);
1380 // Cached images are complete before `onLoad` can fire.
1381 const markLoadedIfComplete = useCallback((image: HTMLImageElement | null) => {
1382 if (image?.complete && image.naturalWidth > 0) setLoadedSrc(image.currentSrc || image.src);
1383 }, []);
1384 const imageEvents = (loadingSrc: string) => ({
1385 onLoad: () => {
1386 setLoadedSrc(loadingSrc);
1387 setFailedSrc(null);
1388 },
1389 onError: () => {
1390 setFailedSrc(loadingSrc);
1391 setLoadedSrc(null);
1392 },
1393 });
1394
1395 if (settled) {
1396 return (
1397 <MediaActions source={props.actionsSource}>
1398 <img
1399 {...props.imageProps}
1400 ref={markLoadedIfComplete}
1401 src={src}
1402 alt={props.alt}
1403 data-markdown-copy={props.copyMarkdown}
1404 decoding="async"
1405 draggable={false}
1406 className={cn(
1407 CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME,
1408 props.className,
1409 props.onImageExpand && "cursor-zoom-in",
1410 )}
1411 style={props.style}
1412 {...expandableMarkdownImageProps(
1413 props.onImageExpand,
1414 src,
1415 props.alt,
1416 props.originalUrl,
1417 props.actionsSource,
1418 )}
1419 {...imageEvents(src)}
1420 />
1421 </MediaActions>
1422 );
1423 }
1424 if (!props.standalone) {
1425 return failed ? (
1426 <ChatMarkdownImageFallback
1427 alt={props.alt}
1428 copyMarkdown={props.copyMarkdown}
1429 actionsSource={props.actionsSource}
1430 />
1431 ) : (
1432 <span
1433 id={props.imageProps?.id}
1434 data-markdown-copy={props.copyMarkdown}
1435 role="status"
1436 aria-label="Loading image"
1437 className={CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME}
1438 />
1439 );
1440 }
1441 return (
1442 <MediaActions source={props.actionsSource}>
1443 <span
1444 id={props.imageProps?.id}
1445 data-markdown-copy={props.copyMarkdown}
1446 className={cn(
1447 CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME,
1448 CHAT_MARKDOWN_IMAGE_FRAME_CLASS_NAME,
1449 "relative",
1450 )}
1451 style={props.style}
1452 {...(failed
1453 ? { role: "alert" as const }
1454 : { role: "status" as const, "aria-label": "Loading image" })}
1455 >
1456 {failed ? (
1457 <span className="flex size-full items-center justify-center p-2 text-center text-xs text-muted-foreground">
1458 <ChatMarkdownMediaUnavailableLabel alt={props.alt} />
1459 </span>
1460 ) : src !== null ? (
1461 <img
1462 ref={markLoadedIfComplete}
1463 src={src}
1464 alt={props.alt}
1465 decoding="async"
1466 draggable={false}
1467 className="invisible absolute inset-0 size-full"
1468 {...imageEvents(src)}
1469 />
1470 ) : null}
1471 </span>
1472 </MediaActions>
1473 );
1474}
1475
12731476function ChatMarkdownVideo(props: {
12741477 readonly src: string | null;
12751478 readonly alt: string;
......@@ -1316,13 +1519,14 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props
13161519 readonly alt: string;
13171520 readonly copyMarkdown?: string;
13181521 readonly srcFragment?: string;
1522 /** Reserve a slot while loading; off for images that share a line with text. */
1523 readonly standalone?: boolean | undefined;
13191524 readonly style?: CSSProperties | undefined;
13201525 readonly workspaceRoot?: string | undefined;
13211526 readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
13221527}) {
13231528 const assetUrl = useAssetUrlState(props.environmentId, props.resource);
13241529 const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource);
1325 const [failedUrl, setFailedUrl] = useState<string | null>(null);
13261530 const resource = props.resource;
13271531 const path =
13281532 resource._tag === "media-file"
......@@ -1367,56 +1571,19 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props
13671571 );
13681572 }
13691573
1370 if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) {
1371 return (
1372 <ChatMarkdownImageFallback
1373 alt={props.alt}
1374 copyMarkdown={props.copyMarkdown}
1375 kind={props.kind ?? "image"}
1376 actionsSource={actionsSource}
1377 />
1378 );
1379 }
1380 if (assetUrl._tag !== "Success") {
1381 return (
1382 <MediaActions source={actionsSource}>
1383 <span
1384 data-markdown-copy={props.copyMarkdown}
1385 role="status"
1386 aria-label="Loading image"
1387 className={cn(
1388 CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME,
1389 "aspect-video w-64 max-w-full rounded-lg bg-muted/60",
1390 CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME,
1391 )}
1392 style={props.style}
1393 />
1394 </MediaActions>
1395 );
1396 }
13971574 return (
1398 <MediaActions source={actionsSource}>
1399 <img
1400 src={src ?? undefined}
1401 alt={props.alt}
1402 data-markdown-copy={props.copyMarkdown}
1403 loading="lazy"
1404 draggable={false}
1405 className={cn(
1406 CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME,
1407 props.onImageExpand && "cursor-zoom-in",
1408 )}
1409 style={props.style}
1410 {...expandableMarkdownImageProps(
1411 props.onImageExpand,
1412 src ?? assetUrl.url,
1413 props.alt,
1414 undefined,
1415 actionsSource,
1416 )}
1417 onError={() => setFailedUrl(assetUrl.url)}
1418 />
1419 </MediaActions>
1575 <ChatMarkdownImage
1576 key={JSON.stringify([props.environmentId, props.resource, props.srcFragment])}
1577 src={src}
1578 sourceFailed={assetUrl._tag === "Failure"}
1579 alt={props.alt}
1580 copyMarkdown={props.copyMarkdown}
1581 standalone={props.standalone ?? true}
1582 className={CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME}
1583 style={props.style}
1584 actionsSource={actionsSource}
1585 onImageExpand={props.onImageExpand}
1586 />
14201587 );
14211588});
14221589
......@@ -2772,6 +2939,7 @@ const CHAT_MARKDOWN_COMPONENTS = {
27722939 const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia;
27732940 const localSrc = node?.properties?.dataLocalSrc;
27742941 const markdownTitle = node?.properties?.dataMarkdownTitle;
2942 const standalone = node?.properties?.dataStandalone === true;
27752943 const authoredSrc = typeof localSrc === "string" ? localSrc : src;
27762944 const authoredTitle = typeof markdownTitle === "string" ? markdownTitle : title;
27772945 const srcString =
......@@ -2780,7 +2948,8 @@ const CHAT_MARKDOWN_COMPONENTS = {
27802948 typeof localSrc === "string" ? srcString.replaceAll("\\", "/") : srcString;
27812949 const altText = alt ?? "";
27822950 const copyMarkdown = markdownImageCopy(altText, srcString, authoredTitle);
2783 const authoredSizeStyle = authoredImageSizeStyle(props.width, props.height);
2951 const { className, style: _style, width, height, ...imageProps } = props;
2952 const authoredSizeStyle = authoredImageSizeStyle(width, height);
27842953 const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd);
27852954 const kind = mediaKindFromPath(classifiedSrc) ?? "image";
27862955 if (imageSource._tag === "Direct") {
......@@ -2807,27 +2976,19 @@ const CHAT_MARKDOWN_COMPONENTS = {
28072976 );
28082977 }
28092978 return (
2810 <MediaActions source={actionsSource}>
2811 <img
2812 {...props}
2813 src={mediaSrc}
2814 alt={altText}
2815 loading="lazy"
2816 className={cn(
2817 props.className,
2818 CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME,
2819 imageExpand && "cursor-zoom-in",
2820 )}
2821 style={authoredSizeStyle}
2822 {...expandableMarkdownImageProps(
2823 imageExpand,
2824 mediaSrc,
2825 altText,
2826 originalUrl,
2827 actionsSource,
2828 )}
2829 />
2830 </MediaActions>
2979 <ChatMarkdownImage
2980 key={mediaSrc}
2981 src={mediaSrc}
2982 alt={altText}
2983 copyMarkdown={copyMarkdown}
2984 standalone={standalone}
2985 className={className}
2986 style={authoredSizeStyle}
2987 imageProps={imageProps}
2988 actionsSource={actionsSource}
2989 originalUrl={originalUrl}
2990 onImageExpand={imageExpand}
2991 />
28312992 );
28322993 }
28332994 if (imageSource._tag === "WorkspaceFile" && threadRef) {
......@@ -2843,6 +3004,7 @@ const CHAT_MARKDOWN_COMPONENTS = {
28433004 kind={kind}
28443005 copyMarkdown={copyMarkdown}
28453006 srcFragment={markdownImageSourceFragment(classifiedSrc)}
3007 standalone={standalone}
28463008 style={authoredSizeStyle}
28473009 workspaceRoot={cwd}
28483010 onImageExpand={imageExpand}
apps/web/src/components/ChatMarkdown.workspace-images.test.tsx+62-16
......@@ -139,9 +139,10 @@ describe("ChatMarkdown workspace images", () => {
139139 path: "\\\\server\\share\\workspace-image.svg",
140140 },
141141 ]);
142 expect(html.match(/https:\/\/signed\.test\/workspace-image\.svg/g)).toHaveLength(4);
142 expect(html.match(/<img[^>]*src="https:\/\/signed\.test\/workspace-image\.svg"/g)).toHaveLength(
143 4,
144 );
143145 expect(html.match(/max-w-\[min\(100%,30rem\)\]/g)).toHaveLength(4);
144 expect(html.match(/max-h-\[30rem\]/g)).toHaveLength(4);
145146 expect(html).not.toContain("Image unavailable");
146147 });
147148
......@@ -203,23 +204,49 @@ describe("ChatMarkdown workspace images", () => {
203204 expect(loadedStyle).toHaveProperty(constraint, expectedValue);
204205 });
205206
206 it("keeps all images baseline-aligned and workspace images inline", () => {
207 it("keeps images that share a line inline and lets a standalone one reserve a slot", () => {
207208 const html = render(
208209 "![remote](https://example.com/badge.svg) ![workspace](.t3/workspace-image.svg)",
209210 );
210 const classNames = Array.from(html.matchAll(/<img[^>]*class="([^"]*)"/g), (match) =>
211 match[1]?.split(" "),
212 );
213211
214 expect(classNames).toHaveLength(2);
215 expect(classNames[1]).toContain("inline-block!");
212 // Two images in one paragraph are badges: neither reserves a slot.
213 expect(html).not.toContain("aspect-video");
214 expect(html).toContain('src="https://example.com/badge.svg"');
215 expect(html).toContain('src="https://signed.test/workspace-image.svg"');
216 expect(html.match(/<img[^>]*class="[^"]*inline-block![^"]*"/g)).toHaveLength(1);
217 expect(html).not.toContain("invisible");
216218
217219 const centeredHtml = render(
218220 '<p align="center"><img src=".t3/workspace-image.svg" alt="logo"></p>',
219221 );
220 const centeredClassName = /<img[^>]*class="([^"]*)"/.exec(centeredHtml)?.[1];
222 const frame = /<span[^>]*role="status"[^>]*>/.exec(centeredHtml)?.[0];
223
224 expect(frame).toContain("inline-block!");
225 expect(frame).toContain("aspect-video");
226 });
227
228 it("reserves a slot for an image that is the only content of its link", () => {
229 const html = render("[![shot](.t3/workspace-image.svg)](https://example.com)");
230
231 expect(html).toContain("aspect-video");
232 });
233
234 it.each([
235 ["a link", "Figure: [![shot](.t3/workspace-image.svg)](https://example.com)"],
236 ["emphasis", "**![shot](.t3/workspace-image.svg)** caption"],
237 ])("keeps an image wrapped in %s inline when text shares its block", (_wrapper, markdown) => {
238 expect(render(markdown)).not.toContain("aspect-video");
239 });
240
241 it("keeps an authored id on a remote image so fragment links resolve", () => {
242 const html = render('<img id="diagram" src="https://example.com/diagram.png" alt="diagram">');
243
244 // The sanitizer prefixes authored ids; the loading slot carries it too.
245 expect(html).toContain('<span id="user-content-diagram"');
246 });
221247
222 expect(centeredClassName?.split(" ")).toContain("inline-block!");
248 it("reserves a slot for an image that is alone in a list item", () => {
249 expect(render("- ![shot](.t3/workspace-image.svg)")).toContain("aspect-video");
223250 });
224251
225252 it("retains an authored SVG fragment on the signed URL", () => {
......@@ -278,15 +305,35 @@ describe("ChatMarkdown workspace images", () => {
278305 );
279306 });
280307
281 it("uses a static bounded-width placeholder while a signed asset URL loads", () => {
308 it("reserves the same 16:9 frame while the URL, the bytes, and a failure resolve", () => {
309 const frameClassName = (html: string) => {
310 const frame = /<span[^>]*role="(?:status|alert)"[^>]*>/.exec(html)?.[0] ?? "";
311 return /class="([^"]*)"/.exec(frame)?.[1]?.split(" ") ?? [];
312 };
313 const markdown = "![shot](.t3/workspace-image.svg)";
314
282315 testState.assetState = "loading";
316 const loadingUrl = frameClassName(render(markdown));
317 testState.assetState = "success";
318 const loadingBytes = render(markdown);
319 testState.assetState = "failure";
320 const failure = render(markdown);
321
322 expect(loadingUrl).toEqual(expect.arrayContaining(["aspect-video", "w-full"]));
323 expect(loadingUrl).not.toContain("animate-pulse");
324 expect(frameClassName(loadingBytes)).toEqual(loadingUrl);
325 expect(frameClassName(failure)).toEqual(loadingUrl);
326 expect(failure).toContain("Image unavailable");
327 // The bytes are requested inside the frame but never paint at an unknown size.
328 expect(loadingBytes).toMatch(/<img[^>]*src="https:\/\/signed[^>]*class="invisible/);
329 expect(loadingBytes).not.toContain('loading="lazy"');
330 });
283331
284 const html = render("![loading](.t3/workspace-image.svg)");
285 const className = /<span[^>]*aria-label="Loading image"[^>]*class="([^"]*)"/.exec(html)?.[1];
332 it("gives a standalone remote image the same frame instead of a bare tag", () => {
333 const html = render("![remote](https://example.com/shot.png)");
286334
287335 expect(html).toContain('aria-label="Loading image"');
288 expect(html).not.toContain("animate-pulse");
289 expect(className?.split(" ")).toContain("w-64");
336 expect(html).toContain("aspect-video");
290337 });
291338
292339 it("never passes a workspace source to a raw image when thread context is unavailable", () => {
......@@ -313,7 +360,6 @@ describe("ChatMarkdown workspace images", () => {
313360 expect(testState.resources).toEqual([]);
314361 expect(html).toContain('src="https://example.com/image.png"');
315362 expect(html).toContain("max-w-[min(100%,30rem)]");
316 expect(html).toContain("max-h-[30rem]");
317363 expect(html).not.toContain("Image unavailable");
318364 });
319365});
apps/web/src/components/media/MediaVideoPlayer.tsx+3-1
......@@ -127,7 +127,9 @@ export function MediaVideoPlayer({
127127 <span
128128 role="alert"
129129 className={cn(
130 "flex min-h-28 flex-col items-center justify-center gap-3 rounded-lg border border-border/40 bg-muted/40 p-4 text-center text-sm text-muted-foreground",
130 // Same 16:9 slot as the loading and playing states, so a failed or
131 // retried video does not move the rows below it.
132 "flex aspect-video max-h-full min-h-28 w-full flex-col items-center justify-center gap-3 rounded-lg border border-border/40 bg-muted/40 p-4 text-center text-sm text-muted-foreground",
131133 stateClassName,
132134 )}
133135 >