feat: add $D gallery command for design history timeline

Generates a self-contained HTML page showing all prior design explorations
for a project: every variant (approved or not), feedback notes, organized
by date (newest first). Images embedded as base64. Handles corrupted
approved.json gracefully (skips, still shows the session). Empty state
shows "No history yet" with /design-shotgun prompt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-27 08:37:12 -06:00
parent eb6126b404
commit e7816fc5ad
3 changed files with 264 additions and 0 deletions
+8
View File
@@ -24,6 +24,7 @@ import { diffMockups, verifyAgainstMockup } from "./diff";
import { evolve } from "./evolve";
import { generateDesignToCodePrompt } from "./design-to-code";
import { serve } from "./serve";
import { gallery } from "./gallery";
function parseArgs(argv: string[]): { command: string; flags: Record<string, string | boolean> } {
const args = argv.slice(2); // skip bun/node and script path
@@ -237,6 +238,13 @@ async function main(): Promise<void> {
});
break;
case "gallery":
gallery({
designsDir: flags["designs-dir"] as string,
output: (flags.output as string) || "/tmp/gstack-design-gallery.html",
});
break;
case "serve":
await serve({
html: flags.html as string,
+5
View File
@@ -64,6 +64,11 @@ export const COMMANDS = new Map<string, {
usage: "extract --image approved.png",
flags: ["--image"],
}],
["gallery", {
description: "Generate HTML timeline of all design explorations for a project",
usage: "gallery --designs-dir ~/.gstack/projects/$SLUG/designs/ --output /path/gallery.html",
flags: ["--designs-dir", "--output"],
}],
["serve", {
description: "Serve comparison board over HTTP and collect user feedback",
usage: "serve --html /path/board.html [--timeout 600]",
+251
View File
@@ -0,0 +1,251 @@
/**
* Design history gallery — generates an HTML timeline of all design explorations
* for a project. Shows every approved/rejected variant, feedback notes, organized
* by date. Self-contained HTML with base64-embedded images.
*/
import fs from "fs";
import path from "path";
export interface GalleryOptions {
designsDir: string; // ~/.gstack/projects/$SLUG/designs/
output: string;
}
interface SessionData {
dir: string;
name: string;
date: string;
approved: any | null;
variants: string[]; // paths to variant PNGs
}
export function generateGalleryHtml(designsDir: string): string {
const sessions: SessionData[] = [];
if (!fs.existsSync(designsDir)) {
return generateEmptyGallery();
}
const entries = fs.readdirSync(designsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const sessionDir = path.join(designsDir, entry.name);
let approved: any = null;
// Read approved.json if it exists
const approvedPath = path.join(sessionDir, "approved.json");
if (fs.existsSync(approvedPath)) {
try {
approved = JSON.parse(fs.readFileSync(approvedPath, "utf-8"));
} catch {
// Corrupted JSON, skip but still show the session
}
}
// Find variant PNGs
const variants: string[] = [];
try {
const files = fs.readdirSync(sessionDir);
for (const f of files) {
if (f.match(/variant-[A-Z]\.png$/i) || f.match(/variant-\d+\.png$/i)) {
variants.push(path.join(sessionDir, f));
}
}
variants.sort();
} catch {
// Can't read directory, skip
}
// Extract date from directory name (e.g., homepage-20260327)
const dateMatch = entry.name.match(/(\d{8})$/);
const date = dateMatch
? `${dateMatch[1].slice(0, 4)}-${dateMatch[1].slice(4, 6)}-${dateMatch[1].slice(6, 8)}`
: approved?.date?.slice(0, 10) || "Unknown";
sessions.push({
dir: sessionDir,
name: entry.name.replace(/-\d{8}$/, "").replace(/-/g, " "),
date,
approved,
variants,
});
}
if (sessions.length === 0) {
return generateEmptyGallery();
}
// Sort by date, newest first
sessions.sort((a, b) => b.date.localeCompare(a.date));
const sessionCards = sessions.map(session => {
const variantImgs = session.variants.map((vPath, i) => {
try {
const imgData = fs.readFileSync(vPath).toString("base64");
const ext = path.extname(vPath).slice(1) || "png";
const label = path.basename(vPath, `.${ext}`).replace("variant-", "");
const isApproved = session.approved?.approved_variant === label;
return `
<div class="gallery-variant ${isApproved ? "approved" : ""}">
<img src="data:image/${ext};base64,${imgData}" alt="Variant ${label}" />
<div class="gallery-variant-label">
${label}${isApproved ? ' <span class="approved-badge">approved</span>' : ""}
</div>
</div>`;
} catch {
return ""; // Skip unreadable images
}
}).filter(Boolean).join("\n");
const feedbackNote = session.approved?.feedback
? `<div class="gallery-feedback">"${escapeHtml(String(session.approved.feedback))}"</div>`
: "";
return `
<div class="gallery-session">
<div class="gallery-session-header">
<h2>${escapeHtml(session.name)}</h2>
<span class="gallery-date">${session.date}</span>
</div>
${feedbackNote}
<div class="gallery-variants">${variantImgs}</div>
</div>`;
}).join("\n");
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Design History</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
background: #fff;
color: #333;
}
.header {
padding: 16px 24px;
border-bottom: 1px solid #e5e5e5;
}
.header h1 { font-size: 16px; font-weight: 600; }
.header .meta { font-size: 13px; color: #999; margin-top: 4px; }
.gallery { max-width: 1200px; margin: 0 auto; padding: 0 24px; }
.gallery-session {
border-bottom: 1px solid #e5e5e5;
padding: 24px 0;
}
.gallery-session:last-child { border-bottom: none; }
.gallery-session-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 12px;
}
.gallery-session-header h2 {
font-size: 15px;
font-weight: 600;
text-transform: capitalize;
}
.gallery-date { font-size: 13px; color: #999; }
.gallery-feedback {
font-size: 13px;
color: #666;
font-style: italic;
margin-bottom: 12px;
padding: 8px 12px;
background: #f9f9f9;
border-radius: 4px;
}
.gallery-variants {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.gallery-variant img {
width: 100%;
height: auto;
display: block;
border-radius: 4px;
border: 2px solid transparent;
}
.gallery-variant.approved img {
border-color: #000;
}
.gallery-variant-label {
font-size: 13px;
color: #666;
margin-top: 6px;
text-align: center;
}
.approved-badge {
background: #000;
color: #fff;
font-size: 11px;
padding: 2px 6px;
border-radius: 3px;
font-style: normal;
}
.empty {
text-align: center;
padding: 80px 24px;
color: #999;
}
.empty h2 { font-size: 18px; margin-bottom: 8px; color: #666; }
</style>
</head>
<body>
<div class="header">
<h1>Design History</h1>
<div class="meta">${sessions.length} exploration${sessions.length === 1 ? "" : "s"}</div>
</div>
<div class="gallery">
${sessionCards}
</div>
</body>
</html>`;
}
function generateEmptyGallery(): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Design History</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
background: #fff; color: #333;
}
.empty { text-align: center; padding: 80px 24px; color: #999; }
.empty h2 { font-size: 18px; margin-bottom: 8px; color: #666; }
</style>
</head>
<body>
<div class="empty">
<h2>No design history yet</h2>
<p>Run <code>/design-shotgun</code> to start exploring design directions.</p>
</div>
</body>
</html>`;
}
function escapeHtml(str: string): string {
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
/**
* Gallery command: generate HTML timeline from design explorations.
*/
export function gallery(options: GalleryOptions): void {
const html = generateGalleryHtml(options.designsDir);
const outputDir = path.dirname(options.output);
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(options.output, html);
console.log(JSON.stringify({ outputPath: options.output }));
}