[object Object]

← back to Feature Harvest

feature-harvest: interactive DW/WPB review deck over a year of shipped apps + DW-Programming tools

fae947390e5a2e5dc5d8f0d0c16d000c835fcc15 · 2026-06-26 08:39:31 -0700 · Steve

Files touched

Diff

commit fae947390e5a2e5dc5d8f0d0c16d000c835fcc15
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Jun 26 08:39:31 2026 -0700

    feature-harvest: interactive DW/WPB review deck over a year of shipped apps + DW-Programming tools
---
 .gitignore         |  10 ++
 README.md          |  34 +++++
 build-corpus.js    | 165 +++++++++++++++++++++++
 data/verdicts.json |   1 +
 package.json       |  10 ++
 public/index.html  | 388 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js          | 149 ++++++++++++++++++++
 7 files changed, 757 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e7f791a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+data/wins-snapshot.json
+data/items.json
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..661d199
--- /dev/null
+++ b/README.md
@@ -0,0 +1,34 @@
+# Feature Harvest
+
+Interactive review deck for a year of work. Surfaces every shipped app/feature and
+DW programming tool, and lets you triage each one into **→ DW**, **→ WPB**, **Both**,
+or **Skip** — then exports the two shortlists so the best ideas get ported into
+designerwallcoverings.com or wallpapersback.com.
+
+## Corpus (3 sources, ~1,682 items)
+- **Shipped wins** — the CNCP "Today's Wins" log (`:3333/api/wins`), snapshotted locally.
+- **DW apps** — every standalone tool under `Designer-Wallcoverings/DW-Programming/*`
+  (the recolor engine, SearchByColorWheel/color-dots, vendor taggers, dashboards…).
+- **WPB tools** — the curator/viewer HTML tools in the wallco-ai + wallpapersback storefronts.
+
+## Run
+```sh
+node build-corpus.js   # (re)aggregate data/items.json from the 3 sources
+node server.js         # serve the review deck (auto-picks a free port)
+```
+Login: `admin` / `DW2024!` (override with `FH_USER` / `FH_PASS`).
+
+## Review UX
+- **Queue mode** — one card at a time. Keys: `D`=DW, `W`=WPB, `B`=Both, `S`=Skip,
+  `U`=unset, `←`/`→` navigate. Auto-advances through the unreviewed queue.
+- **Board mode** — five columns (Unreviewed / DW / WPB / Both / Skip), drag cards
+  between piles. Density slider sets column count.
+- Filter by source / status / project, search, sort. Per-card note field.
+- **⬇ Export shortlist** → markdown of the DW + WPB + Both piles with your notes.
+
+## Data
+- `data/verdicts.json` — your decisions (tracked in git, durable).
+- `data/items.json`, `data/wins-snapshot.json` — regenerable corpus (gitignored).
+- **↻ Refresh** re-pulls wins from CNCP and rebuilds; falls back to cache if CNCP is down.
+
+Local-only. No deploy.
diff --git a/build-corpus.js b/build-corpus.js
new file mode 100644
index 0000000..0051802
--- /dev/null
+++ b/build-corpus.js
@@ -0,0 +1,165 @@
+#!/usr/bin/env node
+/*
+ * build-corpus.js — aggregate a year of work into one reviewable list (data/items.json).
+ * Sources:
+ *   A) CNCP wins             → shipped features/apps           (source: "win")
+ *   B) DW-Programming/*       → standalone DW apps/tools        (source: "dw-app")
+ *   C) wallco/WPB curators    → AI-storefront review tools      (source: "wpb-tool")
+ * Each item: { id, source, title, project, date, summary, value, commit, path, port, hasUI }
+ */
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+const HOME = os.homedir();
+const OUT = path.join(__dirname, 'data', 'items.json');
+const items = [];
+
+// ---- A) CNCP wins -------------------------------------------------
+function addWins() {
+  const snap = path.join(__dirname, 'data', 'wins-snapshot.json');
+  let wins = [];
+  try { wins = JSON.parse(fs.readFileSync(snap, 'utf8')); } catch {}
+  for (const w of wins) {
+    items.push({
+      id: w.id,
+      source: 'win',
+      title: w.title,
+      project: w.project || '',
+      date: w.date || (w.createdAt ? new Date(w.createdAt).toISOString().slice(0, 10) : ''),
+      ts: w.createdAt || 0,
+      summary: w.summary || '',
+      value: w.valueToday || '',
+      commit: w.commit || '',
+      path: '',
+      port: '',
+      hasUI: false,
+    });
+  }
+  return wins.length;
+}
+
+// ---- helpers for filesystem apps ----------------------------------
+function firstReadmeLine(dir) {
+  for (const name of ['README.md', 'readme.md', 'VERSION_NOTES.md', 'VERSION.md']) {
+    const f = path.join(dir, name);
+    if (fs.existsSync(f)) {
+      const lines = fs.readFileSync(f, 'utf8').split('\n').map((l) => l.trim());
+      // first non-empty, non-heading-only line, else first heading text
+      const para = lines.find((l) => l && !l.startsWith('#') && !l.startsWith('---'));
+      const head = lines.find((l) => l.startsWith('#'));
+      const txt = (para || (head ? head.replace(/^#+\s*/, '') : '') || '').slice(0, 400);
+      if (txt) return txt;
+    }
+  }
+  return '';
+}
+function pkgDesc(dir) {
+  const f = path.join(dir, 'package.json');
+  if (fs.existsSync(f)) {
+    try { return (JSON.parse(fs.readFileSync(f, 'utf8')).description || '').slice(0, 400); } catch {}
+  }
+  return '';
+}
+function detectPort(dir) {
+  for (const name of ['ecosystem.config.js', '.env', 'server.js', 'index.js']) {
+    const f = path.join(dir, name);
+    if (fs.existsSync(f)) {
+      try {
+        const m = fs.readFileSync(f, 'utf8').match(/(?:PORT|port)\s*[:=]\s*['"]?(\d{3,5})/);
+        if (m) return m[1];
+      } catch {}
+    }
+  }
+  return '';
+}
+function hasUI(dir) {
+  if (fs.existsSync(path.join(dir, 'public', 'index.html'))) return true;
+  try { return fs.readdirSync(dir).some((f) => f.endsWith('.html')); } catch { return false; }
+}
+function dirDate(dir) {
+  try {
+    const t = fs.statSync(dir).mtime;
+    return { date: t.toISOString().slice(0, 10), ts: t.getTime() };
+  } catch { return { date: '', ts: 0 }; }
+}
+
+// ---- B) DW-Programming app folders --------------------------------
+const SKIP_DIRS = new Set([
+  'config', 'data', 'lib', 'ls', 'scripts', 'state', 'templates',
+  'shared-enhancements', 'node_modules', '.git',
+]);
+function addDirApps(parent, source, projectLabel) {
+  if (!fs.existsSync(parent)) return 0;
+  let n = 0;
+  for (const name of fs.readdirSync(parent)) {
+    if (SKIP_DIRS.has(name) || name.startsWith('.')) continue;
+    const dir = path.join(parent, name);
+    let st; try { st = fs.statSync(dir); } catch { continue; }
+    if (!st.isDirectory()) continue;
+    const { date, ts } = dirDate(dir);
+    const summary = firstReadmeLine(dir) || pkgDesc(dir) || '';
+    items.push({
+      id: `${source}:${name}`,
+      source,
+      title: name,
+      project: projectLabel,
+      date, ts,
+      summary,
+      value: '',
+      commit: '',
+      path: dir,
+      port: detectPort(dir),
+      hasUI: hasUI(dir),
+    });
+    n++;
+  }
+  return n;
+}
+
+// ---- C) Standalone curator/viewer HTML tools (wallco + WPB) -------
+function addHtmlTools() {
+  const roots = [
+    { dir: path.join(HOME, 'Projects/wallco-ai/public'), source: 'wpb-tool', label: 'wallco-ai' },
+    { dir: path.join(HOME, 'Projects/WallpapersBack/wallpapersback-fork/public'), source: 'wpb-tool', label: 'wallpapersback' },
+  ];
+  let n = 0;
+  for (const { dir, source, label } of roots) {
+    if (!fs.existsSync(dir)) continue;
+    for (const f of fs.readdirSync(dir)) {
+      if (!f.endsWith('.html')) continue;
+      if (['index.html', 'privacy.html', 'about.html'].includes(f)) continue;
+      const full = path.join(dir, f);
+      const { date, ts } = dirDate(full);
+      items.push({
+        id: `${source}:${label}:${f}`,
+        source,
+        title: f.replace(/\.html$/, '').replace(/[-_]/g, ' '),
+        project: label,
+        date, ts,
+        summary: `Standalone review/curator tool (${f}) in the ${label} storefront.`,
+        value: '',
+        commit: '',
+        path: full,
+        port: '',
+        hasUI: true,
+      });
+      n++;
+    }
+  }
+  return n;
+}
+
+// ---- run ----------------------------------------------------------
+const nWins = addWins();
+const nDW = addDirApps(path.join(HOME, 'Projects/Designer-Wallcoverings/DW-Programming'), 'dw-app', 'DW-Programming');
+const nTools = addHtmlTools();
+
+// stable sort: newest first
+items.sort((a, b) => (b.ts || 0) - (a.ts || 0));
+fs.writeFileSync(OUT, JSON.stringify(items, null, 2));
+console.log(`corpus built → ${OUT}`);
+console.log(`  wins:     ${nWins}`);
+console.log(`  dw-apps:  ${nDW}`);
+console.log(`  wpb-tools:${nTools}`);
+console.log(`  TOTAL:    ${items.length}`);
diff --git a/data/verdicts.json b/data/verdicts.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/data/verdicts.json
@@ -0,0 +1 @@
+{}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..046e33d
--- /dev/null
+++ b/package.json
@@ -0,0 +1,10 @@
+{
+  "name": "feature-harvest",
+  "version": "1.0.0",
+  "private": true,
+  "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"
+  }
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..7eb8a82
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,388 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1" />
+<title>Feature Harvest — DW / WPB review</title>
+<style>
+  :root{
+    --bg:#0f1115; --panel:#171a21; --panel2:#1d212b; --line:#2a2f3a;
+    --ink:#e8eaf0; --mut:#8a92a6; --gold:#caa45a; --teal:#34a5f4;
+    --dw:#caa45a; --wpb:#b07cf0; --both:#3ddc97; --skip:#5a6172;
+    --win:#34a5f4; --app:#e0a93b; --tool:#b07cf0;
+    --r:14px; --shadow:0 8px 30px rgba(0,0,0,.35);
+  }
+  *{box-sizing:border-box}
+  body{margin:0;background:var(--bg);color:var(--ink);
+    font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,sans-serif}
+  a{color:var(--teal)}
+  /* top bar */
+  header{position:sticky;top:0;z-index:10;background:rgba(15,17,21,.92);
+    backdrop-filter:blur(8px);border-bottom:1px solid var(--line);padding:12px 18px}
+  .row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
+  .brand{font-weight:700;letter-spacing:.3px;font-size:17px}
+  .brand span{color:var(--gold)}
+  .counts{display:flex;gap:8px;flex-wrap:wrap;margin-left:auto}
+  .chip{padding:3px 10px;border-radius:999px;font-size:12.5px;font-weight:600;
+    background:var(--panel2);border:1px solid var(--line);white-space:nowrap}
+  .chip.dw{color:var(--dw)} .chip.wpb{color:var(--wpb)}
+  .chip.both{color:var(--both)} .chip.skip{color:var(--skip)}
+  .chip.left{color:var(--ink)}
+  button,select,input{font:inherit;color:var(--ink)}
+  .btn{background:var(--panel2);border:1px solid var(--line);border-radius:10px;
+    padding:7px 13px;cursor:pointer;transition:.12s}
+  .btn:hover{border-color:var(--gold);background:#222734}
+  .btn.primary{background:var(--gold);color:#1a1407;border-color:var(--gold);font-weight:700}
+  .btn.ghost{background:transparent}
+  .progress{height:5px;background:var(--panel2);border-radius:4px;margin-top:10px;overflow:hidden}
+  .progress > i{display:block;height:100%;background:linear-gradient(90deg,var(--gold),var(--teal));width:0}
+  /* controls */
+  .controls{margin-top:10px;gap:8px}
+  .controls select,.controls input[type=search]{
+    background:var(--panel2);border:1px solid var(--line);border-radius:9px;padding:6px 10px}
+  .seg{display:flex;border:1px solid var(--line);border-radius:9px;overflow:hidden}
+  .seg button{background:transparent;border:0;padding:6px 13px;cursor:pointer;color:var(--mut)}
+  .seg button.on{background:var(--gold);color:#1a1407;font-weight:700}
+  .dens{display:flex;align-items:center;gap:6px;color:var(--mut);font-size:12.5px}
+  /* main */
+  main{padding:18px;max-width:1400px;margin:0 auto}
+  /* QUEUE mode */
+  .queue-wrap{max-width:760px;margin:0 auto}
+  .qnav{display:flex;justify-content:space-between;align-items:center;color:var(--mut);
+    font-size:13px;margin-bottom:10px}
+  /* card */
+  .card{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);
+    box-shadow:var(--shadow);overflow:hidden}
+  .card .top{padding:6px 16px;display:flex;gap:8px;align-items:center;
+    border-bottom:1px solid var(--line);background:var(--panel2)}
+  .src{padding:2px 9px;border-radius:6px;font-size:11px;font-weight:800;letter-spacing:.4px;text-transform:uppercase}
+  .src.win{background:rgba(52,165,244,.16);color:var(--win)}
+  .src.dw-app{background:rgba(224,169,59,.16);color:var(--app)}
+  .src.wpb-tool{background:rgba(176,124,240,.16);color:var(--tool)}
+  .when{color:var(--mut);font-size:12px;margin-left:auto;white-space:nowrap}
+  .verdict-tag{font-size:11px;font-weight:800;padding:2px 8px;border-radius:6px}
+  .vt-dw{background:rgba(202,164,90,.18);color:var(--dw)}
+  .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)}
+  .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}
+  .meta code{background:var(--panel2);padding:1px 6px;border-radius:5px;color:var(--gold)}
+  .summary{white-space:pre-wrap;color:#d3d7e0}
+  .value{margin-top:10px;padding:10px 12px;border-left:3px solid var(--both);
+    background:rgba(61,220,151,.06);border-radius:0 8px 8px 0;font-size:14px}
+  .value b{color:var(--both)}
+  .pathline{margin-top:10px;font-size:12px;color:var(--mut)}
+  /* verdict bar */
+  .verdict{display:flex;gap:8px;padding:14px 16px;border-top:1px solid var(--line);
+    background:var(--panel2);flex-wrap:wrap;align-items:center}
+  .vbtn{flex:1;min-width:90px;border:1px solid var(--line);border-radius:10px;padding:11px;
+    cursor:pointer;font-weight:700;background:var(--panel);transition:.12s;text-align:center}
+  .vbtn:hover{transform:translateY(-1px)}
+  .vbtn .k{font-size:11px;opacity:.6;font-weight:600}
+  .vbtn.dw{border-color:var(--dw)} .vbtn.dw.on{background:var(--dw);color:#1a1407}
+  .vbtn.wpb{border-color:var(--wpb)} .vbtn.wpb.on{background:var(--wpb);color:#150b22}
+  .vbtn.both{border-color:var(--both)} .vbtn.both.on{background:var(--both);color:#04130c}
+  .vbtn.skip{border-color:var(--skip)} .vbtn.skip.on{background:var(--skip);color:#fff}
+  .note{width:100%;margin-top:10px;background:var(--panel);border:1px solid var(--line);
+    border-radius:9px;padding:9px 11px;resize:vertical;min-height:38px;color:var(--ink)}
+  /* BOARD mode */
+  .board{display:grid;grid-template-columns:repeat(5,1fr);gap:14px;align-items:start}
+  @media(max-width:1100px){.board{grid-template-columns:repeat(2,1fr)}}
+  .col{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:10px;min-height:120px}
+  .col h3{margin:2px 4px 10px;font-size:13px;text-transform:uppercase;letter-spacing:.5px;
+    display:flex;justify-content:space-between}
+  .col.dw h3{color:var(--dw)} .col.wpb h3{color:var(--wpb)}
+  .col.both h3{color:var(--both)} .col.skip h3{color:var(--skip)} .col.left h3{color:var(--ink)}
+  .mini{background:var(--panel2);border:1px solid var(--line);border-radius:9px;padding:8px 10px;
+    margin-bottom:8px;cursor:grab;font-size:13px}
+  .mini:hover{border-color:var(--gold)}
+  .mini .mt{font-weight:600;line-height:1.3}
+  .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]{}
+  .empty{color:var(--mut);text-align:center;padding:40px;font-style:italic}
+  .hint{color:var(--mut);font-size:12px}
+  kbd{background:var(--panel2);border:1px solid var(--line);border-radius:5px;padding:1px 6px;font-size:11px}
+</style>
+</head>
+<body>
+<header>
+  <div class="row">
+    <div class="brand">Feature <span>Harvest</span> · review a year of apps → DW / WPB</div>
+    <div class="counts" id="counts"></div>
+  </div>
+  <div class="row controls">
+    <div class="seg" id="mode">
+      <button data-mode="queue" class="on">Queue</button>
+      <button data-mode="board">Board</button>
+    </div>
+    <select id="fSource">
+      <option value="">All sources</option>
+      <option value="win">Shipped wins</option>
+      <option value="dw-app">DW apps</option>
+      <option value="wpb-tool">WPB tools</option>
+    </select>
+    <select id="fStatus">
+      <option value="unreviewed">Unreviewed</option>
+      <option value="">All statuses</option>
+      <option value="dw">→ DW</option>
+      <option value="wpb">→ WPB</option>
+      <option value="both">→ Both</option>
+      <option value="skip">Skipped</option>
+    </select>
+    <select id="fProject"><option value="">All projects</option></select>
+    <select id="fSort">
+      <option value="new">Newest</option>
+      <option value="old">Oldest</option>
+      <option value="title">Title A→Z</option>
+    </select>
+    <input type="search" id="fSearch" placeholder="search title / summary…" style="min-width:200px" />
+    <div class="dens" id="densWrap" style="display:none">density
+      <input type="range" id="dens" min="2" max="6" value="5" />
+    </div>
+    <button class="btn" id="refreshBtn" title="Re-pull wins from CNCP + rebuild">↻ Refresh</button>
+    <button class="btn primary" id="exportBtn">⬇ Export shortlist</button>
+  </div>
+  <div class="progress"><i id="bar"></i></div>
+</header>
+<main>
+  <div id="queue" class="queue-wrap"></div>
+  <div id="board" style="display:none"></div>
+</main>
+
+<script>
+const $ = (s,r=document)=>r.querySelector(s);
+const $$ = (s,r=document)=>[...r.querySelectorAll(s)];
+const SRC_LABEL = {win:'Shipped win', 'dw-app':'DW app', 'wpb-tool':'WPB tool'};
+const DEC = {dw:'→ DW', wpb:'→ WPB', both:'Both', skip:'Skip'};
+let ITEMS=[], V={}, qi=0;
+const state = {
+  mode:'queue',
+  source:'', status:'unreviewed', project:'', sort:'new', search:'',
+  cols: +(localStorage.getItem('fh_cols')||5),
+};
+
+// ---- load ----
+async function load(){
+  const r = await fetch('/api/items'); const d = await r.json();
+  ITEMS = d.items; V = d.verdicts || {};
+  buildProjectFacet();
+  render();
+}
+function buildProjectFacet(){
+  const sel = $('#fProject');
+  const seen = new Set();
+  const opts = [];
+  for(const it of ITEMS){ const p=it.project||'(none)'; if(!seen.has(p)){seen.add(p); opts.push(p);} }
+  opts.sort();
+  sel.innerHTML = '<option value="">All projects</option>' +
+    opts.map(p=>`<option value="${esc(p)}">${esc(p)}</option>`).join('');
+}
+
+// ---- helpers ----
+function esc(s){return (s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
+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'});
+}
+function filtered(){
+  let a = ITEMS;
+  if(state.source) a=a.filter(i=>i.source===state.source);
+  if(state.project) a=a.filter(i=>(i.project||'(none)')===state.project);
+  if(state.status==='unreviewed') a=a.filter(i=>!V[i.id]);
+  else if(state.status) a=a.filter(i=>V[i.id]&&V[i.id].decision===state.status);
+  if(state.search){const q=state.search.toLowerCase();
+    a=a.filter(i=>(i.title+' '+i.summary+' '+i.value+' '+i.project).toLowerCase().includes(q));}
+  a=[...a];
+  if(state.sort==='new') a.sort((x,y)=>(y.ts||0)-(x.ts||0));
+  else if(state.sort==='old') a.sort((x,y)=>(x.ts||0)-(y.ts||0));
+  else if(state.sort==='title') a.sort((x,y)=>(x.title||'').localeCompare(y.title||''));
+  return a;
+}
+function counts(){
+  const c={dw:0,wpb:0,both:0,skip:0,left:0};
+  for(const it of ITEMS){const v=V[it.id]; if(v&&c[v.decision]!=null)c[v.decision]++; else c.left++;}
+  return c;
+}
+function renderCounts(){
+  const c=counts();
+  $('#counts').innerHTML =
+    `<span class="chip left">${ITEMS.length} total</span>`+
+    `<span class="chip dw">DW ${c.dw}</span>`+
+    `<span class="chip wpb">WPB ${c.wpb}</span>`+
+    `<span class="chip both">Both ${c.both}</span>`+
+    `<span class="chip skip">Skip ${c.skip}</span>`+
+    `<span class="chip left">${c.left} left</span>`;
+  const reviewed=ITEMS.length-c.left;
+  $('#bar').style.width = (ITEMS.length? (reviewed/ITEMS.length*100):0)+'%';
+}
+
+// ---- verdict ----
+async function setVerdict(id, decision){
+  const cur = V[id] && V[id].decision;
+  if(cur===decision){ decision='unset'; }   // toggle off
+  const note = (V[id]&&V[id].note)||'';
+  if(decision==='unset') delete V[id]; else V[id]={decision,note,ts:Date.now()};
+  renderCounts();
+  fetch('/api/verdict',{method:'POST',headers:{'Content-Type':'application/json'},
+    body:JSON.stringify({id,decision,note})});
+}
+function setNote(id,note){
+  const decision = (V[id]&&V[id].decision)||'skip';
+  if(!V[id]) V[id]={decision,note,ts:Date.now()}; else V[id].note=note;
+  fetch('/api/verdict',{method:'POST',headers:{'Content-Type':'application/json'},
+    body:JSON.stringify({id,decision:V[id].decision,note})});
+}
+
+// ---- render dispatch ----
+function render(){
+  renderCounts();
+  $('#queue').style.display = state.mode==='queue'?'block':'none';
+  $('#board').style.display = state.mode==='board'?'block':'none';
+  $('#densWrap').style.display = state.mode==='board'?'flex':'none';
+  if(state.mode==='queue') renderQueue(); else renderBoard();
+}
+
+// ---- QUEUE ----
+function renderQueue(){
+  const list = filtered();
+  if(!list.length){ $('#queue').innerHTML = `<div class="empty">Nothing matches these filters. 🎉</div>`; return; }
+  if(qi>=list.length) qi=list.length-1; if(qi<0) qi=0;
+  const it = list[qi];
+  $('#queue').innerHTML = `
+    <div class="qnav">
+      <button class="btn ghost" id="prev">← Prev</button>
+      <div>${qi+1} / ${list.length} &nbsp;·&nbsp; <span class="hint">keys <kbd>D</kbd><kbd>W</kbd><kbd>B</kbd><kbd>S</kbd> · <kbd>←</kbd><kbd>→</kbd></span></div>
+      <button class="btn ghost" id="next">Next →</button>
+    </div>
+    ${cardHTML(it,true)}`;
+  $('#prev').onclick=()=>{qi--;renderQueue();};
+  $('#next').onclick=()=>{qi++;renderQueue();};
+  wireCard(it, ()=>{ // after a verdict in queue: advance to next unreviewed-in-list
+    const nl=filtered();
+    if(state.status==='unreviewed'){ if(qi>=nl.length)qi=nl.length-1; renderQueue(); }
+    else { qi++; renderQueue(); }
+  });
+}
+function cardHTML(it, big){
+  const v = V[it.id];
+  const tag = v ? `<span class="verdict-tag vt-${v.decision}">${DEC[v.decision]}</span>` : '';
+  const path = it.path ? `<div class="pathline">📁 <code>${esc(it.path)}</code>${it.port?` · port ${esc(it.port)}`:''}${it.hasUI?' · has UI':''}</div>` : '';
+  const commit = it.commit ? ` · <code>${esc(it.commit)}</code>` : '';
+  const val = it.value ? `<div class="value"><b>Value:</b> ${esc(it.value)}</div>` : '';
+  return `
+  <div class="card" data-id="${esc(it.id)}">
+    <div class="top">
+      <span class="src ${it.source}">${SRC_LABEL[it.source]||it.source}</span>
+      ${tag}
+      <span class="when" title="${esc(it.date)}">🕓 ${esc(whenStr(it))}</span>
+    </div>
+    <div class="body">
+      <h2>${esc(it.title)}</h2>
+      <div class="meta">${esc(it.project||'')}${commit}</div>
+      <div class="summary">${esc(it.summary||'')||'<span class="hint">no description captured</span>'}</div>
+      ${val}${path}
+    </div>
+    <div class="verdict">
+      <div class="vbtn dw ${v&&v.decision==='dw'?'on':''}" data-d="dw">→ DW <span class="k">D</span></div>
+      <div class="vbtn wpb ${v&&v.decision==='wpb'?'on':''}" data-d="wpb">→ WPB <span class="k">W</span></div>
+      <div class="vbtn both ${v&&v.decision==='both'?'on':''}" data-d="both">Both <span class="k">B</span></div>
+      <div class="vbtn skip ${v&&v.decision==='skip'?'on':''}" data-d="skip">Skip <span class="k">S</span></div>
+      <textarea class="note" placeholder="note (why it fits / how to adapt)…">${esc(v&&v.note||'')}</textarea>
+    </div>
+  </div>`;
+}
+function wireCard(it, after){
+  const card = $(`.card[data-id="${cssEsc(it.id)}"]`);
+  if(!card) return;
+  $$('.vbtn',card).forEach(b=>b.onclick=()=>{ setVerdict(it.id,b.dataset.d); after&&after(); });
+  const note=$('.note',card);
+  if(note) note.onchange=()=>setNote(it.id, note.value);
+}
+function cssEsc(s){ return (window.CSS&&CSS.escape)?CSS.escape(s):s.replace(/["\\]/g,'\\$&'); }
+
+// ---- BOARD ----
+function renderBoard(){
+  const list = filtered();
+  const cols=[
+    {k:'left',label:'Unreviewed',test:it=>!V[it.id]},
+    {k:'dw',label:'→ DW',test:it=>V[it.id]&&V[it.id].decision==='dw'},
+    {k:'wpb',label:'→ WPB',test:it=>V[it.id]&&V[it.id].decision==='wpb'},
+    {k:'both',label:'Both',test:it=>V[it.id]&&V[it.id].decision==='both'},
+    {k:'skip',label:'Skip',test:it=>V[it.id]&&V[it.id].decision==='skip'},
+  ];
+  $('#board').innerHTML = `<div class="board">`+cols.map(c=>{
+    const its=list.filter(c.test);
+    return `<div class="col ${c.k}" data-col="${c.k}">
+      <h3>${c.label}<span>${its.length}</span></h3>
+      ${its.map(miniHTML).join('')||'<div class="hint" style="padding:8px">—</div>'}
+    </div>`;
+  }).join('')+`</div>`;
+  // drag
+  $$('.mini').forEach(m=>{
+    m.draggable=true;
+    m.addEventListener('dragstart',e=>e.dataTransfer.setData('text/plain',m.dataset.id));
+  });
+  $$('.col').forEach(col=>{
+    col.addEventListener('dragover',e=>{e.preventDefault();col.classList.add('drop');});
+    col.addEventListener('dragleave',()=>col.classList.remove('drop'));
+    col.addEventListener('drop',e=>{
+      e.preventDefault();col.classList.remove('drop');
+      const id=e.dataTransfer.getData('text/plain');
+      const dec=col.dataset.col==='left'?'unset':col.dataset.col;
+      setVerdict(id, dec==='unset'?'unset':dec);
+      if(V[id]&&V[id].decision===dec){} // no-op guard
+      // setVerdict toggles if same; for board force the target:
+      const cur=V[id]&&V[id].decision;
+      if(dec!=='unset' && cur!==dec){ V[id]={decision:dec,note:(V[id]&&V[id].note)||'',ts:Date.now()};
+        fetch('/api/verdict',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,decision:dec,note:V[id].note})});
+      } else if(dec==='unset'){ delete V[id]; }
+      renderBoard();renderCounts();
+    });
+  });
+  $('#board .board').style.gridTemplateColumns=`repeat(${state.cols},1fr)`;
+}
+function miniHTML(it){
+  return `<div class="mini" data-id="${esc(it.id)}" title="${esc(it.title)}">
+    <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>`;
+}
+
+// ---- controls wiring ----
+$$('#mode button').forEach(b=>b.onclick=()=>{
+  state.mode=b.dataset.mode; $$('#mode button').forEach(x=>x.classList.toggle('on',x===b)); render();
+});
+$('#fSource').onchange=e=>{state.source=e.target.value;qi=0;render();};
+$('#fStatus').onchange=e=>{state.status=e.target.value;qi=0;render();};
+$('#fProject').onchange=e=>{state.project=e.target.value;qi=0;render();};
+$('#fSort').onchange=e=>{state.sort=e.target.value;qi=0;render();};
+$('#fSearch').oninput=e=>{state.search=e.target.value;qi=0;render();};
+$('#dens').value=state.cols;
+$('#dens').oninput=e=>{state.cols=+e.target.value;localStorage.setItem('fh_cols',state.cols);renderBoard();};
+$('#exportBtn').onclick=()=>window.open('/api/export','_blank');
+$('#refreshBtn').onclick=async()=>{
+  $('#refreshBtn').textContent='↻ …';
+  const r=await fetch('/api/refresh',{method:'POST'}); const d=await r.json();
+  $('#refreshBtn').textContent='↻ Refresh';
+  await load();
+  alert(d.ok?`Refreshed — ${d.count} items`:`Kept cached corpus (${d.count}) — ${d.error||'CNCP unreachable'}`);
+};
+// keyboard (queue mode)
+document.addEventListener('keydown',e=>{
+  if(state.mode!=='queue') return;
+  if(/input|textarea|select/i.test(e.target.tagName)) return;
+  const list=filtered(); if(!list.length) return; const it=list[Math.min(qi,list.length-1)];
+  const map={d:'dw',w:'wpb',b:'both',s:'skip',u:'unset'};
+  if(map[e.key.toLowerCase()]){ e.preventDefault();
+    setVerdict(it.id, map[e.key.toLowerCase()]==='unset'?'unset':map[e.key.toLowerCase()]);
+    if(state.status==='unreviewed') renderQueue(); else {qi++;renderQueue();}
+  } else if(e.key==='ArrowRight'){qi++;renderQueue();}
+  else if(e.key==='ArrowLeft'){qi--;renderQueue();}
+});
+load();
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..d668f87
--- /dev/null
+++ b/server.js
@@ -0,0 +1,149 @@
+#!/usr/bin/env node
+/*
+ * feature-harvest — interactive review deck for a year of shipped apps (CNCP wins),
+ * triaging each into a "Add to DW" / "Add to WPB" / "Both" / "Skip" pile so the best
+ * ideas get ported into designerwallcoverings.com or wallpapersback.com.
+ *
+ * Zero npm dependencies (pure Node http). Basic-auth gated (admin / DW2024!).
+ * Source of truth for verdicts = data/verdicts.json (server-side, durable).
+ * Corpus = data/wins-snapshot.json, refreshable live from CNCP (:3333).
+ */
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+
+const ROOT = __dirname;
+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 CNCP_WINS = 'http://127.0.0.1:3333/api/wins';
+
+const USER = process.env.FH_USER || 'admin';
+const PASS = process.env.FH_PASS || 'DW2024!';
+const PREFERRED_PORTS = [9771, 9779, 9787, 9793, 0];
+
+// ---- tiny helpers -------------------------------------------------
+function readJSON(file, fallback) {
+  try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
+  catch { return fallback; }
+}
+function writeJSON(file, obj) {
+  fs.writeFileSync(file, JSON.stringify(obj, null, 2));
+}
+function send(res, code, body, type = 'application/json') {
+  res.writeHead(code, { 'Content-Type': type, 'Cache-Control': 'no-store' });
+  res.end(typeof body === 'string' ? body : JSON.stringify(body));
+}
+function authed(req) {
+  const h = req.headers.authorization || '';
+  if (!h.startsWith('Basic ')) return false;
+  const [u, p] = Buffer.from(h.slice(6), 'base64').toString().split(':');
+  return u === USER && p === PASS;
+}
+// Fetch JSON over http (Node http, no deps). Resolves null on any failure.
+function fetchJSON(url, cb) {
+  const req = http.get(url, { timeout: 8000 }, (r) => {
+    let d = '';
+    r.on('data', (c) => (d += c));
+    r.on('end', () => { try { cb(JSON.parse(d)); } catch { cb(null); } });
+  });
+  req.on('error', () => cb(null));
+  req.on('timeout', () => { req.destroy(); cb(null); });
+}
+
+// ---- corpus + verdicts --------------------------------------------
+function loadWins() { return readJSON(SNAP, []); }
+function loadItems() { return readJSON(ITEMS, loadWins()); }
+function loadVerdicts() { return readJSON(VERDICTS, {}); }
+if (!fs.existsSync(VERDICTS)) writeJSON(VERDICTS, {});
+
+function exportMarkdown() {
+  const list = loadItems();
+  const byId = Object.fromEntries(list.map((w) => [w.id, w]));
+  const v = loadVerdicts();
+  const piles = { dw: [], wpb: [], both: [] };
+  for (const [id, rec] of Object.entries(v)) {
+    if (!piles[rec.decision]) continue;
+    const w = byId[id];
+    if (w) piles[rec.decision].push({ w, note: rec.note });
+  }
+  const fmt = (rows) =>
+    rows.length
+      ? rows.map(({ w, note }) =>
+          `### ${w.title}\n` +
+          `*${w.source} · ${w.project} · ${w.date}*${w.commit ? ` · \`${w.commit}\`` : ''}${w.path ? `\n\`${w.path}\`` : ''}\n\n` +
+          `${w.summary || ''}\n\n` +
+          (w.value ? `**Value:** ${w.value}\n\n` : '') +
+          (note ? `**Steve's note:** ${note}\n\n` : '')
+        ).join('\n---\n\n')
+      : '_(none yet)_\n';
+  return (
+    `# Feature Harvest — Shortlist\n\nGenerated ${new Date().toLocaleString()}\n\n` +
+    `## ➜ Add to Designer Wallcoverings (${piles.dw.length})\n\n${fmt(piles.dw)}\n\n` +
+    `## ➜ Add to Wallpaper's Back (${piles.wpb.length})\n\n${fmt(piles.wpb)}\n\n` +
+    `## ➜ Add to Both (${piles.both.length})\n\n${fmt(piles.both)}\n`
+  );
+}
+
+// ---- routes -------------------------------------------------------
+const server = http.createServer((req, res) => {
+  if (!authed(req)) {
+    res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="feature-harvest"' });
+    return res.end('Auth required');
+  }
+  const u = new URL(req.url, 'http://x');
+
+  if (u.pathname === '/' || u.pathname === '/index.html') {
+    return send(res, 200, fs.readFileSync(path.join(ROOT, 'public', 'index.html'), 'utf8'), 'text/html');
+  }
+  if (u.pathname === '/api/items') {
+    return send(res, 200, { items: loadItems(), verdicts: loadVerdicts() });
+  }
+  if (u.pathname === '/api/verdict' && req.method === 'POST') {
+    let body = '';
+    req.on('data', (c) => (body += c));
+    req.on('end', () => {
+      let p;
+      try { p = JSON.parse(body); } catch { return send(res, 400, { error: 'bad json' }); }
+      const v = loadVerdicts();
+      if (p.decision === 'unset') delete v[p.id];
+      else v[p.id] = { decision: p.decision, note: p.note || '', ts: Date.now() };
+      writeJSON(VERDICTS, v);
+      send(res, 200, { ok: true });
+    });
+    return;
+  }
+  if (u.pathname === '/api/refresh' && req.method === 'POST') {
+    return fetchJSON(CNCP_WINS, (data) => {
+      if (Array.isArray(data) && data.length) {
+        writeJSON(SNAP, data);
+        // rebuild unified corpus (wins + dw-apps + wpb-tools)
+        require('child_process').execFile('node', [path.join(ROOT, 'build-corpus.js')], (err) => {
+          send(res, 200, { ok: !err, count: loadItems().length, wins: data.length });
+        });
+      } else {
+        send(res, 200, { ok: false, error: 'CNCP unreachable — kept cached corpus', count: loadItems().length });
+      }
+    });
+  }
+  if (u.pathname === '/api/export') {
+    return send(res, 200, exportMarkdown(), 'text/markdown; charset=utf-8');
+  }
+  send(res, 404, { error: 'not found' });
+});
+
+// ---- listen with port fallback ------------------------------------
+(function listen(i) {
+  const port = PREFERRED_PORTS[i];
+  server.once('error', (e) => {
+    if (e.code === 'EADDRINUSE' && i < PREFERRED_PORTS.length - 1) listen(i + 1);
+    else throw e;
+  });
+  server.listen(port, () => {
+    const p = server.address().port;
+    console.log(`\n  feature-harvest  →  http://127.0.0.1:${p}`);
+    console.log(`  login: ${USER} / ${PASS}`);
+    console.log(`  corpus: ${loadItems().length} items (wins + DW apps + WPB tools)\n`);
+  });
+})(0);

(oldest)  ·  back to Feature Harvest  ·  feature-harvest: add static-render preview thumbnails for ap 71db8fd →