diff --git a/docs/VREF.md b/docs/VREF.md index bb107ca..80eb912 100644 --- a/docs/VREF.md +++ b/docs/VREF.md @@ -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: diff --git a/src/render.ts b/src/render.ts index 513515f..372895b 100644 --- a/src/render.ts +++ b/src/render.ts @@ -1,5 +1,7 @@ import type { VrefManifest, VrefScreenshot } from "./types.js"; +type ScreenshotOrientation = "landscape" | "portrait" | "square"; + type GalleryViewModel = { manifest: VrefManifest; manifestLabel: string; @@ -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 ` + return `
@@ -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; @@ -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; } @@ -522,6 +527,14 @@ function renderPutioTitle(title: string): string { return escapeHtml(title).replace("put.io", "put.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}`; } diff --git a/test/vref.test.ts b/test/vref.test.ts index 6fb2216..57e3c38 100644 --- a/test/vref.test.ts +++ b/test/vref.test.ts @@ -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 · Updated May 19, 2026"); expect(html).toContain(".footer code { color: var(--text-2); font: inherit; }"); expect(html).toContain( @@ -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"]);