[object Object]

← back to Pattern Vault

feat: Trend Board page (/trends) — renders design-trend-scout market research + original briefs

bbe592a9ca39b98fa907d98c2a89e2d284c875b7 · 2026-07-02 09:00:17 -0700 · Steve

Files touched

Diff

commit bbe592a9ca39b98fa907d98c2a89e2d284c875b7
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 2 09:00:17 2026 -0700

    feat: Trend Board page (/trends) — renders design-trend-scout market research + original briefs
---
 public/index.html  |   1 +
 public/trends.html | 112 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js          |  17 ++++++++
 3 files changed, 130 insertions(+)

diff --git a/public/index.html b/public/index.html
index d6d740b..90dcacc 100644
--- a/public/index.html
+++ b/public/index.html
@@ -36,6 +36,7 @@ footer a{color:var(--gold)}
   <h1>The <em>Pattern</em> Vault</h1>
   <div class="tag">License original, seamless, print-ready designs from the Wallpaper's Back studio</div>
   <div class="badge" id="modeBadge">loading…</div>
+  <div style="margin-top:14px"><a href="/trends" style="color:var(--gold);text-decoration:none;font-size:13px;letter-spacing:.06em;border-bottom:1px solid var(--line);padding-bottom:2px">Trend Board →</a></div>
 </header>
 <div class="controls">
   <label>Sort <select id="sortSel">
