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:
TonioandClaude Fable 5.1 authored and GitHub committed 2026-10-26 21:08:49 -07:00
1 parent 4da876ab0b
commit 3e7c3ee9a9
8 files changed
+158 -17

No files matched your search

+4
View File
@@ -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 -1
View File
@@ -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";