← 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
A fanout-report.jsonA fanout.mjsA hero-ledger.jsonM viewer/server.mjs
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(/&/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 →