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
4 changes: 4 additions & 0 deletions docs/VREF.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ JSON output is the default when stdout is not a TTY. Use `--fields` with
top-level result fields such as `screenshotCount`, `groupCount`, `commands`, or
`automation` to keep agent context small.

Gallery cards derive their orientation from each screenshot's `viewport` dimensions. Landscape
references use a 16:9 preview frame, portrait references use 3:4, and square references use 1:1.
Previews contain the complete image without cropping; open a card to inspect it at full size.

## Roku Migration Path

`putio-roku` can replace its hand-written gallery script with `vref` while keeping the current `docs/visual/` location during migration:
Expand Down
19 changes: 16 additions & 3 deletions src/render.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { VrefManifest, VrefScreenshot } from "./types.js";

type ScreenshotOrientation = "landscape" | "portrait" | "square";

type GalleryViewModel = {
manifest: VrefManifest;
manifestLabel: string;
Expand Down Expand Up @@ -170,8 +172,9 @@ function renderCard(screenshot: VrefScreenshot): string {
: "";
const metadata = `${screenshot.viewport.width}x${screenshot.viewport.height} / ${formatBytes(screenshot.sizeBytes)}`;
const platformSlug = slug(screenshot.platform);
const orientation = screenshotOrientation(screenshot);

return `<a class="card" href="${escapeHtml(screenshot.file)}" aria-label="${escapeHtml(screenshot.title)} screenshot" data-card data-title="${escapeHtml(screenshot.title)}" data-platform="${escapeHtml(platformSlug)}" data-group="${escapeHtml(slug(screenshot.group))}" data-tags="${escapeHtml(tagList)}">
return `<a class="card" href="${escapeHtml(screenshot.file)}" aria-label="${escapeHtml(screenshot.title)} screenshot" data-card data-title="${escapeHtml(screenshot.title)}" data-platform="${escapeHtml(platformSlug)}" data-group="${escapeHtml(slug(screenshot.group))}" data-tags="${escapeHtml(tagList)}" data-orientation="${orientation}">
Comment thread
altaywtf marked this conversation as resolved.
<img class="preview" src="${escapeHtml(screenshot.file)}" alt="" loading="lazy">
<div class="card-body">
<div class="item-info">
Expand Down Expand Up @@ -295,7 +298,7 @@ ${renderFilterStateCss(viewModel)}
margin-bottom: 16px;
font-family: 'Berkeley Mono', ui-monospace, 'SF Mono', Menlo, monospace;
}
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); gap: 18px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); align-items: start; gap: 18px; }
.card {
display: block;
text-decoration: none;
Expand All @@ -311,11 +314,13 @@ ${renderFilterStateCss(viewModel)}
display: block;
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
object-fit: contain;
background: #000;
outline: 1px solid rgba(255,255,255,0.08);
outline-offset: -1px;
}
.card[data-orientation="portrait"] .preview { aspect-ratio: 3 / 4; }
.card[data-orientation="square"] .preview { aspect-ratio: 1; }
.card-body { display: flex; align-items: flex-start; gap: 10px; padding: 14px; }
.item-info { flex: 1; min-width: 0; }
.item-name { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
Expand Down Expand Up @@ -522,6 +527,14 @@ function renderPutioTitle(title: string): string {
return escapeHtml(title).replace("put.io", "put<span>.</span>io");
}

function screenshotOrientation(screenshot: VrefScreenshot): ScreenshotOrientation {
if (screenshot.viewport.width === screenshot.viewport.height) {
return "square";
}

return screenshot.viewport.width > screenshot.viewport.height ? "landscape" : "portrait";
}

function filterInputId(group: string, value: string): string {
return `filter-${group}-${value}`;
}
Expand Down
63 changes: 63 additions & 0 deletions test/vref.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ describe("vref", () => {
expect(html).toContain("padding: 10px 0 16px");
expect(html).toContain("min-height: 24px");
expect(html).toContain("minmax(min(100%, 340px), 1fr)");
expect(html).toContain('data-orientation="landscape"');
expect(html).toContain("object-fit: contain");
expect(html).toContain("1 reference &middot; Updated May 19, 2026");
expect(html).toContain(".footer code { color: var(--text-2); font: inherit; }");
expect(html).toContain(
Expand All @@ -52,6 +54,67 @@ describe("vref", () => {
expect(html).not.toContain(">TV<");
});

it("derives portrait and square card layouts from viewport dimensions", async () => {
const root = await mkdtemp(join(tmpdir(), "vref-"));
await mkdir(join(root, ".vref/screenshots"), { recursive: true });
await writeFile(join(root, ".vref/screenshots/phone.png"), "image");
await writeFile(join(root, ".vref/screenshots/square.png"), "image");

const manifest: VrefManifest = {
version: 1,
title: "put.io mobile visual reference",
description: "Mobile screenshots.",
updatedAt: "2026-05-19T13:35:00.000Z",
screenshots: [
{
id: "phone",
title: "Phone",
group: "Screens",
platform: "iOS",
device: "iPhone",
viewport: { width: 1320, height: 2868 },
file: "screenshots/phone.png",
capturedAt: "2026-05-19T13:34:00.000Z",
sizeBytes: 5,
tags: ["phone"],
notes: ["Portrait screen."],
},
{
id: "square",
title: "Square",
group: "Components",
platform: "iOS",
device: "Component",
viewport: { width: 720, height: 720 },
file: "screenshots/square.png",
capturedAt: "2026-05-19T13:34:00.000Z",
sizeBytes: 5,
tags: ["component"],
notes: ["Square component."],
},
],
};

await writeFile(join(root, ".vref/manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);

await buildGallery({
cwd: root,
manifestPath: ".vref/manifest.json",
outputPath: ".vref/index.html",
});

const html = await readFile(join(root, ".vref/index.html"), "utf8");
expect(html).toContain(
'data-title="Phone" data-platform="ios" data-group="screens" data-tags="phone" data-orientation="portrait"',
);
expect(html).toContain(
'data-title="Square" data-platform="ios" data-group="components" data-tags="component" data-orientation="square"',
);
expect(html).toContain('.card[data-orientation="portrait"] .preview { aspect-ratio: 3 / 4; }');
expect(html).toContain('.card[data-orientation="square"] .preview { aspect-ratio: 1; }');
expect(html).toContain("align-items: start");
});

it("keeps single tags filterable without rendering a card tag chip", async () => {
const root = await makeFixture("screenshots/roku-720p/home.jpg", ["home"]);

Expand Down