diff --git a/apps/web/src/pages/project-cover.test.ts b/apps/web/src/pages/project-cover.test.ts
new file mode 100644
index 00000000..20f79552
--- /dev/null
+++ b/apps/web/src/pages/project-cover.test.ts
@@ -0,0 +1,33 @@
+import { expect, test } from "bun:test";
+import { coverArtifact, coverPage } from "./project-cover.tsx";
+
+const item = (kind: string, artifactId: string) => ({
+ artifactId,
+ kind,
+ title: kind,
+ version: 1,
+ sha256: "a".repeat(64),
+});
+
+test("the chosen design is the cover, else the first concept filed, else none", () => {
+ const brief = item("problem_brief", "brief");
+ const first = item("design_option", "first");
+ const second = item("design_option", "second");
+ const chosen = item("design", "chosen");
+ expect(coverArtifact([brief, first, second, chosen])?.artifactId).toBe(
+ "chosen",
+ );
+ expect(coverArtifact([brief, first, second])?.artifactId).toBe("first");
+ expect(coverArtifact([brief])).toBeNull();
+ expect(coverArtifact([])).toBeNull();
+});
+
+test("a bundle with no page to show leaves the tile its pattern", () => {
+ expect(coverPage({})).toBeNull();
+ expect(coverPage({ "designs/index.html": " " })).toBeNull();
+ expect(
+ coverPage({
+ "designs/index.html": "
Hi",
+ }),
+ ).toContain("");
+});
diff --git a/apps/web/src/pages/project-cover.tsx b/apps/web/src/pages/project-cover.tsx
new file mode 100644
index 00000000..b6f63fc0
--- /dev/null
+++ b/apps/web/src/pages/project-cover.tsx
@@ -0,0 +1,104 @@
+/**
+ * A project tile's cover: the first page of the design the person chose, or of the first concept
+ * filed before a choice, as a still picture; a project with no mockup keeps its pattern. The
+ * mockup is read only once the tile is on screen, so a long list does not read every design.
+ */
+import { useQuery } from "@tanstack/react-query";
+import type { ProjectArtifact } from "@corbits-builder/client";
+import { useEffect, useRef, useState, type CSSProperties } from "react";
+import { listProjectArtifacts, readArtifact } from "../api/artifacts.ts";
+import { keys } from "../queries/keys.ts";
+import { firstPage, parseBundle } from "./workspace/design-preview.tsx";
+import { GuiFrame } from "./workspace/gui-frame.tsx";
+
+const COVER_STALE_MS = 5 * 60_000;
+// A tile a screen's height away starts reading, so it is there by the time it scrolls in.
+const AHEAD = "100%";
+
+/** The mockup a tile shows: the design the person chose, else the first concept filed, else none. */
+export function coverArtifact(
+ artifacts: readonly ProjectArtifact[],
+): ProjectArtifact | null {
+ const chosen = artifacts.find((item) => item.kind === "design");
+ if (chosen !== undefined) return chosen;
+ const concept = artifacts.find((item) => item.kind === "design_option");
+ return concept === undefined ? null : concept;
+}
+
+/** The bundle's first page as the tile shows it, or null for a bundle with no page to show yet. */
+export function coverPage(
+ files: Readonly>,
+): string | null {
+ const page = firstPage(files);
+ return page.trim() === "" ? null : page;
+}
+
+async function readCoverPage(projectId: string): Promise {
+ const { artifacts } = await listProjectArtifacts(projectId);
+ const pick = coverArtifact(artifacts);
+ if (pick === null) return null;
+ const filed = await readArtifact(projectId, pick.artifactId, pick.version);
+ const files = parseBundle(filed.content);
+ return files === null ? null : coverPage(files);
+}
+
+/** True once the element has come within a screen of the viewport, and from then on. */
+function useNearView(ref: React.RefObject): boolean {
+ const [near, setNear] = useState(false);
+ useEffect(() => {
+ const node = ref.current;
+ if (node === null || near) return;
+ if (typeof IntersectionObserver === "undefined") {
+ setNear(true);
+ return;
+ }
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries.some((entry) => entry.isIntersecting)) setNear(true);
+ },
+ { rootMargin: AHEAD },
+ );
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, [near]);
+ return near;
+}
+
+export function ProjectCover({
+ projectId,
+ pattern,
+ style,
+ mockup,
+}: {
+ projectId: string;
+ pattern: string;
+ style: CSSProperties;
+ /** Whether to show the mockup at all; a list's small swatch shows the pattern only. */
+ mockup: boolean;
+}) {
+ const ref = useRef(null);
+ const near = useNearView(ref);
+ const cover = useQuery({
+ queryKey: keys.projectCover.of(projectId),
+ queryFn: () => readCoverPage(projectId),
+ enabled: mockup && near,
+ staleTime: COVER_STALE_MS,
+ retry: false,
+ refetchOnWindowFocus: false,
+ });
+ const html = mockup && cover.data !== undefined ? cover.data : null;
+ return (
+ // A still picture: inert, so it neither takes focus nor eats the click that opens the project.
+
+ {html === null ? null : }
+
+ );
+}
diff --git a/apps/web/src/pages/project-library.css b/apps/web/src/pages/project-library.css
index 97b7e0cd..0f0a40a7 100644
--- a/apps/web/src/pages/project-library.css
+++ b/apps/web/src/pages/project-library.css
@@ -90,6 +90,27 @@
bottom: var(--space-3);
}
+/* A cover that shows the mockup lays its first page out at four times the tile's width, the way a
+ desktop would, and scales it down. The frame is white like every other mockup frame, so a page
+ that sets no background of its own reads the same here as in the themes pane. */
+.library-page .library-cover[data-mockup] {
+ position: relative;
+ overflow: hidden;
+}
+
+.library-page .library-cover .gui-frame {
+ position: absolute;
+ inset: 0 auto auto 0;
+ width: 400%;
+ height: 400%;
+ min-height: 0;
+ border: 0;
+ background: #fff;
+ transform: scale(0.25);
+ transform-origin: 0 0;
+ pointer-events: none;
+}
+
/* Each cover mixes two theme colours into the card's own surface, so it stays
soft in light mode and does not glare in dark mode. */
.library-page .library-cover {
diff --git a/apps/web/src/pages/project-library.tsx b/apps/web/src/pages/project-library.tsx
index 6a76f195..737875cc 100644
--- a/apps/web/src/pages/project-library.tsx
+++ b/apps/web/src/pages/project-library.tsx
@@ -29,6 +29,7 @@ import {
import type { ProjectStage } from "@corbits-builder/schemas/project";
import type { ProjectSummary } from "../api/types.ts";
import type { NoticeAction } from "../components.jsx";
+import { ProjectCover } from "./project-cover.tsx";
import { ProjectMenu } from "./project-menu.jsx";
import "./project-library.css";
@@ -229,11 +230,11 @@ export function ProjectLibrary({
className="library-card-open"
onClick={() => onOpen(project.id)}
>
-
{project.title}
diff --git a/apps/web/src/queries/keys.ts b/apps/web/src/queries/keys.ts
index 2e271785..9006ef26 100644
--- a/apps/web/src/queries/keys.ts
+++ b/apps/web/src/queries/keys.ts
@@ -65,6 +65,11 @@ export const keys = {
all: ["projectRoom"] as const,
of: (projectId: string) => ["projectRoom", projectId] as const,
},
+ /** A project tile's cover: the first page of its chosen or first mockup. */
+ projectCover: {
+ all: ["projectCover"] as const,
+ of: (projectId: string) => ["projectCover", projectId] as const,
+ },
/** A project: its lifecycle status and the tenant its runs live under. */
project: {
all: ["project"] as const,