diff --git a/public/trends.html b/public/trends.html
new file mode 100644
index 0000000..b122c18
--- /dev/null
+++ b/public/trends.html
@@ -0,0 +1,112 @@
+<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Trend Board — The Pattern Vault</title>
+<style>
+:root{--bg:#0d0b10;--card:#17141d;--ink:#efe9f7;--mut:#9a8fb0;--gold:#d4af6a;--line:#2a2436;--cols:3;--gap-green:#7fd18a;--covered:#9a8fb0}
+*{box-sizing:border-box;margin:0}
+body{background:var(--bg);color:var(--ink);font:15px/1.55 "Avenir Next",system-ui,sans-serif}
+header{padding:56px 6vw 24px;text-align:center;border-bottom:1px solid var(--line)}
+h1{font-size:clamp(26px,4.2vw,48px);font-weight:600;letter-spacing:.12em;text-transform:uppercase}
+h1 em{color:var(--gold);font-style:normal}
+.tag{color:var(--mut);font-style:italic;margin-top:8px}
+.back{display:inline-block;margin-top:14px;color:var(--gold);text-decoration:none;font-size:13px;letter-spacing:.06em}
+.chip{display:inline-block;margin-top:14px;margin-left:10px;padding:4px 14px;border:1px solid var(--line);border-radius:99px;color:var(--mut);font-size:12px;letter-spacing:.04em}
+.controls{display:flex;gap:14px;align-items:center;justify-content:center;padding:18px 6vw 4px;flex-wrap:wrap}
+select,input[type=range]{background:var(--card);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 10px}
+.wrap{padding:20px 6vw 60px;max-width:1400px;margin:0 auto}
+.lane{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:22px 24px;margin-bottom:22px}
+.lane.gap{border-color:var(--gold)}
+.laneHead{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;flex-wrap:wrap}
+.laneName{font-size:20px;font-weight:600;letter-spacing:.04em}
+.gapbadge{white-space:nowrap;padding:5px 13px;border-radius:99px;font-size:12.5px;font-weight:600;letter-spacing:.03em}
+.gapbadge.g{background:rgba(127,209,138,.14);color:var(--gap-green);border:1px solid var(--gap-green)}
+.gapbadge.c{background:transparent;color:var(--covered);border:1px solid var(--line)}
+.why{color:var(--ink);margin:12px 0 8px;max-width:70ch}
+.meta{display:flex;gap:10px;flex-wrap:wrap;margin:6px 0 4px}
+.pill{font-size:12px;color:var(--mut);border:1px solid var(--line);border-radius:99px;padding:3px 11px}
+.pill b{color:var(--gold);font-weight:600}
+.ev{color:var(--mut);font-size:12.5px;font-style:italic;margin-top:6px}
+.briefs{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:14px;margin-top:16px}
+.brief{background:var(--bg);border:1px solid var(--line);border-radius:12px;padding:14px 15px}
+.brief .bt{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--gold);margin-bottom:8px}
+.brief .prompt{font-size:13.5px;color:var(--ink);margin-bottom:10px}
+.brief .row{font-size:12.5px;color:var(--mut);margin:3px 0}
+.brief .row b{color:var(--ink);font-weight:600}
+.brief .price{color:var(--gold);font-weight:600}
+.briefHdr{font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--mut);margin-top:18px}
+.empty{text-align:center;padding:80px 6vw;color:var(--mut)}
+.empty .big{font-size:22px;color:var(--ink);margin-bottom:12px;letter-spacing:.04em}
+.empty code{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:4px 10px;color:var(--gold)}
+footer{border-top:1px solid var(--line);color:var(--mut);text-align:center;padding:26px;font-size:13px}
+footer a{color:var(--gold)}
+</style></head><body>
+<header>
+  <h1>Trend <em>Board</em></h1>
+  <div class="tag">Where the market is hot — and the gaps we can own with original designs</div>
+  <div><a class="back" href="/">← Pattern Vault</a><span class="chip" id="scanChip"></span></div>
+</header>
+<div class="controls" id="controls" style="display:none">
+  <label>Sort <select id="sortSel">
+    <option value="gap">Gaps first</option>
+    <option value="lane">By lane A→Z</option>
+  </select></label>
+  <label>Density <input type="range" id="density" min="1" max="4" value="3"></label>
+</div>
+<div class="wrap" id="wrap"></div>
+<footer>Original designs only — the board identifies demand; every brief is generated in our own hand, settlement-gated. · <a href="/">Back to the Vault</a></footer>
+<script>
+let trends=[],scannedAt=null;
+const wrap=document.getElementById('wrap'),controls=document.getElementById('controls');
+const sortSel=document.getElementById('sortSel'),density=document.getElementById('density');
+const esc=s=>String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
+sortSel.value=localStorage.pvTrendSort||'gap';density.value=localStorage.pvTrendCols||'3';
+document.documentElement.style.setProperty('--cols',density.value);
+sortSel.onchange=()=>{localStorage.pvTrendSort=sortSel.value;render()};
+density.oninput=()=>{localStorage.pvTrendCols=density.value;document.documentElement.style.setProperty('--cols',density.value)};
+function sortedTrends(){const a=[...trends];
+  if(sortSel.value==='lane')a.sort((x,y)=>(x.lane||'').localeCompare(y.lane||''));
+  else a.sort((x,y)=>((y.gap?1:0)-(x.gap?1:0))||((x.lane||'').localeCompare(y.lane||'')));
+  return a}
+function briefCard(b){return `<div class="brief">
+  <div class="bt">Original design brief</div>
+  <div class="prompt">${esc(b.prompt)}</div>
+  ${b.suggestedColorway?`<div class="row"><b>Colorway:</b> ${esc(b.suggestedColorway)}</div>`:''}
+  ${b.targetTier?`<div class="row"><b>Tier:</b> ${esc(b.targetTier)}</div>`:''}
+  ${b.targetPriceUsd!=null?`<div class="row price">$${esc(b.targetPriceUsd)}</div>`:''}
+</div>`}
+function laneCard(t){const briefs=Array.isArray(t.briefs)?t.briefs:[];
+  const isGap=t.gap===true||(t.gap==null&&t.haveCoverage===false);
+  return `<div class="lane ${isGap?'gap':''}">
+    <div class="laneHead">
+      <div class="laneName">${esc(t.lane||'Untitled lane')}</div>
+      <div class="gapbadge ${isGap?'g':'c'}">${isGap?'🟢 GAP — opportunity':'✓ covered'}</div>
+    </div>
+    ${t.why?`<div class="why">${esc(t.why)}</div>`:''}
+    <div class="meta">
+      ${t.priceBand?`<span class="pill">Price band <b>${esc(t.priceBand)}</b></span>`:''}
+      <span class="pill">${t.haveCoverage?'We have coverage':'No coverage yet'}</span>
+    </div>
+    ${t.evidence?`<div class="ev">${esc(t.evidence)}</div>`:''}
+    ${briefs.length?`<div class="briefHdr">Design briefs (${briefs.length})</div>
+      <div class="briefs">${briefs.map(briefCard).join('')}</div>`:''}
+  </div>`}
+function render(){
+  wrap.innerHTML=sortedTrends().map(laneCard).join('');
+}
+(async()=>{
+  let data;
+  try{data=await(await fetch('/api/trends')).json()}catch(e){data={trends:[],scannedAt:null,empty:true}}
+  trends=Array.isArray(data.trends)?data.trends:[];scannedAt=data.scannedAt;
+  const chip=document.getElementById('scanChip');
+  if(scannedAt){const d=new Date(scannedAt);chip.textContent='🕓 scanned '+(isNaN(d)?scannedAt:d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}))}
+  if(!trends.length){
+    wrap.innerHTML=`<div class="empty"><div class="big">Trend scout hasn't run yet</div>
+      <div>Run <code>/design-trend-scout</code> to research the top-selling designs on Spoonflower, Etsy, Society6 &amp; friends, find the gaps in our catalog, and brief originals to fill them.</div></div>`;
+    return;
+  }
+  const totalBriefs=trends.reduce((n,t)=>n+((Array.isArray(t.briefs)?t.briefs.length:0)),0);
+  if(totalBriefs>12)controls.style.display='flex';
+  render();
+})();
+</script></body></html>
diff --git a/server.js b/server.js
index d869b19..81587f8 100644
--- a/server.js
+++ b/server.js
@@ -68,5 +68,22 @@ app.post('/api/license/checkout', async (req, res) => {
   res.json({ ok: true, mode: 'test', url: session.url });
 });
 
+// Trend Board — renders the market-research board a sibling process writes to data/trend-board.json.
+// Never 500s: if the file is missing/unparseable, returns an explicit empty shape the page renders gracefully.
+app.get('/api/trends', (req, res) => {
+  const board = readJson('trend-board.json', null);
+  if (!board || typeof board !== 'object' || !Array.isArray(board.trends)) {
+    return res.json({ trends: [], scannedAt: null, source: null, empty: true });
+  }
+  res.json({
+    trends: board.trends,
+    scannedAt: board.scannedAt || null,
+    source: board.source || null,
+    empty: board.trends.length === 0,
+  });
+});
+
+app.get('/trends', (req, res) => res.sendFile(path.join(__dirname, 'public', 'trends.html')));
+
 app.get('/api/healthz', (req, res) => res.json({ ok: true, service: 'pattern-vault' }));
 app.listen(PORT, () => console.log(`pattern-vault on :${PORT} · public=${PUBLIC_VAULT} · stripe=${stripe ? 'test' : 'none'}`));

← f781872 chore: lint guards (stripe try/catch, checkout fetch try/cat  ·  back to Pattern Vault  ·  feat: first design-trend-scout board — 7 lanes, 21 original b279f3b →