← back to Feature Harvest
feature-harvest: add static-render preview thumbnails for app items (headless-Chrome screenshots + gradient-poster fallback)
71db8fdbe29ae6e3d083cbc0f2db2257ade50a0f · 2026-06-26 08:56:19 -0700 · Steve
Files touched
M .gitignoreA build-thumbs.jsM package.jsonM public/index.htmlM server.js
Diff
commit 71db8fdbe29ae6e3d083cbc0f2db2257ade50a0f
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Jun 26 08:56:19 2026 -0700
feature-harvest: add static-render preview thumbnails for app items (headless-Chrome screenshots + gradient-poster fallback)
---
.gitignore | 1 +
build-thumbs.js | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
package.json | 3 +-
public/index.html | 19 +++++++++
server.js | 21 +++++++++-
5 files changed, 160 insertions(+), 2 deletions(-)
diff --git a/.gitignore b/.gitignore
index e7f791a..92e9bd6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@ build/
.next/
data/wins-snapshot.json
data/items.json
+data/thumbs/
diff --git a/build-thumbs.js b/build-thumbs.js
new file mode 100644
index 0000000..17c7c08
--- /dev/null
+++ b/build-thumbs.js
@@ -0,0 +1,118 @@
+#!/usr/bin/env node
+/*
+ * build-thumbs.js — generate static-render preview thumbnails for the APP items
+ * (source dw-app / wpb-tool) by screenshotting their HTML via headless Chrome,
+ * then downscaling with sips (shrink-only, never upscale).
+ *
+ * Output: data/thumbs/<safeid>.jpg + data/thumbs/manifest.json {id:{file,ok}}
+ * Items with no usable HTML, or whose render is near-blank, get ok:false → the
+ * UI shows a deterministic gradient poster instead (never a broken image).
+ *
+ * Usage: node build-thumbs.js [--only <substr>] [--limit N]
+ */
+const fs = require('fs');
+const path = require('path');
+const { execFile, execFileSync } = require('child_process');
+
+const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
+const ROOT = __dirname;
+const THUMBS = path.join(ROOT, 'data', 'thumbs');
+const TMP = path.join(THUMBS, '_tmp');
+const ITEMS = path.join(ROOT, 'data', 'items.json');
+const BLANK_BYTES = 5500; // jpeg smaller than this ≈ blank/login/empty
+const WIDTH = 480; // thumb width cap (shrink-only)
+const POOL = 3; // concurrent chrome instances
+
+const argOnly = (() => { const i = process.argv.indexOf('--only'); return i > -1 ? process.argv[i + 1] : null; })();
+const argLimit = (() => { const i = process.argv.indexOf('--limit'); return i > -1 ? +process.argv[i + 1] : Infinity; })();
+
+fs.mkdirSync(TMP, { recursive: true });
+const items = JSON.parse(fs.readFileSync(ITEMS, 'utf8'))
+ .filter((i) => (i.source === 'dw-app' || i.source === 'wpb-tool') && i.path);
+
+function resolveHtml(p) {
+ try {
+ const st = fs.statSync(p);
+ if (st.isFile()) return p.endsWith('.html') ? p : null;
+ for (const c of ['public/index.html', 'index.html']) {
+ const f = path.join(p, c);
+ if (fs.existsSync(f)) return f;
+ }
+ // first html anywhere shallow
+ const hit = (function find(dir, d) {
+ if (d < 0) return null;
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+ if (e.name.startsWith('.') || e.name === 'node_modules') continue;
+ const fp = path.join(dir, e.name);
+ if (e.isFile() && e.name.endsWith('.html')) return fp;
+ if (e.isDirectory()) { const r = find(fp, d - 1); if (r) return r; }
+ }
+ return null;
+ })(p, 1);
+ return hit;
+ } catch { return null; }
+}
+const safe = (id) => id.replace(/[^a-z0-9]+/gi, '_').slice(0, 80);
+
+function shot(html, outPng) {
+ return new Promise((resolve) => {
+ const args = [
+ '--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
+ '--force-color-profile=srgb', '--window-size=1280,860',
+ '--run-all-compositor-stages-before-draw', '--virtual-time-budget=4500',
+ '--default-background-color=ffffffff',
+ `--screenshot=${outPng}`, `file://${html}`,
+ ];
+ const ch = execFile(CHROME, args, { timeout: 20000 }, () => {
+ resolve(fs.existsSync(outPng));
+ });
+ ch.on('error', () => resolve(false));
+ });
+}
+function thumb(png, jpg) {
+ // sips: clamp width to WIDTH (only if larger), export jpeg. Returns bytes or 0.
+ try {
+ const w = parseInt(execFileSync('sips', ['-g', 'pixelWidth', png]).toString().match(/pixelWidth: (\d+)/)?.[1] || '0', 10);
+ const args = w > WIDTH ? ['--resampleWidth', String(WIDTH)] : [];
+ execFileSync('sips', [...args, '-s', 'format', 'jpeg', '-s', 'formatOptions', '70', png, '--out', jpg],
+ { stdio: 'ignore' });
+ return fs.existsSync(jpg) ? fs.statSync(jpg).size : 0;
+ } catch { return 0; }
+}
+
+async function run() {
+ let pool = items.filter((i) => !argOnly || (i.title + i.path).toLowerCase().includes(argOnly.toLowerCase()));
+ pool = pool.slice(0, argLimit);
+ const manifest = {};
+ let done = 0;
+ async function one(it) {
+ const html = resolveHtml(it.path);
+ const out = { file: `${safe(it.id)}.jpg`, ok: false };
+ if (html) {
+ const png = path.join(TMP, `${safe(it.id)}.png`);
+ const jpg = path.join(THUMBS, out.file);
+ if (await shot(html, png)) {
+ const bytes = thumb(png, jpg);
+ out.ok = bytes >= BLANK_BYTES;
+ if (!out.ok && fs.existsSync(jpg)) fs.unlinkSync(jpg);
+ }
+ try { fs.existsSync(png) && fs.unlinkSync(png); } catch {}
+ }
+ manifest[it.id] = out;
+ done++;
+ process.stdout.write(`\r ${done}/${pool.length} ${out.ok ? '✓' : '·'} ${it.title.slice(0, 34).padEnd(34)}`);
+ }
+ // simple concurrency pool
+ const queue = [...pool];
+ await Promise.all(Array.from({ length: POOL }, async () => {
+ while (queue.length) await one(queue.shift());
+ }));
+ // merge with any prior manifest (so partial --only runs don't wipe others)
+ const mpath = path.join(THUMBS, 'manifest.json');
+ let prior = {}; try { prior = JSON.parse(fs.readFileSync(mpath, 'utf8')); } catch {}
+ fs.writeFileSync(mpath, JSON.stringify({ ...prior, ...manifest }, null, 2));
+ const okN = Object.values(manifest).filter((m) => m.ok).length;
+ console.log(`\n thumbnails: ${okN}/${pool.length} real renders, ${pool.length - okN} → gradient fallback`);
+ try { fs.rmdirSync(TMP, { recursive: true }); } catch {}
+}
+run();
diff --git a/package.json b/package.json
index 046e33d..0f9dd1b 100644
--- a/package.json
+++ b/package.json
@@ -5,6 +5,7 @@
"description": "Interactive review deck — triage a year of shipped apps/features into a DW or WPB shortlist.",
"scripts": {
"build": "node build-corpus.js",
- "start": "node server.js"
+ "start": "node server.js",
+ "thumbs": "node build-thumbs.js"
}
}
diff --git a/public/index.html b/public/index.html
index 7eb8a82..ba82730 100644
--- a/public/index.html
+++ b/public/index.html
@@ -65,6 +65,12 @@
.vt-wpb{background:rgba(176,124,240,.18);color:var(--wpb)}
.vt-both{background:rgba(61,220,151,.18);color:var(--both)}
.vt-skip{background:rgba(90,97,114,.25);color:var(--skip)}
+ /* preview (app items only): gradient poster background + screenshot on top */
+ .preview{position:relative;width:100%;aspect-ratio:16/10;overflow:hidden;
+ border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:center}
+ .preview .thumb{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:top}
+ .poster-label{position:relative;z-index:1;color:#fff;font-weight:700;font-size:17px;
+ text-align:center;padding:16px;text-shadow:0 1px 6px rgba(0,0,0,.45);letter-spacing:.3px}
.card .body{padding:16px}
.card h2{margin:0 0 4px;font-size:20px;line-height:1.25}
.meta{color:var(--mut);font-size:12.5px;margin-bottom:12px}
@@ -99,6 +105,8 @@
margin-bottom:8px;cursor:grab;font-size:13px}
.mini:hover{border-color:var(--gold)}
.mini .mt{font-weight:600;line-height:1.3}
+ .mini .preview{aspect-ratio:16/9;border:0;border-radius:7px;margin-bottom:7px}
+ .mini .poster-label{font-size:11px;padding:7px}
.mini .ms{color:var(--mut);font-size:11px;margin-top:3px;display:flex;gap:6px;align-items:center}
.col.drop{outline:2px dashed var(--gold);outline-offset:-4px}
.grid-board[data-cols]{}
@@ -183,6 +191,15 @@ function buildProjectFacet(){
// ---- helpers ----
function esc(s){return (s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
+function hueFromId(s){let h=2166136261;for(let i=0;i<s.length;i++){h^=s.charCodeAt(i);h=Math.imul(h,16777619);}return (h>>>0)%360;}
+function previewHTML(it){
+ if(it.source!=='dw-app' && it.source!=='wpb-tool') return ''; // app items only
+ const h=hueFromId(it.id);
+ const grad=`linear-gradient(135deg,hsl(${h} 52% 33%),hsl(${(h+45)%360} 58% 19%))`;
+ const img = it.thumb ? `<img class="thumb" loading="lazy" src="/thumb/${esc(it.thumb)}" alt="${esc(it.title)} preview" onerror="this.remove()">` : '';
+ const label = it.thumb ? '' : `<span class="poster-label">${esc(it.title)}</span>`;
+ return `<div class="preview" style="background:${grad}">${img}${label}</div>`;
+}
function whenStr(it){
if(!it.ts) return it.date||'';
return new Date(it.ts).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
@@ -274,6 +291,7 @@ function cardHTML(it, big){
const val = it.value ? `<div class="value"><b>Value:</b> ${esc(it.value)}</div>` : '';
return `
<div class="card" data-id="${esc(it.id)}">
+ ${previewHTML(it)}
<div class="top">
<span class="src ${it.source}">${SRC_LABEL[it.source]||it.source}</span>
${tag}
@@ -346,6 +364,7 @@ function renderBoard(){
}
function miniHTML(it){
return `<div class="mini" data-id="${esc(it.id)}" title="${esc(it.title)}">
+ ${previewHTML(it)}
<div class="mt">${esc(it.title)}</div>
<div class="ms"><span class="src ${it.source}" style="font-size:9px;padding:1px 5px">${(SRC_LABEL[it.source]||it.source)}</span> ${esc(it.project||'')}</div>
</div>`;
diff --git a/server.js b/server.js
index d668f87..02930d0 100644
--- a/server.js
+++ b/server.js
@@ -17,6 +17,8 @@ const DATA = path.join(ROOT, 'data');
const SNAP = path.join(DATA, 'wins-snapshot.json');
const ITEMS = path.join(DATA, 'items.json');
const VERDICTS = path.join(DATA, 'verdicts.json');
+const THUMBS = path.join(DATA, 'thumbs');
+const THUMB_MANIFEST = path.join(THUMBS, 'manifest.json');
const CNCP_WINS = 'http://127.0.0.1:3333/api/wins';
const USER = process.env.FH_USER || 'admin';
@@ -54,7 +56,15 @@ function fetchJSON(url, cb) {
// ---- corpus + verdicts --------------------------------------------
function loadWins() { return readJSON(SNAP, []); }
-function loadItems() { return readJSON(ITEMS, loadWins()); }
+function loadItems() {
+ const items = readJSON(ITEMS, loadWins());
+ const man = readJSON(THUMB_MANIFEST, {});
+ for (const it of items) {
+ const m = man[it.id];
+ it.thumb = m && m.ok ? m.file : null; // null → UI shows gradient poster
+ }
+ return items;
+}
function loadVerdicts() { return readJSON(VERDICTS, {}); }
if (!fs.existsSync(VERDICTS)) writeJSON(VERDICTS, {});
@@ -130,6 +140,15 @@ const server = http.createServer((req, res) => {
if (u.pathname === '/api/export') {
return send(res, 200, exportMarkdown(), 'text/markdown; charset=utf-8');
}
+ if (u.pathname.startsWith('/thumb/')) {
+ const file = path.basename(u.pathname.slice('/thumb/'.length)); // basename → no traversal
+ const fp = path.join(THUMBS, file);
+ if (file.endsWith('.jpg') && fs.existsSync(fp)) {
+ res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=300' });
+ return fs.createReadStream(fp).pipe(res);
+ }
+ return send(res, 404, { error: 'no thumb' });
+ }
send(res, 404, { error: 'not found' });
});
← fae9473 feature-harvest: interactive DW/WPB review deck over a year
·
back to Feature Harvest
·
feature-harvest: in-UI 'Rebuild thumbnails' button with asyn a537317 →