Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions apps/web/src/pages/project-cover.test.ts
Original file line number Diff line number Diff line change
@@ -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": "<html><body><main>Hi</main></body></html>",
}),
).toContain("<main>");
});
104 changes: 104 additions & 0 deletions apps/web/src/pages/project-cover.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>>,
): string | null {
const page = firstPage(files);
return page.trim() === "" ? null : page;
}

async function readCoverPage(projectId: string): Promise<string | null> {
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);
Comment on lines +37 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Each tile calls the heavy room-listing endpoint and downloads the whole design bundle

listProjectArtifacts hits the hub's service.list. That handler queries workflow runs and pending approvals across the tenant, reads failure details for failed runs, and runs approvedVersion and filedAt queries for every artifact. readArtifact then downloads the full design bundle. All of this is only to show a thumbnail, and it runs once for every grid tile that comes near the viewport.

A library with many projects in grid view, scrolled through: one full room listing and one full bundle download per tile.

Resolve this comment. Triage will not finish while it is open.

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<HTMLElement | null>): 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<HTMLSpanElement>(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.
<span
ref={ref}
className="library-cover"
data-pattern={pattern}
data-mockup={html === null ? undefined : ""}
style={style}
aria-hidden="true"
inert
>
{html === null ? null : <GuiFrame html={html} scripts />}
</span>
);
}
21 changes: 21 additions & 0 deletions apps/web/src/pages/project-library.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 5 additions & 4 deletions apps/web/src/pages/project-library.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -229,11 +230,11 @@ export function ProjectLibrary({
className="library-card-open"
onClick={() => onOpen(project.id)}
>
<span
className="library-cover"
data-pattern={cover.pattern}
<ProjectCover
projectId={project.id}
pattern={cover.pattern}
style={cover.style}
aria-hidden="true"
mockup={view === "grid"}
Comment on lines +233 to +237

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Tile can keep showing an old mockup after the project's design changes

The cover query's key is just the projectId, it stays fresh for 5 minutes, and nothing anywhere calls invalidate on keys.projectCover. Its key also doesn't include anything like project.lastActivityAt. So after a person picks a design or a new concept is filed, going back to the library shows the cached cover from before.

Open the library so covers load, open a project, choose a design or file a concept, then return to the library within 5 minutes. The tile still shows the earlier concept, or the plain pattern if the project had no mockup before.

Resolve this comment. Triage will not finish while it is open.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 · Grid view runs a scripted iframe for every tile and never unmounts them

Each grid tile that has a mockup mounts a GuiFrame iframe with scripts on. Once a tile gets near the viewport, its iframe stays mounted even after the tile scrolls away. On a large library this means many live documents running the designs' scripts and animations at once, which costs a lot of memory and CPU.

Scroll through a library with dozens of projects in grid view.

Resolve this comment. Triage will not finish while it is open.

/>
Comment on lines +233 to 238

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Tile shows the text "No design to show." when the bundle has no index page or an empty one

readCoverPage returns firstPage(files), and firstPage falls back to "" when designs/index is missing. Because "" is not null, ProjectCover sets data-mockup and renders GuiFrame. GuiFrame treats empty HTML as no design and renders a

No design to show.

inside the tile's cover, in place of the pattern the PR says should remain.

A project whose chosen design or first concept has a bundle with no designs/index page (for example a concept filed before its index was written, or an empty bundle), viewed in grid mode.

Resolve this comment. Triage will not finish while it is open.

<span className="library-card-body">
<span className="library-card-title">{project.title}</span>
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/queries/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down