[object Object]

← back to Dw Fleet Registry

fleet fan-out engine + hero ledger: 4 sites rebuilt (jute+metallic+mylar+wallpapersback), 3 skipped (no high-res hero in slice)

80203d0d2a971e0d27236763172031239b05c6f7 · 2026-06-01 13:34:20 -0700 · Steve Abrams

Files touched

Diff

commit 80203d0d2a971e0d27236763172031239b05c6f7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 1 13:34:20 2026 -0700

    fleet fan-out engine + hero ledger: 4 sites rebuilt (jute+metallic+mylar+wallpapersback), 3 skipped (no high-res hero in slice)
---
 fanout-report.json |  62 +++++++++++++++++++++++++
 fanout.mjs         | 133 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 hero-ledger.json   |  38 +++++++++++++++
 viewer/server.mjs  |  53 +++++++++++++++++----
 4 files changed, 276 insertions(+), 10 deletions(-)

diff --git a/fanout-report.json b/fanout-report.json
new file mode 100644
index 0000000..41ee048
--- /dev/null
+++ b/fanout-report.json
@@ -0,0 +1,62 @@
+{
+  "ranAt": "2026-06-01T20:33:10.544Z",
+  "results": [
+    {
+      "slug": "metallicwallpaper",
+      "steps": [
+        "header-component",
+        "header-injected",
+        "corner-nav-off",
+        "accent #1A1A1A->#A98B5B",
+        "hero-unique"
+      ],
+      "hero": "1575x1800",
+      "commit": "c29e354",
+      "status": "DONE"
+    },
+    {
+      "slug": "mylarwallpaper",
+      "steps": [
+        "header-component",
+        "header-injected",
+        "corner-nav-off",
+        "accent #1A1A1A->#A98B5B",
+        "hero-unique"
+      ],
+      "hero": "2400x2400",
+      "commit": "3dcfa6e",
+      "status": "DONE"
+    },
+    {
+      "slug": "fabricwallpaper",
+      "steps": [],
+      "status": "SKIP",
+      "why": "no unique high-res hero candidate in catalog"
+    },
+    {
+      "slug": "apartmentwallpaper",
+      "steps": [],
+      "status": "SKIP",
+      "why": "no unique high-res hero candidate in catalog"
+    },
+    {
+      "slug": "selfadhesivewallpaper",
+      "steps": [],
+      "status": "SKIP",
+      "why": "no unique high-res hero candidate in catalog"
+    },
+    {
+      "slug": "wallpapersback",
+      "steps": [
+        "header-component",
+        "header-injected",
+        "corner-nav-off",
+        "accent-skip (mono/neutral or no drift)",
+        "hero-unique"
+      ],
+      "hero": "1575x1800",
+      "commit": "0a5f981",
+      "status": "DONE"
+    }
+  ]
+}
diff --git a/fanout.mjs b/fanout.mjs
new file mode 100644
index 0000000..a134c02
--- /dev/null
+++ b/fanout.mjs
@@ -0,0 +1,133 @@
+#!/usr/bin/env node
+// Fleet rebuild fan-out (Steve 2026-06-01: "unique sites for every site").
+// Per site, on a branch, LOCAL ONLY (prod frozen):
+//   1. drop in dw-header.js (hamburgers UL + centered name) + per-site config
+//   2. disable the upper-right corner-nav
+//   3. accent → assigned-template colour (clear COLOR-template drift cases only)
+//   4. unique HIGH-RES (>=1500px) niche hero from the site's own catalog slice,
+//      deduped against a fleet-wide ledger so NO image repeats
+// Defensive: any per-site failure is caught, flagged, and the site is skipped —
+// commits happen only on success. Resumable via hero-ledger.json.
+//
+//   node fanout.mjs                 # default batch = the audit's drift sites
+//   node fanout.mjs slugA slugB ... # explicit site list
+
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { execSync } from 'node:child_process';
+
+const PROJECTS = path.join(os.homedir(), 'Projects');
+const SELF = path.dirname(new URL(import.meta.url).pathname);
+const COMPONENT = path.join(SELF, 'component', 'dw-header.js');
+const LEDGER_PATH = path.join(SELF, 'hero-ledger.json');
+const audit = JSON.parse(fs.readFileSync(path.join(SELF, 'template-audit.json'), 'utf8'));
+const auditBySlug = Object.fromEntries(audit.sites.map(s => [s.slug, s]));
+
+const DEFAULT = ['metallicwallpaper','mylarwallpaper','fabricwallpaper','apartmentwallpaper','selfadhesivewallpaper','wallpapersback'];
+const targets = process.argv.slice(2).length ? process.argv.slice(2) : DEFAULT;
+
+const ledger = fs.existsSync(LEDGER_PATH) ? JSON.parse(fs.readFileSync(LEDGER_PATH, 'utf8')) : { usedUrls: {}, sites: {} };
+const sh = (cmd, cwd) => execSync(cmd, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
+const titleCase = s => s.replace(/(wallcoverings?|wallpapers?|walls?|fabric|covering|paper|cover)/gi, ' $1').replace(/\s+/g,' ').trim().replace(/\b\w/g, c => c.toUpperCase());
+
+function probeDims(file) {
+  try { const o = execSync(`sips -g pixelWidth -g pixelHeight "${file}" 2>/dev/null`).toString();
+    return { w:+(o.match(/pixelWidth: (\d+)/)||[])[1]||0, h:+(o.match(/pixelHeight: (\d+)/)||[])[1]||0 }; }
+  catch { return { w:0, h:0 }; }
+}
+
+// pick a UNIQUE high-res hero from the site's products.json; returns {url} or null
+function pickHero(slug) {
+  const pj = path.join(PROJECTS, slug, 'data', 'products.json');
+  if (!fs.existsSync(pj)) return null;
+  let arr; try { const d = JSON.parse(fs.readFileSync(pj,'utf8')); arr = Array.isArray(d) ? d : (d.products||[]); } catch { return null; }
+  const urls = [...new Set(arr.map(p => p && p.image_url).filter(Boolean))];
+  let probed = 0;
+  for (const url of urls) {
+    if (probed >= 40) break;
+    if (ledger.usedUrls[url]) continue;               // already used by another site
+    const base = url.split('?')[0];
+    try {
+      execSync(`curl -s --max-time 12 "${base}?width=2400" -o /tmp/fo.jpg`, { stdio:'ignore' });
+      probed++;
+      const { w, h } = probeDims('/tmp/fo.jpg');
+      if (w >= 1500 && h >= 1500) return { url, base };
+    } catch {}
+  }
+  return null;
+}
+
+const results = [];
+for (const slug of targets) {
+  const r = { slug, steps: [] };
+  try {
+    const dir = path.join(PROJECTS, slug);
+    const idxPath = path.join(dir, 'public', 'index.html');
+    if (!fs.existsSync(idxPath)) { r.status = 'SKIP'; r.why = 'no public/index.html'; results.push(r); continue; }
+
+    // hero FIRST (so we don't branch/commit a site we can't give a unique hero)
+    const hero = pickHero(slug);
+    if (!hero) { r.status = 'SKIP'; r.why = 'no unique high-res hero candidate in catalog'; results.push(r); continue; }
+
+    sh(`git checkout -B style-rebuild-pilot`, dir);
+
+    // 1. component
+    fs.copyFileSync(COMPONENT, path.join(dir, 'public', 'dw-header.js'));
+    r.steps.push('header-component');
+
+    // 2. inject config + script (additive, idempotent) + disable corner-nav
+    let html = fs.readFileSync(idxPath, 'utf8');
+    const a = auditBySlug[slug] || {};
+    const accent = (a.expected && String(a.expected).startsWith('#')) ? a.expected : '#9B7B4F';
+    const name = titleCase(slug);
+    if (!html.includes('/dw-header.js')) {
+      const cfg = `<script>window.DwHeaderConfig={siteName:${JSON.stringify(name)},accent:${JSON.stringify(accent)},`
+        + `navLinks:[{name:'Home',url:'/'},{name:'Collections',url:'#shop'},{name:'About',url:'/about'},{name:'Care',url:'/care.html'},{name:'History',url:'/history.html'}],`
+        + `adminLinks:[{name:'Shopify admin',url:'https://admin.shopify.com',external:true},{name:'Edit hero',url:'#'},{name:'Analytics',url:'#'}]};</script>\n`
+        + `<script src="/dw-header.js" defer></script>\n`;
+      html = html.replace('</body>', cfg + '</body>');
+      r.steps.push('header-injected');
+    }
+    if (html.includes('src="/corner-nav.js"')) {
+      html = html.replace(/<script src="\/corner-nav\.js"[^>]*><\/script>/g, '<!-- corner-nav disabled (UL-hamburger rebuild) -->');
+      r.steps.push('corner-nav-off');
+    }
+
+    // 3. accent — only for clear COLOR-template drift (audit DRIFT + hex expected + hex actual)
+    if (a.status === 'DRIFT' && String(a.expected).startsWith('#') && a.actualAccent && a.actualAccent.startsWith('#')) {
+      const re = new RegExp('(--accent\\s*:\\s*)' + a.actualAccent.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'), 'gi');
+      const before = html;
+      html = html.replace(re, '$1' + a.expected);
+      if (html !== before) r.steps.push(`accent ${a.actualAccent}->${a.expected}`);
+      else r.steps.push('accent-skip (pattern not found)');
+    } else {
+      r.steps.push('accent-skip (mono/neutral or no drift)');
+    }
+    fs.writeFileSync(idxPath, html);
+
+    // 4. unique hero
+    execSync(`curl -s --max-time 25 "${hero.base}?width=2400" -o "${path.join(dir,'public','hero-bg.jpg')}"`, { stdio:'ignore' });
+    const dims = probeDims(path.join(dir,'public','hero-bg.jpg'));
+    ledger.usedUrls[hero.url] = slug;
+    ledger.sites[slug] = { url: hero.url, dims };
+    r.hero = `${dims.w}x${dims.h}`;
+    r.steps.push('hero-unique');
+
+    // commit
+    sh(`git add -A`, dir);
+    sh(`git -c user.email="steve@designerwallcoverings.com" -c user.name="Steve Abrams" commit -q -m "fleet rebuild: dw-header (hamburgers UL + centered name) + unique high-res hero + accent — local pilot branch"`, dir);
+    r.commit = sh(`git rev-parse --short HEAD`, dir);
+    r.status = 'DONE';
+  } catch (e) {
+    r.status = 'FAIL';
+    r.why = String(e.message || e).split('\n')[0].slice(0, 160);
+  }
+  results.push(r);
+  console.log(`${(r.status||'?').padEnd(5)} ${slug.padEnd(22)} ${r.hero||''} ${r.commit||''} ${r.why||''}`);
+}
+
+fs.writeFileSync(LEDGER_PATH, JSON.stringify(ledger, null, 2) + '\n');
+fs.writeFileSync(path.join(SELF, 'fanout-report.json'), JSON.stringify({ ranAt: new Date().toISOString(), results }, null, 2) + '\n');
+const done = results.filter(r=>r.status==='DONE').length;
+console.log(`\n${done}/${results.length} sites rebuilt · ledger has ${Object.keys(ledger.usedUrls).length} unique heroes · report: fanout-report.json`);
diff --git a/hero-ledger.json b/hero-ledger.json
new file mode 100644
index 0000000..051f2fe
--- /dev/null
+++ b/hero-ledger.json
@@ -0,0 +1,38 @@
+{
+  "usedUrls": {
+    "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/P2025101_34.jpg?v=1776795830": "jutewallpaper",
+    "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/1dc3167f1f2f38b43c1a57090aaabb58.jpg?v=1773706439": "metallicwallpaper",
+    "https://cdn.shopify.com/s/files/1/0015/4117/7456/products/T83020_0405cee7-a5ad-46c1-a4cc-00253b2691f6.jpg?v=1733894266": "mylarwallpaper",
+    "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/12a5a47cc506ce3d20fb20bc087f6093.jpg?v=1773706367": "wallpapersback"
+  },
+  "sites": {
+    "jutewallpaper": {
+      "url": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/P2025101_34.jpg?v=1776795830",
+      "dims": {
+        "w": 2400,
+        "h": 2400
+      }
+    },
+    "metallicwallpaper": {
+      "url": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/1dc3167f1f2f38b43c1a57090aaabb58.jpg?v=1773706439",
+      "dims": {
+        "w": 1575,
+        "h": 1800
+      }
+    },
+    "mylarwallpaper": {
+      "url": "https://cdn.shopify.com/s/files/1/0015/4117/7456/products/T83020_0405cee7-a5ad-46c1-a4cc-00253b2691f6.jpg?v=1733894266",
+      "dims": {
+        "w": 2400,
+        "h": 2400
+      }
+    },
+    "wallpapersback": {
+      "url": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/12a5a47cc506ce3d20fb20bc087f6093.jpg?v=1773706367",
+      "dims": {
+        "w": 1575,
+        "h": 1800
+      }
+    }
+  }
+}
diff --git a/viewer/server.mjs b/viewer/server.mjs
index a2cf688..79449ce 100644
--- a/viewer/server.mjs
+++ b/viewer/server.mjs
@@ -14,30 +14,62 @@ const DIR = path.dirname(new URL(import.meta.url).pathname);
 const REG = path.join(DIR, '..', 'dw-fleet-registry.json');
 const PORT = process.env.PORT || 9774;
 const heroCache = new Map();   // domain -> {image, at}
+const HERO_TTL = 10 * 60 * 1000;        // re-resolve after 10 min so fixed sites recover
+const JUNK = /(?:close-?icon|sprite|favicon|logo|badge|placeholder|loader|spinner|1x1|pixel|blank|transparent|data:image|\.svg(?:[?#]|$)|_(?:48|64|96|125|150|180)x)/i;
 
 function send(res, code, type, body) {
   res.writeHead(code, { 'Content-Type': type, 'Cache-Control': 'no-store' });
   res.end(body);
 }
 
+// Normalize a possibly protocol-relative / root-relative URL to absolute https.
+function absUrl(u, domain) {
+  if (!u) return null;
+  u = u.trim().replace(/&amp;/g, '&');
+  if (u.startsWith('//')) return 'https:' + u;
+  if (u.startsWith('/'))  return 'https://' + domain + u;
+  if (!/^https?:/i.test(u)) return 'https://' + domain + '/' + u.replace(/^\.?\//, '');
+  return u;
+}
+
 async function liveHero(domain) {
   const hit = heroCache.get(domain);
-  if (hit) return hit.image;
+  if (hit && Date.now() - hit.at < HERO_TTL) return hit.image;
   let image = null;
   try {
     const ctrl = new AbortController();
-    const t = setTimeout(() => ctrl.abort(), 6000);
+    const t = setTimeout(() => ctrl.abort(), 8000);
     const r = await fetch('https://' + domain + '/', { signal: ctrl.signal, headers: { 'User-Agent': 'dw-fleet-viewer/1.0' } });
     clearTimeout(t);
-    const html = (await r.text()).slice(0, 60000);
-    const og = html.match(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i)
-            || html.match(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i);
-    if (og) image = og[1];
-    else {
-      const img = html.match(/<img[^>]+src=["']([^"']+\.(?:jpg|jpeg|png|webp|avif)[^"']*)["']/i);
-      if (img) image = img[1];
+    const html = (await r.text()).slice(0, 120000);
+
+    // 1) social meta (og:image / twitter:image), either attribute order
+    const meta = html.match(/<meta[^>]+(?:property|name)=["'](?:og:image(?::secure_url)?|twitter:image(?::src)?)["'][^>]+content=["']([^"']+)["']/i)
+              || html.match(/<meta[^>]+content=["']([^"']+)["'][^>]+(?:property|name)=["'](?:og:image(?::secure_url)?|twitter:image(?::src)?)["']/i);
+    if (meta) image = meta[1];
+
+    // 2) link rel=image_src / preload as=image
+    if (!image) {
+      const link = html.match(/<link[^>]+rel=["']image_src["'][^>]+href=["']([^"']+)["']/i)
+                || html.match(/<link[^>]+rel=["']preload["'][^>]+as=["']image["'][^>]+href=["']([^"']+)["']/i);
+      if (link) image = link[1];
     }
-    if (image && image.startsWith('/')) image = 'https://' + domain + image;
+
+    // 3) CSS background-image:url(...) — half the DW microsites put the hero here
+    if (!image) {
+      const bgs = [...html.matchAll(/background(?:-image)?\s*:\s*[^;}"']*url\(\s*(["']?)([^"')]+)\1\s*\)/gi)].map(m => m[2]);
+      const bg = bgs.find(u => !JUNK.test(u) && /\.(?:jpg|jpeg|png|webp|avif)/i.test(u)) || bgs.find(u => !JUNK.test(u) && !/^data:/i.test(u));
+      if (bg) image = bg;
+    }
+
+    // 4) first real content <img> (skip icons / logos / sprites / tiny thumbnails)
+    if (!image) {
+      const imgs = [...html.matchAll(/<img[^>]+(?:src|data-src|data-lazy-src)=["']([^"']+\.(?:jpg|jpeg|png|webp|avif)[^"']*)["']/gi)].map(m => m[1]);
+      const good = imgs.find(u => !JUNK.test(u));
+      if (good) image = good;
+    }
+
+    image = absUrl(image, domain);
   } catch { /* unreachable / timeout */ }
   heroCache.set(domain, { image, at: Date.now() });
   return image;
@@ -56,6 +88,7 @@ const server = http.createServer(async (req, res) => {
     return send(res, 200, 'application/json', JSON.stringify({ image: await liveHero(d) }));
   }
   if (u.pathname === '/api/reprobe' && req.method === 'POST') {
+    heroCache.clear();   // force fresh hero resolution after a re-probe
     const p = spawn('node', [path.join(DIR, '..', 'probe.mjs')], { cwd: path.join(DIR, '..') });
     let out = '';
     p.stderr.on('data', d => out += d); p.stdout.on('data', d => out += d);

← d8d6629 reusable dw-header.js drop-in: hamburgers UL (nav + admin) +  ·  back to Dw Fleet Registry  ·  viewer: 'Hide redirect dupes' toggle (default on) — folds 6 6392f46 →