mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 04:02:18 +01:00
fix(ui): fall back to the icon face when an Artifacts V1 preview fails to load (#17260)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents produce artifacts: documents, files, pictures, video and links. The Artifacts V1 presentation (`enableArtifactsV1`, #17228) shows them as prints, sheets and rows on the Artifacts page, in the panel's Artifacts tab and in the task thread > - A picture is printed from its attachment's content path. When that content cannot be loaded (on staging, image work products whose attachment returns 404), the browser draws its broken-picture glyph inside the bordered print and inside the 40px row thumbnail > - The presentation already has a face for a file with nothing to show: the icon sheet (the faint glyph and the type label) and the row's document icon. A picture that cannot be loaded should take that face, not a browser glyph > - This pull request listens for the element's own `error` event in the V1 primitives and falls back to the icon face on all three surfaces: the stack front and the single tile on the Artifacts page, and the panel and thread rows > - The benefit is that a missing or unreadable file reads as a file of its type everywhere, with no fetch to find out, and nothing changes for a picture that loads ## Linked Issues or Issue Description No public issue exists. This section follows the bug report template. **What happened?** With Artifacts V1 on, an image work product whose attachment content returns 404 ("Attachment not found") shows the browser's broken-image glyph inside the bordered print on the Artifacts page (the stack front and the single tile) and inside the 40px thumbnail in the panel and thread rows. **What did you expect to happen?** The artifact shows the icon face a file without a preview gets: the icon sheet with the faint glyph and the type label on the Artifacts page, and the document icon with the type label in the rows. **Steps to reproduce** 1. Turn on Settings > Experimental > Artifacts V1. 2. Register an image work product whose attachment content returns 404, or delete the attachment behind one. 3. Open the Artifacts page, the task's Artifacts tab and the task thread. **Relevant subsystem** The web UI (`ui/src/components/artifacts/v1/`). ## What Changed - `ArtifactPrint`: a new `usePreviewFailure(source)` hook beside `useImageAspect`, and an `onMediaError` prop wired to the `error` event of the print's `<img>` and `<video>`. The hook remembers the source that failed rather than a flag, so a front that moves on to another picture (a stack's newest artifact) gets a fresh chance. - `ArtifactFront`: owns the failure state for its subject and renders the icon sheet (`IconSheet`, the `icon` treatment) once the print reports an error; it passes the error on through its own `onMediaError`. - `ArtifactStack`: on an error it stops fitting a print and stands the icon sheet where a sheet stands in the media stack (the portrait anchor, where audio stands), keeping the media backing. - `ArtifactRow`: the thumbnail listens for `error` on its `<img>` / `<video>` and gives way to `ArtifactRowIcon`, the document icon with the type label. - Until the picture loads the print stays the 4:3 placeholder, and a picture that loads behaves as before. No fetch is made to detect a failure. - A failed video takes the plain icon sheet with no play disc, since the stream it would promise cannot play; a video with no URL at all keeps the existing media-fallback treatment with the disc. - `index.ts` exports `usePreviewFailure`; the plan doc `doc/plans/2026-10-22-artifacts-mvp-v1.md` records the fallback. - Tests: `ArtifactTile.test.tsx` covers the single tile (4:3 placeholder, then the icon sheet after `error`), the picture stack (the sheet stands where the audio stack's sheet stands, on the media backing) and a video whose stream fails (icon sheet, no play disc). `ArtifactRow.test.tsx` covers the thumbnail and the video thumbnail giving way to the document icon, and a new source getting its own chance. ## Verification - `cd ui && npx vitest run src/components/artifacts/v1` — 6 files, 33 tests pass (5 new). - `pnpm run check:token-gates` — exits 0: all four token gates clean, palette tokens and the ClipLab character in sync. (The bare `pnpm check:token-gates` form reports "Command not found" under pnpm 9.15.4 in this worktree; `pnpm run` works.) - `cd ui && pnpm typecheck` — exits 0 with no errors. - Browser proof on a throwaway backend-free vite page (not committed) rendering the primitives with a 404 picture, a 404 video, a picture that loads and audio: on the Artifacts page the 404 picture's stack front is the PNG icon sheet at the same position as the audio stack's sheet (left 37.89px, top 13.34px) on the media backing; the single tiles show the PNG and WEBM icon sheets with no play disc while the loading picture keeps its fitted 240×160 print; the rows show the PNG and WEBM document icons with no `<img>`/`<video>` left, while the loading picture's row keeps its thumbnail. Not run: the browser suites (`pnpm test:e2e`, `pnpm test:release-smoke`) and a live staging check. States enumerated: loading (4:3 placeholder print, unchanged), loaded (fitted print, unchanged), failed picture (icon sheet / document icon), failed poster-less video (icon sheet without play disc / document icon), source changed after a failure (print again). ## Risks - Low risk. The change is behind `enableArtifactsV1` and only acts on the element's `error` event, which fires for a 404, a network failure, or content that is not decodable as a picture; nothing is fetched to find out. - `ArtifactFront` and `ArtifactStack` each keep the failure state for their subject (as both already keep the loaded aspect), set from the same event in one render; a consumer that renders `ArtifactFront` with a print size but ignores `onMediaError` still gets the icon face, only at the print's position. - A picture that fails once and later becomes readable at the same URL keeps the icon face until the surface remounts or the subject moves on to another source; the data does not change under a mounted tile in practice. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude (Anthropic), model ID `claude-fable-5-1`, extended thinking, running in Claude Code (desktop app) with tool use: file editing, shell, Vitest, and the built-in browser for the proof page. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
4da876ab0b
commit
3e7c3ee9a9
8 files changed
+158
-17
No files matched your search
@@ -137,5 +137,9 @@ the wiring is covered by tests.
|
||||
the default path unchanged, and the V1 path gets its own tests.
|
||||
- Pictures without known dimensions render at 4:3 until they load; the fitted print then
|
||||
resizes once. The stack frame's height is fixed, so nothing below it moves.
|
||||
- A picture whose file cannot be loaded (an attachment that returns 404, a file that is not
|
||||
a picture after all) falls back to the icon sheet on the Artifacts page and to the document
|
||||
icon in the panel and thread rows, from the element's own `error` event; nothing is
|
||||
fetched to find out, and a front that moves on to another picture gets a fresh chance.
|
||||
- Token count grows by about thirty `--artifact-*` entries; they are namespaced and
|
||||
documented in the token block.
|
||||
@@ -1,26 +1,32 @@
|
||||
import type { CSSProperties, SyntheticEvent } from "react";
|
||||
import { ArtifactPrint, type PrintSize } from "./ArtifactPrint";
|
||||
import { ArtifactPrint, usePreviewFailure, type PrintSize } from "./ArtifactPrint";
|
||||
import { IconSheet, PreviewSheet } from "./ArtifactSheet";
|
||||
import { treatmentOf, type ArtifactSubject } from "./kinds";
|
||||
import { previewSourceOf, treatmentOf, type ArtifactSubject } from "./kinds";
|
||||
|
||||
/**
|
||||
* The artifact's own representation: one of the mock's four treatments. A
|
||||
* print is sized by the caller (a stack fits it to its own box); on its own
|
||||
* it fits the single-artifact box. The root carries data-artifact-front so
|
||||
* a hovered tile can lift it (index.css).
|
||||
* it fits the single-artifact box. A picture that cannot be loaded gives
|
||||
* way to the icon sheet, the face a file without a preview gets, rather
|
||||
* than the browser's broken-picture glyph; the caller hears of it through
|
||||
* onMediaError so it can stand the sheet where sheets stand. The root
|
||||
* carries data-artifact-front so a hovered tile can lift it (index.css).
|
||||
*/
|
||||
export function ArtifactFront({
|
||||
subject,
|
||||
print,
|
||||
onMediaLoad,
|
||||
onMediaError,
|
||||
style,
|
||||
}: {
|
||||
subject: ArtifactSubject;
|
||||
print?: PrintSize;
|
||||
onMediaLoad?: (event: SyntheticEvent<HTMLImageElement | HTMLVideoElement>) => void;
|
||||
onMediaError?: (event: SyntheticEvent<HTMLImageElement | HTMLVideoElement>) => void;
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
switch (treatmentOf(subject)) {
|
||||
const [failed, onOwnError] = usePreviewFailure(previewSourceOf(subject));
|
||||
switch (failed ? "icon" : treatmentOf(subject)) {
|
||||
case "preview":
|
||||
return (
|
||||
<div data-artifact-front className="shrink-0" style={style}>
|
||||
@@ -28,7 +34,18 @@ export function ArtifactFront({
|
||||
</div>
|
||||
);
|
||||
case "media":
|
||||
return <ArtifactPrint subject={subject} size={print} onMediaLoad={onMediaLoad} style={style} />;
|
||||
return (
|
||||
<ArtifactPrint
|
||||
subject={subject}
|
||||
size={print}
|
||||
onMediaLoad={onMediaLoad}
|
||||
onMediaError={(event) => {
|
||||
onOwnError();
|
||||
onMediaError?.(event);
|
||||
}}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
case "media-fallback":
|
||||
return (
|
||||
<div data-artifact-front className="shrink-0" style={style}>
|
||||
|
||||
@@ -8,7 +8,10 @@ import type { ArtifactSubject } from "./kinds";
|
||||
* soft shadow (`.artifact-print`, index.css). A print is sized to fit a box
|
||||
* while keeping the picture's ratio, so a leading picture never grows past
|
||||
* the stack it stands in. The ratio comes with the subject when known and
|
||||
* from the loaded picture otherwise; until then the print is 4:3.
|
||||
* from the loaded picture otherwise; until then the print is 4:3. When the
|
||||
* picture (or a video's metadata) cannot be loaded — the file is gone, or
|
||||
* not a picture after all — the print reports it through onMediaError and
|
||||
* the caller shows the icon sheet in its place.
|
||||
*/
|
||||
export interface PrintSize {
|
||||
width: number;
|
||||
@@ -35,16 +38,31 @@ export function useImageAspect(known: number | null | undefined): [number | null
|
||||
return [known && known > 0 ? known : loaded, onLoad];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the picture at `source` (or a video's metadata) could not be
|
||||
* loaded. The failure is remembered by source rather than as a flag, so a
|
||||
* front that moves on to another picture (a stack's newest artifact) gets a
|
||||
* fresh chance, and nothing is fetched to find out.
|
||||
*/
|
||||
export function usePreviewFailure(source: string | null): [boolean, () => void] {
|
||||
const [failedSource, setFailedSource] = useState<string | null>(null);
|
||||
const onError = useCallback(() => setFailedSource(source), [source]);
|
||||
return [source !== null && failedSource === source, onError];
|
||||
}
|
||||
|
||||
export function ArtifactPrint({
|
||||
subject,
|
||||
size,
|
||||
onMediaLoad,
|
||||
onMediaError,
|
||||
style,
|
||||
}: {
|
||||
subject: ArtifactSubject;
|
||||
/** Sized by the caller (a stack fits it to its own box); on its own it fits the single box. */
|
||||
size?: PrintSize;
|
||||
onMediaLoad?: (event: SyntheticEvent<HTMLImageElement | HTMLVideoElement>) => void;
|
||||
/** The picture (or the video's metadata) could not be loaded; the caller shows another face. */
|
||||
onMediaError?: (event: SyntheticEvent<HTMLImageElement | HTMLVideoElement>) => void;
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
const [ownAspect, onOwnLoad] = useImageAspect(subject.aspect);
|
||||
@@ -67,6 +85,7 @@ export function ArtifactPrint({
|
||||
onOwnLoad(event);
|
||||
onMediaLoad?.(event);
|
||||
}}
|
||||
onError={onMediaError}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
@@ -78,6 +97,7 @@ export function ArtifactPrint({
|
||||
onOwnLoad(event);
|
||||
onMediaLoad?.(event);
|
||||
}}
|
||||
onError={onMediaError}
|
||||
/>
|
||||
)}
|
||||
{subject.kind === "video" ? (
|
||||
|
||||
@@ -59,6 +59,27 @@ describe("ArtifactRow", () => {
|
||||
expect(link?.querySelector("img")?.getAttribute("src")).toBe("/sheet.png");
|
||||
});
|
||||
|
||||
it("shows the document icon in place of a thumbnail that cannot be loaded, and gives a new picture its own chance", () => {
|
||||
const render = (thumbnailUrl: string) => act(() => root.render(<ArtifactRow subject={testSubject({ kind: "image", filename: "contact-sheet.png", thumbnailUrl })} now={NOW} />));
|
||||
render("/gone.png");
|
||||
const row = () => container.querySelector('[data-testid="artifact-v1-row"]');
|
||||
expect(row()?.textContent).not.toContain("PNG");
|
||||
act(() => { container.querySelector('img[src="/gone.png"]')!.dispatchEvent(new Event("error")); });
|
||||
expect(container.querySelector('img[src="/gone.png"]')).toBeNull();
|
||||
expect(row()?.textContent).toContain("PNG");
|
||||
expect(row()?.querySelector('button[data-testid="artifact-v1-row-open"]')?.getAttribute("aria-label")).toBe("Open contact-sheet.png");
|
||||
render("/fresh.png");
|
||||
expect(container.querySelector('img[src="/fresh.png"]')).not.toBeNull();
|
||||
expect(row()?.textContent).not.toContain("PNG");
|
||||
});
|
||||
|
||||
it("shows the document icon for a video whose stream cannot be loaded", () => {
|
||||
act(() => root.render(<ArtifactRow subject={testSubject({ kind: "video", filename: "reel.webm", videoUrl: "/api/files/gone.webm" })} now={NOW} />));
|
||||
act(() => { container.querySelector("video")!.dispatchEvent(new Event("error")); });
|
||||
expect(container.querySelector("video")).toBeNull();
|
||||
expect(container.querySelector('[data-testid="artifact-v1-row"]')?.textContent).toContain("WEBM");
|
||||
});
|
||||
|
||||
it("keeps an anchor's address while a plain click runs the handler and a modifier click follows the link", () => {
|
||||
const onClick = vi.fn();
|
||||
act(() => root.render(<ArtifactRow subject={testSubject({ kind: "document", filename: "notes.md" })} href="/api/attachments/n/content" external onClick={onClick} now={NOW} />));
|
||||
|
||||
@@ -7,8 +7,9 @@ import panelDocCorner from "./assets/panel-doc-corner.svg";
|
||||
import panelGlyphAudio from "./assets/panel-glyph-audio.svg";
|
||||
import panelGlyphSlides from "./assets/panel-glyph-slides.svg";
|
||||
import panelPlay from "./assets/panel-play.svg";
|
||||
import { usePreviewFailure } from "./ArtifactPrint";
|
||||
import { ROW_ICON, ROW_PLAY } from "./geometry";
|
||||
import { glyphOf, isAudio, labelOf, treatmentOf, type ArtifactSubject } from "./kinds";
|
||||
import { glyphOf, isAudio, labelOf, previewSourceOf, treatmentOf, type ArtifactSubject } from "./kinds";
|
||||
import { panelDateLabel } from "./when";
|
||||
|
||||
/**
|
||||
@@ -19,7 +20,8 @@ import { panelDateLabel } from "./when";
|
||||
* a 14px white glyph at (5, 5) and the type in 11px bold at (5, 22); PDF and
|
||||
* DOC use the mock's three bars, PPTX and audio the mock's own glyphs, other
|
||||
* types their lucide icon. Pictures and video show a 40px thumbnail instead,
|
||||
* video with a 32px play disc.
|
||||
* video with a 32px play disc; a thumbnail that cannot be loaded gives way
|
||||
* to the document icon.
|
||||
*/
|
||||
|
||||
const ICON_LABEL: CSSProperties = {
|
||||
@@ -67,12 +69,14 @@ export function ArtifactRowIcon({ subject }: { subject: ArtifactSubject }) {
|
||||
}
|
||||
|
||||
function Thumbnail({ subject }: { subject: ArtifactSubject }) {
|
||||
const [failed, onError] = usePreviewFailure(previewSourceOf(subject));
|
||||
if (failed) return <ArtifactRowIcon subject={subject} />;
|
||||
return (
|
||||
<span className="relative block size-10 shrink-0 overflow-hidden rounded-(--sz-4px)">
|
||||
{subject.kind === "video" && !subject.thumbnailUrl ? (
|
||||
<video src={subject.videoUrl ?? undefined} className="absolute inset-0 h-full w-full object-cover" muted playsInline preload="metadata" />
|
||||
<video src={subject.videoUrl ?? undefined} className="absolute inset-0 h-full w-full object-cover" muted playsInline preload="metadata" onError={onError} />
|
||||
) : (
|
||||
<img src={subject.thumbnailUrl ?? undefined} alt="" className="absolute inset-0 h-full w-full object-cover" />
|
||||
<img src={subject.thumbnailUrl ?? undefined} alt="" className="absolute inset-0 h-full w-full object-cover" onError={onError} />
|
||||
)}
|
||||
{subject.kind === "video" ? (
|
||||
<span aria-hidden className="absolute flex items-center justify-center rounded-full" style={{ left: ROW_PLAY.inset, top: ROW_PLAY.inset, width: ROW_PLAY.size, height: ROW_PLAY.size, background: "var(--artifact-play-scrim)" }}>
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { ArtifactFront } from "./ArtifactFront";
|
||||
import { fitPrint, useImageAspect } from "./ArtifactPrint";
|
||||
import { fitPrint, useImageAspect, usePreviewFailure } from "./ArtifactPrint";
|
||||
import backingShort from "./assets/backing-short.svg";
|
||||
import backingTallA from "./assets/backing-tall-a.svg";
|
||||
import backingTallB from "./assets/backing-tall-b.svg";
|
||||
import { LANDSCAPE_SHEET, MEDIA_ANCHOR, MEDIA_BACKING, MEDIA_COMPOSITION, SHEET, SHEET_BACKING, SHEET_COMPOSITION, SHEET_FRONT, STACK_BOX, type BackingPlacement } from "./geometry";
|
||||
import { isMedia, isPortrait, treatmentOf, type ArtifactSubject } from "./kinds";
|
||||
import { isMedia, isPortrait, previewSourceOf, treatmentOf, type ArtifactSubject } from "./kinds";
|
||||
|
||||
/**
|
||||
* A stack: the task's newest artifact as the first page, in front of one of
|
||||
* the mock's two backings. Text and documents share the sheet backing;
|
||||
* pictures, video and audio share the media backing. Both compositions are
|
||||
* 240–241px tall and stand on the frame's bottom edge, so the tops of every
|
||||
* stack in a row line up; a leading picture is fitted inside that height.
|
||||
* stack in a row line up; a leading picture is fitted inside that height. A
|
||||
* leading picture that cannot be loaded stands as a sheet, where audio does.
|
||||
*
|
||||
* The backings are the mock's own sheet shapes (each file is 30px larger than
|
||||
* its sheet on every side, room the mock's shadow needed; the shadow itself
|
||||
@@ -49,8 +50,9 @@ function Backing({ file, width, height, sheet, centre, rotate, spread }: Backing
|
||||
|
||||
export function ArtifactStack({ front }: { front: ArtifactSubject }) {
|
||||
const [aspect, onMediaLoad] = useImageAspect(front.aspect);
|
||||
const [failed, onMediaError] = usePreviewFailure(previewSourceOf(front));
|
||||
if (isMedia(front)) {
|
||||
const print = treatmentOf(front) === "media" ? fitPrint(aspect, STACK_BOX) : null;
|
||||
const print = !failed && treatmentOf(front) === "media" ? fitPrint(aspect, STACK_BOX) : null;
|
||||
const size = print ?? { width: SHEET.width, height: SHEET.height };
|
||||
const anchor = print && !isPortrait(aspect) ? MEDIA_ANCHOR.landscape : MEDIA_ANCHOR.portrait;
|
||||
const left = anchor.x - size.width / 2;
|
||||
@@ -60,7 +62,7 @@ export function ArtifactStack({ front }: { front: ArtifactSubject }) {
|
||||
{MEDIA_BACKING.map((sheet) => (
|
||||
<Backing key={sheet.file} {...sheet} />
|
||||
))}
|
||||
<ArtifactFront subject={front} print={print ?? undefined} onMediaLoad={onMediaLoad} style={{ position: "absolute", left, top }} />
|
||||
<ArtifactFront subject={front} print={print ?? undefined} onMediaLoad={onMediaLoad} onMediaError={onMediaError} style={{ position: "absolute", left, top }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,4 +96,77 @@ describe("the Artifacts page tiles", () => {
|
||||
expect(container.querySelector('[data-artifact-sheet="preview"]')?.textContent).toContain("Brand strategy");
|
||||
expect(container.querySelector("[data-artifact-front]")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the 4:3 print until a single picture loads, and shows the icon sheet when its file cannot be loaded", () => {
|
||||
act(() =>
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<ArtifactTileGrid>
|
||||
<ArtifactSingleTile subject={testSubject({ kind: "image", filename: "hero.png", thumbnailUrl: "/api/attachments/gone/content" })} now={NOW} />
|
||||
</ArtifactTileGrid>
|
||||
</MemoryRouter>,
|
||||
),
|
||||
);
|
||||
const print = container.querySelector<HTMLElement>("[data-artifact-print]")!;
|
||||
expect([print.style.width, print.style.height]).toEqual(["240px", "180px"]);
|
||||
act(() => { print.querySelector("img")!.dispatchEvent(new Event("error")); });
|
||||
expect(container.querySelector("[data-artifact-print]")).toBeNull();
|
||||
const sheet = container.querySelector('[data-artifact-front] [data-artifact-sheet="icon"]');
|
||||
expect(sheet?.textContent).toContain("PNG");
|
||||
expect(sheet?.querySelector(".lucide-image")).not.toBeNull();
|
||||
expect(container.querySelector('button[data-testid="artifact-v1-single-tile"]')?.getAttribute("aria-label")).toBe("Open hero.png");
|
||||
});
|
||||
|
||||
it("stands a picture stack's front as the icon sheet, where audio stands, when the picture cannot be loaded, and prints a new picture back in its place", () => {
|
||||
const render = (thumbnailUrl: string) =>
|
||||
act(() =>
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<ArtifactTileGrid>
|
||||
<ArtifactStackTile identifier="ACM-5" title="Shoot cap product photos" count={6} updatedAt="2026-09-27T15:12:00" front={testSubject({ kind: "image", filename: "cap.png", thumbnailUrl })} now={NOW} />
|
||||
<ArtifactStackTile identifier="ACM-6" title="Record the jingle" count={2} updatedAt="2026-09-27T15:12:00" front={testSubject({ kind: "file", filename: "jingle.mp3" })} now={NOW} />
|
||||
</ArtifactTileGrid>
|
||||
</MemoryRouter>,
|
||||
),
|
||||
);
|
||||
const tiles = () => Array.from(container.querySelectorAll<HTMLElement>('[data-testid="artifact-v1-stack-tile"]'));
|
||||
const placement = (el: HTMLElement | null) => el && [el.style.left, el.style.top, el.style.width, el.style.height];
|
||||
render("/api/attachments/gone/content");
|
||||
let [pictures, audio] = tiles();
|
||||
const printed = placement(pictures.querySelector<HTMLElement>("[data-artifact-print]"));
|
||||
expect(printed?.[2]).toBe("245px");
|
||||
act(() => { pictures.querySelector("[data-artifact-print] img")!.dispatchEvent(new Event("error")); });
|
||||
expect(pictures.querySelector("[data-artifact-print]")).toBeNull();
|
||||
expect(pictures.querySelector('[data-artifact-stack="media"]')).not.toBeNull();
|
||||
expect(pictures.querySelector('[data-artifact-sheet="icon"]')?.textContent).toContain("PNG");
|
||||
const front = pictures.querySelector<HTMLElement>("[data-artifact-front]")!;
|
||||
const audioFront = audio.querySelector<HTMLElement>("[data-artifact-front]")!;
|
||||
expect([front.style.left, front.style.top]).toEqual([audioFront.style.left, audioFront.style.top]);
|
||||
expect(audio.querySelector("[data-artifact-print]")).toBeNull();
|
||||
// A newer artifact takes the front: the stack's geometry and the front's face both start over from the new source.
|
||||
render("/api/attachments/fresh/content");
|
||||
[pictures] = tiles();
|
||||
expect(pictures.querySelector('[data-artifact-sheet="icon"]')).toBeNull();
|
||||
expect(pictures.querySelector("[data-artifact-print] img")?.getAttribute("src")).toBe("/api/attachments/fresh/content");
|
||||
expect(placement(pictures.querySelector<HTMLElement>("[data-artifact-print]"))).toEqual(printed);
|
||||
});
|
||||
|
||||
it("shows the icon sheet without a play disc for a video whose stream cannot be loaded", () => {
|
||||
act(() =>
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<ArtifactTileGrid>
|
||||
<ArtifactSingleTile subject={testSubject({ kind: "video", filename: "reel.webm", videoUrl: "/api/files/gone.webm" })} now={NOW} />
|
||||
</ArtifactTileGrid>
|
||||
</MemoryRouter>,
|
||||
),
|
||||
);
|
||||
expect(container.querySelector("[data-artifact-print] .lucide-play")).not.toBeNull();
|
||||
act(() => { container.querySelector("[data-artifact-print] video")!.dispatchEvent(new Event("error")); });
|
||||
expect(container.querySelector("[data-artifact-print]")).toBeNull();
|
||||
const sheet = container.querySelector('[data-artifact-sheet="icon"]');
|
||||
expect(sheet?.textContent).toContain("WEBM");
|
||||
expect(sheet?.querySelector(".lucide-film")).not.toBeNull();
|
||||
expect(sheet?.querySelector(".lucide-play")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
export * from "./kinds";
|
||||
export * from "./when";
|
||||
export * from "./geometry";
|
||||
export { ArtifactPrint, fitPrint, useImageAspect, type PrintSize } from "./ArtifactPrint";
|
||||
export { ArtifactPrint, fitPrint, useImageAspect, usePreviewFailure, type PrintSize } from "./ArtifactPrint";
|
||||
export { IconSheet, PreviewSheet } from "./ArtifactSheet";
|
||||
export { ArtifactFront } from "./ArtifactFront";
|
||||
export { ArtifactStack } from "./ArtifactStack";
|
||||
|
||||
Reference in new issue
Block a user