← back to Consulting Designerwallcoverings Com
build.mjs
794 lines
#!/usr/bin/env node
// Designer Wallcoverings — consulting deliverable generator (RENTV-flagship
// pattern, DW-data-bound). Emits public/portal.html (the 14-section analysis
// app behind the gate) + public/versions/*.html (6 luxury-leader storefront
// concepts, each with the mandatory sort + density controls).
// Render source = data/dw-analysis.json (DTD verdict C, 5/5, 2026-07-26):
// snapshot JSON + visible collected_at stamp + /api/refresh re-collector.
import { writeFileSync, mkdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const OUT = join(HERE, 'public');
mkdirSync(`${OUT}/versions`, { recursive: true });
const readJ = (p, d) => { try { return JSON.parse(readFileSync(join(HERE, p), 'utf8')); } catch { return d; } };
const client = readJ('data/client.json', {});
const SNAP = readJ('data/dw-analysis.json', { catalog: {}, channels: [], fleet: { count: 0, sites: [] }, competitors: [] });
const ADS = readJ('data/ads.json', []);
const TR = SNAP.traffic && SNAP.traffic.available !== false ? SNAP.traffic : null;
const GALLERY = Array.isArray(SNAP.gallery) ? SNAP.gallery : [];
const C = SNAP.catalog || {};
const FF = C.five_field || {};
const SO = C.sample_only || { total: 0, quote_only_by_design: 0, backlog: 0 };
const SR = C.sample_roll || {};
const SRF = SR.funnel || {};
const SRO = SR.order_data || {};
const fmt = n => (n == null ? '—' : Number(n).toLocaleString());
const collected = SNAP.collected_at ? new Date(SNAP.collected_at) : null;
const collectedLabel = collected ? collected.toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : 'not yet collected';
/* ------------------------------------------------------------------ data --- */
const BIZ = {
name: 'Designer Wallcoverings', since: '30+ years', area: 'Los Angeles · nationwide e-commerce + trade',
email: client?.contact?.email || 'steve@designerwallcoverings.com',
phone: client?.contact?.phone || '',
site: 'designerwallcoverings.com',
focus: 'Luxury wallcoverings & designer fabrics — retail + to-the-trade',
};
// Luxe-only hero imagery (standing rule): statement materials — murals, textiles,
// metallic leaf, velvet. Editorial desaturation for calm.
const HERO_IMGS = [
'https://images.unsplash.com/photo-1615873968403-89e068629265?auto=format&fit=crop&w=1920&q=68&sat=-25',
'https://images.unsplash.com/photo-1618221195710-dd6b41faaea6?auto=format&fit=crop&w=1920&q=68&sat=-25',
'https://images.unsplash.com/photo-1600210492486-724fe5c67fb0?auto=format&fit=crop&w=1920&q=68&sat=-25',
'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?auto=format&fit=crop&w=1920&q=68&sat=-25',
'https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?auto=format&fit=crop&w=1920&q=68&sat=-25',
];
/* ---- Luxury-leader concept directions (the "suggested new flagship") --------
* Six industry-LEADER directions, not palette swaps. Models are category
* leaders in luxury wallcoverings / design commerce. All imagery = gradient
* placeholders (leak-scanner-clean: no vendor assets, no upstream names). */
const HOUSES = [
{ key:'atelier', label:'Atelier', model:'de Gournay', bg:'#f4f0e6', panel:'#e9e2d0', ink:'#26221a', gold:'#8a7340', line:'rgba(38,34,26,.16)', disp:"'Cormorant Garamond',Georgia,serif", sans:"'Jost',system-ui,sans-serif", dark:false, tag:'Hand-painted calm. The scenic house.', mood:'Scenic-house minimalism — ivory ground, celadon accents, art-object product pages.' },
{ key:'maison', label:'Maison', model:'Pierre Frey', bg:'#1c2331', panel:'#26304a', ink:'#f2efe6', gold:'#d9b877', line:'rgba(217,184,119,.28)', disp:"'Playfair Display',Georgia,serif", sans:"'Inter',system-ui,sans-serif", dark:true, tag:'The Parisian maison, digitized.', mood:'French maison energy — indigo + ochre, pattern-forward editorial storytelling.' },
{ key:'studio', label:'Studio', model:'Backdrop (DTC)', bg:'#faf7f2', panel:'#f0e9dd', ink:'#1d1b17', gold:'#d96a3b', line:'rgba(29,27,23,.14)', disp:"'Inter',system-ui,sans-serif", sans:"'Inter',system-ui,sans-serif", dark:false, tag:'Wallpaper, without the intimidation.', mood:'DTC-playful — bold color blocks, plain language, sample-first funnel.' },
{ key:'heritage', label:'Heritage', model:'Farrow & Ball', bg:'#232820', panel:'#2e352a', ink:'#efeee6', gold:'#b7a878', line:'rgba(183,168,120,.26)', disp:"'Cormorant Garamond',Georgia,serif", sans:"'Jost',system-ui,sans-serif", dark:true, tag:'Colour, considered for a century.', mood:'Estate heritage — muted greens, archival typography, room-story merchandising.' },
{ key:'texture', label:'Texture', model:'Arte International',bg:'#0f0d0b', panel:'#191510', ink:'#f1ece3', gold:'#c9a24b', line:'rgba(201,162,75,.28)', disp:"'Cormorant Garamond',Georgia,serif", sans:"'Jost',system-ui,sans-serif", dark:true, tag:'Material is the message.', mood:'Immersive texture — full-bleed material macros, dark gallery chrome, slow motion.' },
{ key:'gallery', label:'Gallery', model:'1stDibs', bg:'#f7f5f1', panel:'#ebe7df', ink:'#191919', gold:'#a3803c', line:'rgba(25,25,25,.14)', disp:"'Playfair Display',Georgia,serif", sans:"'Inter',system-ui,sans-serif", dark:false, tag:'The marketplace of extraordinary walls.', mood:'Marketplace-luxe — white-gallery ground, curated collections, provenance chips, designer-first navigation.' },
];
/* ---- chart data straight from the snapshot ---------------------------------- */
const CHART = {
cadence: { labels: (C.activation_cadence || []).map(w => w.week), added: (C.activation_cadence || []).map(w => w.added) },
vendors: { labels: (C.top_vendors || []).slice(0, 10).map(v => v.vendor), counts: (C.top_vendors || []).slice(0, 10).map(v => v.active) },
types: { labels: (C.types || []).slice(0, 6).map(t => t.type), counts: (C.types || []).slice(0, 6).map(t => t.active) },
integrity: {
labels: ['Missing sellable variant', 'Missing sample variant', 'Zero / null price', 'Missing image', 'Missing description'],
counts: [FF.missing_sellable || 0, FF.missing_sample || 0, FF.zero_price || 0, FF.missing_image || 0, FF.missing_description || 0],
},
bands: { labels: (C.price_bands || []).map(b => b.band), counts: (C.price_bands || []).map(b => b.count) },
channels: { labels: (SNAP.channels || []).map(c => c.label || c.key), accounts: (SNAP.channels || []).map(c => c.accounts || 0) },
sampleOnlyVendors: { labels: (C.sample_only_vendors || []).map(v => v.vendor), counts: (C.sample_only_vendors || []).map(v => v.count) },
sampleRoll: {
labels: (SR.vendors || []).slice(0, 8).map(v => v.vendor),
converted: (SR.vendors || []).slice(0, 8).map(v => v.converted),
leak: (SR.vendors || []).slice(0, 8).map(v => v.leak),
},
// Analyst-scored competitive read (authority vs digital polish, 0-100) — analyst tier
comp: { labels: ['Designer Wallcoverings', 'DecoratorsBest', 'Burke Decor', 'Mahones', 'Finest Wallpaper', "Steve's B&W"],
catalog: [96, 62, 48, 55, 40, 45], digital: [58, 82, 78, 60, 42, 55] },
monthly: { labels: (C.monthly_activation || []).map(m => m.month), added: (C.monthly_activation || []).map(m => m.added) },
needs: { labels: (C.needs_tags || []).map(n => n.tag.replace('Needs-', 'Needs ')), counts: (C.needs_tags || []).map(n => n.count) },
styles: { labels: (C.style_mix || []).map(s => s.style), counts: (C.style_mix || []).map(s => s.count) },
colors: { labels: (C.color_mix || []).map(c => c.color), counts: (C.color_mix || []).map(c => c.count), hex: (C.color_mix || []).map(c => c.hex) },
};
/* ---- map: DW + tracked competitor HQs (analyst-compiled geo) ---------------- */
const MAP_POINTS = [
{ name: 'Designer Wallcoverings — Los Angeles', lat: 34.076, lng: -118.377, you: true, note: 'HQ · showroom · ' + fmt(FF.active_total) + ' live SKUs' },
{ name: 'DecoratorsBest', lat: 40.752, lng: -73.993, note: 'New York, NY — designer-brand e-commerce' },
{ name: 'Burke Decor', lat: 41.099, lng: -80.649, note: 'Youngstown, OH — home decor + wallpaper' },
{ name: "Mahone's Wallpaper Shop", lat: 37.089, lng: -76.473, note: 'Newport News, VA — wallpaper specialist' },
{ name: 'Finest Wallpaper', lat: 43.09, lng: -79.08, note: 'Ontario, Canada — designer lines, US/CA pricing' },
{ name: "Steve's Blinds & Wallpaper", lat: 42.58, lng: -83.03, note: 'Sterling Heights, MI — discount volume' },
];
/* -------------------------------------------------------------- SWOT agent --- */
const SWOT_INPUT = {
strengths: [
`${fmt(FF.active_total)} live SKUs across ${fmt(C.vendors_active)} vendor lines — one of the deepest designer-wallcovering catalogs online`,
`A ${fmt(SNAP.fleet?.count)}-site microsite fleet feeding discovery back to the flagship`,
`${(SNAP.channels || []).filter(c => c.connected).length}/7 social channels connected through one marketing command center`,
'Private-label lines with owned margin (Phillipe Romano, Hollywood Wallcoverings, Malibu Wallpaper)',
'30+ years of trade relationships; memo-sample program ($4.25) as a proven funnel entry',
'Automated activation drip already shipping ~500 products/day, textures-first',
],
weaknesses: [
`${fmt(SO.backlog)} active products are sample-only WITHOUT a quote-only designation — browseable but not buyable`,
`${fmt(FF.zero_price)} actives carry zero/null pricing; ${fmt(FF.missing_image)} lack a featured image`,
'Social reach is broad (80 FB pages, 34 IG accounts) but thin — distribution without engagement depth',
'No social→revenue attribution discipline (UTM/coupon per channel) yet',
'Flagship UX carries catalog weight — 80k SKUs need stronger guided curation for retail buyers',
],
opportunities: [
`Convert the ${fmt(SO.backlog)} sample-only backlog into sellable variants — the single largest dormant-revenue pool in the business`,
`${fmt(C.drafts_total)} staged drafts (${fmt(C.drafts_stageable)} fully stageable today) = months of "New Arrivals" marketing fuel`,
'AEO/AI-answer citations for wallcovering queries — the flagship already ships an AEO block; extend it fleet-wide',
'Sample-order → full-roll conversion funnel instrumentation (order sync to the back office already exists)',
'Licensing original design IP (settlement-gated) into marketplaces as a zero-inventory revenue line',
],
threats: [
'DTC-native entrants own the "approachable wallpaper" narrative with modern brand voices',
'Marketplace aggregators (Amazon, Wayfair, Etsy) commoditize mid-tier patterns',
'AI answer engines could disintermediate catalog-depth advantages if citations go to competitors',
'Vendor MAP / brand-control tightening limits pricing latitude on marquee lines',
],
};
function swotAgent(input) {
const quad = (key, title, cls) => `<div class="swotq ${cls}"><h3>${title}</h3><ul>${(input[key] || []).map(x => `<li>${x}</li>`).join('')}</ul></div>`;
return `<div class="swotgrid">${quad('strengths', 'Strengths', 'pos')}${quad('weaknesses', 'Weaknesses', 'neg')}${quad('opportunities', 'Opportunities', 'pos')}${quad('threats', 'Threats', 'neg')}</div>`;
}
const swotGrid = swotAgent(SWOT_INPUT);
/* ----------------------------------------------------------- tables & rows --- */
const compRows = (SNAP.competitors || []).map(c =>
`<tr><td><b>${c.name}</b></td><td>${c.domain}</td><td>${c.enabled ? 'Active watch' : 'Paused'}</td><td>${fmt(c.products_found)}</td><td>${c.notes || ''}</td></tr>`).join('');
const channelRows = (SNAP.channels || []).map(c =>
`<tr><td><b>${c.label || c.key}</b></td><td>${c.connected ? '<span class="good">Connected</span>' : '<span class="flag">Not connected</span>'}</td><td>${fmt(c.accounts)}</td><td>${c.note || (c.connected ? 'Live in the marketing command center' : '')}</td></tr>`).join('');
const adRows = ADS.map(a =>
`<tr${/\(you\)/.test(a.name) ? ' class="you"' : ''}><td><b>${a.name}</b></td><td>${a.domain}</td><td>${a.ad_pixels && a.ad_pixels.length ? a.ad_pixels.join(' · ') : '—'}</td><td>${a.analytics ? 'Yes' : '—'}</td><td>${a.read || ''}</td></tr>`).join('');
const rivalsAdvertising = ADS.filter(a => !/\(you\)/.test(a.name) && a.ad_pixels && a.ad_pixels.length).length;
const vendorRows = (C.top_vendors || []).map((v, i) =>
`<tr><td class="mo">${String(i + 1).padStart(2, '0')}</td><td><b>${v.vendor}</b></td><td>${fmt(v.active)}</td></tr>`).join('');
const soVendorRows = (C.sample_only_vendors || []).map(v =>
`<tr><td><b>${v.vendor}</b></td><td>${fmt(v.count)}</td></tr>`).join('');
// Honest per-site depth (2026-07-28): the aggregator now exposes a real count
// per microsite. Sort deepest-first and show the count on each chip; omit the
// number only when the aggregator genuinely reports null (honest-null).
const fleetChips = (SNAP.fleet?.sites || [])
.slice()
.sort((a, b) => (b.products ?? -1) - (a.products ?? -1))
.slice(0, 48)
.map(s => {
const name = String(s.host).replace('.designerwallcoverings.com', '');
const n = s.products;
// Honest per-site depth = crawled handleCount. Sites whose crawl hit the feed
// page-size cap (200) render as "200+" so the figure isn't misread as exact.
const label = n == null ? '' : (s.capped ? `<b>${fmt(n)}+</b>` : `<b>${fmt(n)}</b>`);
return `<span class="fchip">${name}${label}</span>`;
}).join('');
const partners = [
['Interior-design trade programs (ASID, IIDA chapters)', 'The trade IS the buyer. Chapter sponsorships + a designer-net portal turn 30 years of relationships into a structured acquisition channel.'],
['Professional installers / paper-hangers network', 'An installer directory referral loop: DW sends install-ready clients, installers send spec-stage clients. Both sides win on every roll.'],
['Boutique hotels & hospitality designers', 'Contract lines (Type II) already in catalog — a hospitality lookbook + spec-sheet service opens recurring project-scale orders.'],
['Real-estate stagers & luxury listing agents', 'Statement walls sell listings. A stager program (rapid samples + the peel-and-stick line) creates repeat commercial buyers.'],
['Art-print & framing houses', 'The art-print line proves the adjacency: walls + art as one "finished room" offer, cross-sold at zero acquisition cost.'],
['Paint brands & color consultants', 'Color-matched pairings ("this paper + these three paints") — co-marketing that borrows the paint brand\'s search volume.'],
['Design schools (LA trade programs)', 'Student memo access + a young-designer program seeds the next decade of trade accounts for the cost of samples.'],
['Film / TV set decorators', 'LA-native advantage: a set-decorator fast lane (rush memos, buyout-friendly licensing) monetizes proximity to the studios.'],
];
const partnerRows = partners.map((p, i) => `<div class="pcard rv"><div class="pnum">${String(i + 1).padStart(2, '0')}</div><div><h4>${p[0]}</h4><p>${p[1]}</p></div></div>`).join('');
const calendar = [
['Jan', 'New-year refresh intent peaks', 'Push "one statement wall" entry offers; retarget Q4 samplers'],
['Feb', 'Design-award season', 'Editorial: trend reports off the catalog\'s own tag data'],
['Mar', 'Spring project starts', 'Trade outreach wave; hospitality spec season begins'],
['Apr', 'High-intent renovation search', 'Paid search on pattern + brand terms; AEO content sprint'],
['May', 'Peak sample-ordering', 'Sample-bundle promos; instrument sample→roll conversion'],
['Jun', 'Mid-year new-arrivals surge', 'The activation drip = daily "new" stories across channels'],
['Jul', 'Summer install season', 'Installer-partner co-marketing; project galleries'],
['Aug', 'Pre-fall trade buying', 'Designer-net program push; hospitality lookbook release'],
['Sep', 'Fall flagship season', 'Concept-direction launch (the new front page ships here)'],
['Oct', 'Q4 project commits', 'Retarget spring samplers; gift-adjacent art-line cross-sell'],
['Nov', 'Holiday statement walls', 'Peel-and-stick line forward; fast-ship messaging'],
['Dec', 'Planning season', 'Year-in-review design content; January intent capture'],
];
const calRows = calendar.map(c => `<tr><td class="mo">${c[0]}</td><td>${c[1]}</td><td class="act">${c[2]}</td></tr>`).join('');
/* ---- traffic & search reality (GSC, cached report) --------------------------- */
const pct = x => (x == null ? '—' : (100 * x).toFixed(1) + '%');
const trStriking = (TR?.striking_distance || []).slice(0, 8).map(s =>
`<tr><td><b>${s.query}</b></td><td>${s.position.toFixed(1)}</td><td>${fmt(s.impressions)}</td><td>${fmt(s.clicks)}</td></tr>`).join('');
const trPages = (TR?.top_pages || []).slice(0, 8).map(p =>
`<tr><td><b>${p.path}</b></td><td>${fmt(p.clicks)}</td><td>${fmt(p.impressions)}</td></tr>`).join('');
const trQueries = (TR?.top_queries || []).slice(0, 8).map(q =>
`<tr><td><b>${q.query}</b></td><td>${fmt(q.clicks)}</td><td>${fmt(q.impressions)}</td><td>${pct(q.ctr)}</td><td>${q.position.toFixed(1)}</td></tr>`).join('');
const trDecline = (() => {
const m = TR?.monthly_trend || []; if (m.length < 2) return null;
return { from: m[0], to: m[m.length - 1], pctDown: Math.round(100 * (1 - m[m.length - 1].clicks / m[0].clicks)) };
})();
const CATS = [
['summary', 'Executive Summary'], ['swot', 'SWOT Analysis'], ['empire', 'The Catalog Empire'],
['integrity', 'Catalog Integrity — the 5-Field Read'], ['competitors', 'Competitive Landscape'],
['advertising', 'Channels & Distribution'], ['traffic', 'Traffic & Search Reality'], ['timing', 'Timing & Cadence'],
['audit', 'Website Audit'], ['social', 'Social Audit'], ['suggested', 'Suggested New Flagships'],
['partners', 'Unlikely Partners'], ['revenue', 'Unlikely Revenue'],
['desk', 'Live Commerce Desk'], ['roadmap', '90-Day Roadmap'], ['sources', 'Sources & Methodology'],
];
/* ---- sources & provenance ---------------------------------------------------- */
const SRC_TIER = {
live: ['Live snapshot', 'r'], observed: ['Observed live', 'o'], derived: ['Computed', 'd'],
analyst: ['Analyst view', 'a'], estimate: ['Illustrative', 'e'],
};
const SOURCES = [
['Catalog KPIs — statuses, vendors, types, integrity, cadence', 'The dw_unified local mirror (READ-ONLY psql over the /tmp socket)', `Collected by <code>scripts/collect-dw.mjs</code> at <b>${collectedLabel}</b>; every number on this page renders from that snapshot`, 'live'],
['5-field / sample-only split', 'Mirror rollup columns (has_sample_variant, has_product_variant, has_description, image_url) + the quotes tag', 'Direct SQL counts; quote-only lines (by-design sample-only) separated from true backlog', 'live'],
['Social channel status', 'The DW marketing command center API (marketing.designerwallcoverings.com)', 'Authenticated status read at collection time — connected flags + account counts per platform', 'live'],
['Microsite fleet', 'The fleet aggregator directory API (all.designerwallcoverings.com)', `${fmt(SNAP.fleet?.count)} sites enumerated at collection time`, 'live'],
['Competitor watch-list', 'The connie_competitors table (the DW competitor-scan registry)', 'Rendered verbatim: domain, watch status, products found by prior scans', 'live'],
['Competitor paid-ads table', 'A $0 homepage pixel scan of each tracked domain (Google/Meta/TikTok/Pinterest/Bing tag signatures)', 'Measured at seed time by <code>scripts/seed-buckets.mjs</code>; unreachable homepages say so rather than guessing', 'observed'],
['Competitor scoring chart + map pins', 'Analyst assessment over public sites; HQ geo compiled from public records', 'Authored consulting view — NOT scraped metrics; the map is oriented, not surveyed', 'analyst'],
['Price-band chart', C.price_bands_basis || 'Priced subset of the mirror', 'Partial-basis chart, labeled as such — full variant pricing lives Shopify-side', 'derived'],
['Website & social audits, partners, revenue plays, roadmap', 'Analyst strategy grounded in the operating knowledge of the DW stack', 'Consulting recommendations — models and plays, not measurements', 'analyst'],
['Catalog DNA — style, color & workflow-tag charts', 'Tag-match SQL over all actives (the same tag buckets the storefront sort uses)', 'Live counts per style/color/Needs-* tag; the color doughnut renders in representative hues of the tag names', 'live'],
['Monthly activation ramp', 'created_at_shopify over the last 7 calendar months, live mirror', 'Direct SQL; the ramp multiplier in the copy is computed from the same series', 'live'],
['Product mosaic + concept-grid imagery', "The store's own CDN (one current ACTIVE product per vendor line, forbidden-vendor-filtered)", 'Already customer-facing on the live store; hotlinked at thumbnail width', 'live'],
['Traffic & Search Reality', 'Google Search Console via the cached DW SEO report (age-labeled in the section footer)', 'Totals, branded split, striking-distance and page tables rendered verbatim; the decline copy is computed from the same monthly series', 'observed'],
['Hero imagery', 'Unsplash editorial stock (luxury materials, per the luxe-only imagery rule)', 'Hotlinked, decorative only — carries no data meaning', 'estimate'],
];
const srcRows = SOURCES.map(s => { const t = SRC_TIER[s[3]] || ['—', 'a']; return `<tr><td><b>${s[0]}</b></td><td>${s[1]}</td><td>${s[2]}</td><td class="srccell"><span class="srcb ${t[1]}">${t[0]}</span></td></tr>`; }).join('');
const srcLegend = [...new Set(SOURCES.map(s => s[3]))].map(k => { const t = SRC_TIER[k]; return `<span class="srcb ${t[1]}">${t[0]}</span>`; }).join('');
const houseTiles = HOUSES.map(h => `
<a class="vtile rv" href="/versions/${h.key}.html" target="_blank" style="--vb:${h.bg};--vi:${h.ink};--vg:${h.gold}">
<div class="vswatch"><span style="background:${h.bg}"></span><span style="background:${h.panel}"></span><span style="background:${h.gold}"></span><span style="background:${h.ink}"></span></div>
<div class="vname">${h.label}</div><div class="vmodel">after ${h.model}</div>
<div class="vmood">${h.mood}</div><div class="vopen">Open live concept →</div>
</a>`).join('');
const navLinks = CATS.map(c => `<a href="#${c[0]}">${c[1].replace(/&/g, '&')}</a>`).join('');
const heroSlides = HERO_IMGS.map((u, i) => `<div class="slide${i === 0 ? ' on' : ''}" style="background-image:url('${u}')"></div>`).join('');
const connectedCount = (SNAP.channels || []).filter(c => c.connected).length;
/* =========================================================== the portal app === */
const app = `<!doctype html><html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Designer Wallcoverings — Growth Strategy</title>
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;1,400&family=Jost:wght@300;400;500&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
:root{--bg:#0e0c09;--panel:#171310;--ink:#f1ece3;--gold:#c9a24b;--line:rgba(201,162,75,.22);--dim:rgba(241,236,227,.66)}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg);color:var(--ink);font-family:'Jost',system-ui,sans-serif;font-size:19px;line-height:1.75;-webkit-font-smoothing:antialiased}
.disp{font-family:'Cormorant Garamond',Georgia,serif}a{color:inherit;text-decoration:none}
.bar{position:fixed;top:0;left:0;right:0;z-index:40;display:flex;align-items:center;justify-content:space-between;padding:18px 34px;background:linear-gradient(180deg,rgba(14,12,9,.94),rgba(14,12,9,0))}
.burger{width:30px;height:20px;position:relative;cursor:pointer;background:none;border:0}.burger span{position:absolute;left:0;right:0;height:1.6px;background:var(--ink);transition:.35s}
.burger span:nth-child(1){top:1px}.burger span:nth-child(2){top:9px}.burger span:nth-child(3){top:17px}
body.nav .burger span:nth-child(1){transform:translateY(8px) rotate(45deg)}body.nav .burger span:nth-child(2){opacity:0}body.nav .burger span:nth-child(3){transform:translateY(-8px) rotate(-45deg)}
.stamp{font-size:10.5px;letter-spacing:.18em;text-transform:uppercase;color:var(--dim);display:flex;gap:10px;align-items:center}
.stamp button{background:none;border:1px solid var(--line);color:var(--gold);font-family:'Jost';font-size:10px;letter-spacing:.14em;text-transform:uppercase;padding:6px 12px;cursor:pointer}
.stamp button:hover{background:var(--gold);color:var(--bg)}
.menu{position:fixed;top:0;left:0;bottom:0;width:min(420px,86vw);z-index:39;background:var(--panel);border-right:1px solid var(--line);transform:translateX(-100%);transition:.42s cubic-bezier(.6,0,.2,1);padding:92px 40px;overflow:auto}
.mbrand{display:block;font-family:'Cormorant Garamond',serif;font-size:24px;letter-spacing:.2em;text-transform:uppercase;color:var(--ink);margin-bottom:28px}
.mbrand span{color:var(--gold)}
.mbrand small{display:block;font-family:'Jost',system-ui,sans-serif;font-size:9px;letter-spacing:.32em;color:var(--gold);margin-top:4px}
body.nav .menu{transform:none}body.nav .scrim{opacity:1;pointer-events:auto}
.scrim{position:fixed;inset:0;z-index:38;background:rgba(0,0,0,.5);opacity:0;pointer-events:none;transition:.42s}
.menu .mh{font-family:'Cormorant Garamond',serif;font-size:13px;letter-spacing:.3em;text-transform:uppercase;color:var(--gold);margin-bottom:18px}
.menu a{display:block;font-family:'Cormorant Garamond',serif;font-size:23px;padding:9px 0;border-bottom:1px solid var(--line);opacity:.85;transition:.25s}
.menu a:hover{opacity:1;color:var(--gold);padding-left:8px}
.menu .minfo{margin-top:28px;border-top:1px solid var(--line);padding-top:20px}.menu .minfo p{font-size:13px;line-height:1.7;color:var(--dim);border:0;padding:2px 0}
.hero{min-height:96vh;position:relative;display:flex;flex-direction:column;justify-content:center;padding:0 clamp(24px,7vw,120px);overflow:hidden}
.slides{position:absolute;inset:0;z-index:0}
.slide{position:absolute;inset:0;background-size:cover;background-position:center;opacity:0;transform:scale(1.04);transition:opacity 1.4s ease;will-change:transform,opacity}
.slide.on{opacity:1;animation:kenburns 13s ease-out forwards}
@keyframes kenburns{from{transform:scale(1.04) translate3d(0,0,0)}to{transform:scale(1.13) translate3d(-1.4%,-1.2%,0)}}
.hero:after{content:'';position:absolute;inset:0;background:linear-gradient(90deg,rgba(14,12,9,.94),rgba(14,12,9,.90) 62%,rgba(14,12,9,.55));z-index:1}
.hero .in{position:relative;z-index:2;max-width:960px}
.kick{font-size:12px;letter-spacing:.4em;text-transform:uppercase;color:var(--gold);margin-bottom:22px}
.hero h1{font-family:'Cormorant Garamond',serif;font-weight:400;font-size:clamp(40px,6.6vw,92px);line-height:1.02;text-shadow:0 2px 22px rgba(0,0,0,.72),0 1px 3px rgba(0,0,0,.9)}
/* Hero-only champagne gold: sitewide --gold #c9a24b caps at APCA Lc≈-54 (fails the >=60 wordmark gate on any bg); this brighter tone clears |Lc|>=60 across the wordmark's real position over the dark-plateau gradient. Scoped to the hero so charts/accents keep the signature gold. */
.hero h1 span{color:#d7b968}
.hero p{max-width:64ch;margin-top:24px;font-size:18px;color:var(--dim)}
.metrics{display:flex;flex-wrap:wrap;gap:46px;margin-top:46px}
.metrics .v{font-family:'Cormorant Garamond',serif;font-size:40px;color:var(--gold);line-height:1}
.metrics .l{font-size:10px;letter-spacing:.24em;text-transform:uppercase;color:rgba(201,162,75,.72);margin-top:7px}
section{padding:0 clamp(24px,7vw,120px);border-top:1px solid var(--line);scroll-margin-top:60px}
.accbar{display:flex;gap:12px;justify-content:flex-end;padding:14px clamp(24px,7vw,120px);border-top:1px solid var(--line);border-bottom:1px solid var(--line)}
.accbar button{background:none;border:1px solid var(--line);color:var(--gold);font-family:'Jost';font-size:11px;letter-spacing:.18em;text-transform:uppercase;padding:9px 18px;cursor:pointer}.accbar button:hover{background:var(--gold);color:var(--bg)}
.acchead{width:100%;text-align:left;background:none;border:0;cursor:pointer;display:flex;gap:22px;padding:36px 0;color:var(--ink)}
.acchead .htext{flex:1;position:relative;padding-left:26px}
.acchead .htext:before{content:'';position:absolute;left:0;top:3px;bottom:3px;width:1px;background:linear-gradient(180deg,var(--gold),rgba(201,162,75,.14));opacity:.5;transition:opacity .28s}
.acchead:hover .htext:before,section.open .acchead .htext:before{opacity:1}
.eyebrow{font-size:12.5px;letter-spacing:.36em;text-transform:uppercase;color:var(--gold);margin-bottom:12px}
h2{font-family:'Cormorant Garamond',serif;font-weight:400;font-size:clamp(30px,4.4vw,54px);line-height:1.05;max-width:22ch}
.acchead .chev{font-family:'Cormorant Garamond',serif;font-size:36px;color:var(--gold);transition:.3s;line-height:.7}
section.open .chev{transform:rotate(45deg)}.acchead:hover h2{color:var(--gold);transition:.25s}
.accbody{display:none;padding-bottom:60px}section.open .accbody{display:block;animation:af .45s ease}
@keyframes af{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:none}}
.lead2{max-width:72ch;color:var(--dim);margin-top:6px;font-size:20px}
table{width:100%;border-collapse:collapse;margin-top:34px;font-size:16px}th,td{text-align:left;padding:15px 14px;border-bottom:1px solid var(--line);vertical-align:top;font-size:15.5px}
th{font-size:11px;letter-spacing:.2em;text-transform:uppercase;color:var(--gold);font-weight:400}
tr.you{background:rgba(201,162,75,.09)}tr.you td:first-child{color:var(--gold)}
td.mo{font-family:'Cormorant Garamond',serif;color:var(--gold);font-size:18px;width:60px}
.chartwrap{margin-top:34px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:22px 20px 14px;position:relative;height:340px}
.chartwrap.tall{height:410px}.chartwrap .ct{font-family:'Jost',sans-serif;font-size:10px;letter-spacing:.24em;text-transform:uppercase;color:rgba(241,236,227,.42);margin-bottom:12px}
.chartwrap.dgt{display:flex;flex-direction:column}
.chartwrap.dgt .cwrow{flex:1;min-height:0;display:flex;gap:18px;align-items:stretch}
.chartwrap.dgt .cwcanvas{position:relative;flex:1 1 auto;min-width:0;height:100%}
.hleg{list-style:none;margin:0;padding:0;flex:0 0 auto;max-width:52%;min-width:0;display:flex;flex-direction:column;justify-content:center;gap:10px;overflow:auto}
.hleg li{display:flex;align-items:center;cursor:pointer;font-size:11px;font-weight:500;letter-spacing:.02em;color:rgba(241,236,227,.62);transition:color .2s,opacity .2s;line-height:1.1}
.hleg li:hover{color:var(--ink)}
.hleg li.off{opacity:.38}
.hleg .lgmk{flex:0 0 16px;width:16px;height:2px;border-radius:1px;margin-right:10px;box-shadow:0 0 0 1px rgba(241,236,227,.18)}
.hleg .lgtx{flex:1 1 auto;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:10px}
.hleg .lgvl{flex:0 0 auto;font-variant-numeric:tabular-nums;font-feature-settings:'tnum' 1;letter-spacing:.01em;color:rgba(201,162,75,.92);white-space:pre}
.hleg li.off .lgvl{color:rgba(241,236,227,.30)}
.cards2{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1px;background:var(--line);margin-top:34px}
.cards2 .c{background:var(--bg);padding:32px 28px}.cards2 .c h3{font-family:'Cormorant Garamond',serif;font-size:22px;color:var(--gold);margin-bottom:10px}.cards2 .c p{color:var(--dim);font-size:16px;line-height:1.6}
.flag{color:#e8a0a0}.good{color:#9fd6b0}
.swotgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:1px;background:var(--line);margin-top:34px}
.swotq{background:var(--bg);padding:28px 26px}.swotq h3{font-family:'Cormorant Garamond',serif;font-size:22px;margin-bottom:12px}
.swotq.pos{border-top:2px solid #9fd6b0}.swotq.neg{border-top:2px solid #e8a0a0}
.swotq.pos h3{color:#9fd6b0}.swotq.neg h3{color:#e8a0a0}
.swotq ul{margin:0;padding-left:18px}.swotq li{color:var(--dim);font-size:15px;line-height:1.55;margin:6px 0}
.srclegend{display:flex;flex-wrap:wrap;gap:10px;margin:26px 0 4px}
.srcb{display:inline-block;font-size:10px;letter-spacing:.14em;text-transform:uppercase;padding:5px 11px;border-radius:3px;border:1px solid var(--line);white-space:nowrap}
.srcb.r{color:var(--gold)}.srcb.o{color:#78aaff;border-color:rgba(120,170,255,.4)}.srcb.d{color:#b78d3f}.srcb.a{color:var(--dim)}.srcb.e{color:#e8a0a0;border-color:rgba(232,160,160,.4)}
td.srccell{white-space:nowrap;vertical-align:middle}
.accbody td code{font-family:ui-monospace,Menlo,monospace;font-size:12.5px;color:var(--ink);background:rgba(201,162,75,.12);padding:1px 6px;border-radius:3px}
.srcnote{margin-top:34px;display:flex;gap:22px;padding:28px 30px;border:1px solid var(--line);background:var(--panel)}
.srcnote .i{font-size:26px;color:var(--gold);line-height:1}
.srcnote h4{font-family:'Cormorant Garamond',serif;font-size:21px;color:var(--gold);margin-bottom:8px}
.srcnote p{color:var(--dim);font-size:15px;line-height:1.6}
.srcnote code{font-family:ui-monospace,Menlo,monospace;font-size:12.5px;color:var(--ink);background:rgba(201,162,75,.12);padding:1px 6px;border-radius:3px}
.flagbox{display:flex;gap:22px;margin-top:34px;padding:30px 32px;border:1px solid var(--gold);background:rgba(201,162,75,.06)}
.flagbox .i{font-size:30px;color:var(--gold)}.flagbox h3{font-family:'Cormorant Garamond',serif;font-size:24px;color:var(--gold);margin-bottom:10px}.flagbox p{color:var(--ink);opacity:.92;font-size:16px;line-height:1.6}
.vgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:20px;margin-top:20px}
.vtile{background:var(--vb);border:1px solid var(--line);padding:28px 26px;transition:.3s;position:relative}.vtile:hover{transform:translateY(-5px);box-shadow:0 22px 60px rgba(0,0,0,.55)}
.vswatch{display:flex;height:36px;border-radius:3px;overflow:hidden;margin-bottom:20px}.vswatch span{flex:1}
.vname{font-family:'Cormorant Garamond',serif;font-size:26px;color:var(--vi)}.vmodel{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--vg);margin-top:2px}
.vmood{color:var(--vi);opacity:.72;font-size:13px;margin-top:10px;min-height:38px}.vopen{margin-top:16px;font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--vg)}
.pcard{display:flex;gap:22px;padding:24px 0;border-bottom:1px solid var(--line)}.pnum{font-family:'Cormorant Garamond',serif;font-size:30px;color:var(--gold);min-width:44px}.pcard h4{font-family:'Cormorant Garamond',serif;font-size:21px;margin-bottom:6px}.pcard p{color:var(--dim);font-size:16px;line-height:1.6}
.sugg{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:1px;background:var(--line);margin-top:20px}.sugg .s{background:var(--bg);padding:26px 24px}.sugg .s .p{font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:var(--gold)}.sugg .s h4{font-family:'Cormorant Garamond',serif;font-size:20px;margin:8px 0}.sugg .s p{font-size:15px;color:var(--dim);line-height:1.55}
.road{list-style:none;margin-top:20px;position:relative;padding-left:36px}
.road:before{content:'';position:absolute;left:9px;top:10px;bottom:10px;width:2px;background:linear-gradient(180deg,var(--gold),rgba(201,162,75,.14));transform:scaleY(0);transform-origin:top;transition:transform 1.5s cubic-bezier(.5,0,.2,1)}
.road.drawn:before{transform:scaleY(1)}
.road li{padding:22px 0;border-bottom:1px solid var(--line);display:flex;gap:26px;position:relative}
.road li:before{content:'';position:absolute;left:-32px;top:30px;width:12px;height:12px;border-radius:50%;background:var(--bg);border:2px solid var(--gold);box-shadow:0 0 0 4px var(--bg);transform:scale(0);transition:transform .5s cubic-bezier(.2,1.5,.4,1)}
.road.drawn li:before{transform:scale(1)}
.road.drawn li:nth-child(1):before{transition-delay:.30s}.road.drawn li:nth-child(2):before{transition-delay:.62s}.road.drawn li:nth-child(3):before{transition-delay:.94s}.road.drawn li:nth-child(4):before{transition-delay:1.26s}
.road .w{font-family:'Cormorant Garamond',serif;color:var(--gold);font-size:15px;letter-spacing:.2em;text-transform:uppercase;min-width:130px}.road .d{color:var(--dim)}
@media(prefers-reduced-motion:reduce){.slide.on{animation:none;transform:scale(1)}.road:before{transition:none;transform:scaleY(1)}.road li:before{transition:none;transform:scale(1)}.accbody{animation:none!important}}
.rv{opacity:0;transform:translateY(22px);transition:opacity .7s cubic-bezier(.2,.7,.2,1),transform .7s cubic-bezier(.2,.7,.2,1)}.rv.in{opacity:1;transform:none}
#compmap{height:420px;border:1px solid var(--line);border-radius:8px;margin-top:34px;background:#0a0908}
.leaflet-container{background:#0a0908;font-family:'Jost',sans-serif}
.leaflet-popup-content-wrapper{background:var(--panel);color:var(--ink);border:1px solid var(--line)}
.leaflet-popup-tip{background:var(--panel)}
.duo{display:grid;grid-template-columns:1fr 1fr;gap:20px}@media(max-width:900px){.duo{grid-template-columns:1fr}}
.mosaic{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px;margin-top:34px}
.mtile{position:relative;aspect-ratio:1;overflow:hidden;border-radius:5px;border:1px solid var(--line);display:block}
.mtile img{width:100%;height:100%;object-fit:cover;transition:transform .5s ease;display:block}
.mtile:hover img{transform:scale(1.06)}
.mtile span{position:absolute;left:0;right:0;bottom:0;font-size:10px;letter-spacing:.1em;text-transform:uppercase;padding:14px 10px 8px;background:linear-gradient(0deg,rgba(10,8,6,.85),transparent);color:var(--ink);opacity:0;transition:.3s}
.mtile:hover span{opacity:1}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px;margin-top:34px}
.kpi{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:22px 20px}
.kpi .kv{font-family:'Cormorant Garamond',serif;font-size:34px;color:var(--gold);line-height:1}
.kpi .kl{font-size:10.5px;letter-spacing:.18em;text-transform:uppercase;color:var(--dim);margin-top:8px}
.fchips{display:flex;flex-wrap:wrap;gap:8px;margin-top:24px}
.fchip{font-size:11px;letter-spacing:.06em;border:1px solid var(--line);color:var(--dim);padding:5px 11px;border-radius:3px;display:inline-flex;align-items:center;gap:7px}
.fchip b{color:var(--gold);font-weight:600;letter-spacing:.02em;font-variant-numeric:tabular-nums}
footer{padding:56px clamp(24px,7vw,120px);border-top:1px solid var(--line);color:var(--dim);font-size:12px;letter-spacing:.14em;text-transform:uppercase;display:flex;justify-content:space-between;flex-wrap:wrap;gap:20px}
@media(max-width:640px){.metrics{gap:26px}}
</style></head><body>
<header class="bar">
<div class="stamp"><span>Data collected ${collectedLabel}</span><button id="refbtn" title="Re-run the read-only collector">↻ Refresh data</button></div>
<button class="burger" onclick="document.body.classList.toggle('nav')" aria-label="Menu" aria-expanded="false" aria-controls="portalmenu"><span></span><span></span><span></span></button>
</header>
<div class="scrim" onclick="document.body.classList.remove('nav')"></div>
<nav class="menu" id="portalmenu" onclick="if(event.target.closest('a'))document.body.classList.remove('nav')">
<a class="mbrand" href="#summary">Designer <span>Wallcoverings</span><small>Growth Strategy · 2026</small></a>
<div class="mh">Contents</div>${navLinks}
<div class="minfo"><div class="mh">${BIZ.name}</div><p>${BIZ.area}</p><p>${BIZ.email}</p><p>${BIZ.site}</p></div>
</nav>
<div class="hero" id="summary">
<div class="slides">${heroSlides}</div>
<div class="in">
<div class="kick">${BIZ.area} · Confidential strategy brief · prepared for Steve Abrams</div>
<h1 class="disp">Designer <span>Wallcoverings</span></h1>
<p>Thirty years of trade authority, <b>${fmt(FF.active_total)} live SKUs</b> across <b>${fmt(C.vendors_active)} vendor lines</b>, a <b>${fmt(SNAP.fleet?.count)}-site</b> discovery fleet, and <b>${connectedCount} connected social channels</b> — the deepest designer-wallcovering operation online. The opportunity isn't building an audience; it's converting the catalog's dormant depth (a ${fmt(SO.backlog)}-product sellable-variant backlog, ${fmt(C.drafts_total)} staged drafts) into measured revenue. This brief maps exactly that, from the live data.</p>
<div class="metrics">
<div class="m"><div class="v" data-c="${FF.active_total || 0}">0</div><div class="l">Live SKUs (active)</div></div>
<div class="m"><div class="v" data-c="${C.vendors_active || 0}">0</div><div class="l">Vendor lines</div></div>
<div class="m"><div class="v" data-c="${SNAP.fleet?.count || 0}">0</div><div class="l">Microsite fleet</div></div>
<div class="m"><div class="v" data-c="${C.drafts_total || 0}">0</div><div class="l">Staged drafts (fuel)</div></div>
</div>
</div>
</div>
<div class="accbar"><button id="xall">Expand all</button><button id="call">Collapse all</button></div>
<section id="swot"><div class="eyebrow">Strategic Snapshot · SWOT</div><h2 class="disp">The whole board on one screen.</h2><div class="accbody">
<p class="lead2">Four lenses over the live snapshot — internal <b>Strengths</b> and <b>Weaknesses</b>, external <b>Opportunities</b> and <b>Threats</b> — so the strategy that follows starts from one honest picture. Every number here is from the ${collectedLabel} collection.</p>
${swotGrid}
<div class="flagbox" style="margin-top:34px"><div class="i">⚡</div><div><h3 class="disp">The one-sentence thesis</h3><p>DW's moat is <b>catalog depth + trade authority</b>; its constraint is <b>conversion infrastructure</b>. The fastest wins activate what already exists — the sample-only backlog, the staged-draft drip, the connected-but-quiet channels — before building anything new.</p></div></div>
</div></section>
<section id="empire"><div class="eyebrow">01 · The Catalog Empire</div><h2 class="disp">Not a store. A catalog empire.</h2><div class="accbody">
<p class="lead2">The live mirror shows <b>${fmt(FF.active_total)}</b> active products, <b>${fmt(C.drafts_total)}</b> staged drafts, and <b>${fmt((C.statuses || {}).ARCHIVED)}</b> archived — a merchandising engine with more inventory in reserve than most competitors have in total. Distribution runs through the flagship plus a <b>${fmt(SNAP.fleet?.count)}-site</b> microsite fleet.</p>
<div class="chartwrap tall"><div class="ct">Top vendor lines by live products</div><canvas id="vendorChart"></canvas></div>
<div class="chartwrap dgt"><div class="ct">Live catalog by product type</div><div class="cwrow"><div class="cwcanvas"><canvas id="typeChart"></canvas></div><ul class="hleg" id="typeChart-legend"></ul></div></div>
<table><thead><tr><th>#</th><th>Vendor line</th><th>Active products</th></tr></thead><tbody>${vendorRows}</tbody></table>
<div class="eyebrow" style="margin-top:52px">Catalog DNA — what the shelves actually hold</div>
<p class="lead2">Tag analysis across all ${fmt(FF.active_total)} actives. The palette runs <b>neutrals-first</b> — Beige + White alone account for ~${fmt(((C.color_mix || []).find(c => c.color === 'Beige')?.count || 0) + ((C.color_mix || []).find(c => c.color === 'White')?.count || 0))} products — a texture-led, trade-calibrated assortment. The merchandising read: DW owns the "quiet luxury" wall; bolder saturated stories (the DTC entrants' lane) are the underweighted shelf.</p>
<div class="duo">
<div class="chartwrap"><div class="ct">Style mix — tag-matched across actives</div><canvas id="styleChart"></canvas></div>
<div class="chartwrap dgt"><div class="ct">Color mix — rendered in the actual hues</div><div class="cwrow"><div class="cwcanvas"><canvas id="colorChart"></canvas></div><ul class="hleg" id="colorChart-legend"></ul></div></div>
</div>
<div class="mosaic">${GALLERY.slice(0, 12).map(g => `<a class="mtile" title="${String(g.title).replace(/"/g, '"')} — ${g.vendor}"><img src="${g.image}&width=360" loading="lazy" alt="${String(g.title).replace(/"/g, '"')}"><span>${g.vendor}</span></a>`).join('')}</div>
<p class="lead2" style="font-size:14px;opacity:.6;margin-top:10px">Live catalog, not stock photography — one current product from each of twelve vendor lines, straight off the store's own CDN.</p>
<div class="eyebrow" style="margin-top:52px">The discovery fleet</div>
<p class="lead2">${fmt(SNAP.fleet?.count)} microsites — material-, era-, and city-specific storefront satellites that catch long-tail search and funnel to the flagship.${SNAP.fleet?.total_handles ? ` The fleet indexes <b>${fmt(SNAP.fleet.total_handles)} product handles</b> across <b>${fmt(SNAP.fleet.with_feed)} feed-live sites</b>.` : ''} Deepest sites, by real catalog depth:</p>
<div class="fchips">${fleetChips}</div>
</div></section>
<section id="integrity"><div class="eyebrow">02 · Catalog Integrity — the 5-Field Read</div><h2 class="disp">Where the catalog leaks revenue.</h2><div class="accbody">
<p class="lead2">The house rule: every active product needs a sample variant, a sellable variant, complete pricing, a description, and tags — plus a featured image. Against ${fmt(FF.active_total)} actives, the live counts:</p>
<div class="chartwrap"><div class="ct">Actives failing each field (live mirror counts)</div><canvas id="ffChart"></canvas></div>
<div class="flagbox"><div class="i">◆</div><div><h3 class="disp">The ${fmt(SO.backlog)}-product finding</h3><p><b>${fmt(SO.total)}</b> actives are sample-only. <b>${fmt(SO.quote_only_by_design)}</b> of those are quote-only lines by design. The remaining <b>${fmt(SO.backlog)}</b> — a third of the live catalog — can be browsed and sampled but not bought. Every one is paid-for merchandising with the register removed. This is the single largest dormant-revenue pool in the business.</p></div></div>
<div class="chartwrap"><div class="ct">Sample-only actives — where they sit (top lines)</div><canvas id="soChart"></canvas></div>
<table><thead><tr><th>Vendor line</th><th>Sample-only actives</th></tr></thead><tbody>${soVendorRows}</tbody></table>
<div class="eyebrow" style="margin-top:52px">The catalog's own to-do list</div>
<p class="lead2">The workflow tags tell the story without an audit: <b>${fmt((C.needs_tags || []).find(n => n.tag === 'Needs-Width')?.count)}</b> actives are tagged Needs-Width, <b>${fmt((C.needs_tags || []).find(n => n.tag === 'Needs-Price')?.count)}</b> Needs-Price, <b>${fmt((C.needs_tags || []).find(n => n.tag === 'Needs-Image')?.count)}</b> Needs-Image. These self-reported queues ARE the integrity sprint's work orders — pre-triaged, machine-readable, waiting.</p>
<div class="chartwrap"><div class="ct">Workflow-tag backlog on active products (live tag counts)</div><canvas id="needsChart"></canvas></div>
</div></section>
<section id="competitors"><div class="eyebrow">03 · Competitive Landscape</div><h2 class="disp">Depth vs. polish: the field.</h2><div class="accbody">
<p class="lead2">DW's tracked competitor registry, rendered verbatim, plus an analyst read: DW wins on catalog depth and trade authority; the DTC natives win on front-end polish and brand voice. The play is closing the polish gap without surrendering the depth moat.</p>
<div class="chartwrap"><div class="ct">Catalog depth vs. digital polish — analyst-scored, 0–100</div><canvas id="compChart"></canvas></div>
<table><thead><tr><th>Competitor</th><th>Domain</th><th>Watch status</th><th>Products found</th><th>Notes</th></tr></thead><tbody>${compRows}</tbody></table>
<div id="compmap"></div>
<p class="lead2" style="font-size:15px;margin-top:14px;opacity:.7">The field, mapped: DW in Los Angeles vs. tracked competitor HQs (analyst-compiled geo). LA proximity to the design trade + studios is a structural advantage none of them share.</p>
</div></section>
<section id="advertising"><div class="eyebrow">04 · Channels & Distribution</div><h2 class="disp">Seven channels, one command center.</h2><div class="accbody">
<p class="lead2">Live from the marketing command center at collection time: <b>${connectedCount} of ${(SNAP.channels || []).length}</b> platforms connected. The Facebook page network alone spans <b>${fmt((SNAP.channels || []).find(c => c.key === 'facebook')?.accounts)}</b> pages; Instagram <b>${fmt((SNAP.channels || []).find(c => c.key === 'instagram')?.accounts)}</b> accounts. The infrastructure exists — the discipline to convert reach into attributed revenue is the build.</p>
<div class="flagbox"><div class="i">◆</div><div><h3 class="disp">The paid-ads gap — measured, not guessed</h3><p>A $0 pixel scan of every tracked competitor's homepage (${new Date(SNAP.collected_at || Date.now()).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}): <b>${rivalsAdvertising} of ${ADS.length - 1} rivals are paying to advertise</b> — Google Ads on five of them, Meta on one — while <b>DW's own homepage carries analytics but zero paid-ad pixels</b>. The field is buying the demand DW's catalog should own. With attribution wired first (below), a measured Google Shopping + brand-terms program is the obvious counter.</p></div></div>
<table><thead><tr><th>Domain</th><th></th><th>Ad pixels detected</th><th>Analytics</th><th>Read</th></tr></thead><tbody>${adRows}</tbody></table>
<div class="chartwrap"><div class="ct">Connected accounts per platform (live)</div><canvas id="chanChart"></canvas></div>
<table><thead><tr><th>Platform</th><th>Status</th><th>Accounts</th><th>Note</th></tr></thead><tbody>${channelRows}</tbody></table>
<div class="cards2">
<div class="c"><h3>Attribution first</h3><p>Per-channel UTM + coupon discipline before spend scales. Today reach is broad but revenue attribution is dark — no channel can prove its keep.</p></div>
<div class="c"><h3>New-arrivals engine</h3><p>The activation drip ships ~500 products/day. Each batch is a native "new" story for every channel — content fuel most brands would pay to manufacture.</p></div>
<div class="c"><h3>AEO / AI answers</h3><p>The flagship already ships AI-answer-optimized collection copy. Extending AEO fleet-wide targets the search channel that's growing while classic SERP flattens.</p></div>
</div>
</div></section>
${TR ? `<section id="traffic"><div class="eyebrow">05 · Traffic & Search Reality</div><h2 class="disp">What Google actually sends — and what it's offering.</h2><div class="accbody">
<p class="lead2">Search Console, last 28 days: <b>${fmt(TR.totals_28d?.clicks)}</b> clicks on <b>${fmt(TR.totals_28d?.impressions)}</b> impressions (CTR ${pct(TR.totals_28d?.ctr)}, avg position ${TR.totals_28d?.avg_position?.toFixed(1)}). <b>${pct(1 - (TR.branded_split?.branded_pct ?? 0))} of clicks are non-branded</b> — people finding DW without knowing DW. That's earned discovery, and it makes the next number matter more.</p>
${trDecline ? `<div class="flagbox"><div class="i">▼</div><div><h3 class="disp">The decline nobody's measuring</h3><p>Organic clicks have fallen <b>${trDecline.pctDown}%</b> since ${trDecline.from.month} — ${fmt(trDecline.from.clicks)}/mo then, <b>${fmt(trDecline.to.clicks)}</b>/mo now — while the catalog grew by tens of thousands of products. And <b>no GA4 property exists on the main store</b>, so nothing downstream of the click is measured at all. This is the brief's most urgent measured finding: the roadmap's Week-1 attribution KPI is not optional hygiene, it's the tourniquet.</p></div></div>` : ''}
<div class="chartwrap"><div class="ct">Organic clicks per month — the trend, unvarnished (GSC)</div><canvas id="gscChart"></canvas></div>
<div class="eyebrow" style="margin-top:52px">Striking distance — page-one demand already offered</div>
<p class="lead2">Queries ranking positions 8–15 with real impression volume: each is a content/AEO sprint away from page-one clicks. This is the cheapest traffic DW can buy, because it's not bought.</p>
<table><thead><tr><th>Query</th><th>Position</th><th>Impressions (28d)</th><th>Clicks</th></tr></thead><tbody>${trStriking}</tbody></table>
<div class="duo" style="margin-top:34px">
<div><div class="eyebrow">Top queries</div><table><thead><tr><th>Query</th><th>Clicks</th><th>Impr.</th><th>CTR</th><th>Pos.</th></tr></thead><tbody>${trQueries}</tbody></table></div>
<div><div class="eyebrow">Top pages</div><table><thead><tr><th>Path</th><th>Clicks</th><th>Impr.</th></tr></thead><tbody>${trPages}</tbody></table></div>
</div>
<p class="lead2" style="font-size:14px;opacity:.6;margin-top:18px">Source: ${TR.source} · pulled ${TR.age_days} day(s) before this snapshot · data through ${TR.last_data_date}. GA4: ${TR.ga4?.available ? 'connected' : 'not provisioned — a roadmap item'}.</p>
</div></section>` : ''}
<section id="timing"><div class="eyebrow">06 · Timing & Cadence</div><h2 class="disp">The drip is live. Point it at revenue.</h2><div class="accbody">
<p class="lead2">The growth curve, measured: monthly activations ramped from <b>${fmt((C.monthly_activation || [])[0]?.added)}</b> in ${(C.monthly_activation || [])[0]?.month || 'Jan'} to <b>${fmt((C.monthly_activation || []).slice(-1)[0]?.added)}</b> in ${(C.monthly_activation || []).slice(-1)[0]?.month || 'Jul'} — a ${(() => { const m = C.monthly_activation || []; const a = m[0]?.added || 1, b = m.slice(-1)[0]?.added || 0; return fmt(Math.round(b / a)); })()}× ramp as the activation engine came online. The merchandising calendar below aligns campaigns to the category's natural intent cycle.</p>
<div class="chartwrap"><div class="ct">The ramp — new actives per month, last 7 months (live)</div><canvas id="rampChart"></canvas></div>
<div class="chartwrap"><div class="ct">New active products per week — last 12 weeks (live)</div><canvas id="cadenceChart"></canvas></div>
<table><thead><tr><th>Month</th><th>What the market is doing</th><th>The DW play</th></tr></thead><tbody>${calRows}</tbody></table>
</div></section>
<section id="audit"><div class="eyebrow">07 · Website Audit</div><h2 class="disp">A strong engine that hides its best features.</h2><div class="accbody">
<p class="lead2">Analyst audit of the live flagship (Shopify + Boost search): the merchandising infrastructure is genuinely advanced — the gap is guided discovery for the retail buyer facing ${fmt(FF.active_total)} choices.</p>
<div class="cards2">
<div class="c"><h3>Working well</h3><p class="good">Left-rail collection navigation · shop-by-color wheel with type pills · AEO content blocks on collections · sort + density controls on grids · New Arrivals / Trending smart collections refreshed by daily tag-sync · $4.25 memo-sample funnel on every product.</p></div>
<div class="c"><h3>Gaps</h3><p class="flag">Sample-only products present a dead-end buy box (${fmt(SO.backlog)} products) · zero-price actives surface in feeds (${fmt(FF.zero_price)}) · ${fmt(FF.missing_image)} actives lack a featured image · curation layers (rooms, moods, designer picks) are thin relative to catalog depth.</p></div>
<div class="c"><h3>Action</h3><p>Fix the integrity backlog (Section 02), then ship a guided-discovery layer: room/mood entry points, "pairs well with" on every PDP, and a designer-picks editorial tier — the concept directions in Section 09 show the face of it.</p></div>
</div>
</div></section>
<section id="social"><div class="eyebrow">08 · Social Audit</div><h2 class="disp">Broad reach, quiet voice.</h2><div class="accbody">
<p class="lead2">All major platforms are connected through one command center — a real infrastructure asset. The audit finding is consistent across platforms: <b>distribution exists, engagement discipline doesn't yet</b>. The page network posts; it doesn't yet converse, attribute, or convert.</p>
<div class="cards2">
<div class="c"><h3>Connected & ready</h3><p class="good">${(SNAP.channels || []).filter(c => c.connected).map(c => c.label || c.key).join(' · ')}</p></div>
<div class="c"><h3>The gap</h3><p class="flag">No per-channel revenue attribution · posting cadence not synced to the activation drip · reels/video formats underused relative to the category (interiors is a visual-first vertical).</p></div>
<div class="c"><h3>The play</h3><p>One flagship voice + the page network as amplifiers. Daily new-arrival stories (auto-fed by the drip), weekly designer-pick reels, per-channel UTM coupons — measured, not sprayed.</p></div>
</div>
</div></section>
<section id="suggested"><div class="eyebrow">09 · Suggested New Flagships</div><h2 class="disp">Six directions worthy of the catalog.</h2><div class="accbody">
<p class="lead2">Six full front-page concepts — each after a house that owns its lane, each a working page with the mandatory sort + density controls on its grid. Not palette swaps: different merchandising philosophies. Open each live and pick the direction.</p>
<div class="vgrid">${houseTiles}</div>
</div></section>
<section id="partners"><div class="eyebrow">10 · Unlikely Partners</div><h2 class="disp">Channels that don't look like channels.</h2><div class="accbody">
<p class="lead2">Eight partnership lanes where DW's LA position and trade heritage do the acquisition work.</p>
${partnerRows}
</div></section>
<section id="revenue"><div class="eyebrow">11 · Unlikely Revenue</div><h2 class="disp">The money already in the building.</h2><div class="accbody">
<p class="lead2">Before any new audience is bought, six revenue pools sit inside the existing operation:</p>
<div class="sugg">
<div class="s"><div class="p">Pool 1 · largest</div><h4>The sellable-variant backlog</h4><p>${fmt(SO.backlog)} active products browseable but not buyable. Even a 1% conversion at category AOV is a six-figure annual pool. Remediation is data work, not marketing spend.</p></div>
<div class="s"><div class="p">Pool 2 · compounding</div><h4>Sample → roll funnel</h4><p>The $4.25 memo program is the top of the funnel. <b>${fmt(SRF.sampleable)}</b> active products can be memo-sampled, but only <b>${SRF.conversion_pct}%</b> have a roll a customer can then buy — leaving <b>${fmt(SRF.gap_backlog)}</b> recoverable dead-ends. Quantified below.</p></div>
<div class="s"><div class="p">Pool 3 · scheduled</div><h4>The staged-draft drip</h4><p>${fmt(C.drafts_total)} drafts activating ~500/day = a permanent "new arrivals" marketing engine. Sync email + social to the drip and every day ships its own campaign.</p></div>
<div class="s"><div class="p">Pool 4 · zero-inventory</div><h4>Original design licensing</h4><p>Settlement-gated original pattern IP licensed to print-on-demand marketplaces — revenue without inventory, already piloted in the wider operation.</p></div>
<div class="s"><div class="p">Pool 5 · trade</div><h4>Designer-net portal</h4><p>Formalize trade pricing behind a designer login. Trade buyers are the highest-LTV segment and currently buy through the retail flow.</p></div>
<div class="s"><div class="p">Pool 6 · projects</div><h4>Hospitality / contract desk</h4><p>Type II contract lines already in catalog. A spec-sheet + quote desk turns one hotel corridor into 400 rolls.</p></div>
</div>
<h3 class="disp" style="margin-top:38px">Pool 2, quantified — the sample → roll funnel</h3>
<p class="lead2">The $4.25 memo sample is a daily purchase-intent signal. The question is whether that intent has anywhere to convert. Measured from the live catalog mirror:</p>
<div class="kpis">
<div class="kpi"><div class="kv">${fmt(SRF.sampleable)}</div><div class="kl">Products a customer can memo-sample</div></div>
<div class="kpi"><div class="kv">${SRF.conversion_pct}%</div><div class="kl">…that also have a buyable roll (${fmt(SRF.converted)})</div></div>
<div class="kpi"><div class="kv">${fmt(SRF.gap_backlog)}</div><div class="kl">Recoverable dead-ends (sampled intent, no roll)</div></div>
<div class="kpi"><div class="kv">${fmt(SRF.gap_quote_only)}</div><div class="kl">Quote-only by design (excluded — not a leak)</div></div>
</div>
<p class="lead2">Two-thirds of the sampleable catalog converts cleanly; the remaining <b>${fmt(SRF.gap)}</b> products a customer can pay $4.25 to sample but cannot then buy. Of those, <b>${fmt(SRF.gap_quote_only)}</b> are intentionally quote-only, leaving a <b>${fmt(SRF.gap_backlog)}</b>-product recoverable pool — concentrated in a handful of lines. Fixing the roll variant on a line a customer already sampled is data work, not ad spend.</p>
<div class="chartwrap"><div class="ct">Sample → roll by line (top 8 by recoverable backlog): gold = memo converts to a buyable roll · rose = memo hits a dead end</div><canvas id="srChart"></canvas></div>
<div class="flagbox"><div class="i">◆</div><div><h3 class="disp">Provenance — tiered honestly</h3><p><b>Tier A (measured, catalog structure):</b> the funnel counts above come straight from the read-only <code>dw_unified</code> mirror — every ACTIVE product's memo-sample and roll variant flags. Solid.<br><b>Tier B (proxy, labeled):</b> "recoverable backlog" is <i>sampleable-but-not-buyable minus the 'quotes'-tagged lines</i>. That tag under-captures reality — contract lines like Koroseal and Phillip Jeffries are quote-only in practice but untagged — so <b>${fmt(SRF.gap_backlog)} is an upper bound</b>; the truly recoverable share is a subset, weighted toward the merchant private-label and residential lines.<br><b>Tier C (absent — the recommendation):</b> real memo-order-vs-roll-order <i>behavioral</i> conversion is <b>not in this mirror</b>. It is a catalog mirror — no <code>shopify_orders</code> table; <code>mp_orders</code> is ${fmt(SRO.mp_orders?.rows)} seed rows and <code>digital_sample_orders</code> only ${fmt(SRO.digital_sample_orders?.rows)} thin digital-only rows. So the true conversion <i>rate</i> can't be computed here — <b>instrumenting the memo→roll path is itself the first move</b> (Roadmap, Weeks 3–6).</p></div></div>
<div class="chartwrap"><div class="ct">Priced-subset architecture (basis: min variant > $4.25 memo — partial, labeled)</div><canvas id="bandChart"></canvas></div>
</div></section>
<section id="desk"><div class="eyebrow">12 · Live Commerce Desk</div><h2 class="disp">The operation, on one screen.</h2><div class="accbody">
<p class="lead2">Live KPIs from the ${collectedLabel} snapshot — the numbers a weekly growth review should open with. Hit ↻ Refresh (top right) to re-collect on demand.</p>
<div class="kpis">
<div class="kpi"><div class="kv">${fmt(FF.active_total)}</div><div class="kl">Active products</div></div>
<div class="kpi"><div class="kv">${fmt(C.drafts_total)}</div><div class="kl">Staged drafts</div></div>
<div class="kpi"><div class="kv">${fmt(C.drafts_stageable)}</div><div class="kl">Stageable today (variant + image)</div></div>
<div class="kpi"><div class="kv">${fmt(SO.backlog)}</div><div class="kl">Sample-only backlog (non-quote)</div></div>
<div class="kpi"><div class="kv">${fmt(FF.zero_price)}</div><div class="kl">Zero-price actives</div></div>
<div class="kpi"><div class="kv">${fmt(FF.missing_image)}</div><div class="kl">Image-less actives</div></div>
<div class="kpi"><div class="kv">${fmt(C.vendors_active)}</div><div class="kl">Vendor lines live</div></div>
<div class="kpi"><div class="kv">${fmt(SNAP.fleet?.count)}</div><div class="kl">Fleet microsites</div></div>
</div>
<div class="chartwrap"><div class="ct">Weekly activation cadence (same data as Section 05 — the desk's pulse line)</div><canvas id="deskChart"></canvas></div>
</div></section>
<section id="roadmap"><div class="eyebrow">13 · 90-Day Roadmap</div><h2 class="disp">What to do, in order.</h2><div class="accbody">
<ul class="road">
<li><div class="w">Weeks 1–2</div><div class="d"><b>Integrity sprint:</b> triage the ${fmt(SO.backlog)} sample-only backlog by vendor (top 8 lines = ${fmt((C.sample_only_vendors || []).reduce((a, v) => a + v.count, 0))} products); fix zero-price and image-less actives. Wire per-channel UTM + coupon attribution. Pick a concept direction from Section 09.<br><span style="color:var(--gold);font-size:14px">KPI · 2,500+ sellable variants added · zero-price actives < 500 · UTM live on all 7 channels · concept direction chosen (Week-2 decision gate)</span></div></li>
<li><div class="w">Weeks 3–6</div><div class="d"><b>Convert intent:</b> ship the sampled-but-didn't-buy email sequence off the memo funnel. Sync daily social posting to the activation drip. Start the designer-net trade portal build.<br><span style="color:var(--gold);font-size:14px">KPI · first attributed sample→roll conversion · sequence live with ≥25% open rate · 7 drip-synced posts/week shipping · re-collect this brief and re-baseline (↻)</span></div></li>
<li><div class="w">Weeks 6–10</div><div class="d"><b>Ship the face:</b> launch the chosen flagship direction (guided discovery: rooms, moods, designer picks). Extend AEO blocks fleet-wide. Sign 3 unlikely-partner deals (one installer network, one stager program, one hospitality designer).<br><span style="color:var(--gold);font-size:14px">KPI · new front page live · AEO blocks on 100+ fleet sites · 3 signed partner agreements · backlog under 15,000</span></div></li>
<li><div class="w">Weeks 10–13</div><div class="d"><b>Measure & double down:</b> channel-level revenue review off the new attribution; scale the winner. Open the contract/hospitality quote desk. Publish the first licensing-line results.<br><span style="color:var(--gold);font-size:14px">KPI · every channel shows attributed revenue (or is cut) · first contract quote issued · Week-13 review: backlog, conversion rate, channel ROI vs this brief's baseline</span></div></li>
</ul>
</div></section>
<section id="sources"><div class="eyebrow">14 · Sources & Methodology</div><h2 class="disp">Where every number comes from.</h2><div class="accbody">
<p class="lead2">Snapshot collected <b>${collectedLabel}</b> by a read-only collector. Live rows are measured; analyst rows are authored judgment; anything illustrative says so.</p>
<div class="srclegend">${srcLegend}</div>
<table><thead><tr><th>Section</th><th>Where it comes from</th><th>How it's produced</th><th>Provenance</th></tr></thead><tbody>${srcRows}</tbody></table>
<div class="srcnote"><div class="i">⚙</div><div><h4>How this document is built</h4><p>Generated, not hand-typed. A read-only collector (<code>scripts/collect-dw.mjs</code>) snapshots the DW systems into <code>data/dw-analysis.json</code>; the build (<code>build.mjs</code>) renders that snapshot into this page. The ↻ Refresh button re-runs collector + build on demand. Nothing writes to any DW production system.</p></div></div>
</div></section>
<footer><span>Designer Wallcoverings · Growth Strategy Brief</span><span>Data as of ${collectedLabel}</span><span>Confidential — for discussion</span></footer>
<script>
(function(){var s=document.querySelectorAll('.hero .slide'),i=0;if(s.length<2)return;setInterval(function(){s[i].classList.remove('on');i=(i+1)%s.length;s[i].classList.add('on');},5200);})();
var REDUCE=window.matchMedia&&window.matchMedia('(prefers-reduced-motion:reduce)').matches;
function countUp(el){var t=+el.dataset.c;if(!t)return;if(REDUCE){el.textContent=t.toLocaleString();return;}var d=1300,st=null;function step(ts){st=st||ts;var p=Math.min((ts-st)/d,1),e=1-Math.pow(1-p,3);el.textContent=Math.round(t*e).toLocaleString();if(p<1)requestAnimationFrame(step);}requestAnimationFrame(step);}
var secs=[].slice.call(document.querySelectorAll('section[id]'));var accs=[];
secs.forEach(function(sec){var eb=sec.querySelector(':scope>.eyebrow'),h2=sec.querySelector(':scope>h2');if(!eb&&!h2)return;accs.push(sec);
var head=document.createElement('button');head.type='button';head.className='acchead';var txt=document.createElement('div');txt.className='htext';if(eb)txt.appendChild(eb);if(h2)txt.appendChild(h2);
var chev=document.createElement('span');chev.className='chev';chev.textContent='+';head.appendChild(txt);head.appendChild(chev);sec.insertBefore(head,sec.querySelector('.accbody'));
head.addEventListener('click',function(){var open=sec.classList.toggle('open');chev.textContent=open?'\\u2013':'+';if(open){initCharts();initMap();drawRoad(sec);}});});
function drawRoad(sec){var rd=sec.querySelector('.road');if(rd)setTimeout(function(){rd.classList.add('drawn');},140);}
function setAll(o){accs.forEach(function(s){s.classList.toggle('open',o);var c=s.querySelector('.chev');if(c)c.textContent=o?'\\u2013':'+';if(o)drawRoad(s);});if(o){initCharts();initMap();}}
document.getElementById('xall').onclick=function(){setAll(true);};document.getElementById('call').onclick=function(){setAll(false);};
if(location.hash==='#all'){setAll(true);} // deep-link: /portal#all opens the whole brief
document.querySelectorAll('.menu a[href^="#"]').forEach(function(a){a.addEventListener('click',function(){var id=a.getAttribute('href').slice(1),s=document.getElementById(id);if(s&&!s.classList.contains('open')&&s.querySelector('.acchead')){s.querySelector('.acchead').click();}if(s)setTimeout(function(){s.scrollIntoView({behavior:'smooth'});},80);});});
(function(){var burger=document.querySelector('.burger');if(!burger)return;function sync(){burger.setAttribute('aria-expanded',document.body.classList.contains('nav'));}new MutationObserver(sync).observe(document.body,{attributes:true,attributeFilter:['class']});sync();})();
var io=new IntersectionObserver(function(es){es.forEach(function(e){if(e.isIntersecting){e.target.classList.add('in');io.unobserve(e.target);}});},{threshold:.12});
document.querySelectorAll('.rv').forEach(function(el){io.observe(el);});
document.querySelectorAll('.metrics .v[data-c]').forEach(function(el){var mo=new IntersectionObserver(function(es){es.forEach(function(e){if(e.isIntersecting){countUp(el);mo.unobserve(el);}});});mo.observe(el);});
document.getElementById('refbtn').addEventListener('click',function(){var b=this;b.disabled=true;b.textContent='↻ Refreshing…';fetch('/api/refresh',{method:'POST'}).then(function(r){return r.json();}).then(function(d){if(d.ok){b.textContent='↻ Rebuilt — reloading';setTimeout(function(){location.reload();},600);}else{b.textContent='↻ '+(d.error||'unavailable here');b.disabled=false;}}).catch(function(){b.textContent='↻ Refresh unavailable';b.disabled=false;});});
var charted=false;
function initCharts(){if(charted||!window.Chart)return;charted=true;
var gold='#c9a24b',dim='rgba(241,236,227,.6)',line='rgba(201,162,75,.08)';Chart.defaults.color='rgba(241,236,227,.55)';Chart.defaults.font.family='Jost,system-ui,sans-serif';Chart.defaults.font.weight=500;Chart.defaults.font.size=11;
var RM=window.matchMedia&&window.matchMedia('(prefers-reduced-motion:reduce)').matches;
Chart.defaults.animation=RM?false:{duration:1150,easing:'easeInOutQuart'};
if(!RM){Chart.defaults.animations={y:{from:function(c){return c.chart.scales.y?c.chart.scales.y.getPixelForValue(0):0;}}};}
var D=${JSON.stringify(CHART)};
var TRD=${JSON.stringify({ labels: (TR?.monthly_trend || []).map(m => m.month), clicks: (TR?.monthly_trend || []).map(m => m.clicks) })};
function mk(id,cfg){var el=document.getElementById(id);if(el)new Chart(el,cfg);}
// Custom HTML legend for the doughnuts — thin 2x16px line markers instead of box swatches, click-to-toggle
var htmlLegend={id:'htmlLegend',afterUpdate:function(chart){
var ul=document.getElementById(chart.canvas.id+'-legend');if(!ul)return;ul.innerHTML='';
var items=chart.options.plugins.legend.labels.generateLabels(chart);
var ds=chart.data.datasets[0].data;
// Total across only the VISIBLE arcs so percentages re-normalize as rows toggle off.
var total=0;items.forEach(function(it){if(!it.hidden)total+=(+ds[it.index]||0);});
items.forEach(function(item){
var val=+ds[item.index]||0;
var pct=total>0?Math.round(val/total*100):0;
var li=document.createElement('li');if(item.hidden)li.className='off';
li.onclick=function(){chart.toggleDataVisibility(item.index);chart.update();};
var mk=document.createElement('span');mk.className='lgmk';mk.style.background=item.fillStyle;
var tx=document.createElement('span');tx.className='lgtx';tx.textContent=item.text;
var vl=document.createElement('span');vl.className='lgvl';
vl.textContent=item.hidden?'—':(val.toLocaleString()+' '+pct+'%');
li.appendChild(mk);li.appendChild(tx);li.appendChild(vl);ul.appendChild(li);
});
}};
// Vertical gold gradient for area fills — scriptable so it re-derives per chart area
function goldFill(a){return function(c){var ch=c.chart,area=ch.chartArea;if(!area)return 'rgba(201,162,75,.12)';var g=ch.ctx.createLinearGradient(0,area.top,0,area.bottom);g.addColorStop(0,'rgba(201,162,75,'+a+')');g.addColorStop(1,'rgba(201,162,75,0)');return g;};}
mk('rampChart',{type:'bar',data:{labels:D.monthly.labels,datasets:[{label:'New actives / month',data:D.monthly.added,backgroundColor:function(c){var ch=c.chart,area=ch.chartArea;if(!area)return 'rgba(201,162,75,.7)';var g=ch.ctx.createLinearGradient(0,area.bottom,0,area.top);g.addColorStop(0,'rgba(201,162,75,.25)');g.addColorStop(1,'rgba(201,162,75,.9)');return g;},borderRadius:5}]},options:{responsive:true,maintainAspectRatio:false,scales:{y:{grid:{color:line}},x:{grid:{display:false}}},plugins:{legend:{display:false}}}});
mk('gscChart',{type:'line',data:{labels:TRD.labels,datasets:[{label:'Organic clicks / month',data:TRD.clicks,borderColor:'#d4647a',backgroundColor:function(c){var ch=c.chart,area=ch.chartArea;if(!area)return 'rgba(212,100,122,.10)';var g=ch.ctx.createLinearGradient(0,area.top,0,area.bottom);g.addColorStop(0,'rgba(212,100,122,.30)');g.addColorStop(1,'rgba(212,100,122,0)');return g;},fill:true,tension:.4,pointRadius:5,pointBackgroundColor:'#d4647a',pointBorderWidth:0}]},options:{responsive:true,maintainAspectRatio:false,scales:{y:{grid:{color:line}},x:{grid:{display:false}}},plugins:{legend:{display:false}}}});
mk('needsChart',{type:'bar',data:{labels:D.needs.labels,datasets:[{label:'Actives tagged',data:D.needs.counts,backgroundColor:'rgba(212,100,122,.85)',borderRadius:5}]},options:{indexAxis:'y',responsive:true,maintainAspectRatio:false,scales:{x:{grid:{color:line}},y:{grid:{display:false}}},plugins:{legend:{display:false}}}});
mk('styleChart',{type:'bar',data:{labels:D.styles.labels,datasets:[{label:'Actives tagged',data:D.styles.counts,backgroundColor:'rgba(201,162,75,.72)',borderRadius:5}]},options:{indexAxis:'y',responsive:true,maintainAspectRatio:false,scales:{x:{grid:{color:line}},y:{grid:{display:false}}},plugins:{legend:{display:false}}}});
mk('colorChart',{type:'doughnut',plugins:[htmlLegend],data:{labels:D.colors.labels,datasets:[{data:D.colors.counts,backgroundColor:D.colors.hex,borderColor:'#0e0c09',borderWidth:2}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false},htmlLegend:{}},cutout:'58%'}});
mk('vendorChart',{type:'bar',data:{labels:D.vendors.labels,datasets:[{label:'Active products',data:D.vendors.counts,backgroundColor:'rgba(201,162,75,.75)'}]},options:{indexAxis:'y',responsive:true,maintainAspectRatio:false,scales:{x:{grid:{color:line}},y:{grid:{display:false}}},plugins:{legend:{display:false}}}});
mk('typeChart',{type:'doughnut',plugins:[htmlLegend],data:{labels:D.types.labels,datasets:[{data:D.types.counts,backgroundColor:['#c9a24b','#b78d3f','#a67c32','#d9b877','#8f6d2c','#e3c98d'],borderColor:'#0e0c09',borderWidth:2}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false},htmlLegend:{}},cutout:'58%'}});
mk('ffChart',{type:'bar',data:{labels:D.integrity.labels,datasets:[{label:'Actives failing',data:D.integrity.counts,backgroundColor:D.integrity.counts.map(function(v){return v>10000?'rgba(212,100,122,.9)':'rgba(201,162,75,.6)';})}]},options:{indexAxis:'y',responsive:true,maintainAspectRatio:false,scales:{x:{grid:{color:line}},y:{grid:{display:false}}},plugins:{legend:{display:false}}}});
mk('soChart',{type:'bar',data:{labels:D.sampleOnlyVendors.labels,datasets:[{label:'Sample-only actives',data:D.sampleOnlyVendors.counts,backgroundColor:'rgba(212,100,122,.8)'}]},options:{responsive:true,maintainAspectRatio:false,scales:{y:{grid:{color:line}},x:{grid:{display:false}}},plugins:{legend:{display:false}}}});
mk('srChart',{type:'bar',data:{labels:D.sampleRoll.labels,datasets:[{label:'Roll available (sample converts)',data:D.sampleRoll.converted,backgroundColor:'rgba(201,162,75,.8)',borderRadius:3},{label:'Recoverable backlog (memo, no roll)',data:D.sampleRoll.leak,backgroundColor:'rgba(212,100,122,.85)',borderRadius:3}]},options:{indexAxis:'y',responsive:true,maintainAspectRatio:false,scales:{x:{stacked:true,grid:{color:line}},y:{stacked:true,grid:{display:false}}},plugins:{legend:{labels:{color:dim}}}}});
mk('compChart',{type:'bar',data:{labels:D.comp.labels,datasets:[{label:'Catalog depth',data:D.comp.catalog,backgroundColor:'rgba(201,162,75,.75)'},{label:'Digital polish',data:D.comp.digital,backgroundColor:'rgba(120,170,255,.6)'}]},options:{responsive:true,maintainAspectRatio:false,scales:{y:{grid:{color:line},max:100},x:{grid:{display:false}}},plugins:{legend:{labels:{color:dim}}}}});
mk('chanChart',{type:'bar',data:{labels:D.channels.labels,datasets:[{label:'Connected accounts',data:D.channels.accounts,backgroundColor:'rgba(201,162,75,.75)'}]},options:{responsive:true,maintainAspectRatio:false,scales:{y:{grid:{color:line}},x:{grid:{display:false}}},plugins:{legend:{display:false}}}});
mk('cadenceChart',{type:'line',data:{labels:D.cadence.labels,datasets:[{label:'New actives / week',data:D.cadence.added,borderColor:gold,backgroundColor:goldFill(.30),fill:true,tension:.4,pointRadius:5,pointBackgroundColor:gold,pointBorderWidth:0}]},options:{responsive:true,maintainAspectRatio:false,scales:{y:{grid:{color:line}},x:{grid:{display:false}}},plugins:{legend:{display:false}}}});
mk('deskChart',{type:'line',data:{labels:D.cadence.labels,datasets:[{label:'New actives / week',data:D.cadence.added,borderColor:gold,backgroundColor:goldFill(.30),fill:true,tension:.4,pointRadius:5,pointBackgroundColor:gold,pointBorderWidth:0}]},options:{responsive:true,maintainAspectRatio:false,scales:{y:{grid:{color:line}},x:{grid:{display:false}}},plugins:{legend:{display:false}}}});
mk('bandChart',{type:'bar',data:{labels:D.bands.labels,datasets:[{label:'Products (priced subset)',data:D.bands.counts,backgroundColor:'rgba(201,162,75,.6)'}]},options:{responsive:true,maintainAspectRatio:false,scales:{y:{grid:{color:line}},x:{grid:{display:false}}},plugins:{legend:{display:false}}}});
}
var mapped=false;
function initMap(){if(mapped||!window.L||!document.getElementById('compmap'))return;mapped=true;
var pts=${JSON.stringify(MAP_POINTS)};
var m=L.map('compmap',{scrollWheelZoom:false}).setView([38.5,-96],4);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',{attribution:'© OpenStreetMap © CARTO',maxZoom:18}).addTo(m);
pts.forEach(function(p){var mk=L.circleMarker([p.lat,p.lng],{radius:p.you?10:7,color:p.you?'#c9a24b':'#78aaff',fillColor:p.you?'#c9a24b':'#78aaff',fillOpacity:.85,weight:1});
mk.bindPopup('<b>'+p.name+'</b><br>'+(p.note||''));mk.addTo(m);});
setTimeout(function(){m.invalidateSize();},300);
}
</script>
</body></html>`;
/* ================================================== concept version pages === */
const CONCEPT_CARDS = 10;
function versionPage(h, conceptIdx = 0) {
// Real, already-customer-facing product imagery from the snapshot gallery.
// Each of the six concepts owns a DISJOINT slice (conceptIdx*10 .. +10), so no
// two concepts share a single card — the pages read as genuinely distinct
// merchandising sets, not palette swaps (DTD OPTION 1, 5/5, 2026-07-26). The
// collector pulls 60 distinct-on-vendor products so 6×10 fits with zero overlap;
// if a snapshot ever returns fewer, this degrades to a wrap-around (some overlap).
const off = GALLERY.length ? (conceptIdx * CONCEPT_CARDS) % GALLERY.length : 0;
const pool = GALLERY.length >= CONCEPT_CARDS
? Array.from({ length: CONCEPT_CARDS }, (_, i) => GALLERY[(off + i) % GALLERY.length])
: null;
const gridCards = Array.from({ length: CONCEPT_CARDS }, (_, i) => {
const hue = [28, 152, 208, 12, 262, 88, 340, 190, 46, 300][i % 10];
const g = pool ? pool[i] : null;
const title = g ? g.title.split('|')[0].replace(/\s*Wallcoverings?\b.*$/i, '').replace(/\s*[-–,]\s*$/, '').trim() : ['Chinois Garden', 'Venetian Plaster', 'Grasscloth No. 9', 'Deco Fan', 'Midnight Damask', 'Coastal Weave', 'Rosewood Toile', 'Celadon Silk', 'Gilt Trellis', 'Amethyst Moire'][i];
// Only fields that actually exist on the gallery record (vendor + type) — no
// fabricated hue/tier. Sorts below are backed by these real values.
const vendor = g ? g.vendor : ['Atelier House', 'Maison Textile', 'Studio Mural'][i % 3];
const ptype = g ? (g.type || 'Wallcovering') : ['Wallcovering', 'Fabric', 'Mural'][i % 3];
const img = g
? `<div class="gimg"><img src="${g.image}&width=540" loading="lazy" alt="${String(title).replace(/"/g, '"')}"></div>`
: `<div class="gimg" style="background:linear-gradient(135deg,hsl(${hue},22%,${h.dark ? 24 : 78}%),hsl(${(hue + 40) % 360},30%,${h.dark ? 14 : 64}%))"></div>`;
return `<article class="gcard" data-title="${String(title).replace(/"/g, '"')}" data-vendor="${String(vendor).replace(/"/g, '"')}" data-type="${String(ptype).replace(/"/g, '"')}">
${img}
<h4>${title}</h4><div class="gmeta"><span>${vendor}</span><span class="tier">${ptype}</span></div>
</article>`;
}).join('');
return `<!doctype html><html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Designer Wallcoverings — ${h.label} concept</title>
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,600;1,400&family=Cormorant+Garamond:ital,wght@0,400;0,500;1,400&family=Inter:wght@300;400;600;800&family=Jost:wght@300;400;500&display=swap" rel="stylesheet">
<style>
:root{--bg:${h.bg};--panel:${h.panel};--ink:${h.ink};--gold:${h.gold};--line:${h.line};--cols:4}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg);color:var(--ink);font-family:${h.sans};font-size:16px;line-height:1.55;-webkit-font-smoothing:antialiased}
.disp{font-family:${h.disp}}a{color:inherit;text-decoration:none}
.badge{position:fixed;left:16px;bottom:16px;z-index:60;background:var(--gold);color:${h.dark ? '#0b0b0c' : '#fff'};font-size:10px;letter-spacing:.2em;text-transform:uppercase;padding:7px 13px;border-radius:2px}
.rv{opacity:0;transform:translateY(24px);transition:opacity .7s cubic-bezier(.2,.7,.2,1),transform .7s cubic-bezier(.2,.7,.2,1)}.rv.in{opacity:1;transform:none}
header{max-width:1240px;margin:0 auto;padding:26px 22px;display:flex;justify-content:space-between;align-items:flex-end;border-bottom:2px solid var(--gold)}
.logo{font-family:${h.disp};font-size:30px;letter-spacing:-.3px}.logo span{color:var(--gold)}
.logo small{display:block;font-size:9.5px;letter-spacing:.32em;text-transform:uppercase;color:var(--gold);margin-top:3px;font-family:${h.sans};font-weight:400}
nav.sec{border-bottom:1px solid var(--line);position:sticky;top:0;z-index:20;background:var(--bg)}
nav.sec .in{max-width:1240px;margin:0 auto;display:flex;gap:2px;overflow:auto;padding:0 12px}
nav.sec a{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;padding:15px 14px;white-space:nowrap;border-bottom:3px solid transparent}
nav.sec a:hover{color:var(--gold);border-color:var(--gold)}
.heroc{max-width:1240px;margin:0 auto;padding:54px 22px;display:grid;grid-template-columns:1.25fr 1fr;gap:44px;align-items:center}
.heroc h1{font-family:${h.disp};font-size:clamp(34px,4.6vw,58px);line-height:1.06}
.heroc p{margin-top:16px;opacity:.75;font-size:17px;max-width:52ch}
.cta{display:inline-block;margin-top:26px;border:1px solid var(--gold);color:var(--gold);font-size:12px;letter-spacing:.22em;text-transform:uppercase;padding:13px 26px;border-radius:999px;transition:.25s}
.cta:hover{background:var(--gold);color:${h.dark ? '#0b0b0c' : '#fff'}}
.heroimg{aspect-ratio:4/3;border-radius:6px;background:linear-gradient(135deg,var(--panel),var(--gold));overflow:hidden}
.heroimg img{width:100%;height:100%;object-fit:cover;display:block}
.wrap{max-width:1240px;margin:0 auto;padding:0 22px}
.sh{display:flex;align-items:center;justify-content:space-between;gap:18px;border-top:3px solid var(--gold);padding-top:12px;margin:34px 0 8px;flex-wrap:wrap}
.sh h2{font-size:15px;text-transform:uppercase;letter-spacing:.08em;font-weight:600}
.controls{display:flex;gap:14px;align-items:center;font-size:12px}
.controls label{opacity:.7;letter-spacing:.08em;text-transform:uppercase;font-size:10.5px}
.controls .selwrap{position:relative;display:inline-flex;align-items:center}
.controls .selwrap:after{content:'';position:absolute;right:13px;top:calc(50% - 4px);width:6px;height:6px;border-right:1.5px solid var(--gold);border-bottom:1.5px solid var(--gold);transform:rotate(45deg);transform-origin:center;pointer-events:none;transition:transform .2s ease,border-color .2s}
.controls select{appearance:none;-webkit-appearance:none;-moz-appearance:none;background:var(--panel);color:var(--ink);border:1px solid var(--line);padding:7px 32px 7px 13px;border-radius:4px;font-family:${h.sans};font-size:12px;letter-spacing:.04em;cursor:pointer;transition:border-color .2s,box-shadow .2s}
.controls select:hover{border-color:var(--gold)}
.controls select:focus-visible{outline:none;border-color:var(--gold);box-shadow:inset 0 0 0 1px var(--gold)}
.controls .selwrap:hover:after{border-color:var(--gold);transform:rotate(45deg) translate(1px,1px)}
/* Density slider — styled to the same gold system as the select (no raw native slider) */
.controls input[type=range]{appearance:none;-webkit-appearance:none;width:110px;height:22px;background:transparent;cursor:pointer;vertical-align:middle}
.controls input[type=range]:focus{outline:none}
.controls input[type=range]::-webkit-slider-runnable-track{height:3px;border-radius:2px;background:linear-gradient(90deg,var(--gold) 0,var(--gold) var(--fill,50%),var(--line) var(--fill,50%))}
.controls input[type=range]::-moz-range-track{height:3px;border-radius:2px;background:var(--line)}
.controls input[type=range]::-moz-range-progress{height:3px;border-radius:2px;background:var(--gold)}
.controls input[type=range]::-webkit-slider-thumb{appearance:none;-webkit-appearance:none;width:13px;height:13px;margin-top:-5px;border-radius:50%;background:var(--gold);border:2px solid var(--panel);box-shadow:0 0 0 1px var(--line);transition:transform .15s ease,box-shadow .15s ease}
.controls input[type=range]::-moz-range-thumb{width:13px;height:13px;border-radius:50%;background:var(--gold);border:2px solid var(--panel);box-shadow:0 0 0 1px var(--line)}
.controls input[type=range]:hover::-webkit-slider-thumb,.controls input[type=range]:focus-visible::-webkit-slider-thumb{transform:scale(1.12);box-shadow:0 0 0 1px var(--gold)}
.controls input[type=range]:hover::-moz-range-thumb,.controls input[type=range]:focus-visible::-moz-range-thumb{box-shadow:0 0 0 1px var(--gold)}
.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:20px;padding:18px 0 40px}
.gcard .gimg{aspect-ratio:3/4;border-radius:5px;transition:.3s;overflow:hidden;background:var(--panel)}.gcard:hover .gimg{transform:translateY(-4px);box-shadow:0 14px 40px rgba(0,0,0,.28)}
.gcard .gimg img{width:100%;height:100%;object-fit:cover;display:block}
.gcard h4{font-family:${h.disp};font-size:16.5px;margin:10px 0 2px}
.gmeta{display:flex;justify-content:space-between;font-size:11px;opacity:.65;letter-spacing:.06em;text-transform:uppercase}
.tier{color:var(--gold)}
.ns{max-width:1240px;margin:10px auto 0;padding:44px 22px;border-top:1px solid var(--line);display:grid;grid-template-columns:1fr 1fr;gap:30px;align-items:center}
.ns h3{font-family:${h.disp};font-size:26px}.ns p{opacity:.7;font-size:14px;margin-top:8px}
.ns form{display:flex;gap:10px}.ns input[type=email]{flex:1;padding:13px;border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:4px;font-size:14px}
.ns button{padding:13px 22px;background:var(--gold);color:${h.dark ? '#0b0b0c' : '#fff'};border:0;border-radius:4px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;font-size:11px;cursor:pointer}
footer{border-top:1px solid var(--line);margin-top:14px}
footer .in{max-width:1240px;margin:0 auto;padding:36px 22px;display:flex;justify-content:space-between;flex-wrap:wrap;gap:16px;font-size:12px;opacity:.65}
@media(max-width:960px){.heroc{grid-template-columns:1fr}.ns{grid-template-columns:1fr}}
@media(max-width:720px){:root{--cols:2!important}}
</style></head><body>
<a class="badge" href="/portal#suggested">◀ Concept · ${h.model} direction</a>
<header><div class="logo">Designer <span>Wallcoverings</span><small>${h.label} · ${h.tag}</small></div><div style="font-size:12px;opacity:.65;text-align:right">Los Angeles · To the Trade<br>Samples $4.25 · Free shipping</div></header>
<nav class="sec"><div class="in"><a>Wallcoverings</a><a>Fabrics</a><a>Murals</a><a>By Color</a><a>By Room</a><a>New Arrivals</a><a>Trade Program</a><a>Samples</a></div></nav>
<div class="heroc">
<div class="rv"><h1 class="disp">${h.tag}</h1><p>${h.mood} A curated face for the deepest designer-wallcovering catalog online — every pattern sampled for $4.25, every order backed by thirty years of trade expertise.</p><a class="cta">Explore the collection</a></div>
<div class="heroimg rv">${pool ? `<img src="${pool[(conceptIdx + 2) % pool.length].image}&width=1100" alt="" loading="eager">` : ''}</div>
</div>
<div class="wrap">
<div class="sh"><h2>Curated This Week</h2>
<div class="controls">
<label for="sortsel">Sort</label>
<span class="selwrap"><select id="sortsel"><option value="newest">Newest</option><option value="title">Title A→Z</option><option value="vendor">Vendor A→Z</option><option value="type">Type</option></select></span>
<label for="dens">Density</label>
<input type="range" id="dens" min="2" max="6" step="1" value="4">
</div>
</div>
<div class="grid" id="grid">${gridCards}</div>
</div>
<div class="ns"><div><h3 class="disp">The Designer's Brief</h3><p>New arrivals, designer picks, and pattern stories — weekly, from Los Angeles.</p></div><form onsubmit="return false"><input type="email" placeholder="you@studio.com"><button>Subscribe</button></form></div>
<footer><div class="in"><span>Designer Wallcoverings — concept "${h.label}" (${h.model} direction) · placeholder art for layout</span><span>One of six directions · <a href="/portal#suggested" style="color:var(--gold)">back to the brief</a></span></div></footer>
<script>
var io=new IntersectionObserver(function(es){es.forEach(function(e){if(e.isIntersecting){e.target.classList.add('in');io.unobserve(e.target);}});},{threshold:.12});
document.querySelectorAll('.rv').forEach(function(el){io.observe(el);});
// Sort + density — the house rule: functional, persisted.
var grid=document.getElementById('grid'),sel=document.getElementById('sortsel'),dens=document.getElementById('dens');
var KS='dwconcept-${h.key}-sort',KD='dwconcept-${h.key}-cols';
function apply(){var v=sel.value,cards=[].slice.call(grid.children);
cards.sort(function(a,b){if(v==='title')return a.dataset.title.localeCompare(b.dataset.title);
if(v==='vendor')return a.dataset.vendor.localeCompare(b.dataset.vendor);
if(v==='type')return (a.dataset.type||'').localeCompare(b.dataset.type||'')||a.dataset.title.localeCompare(b.dataset.title);
return 0;});
cards.forEach(function(c){grid.appendChild(c);});localStorage.setItem(KS,v);}
function densFill(){var mn=+dens.min||2,mx=+dens.max||6;dens.style.setProperty('--fill',((dens.value-mn)/(mx-mn)*100)+'%');}
sel.addEventListener('change',apply);
dens.addEventListener('input',function(){document.documentElement.style.setProperty('--cols',dens.value);densFill();localStorage.setItem(KD,dens.value);});
var sv=localStorage.getItem(KS),dv=localStorage.getItem(KD);
if(sv){sel.value=sv;apply();}if(dv){dens.value=dv;document.documentElement.style.setProperty('--cols',dv);}
densFill();
</script>
</body></html>`;
}
writeFileSync(`${OUT}/portal.html`, app);
HOUSES.forEach((h, i) => writeFileSync(`${OUT}/versions/${h.key}.html`, versionPage(h, i)));
console.log(`Wrote portal.html (14-section DW analysis app, data of ${collectedLabel}) + ${HOUSES.length} concepts: ${HOUSES.map(h => h.key).join(', ')}`);