From 3aa8b23a0b310dcc955dd02f4667ace5251cba99 Mon Sep 17 00:00:00 2001 From: "Brian J. Fox" Date: Wed, 4 Nov 2026 09:41:39 +0000 Subject: [PATCH 1/2] feat(web): show a project's chosen or first mockup on its tile A tile's cover was a generated pattern whatever the project looked like. A project with a mockup shows its first page, the design the person chose or the first concept filed, as a still picture read once the tile is near the screen; a project without one keeps the pattern. --- apps/web/src/pages/project-cover.test.ts | 23 ++++++ apps/web/src/pages/project-cover.tsx | 96 ++++++++++++++++++++++++ apps/web/src/pages/project-library.css | 20 +++++ apps/web/src/pages/project-library.tsx | 9 ++- apps/web/src/queries/keys.ts | 5 ++ 5 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/pages/project-cover.test.ts create mode 100644 apps/web/src/pages/project-cover.tsx 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..87624cc9 --- /dev/null +++ b/apps/web/src/pages/project-cover.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from "bun:test"; +import { coverArtifact } 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(); +}); diff --git a/apps/web/src/pages/project-cover.tsx b/apps/web/src/pages/project-cover.tsx new file mode 100644 index 00000000..bc525ebe --- /dev/null +++ b/apps/web/src/pages/project-cover.tsx @@ -0,0 +1,96 @@ +/** + * 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; +} + +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 : firstPage(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. + + ); +} diff --git a/apps/web/src/pages/project-library.css b/apps/web/src/pages/project-library.css index fc7b5a8e..a3b16ade 100644 --- a/apps/web/src/pages/project-library.css +++ b/apps/web/src/pages/project-library.css @@ -90,6 +90,26 @@ 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 pattern stays beneath while the page loads. */ +.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: var(--wb-background); + 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)} > -