[object Object]

← back to Microsite Engine

microsite-engine: config-generator dashboard driving dw-domain-fleet (live niche preview + schema-exact sites/<slug>.json emit, gated)

f2735d8ae5967d1005f8708bb3c24a852e0a33e5 · 2026-07-16 20:55:18 -0700 · Steve

Files touched

Diff

commit f2735d8ae5967d1005f8708bb3c24a852e0a33e5
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 16 20:55:18 2026 -0700

    microsite-engine: config-generator dashboard driving dw-domain-fleet (live niche preview + schema-exact sites/<slug>.json emit, gated)
---
 .gitignore         |   2 +
 BRIEF.md           |  32 +++++-
 README.md          |  43 +++++---
 data/products.json |   6 --
 package.json       |   8 ++
 public/index.html  | 280 +++++++++++++++++++++++++++++++++++++++++++----------
 server.js          | 273 +++++++++++++++++++++++++++++++++++++++++++--------
 7 files changed, 535 insertions(+), 109 deletions(-)

diff --git a/.gitignore b/.gitignore
index 1924158..da710c4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,5 @@ tmp/
 dist/
 build/
 .next/
+output/*.json
+data/drafts.json
diff --git a/BRIEF.md b/BRIEF.md
index 12d3780..070c941 100644
--- a/BRIEF.md
+++ b/BRIEF.md
@@ -1,5 +1,29 @@
 # microsite-engine — brief
-- Client:
-- Outcome / "high value":
-- Constraints (brand, domain, deadline):
-- Reference / competitor URLs:
+
+**Built via the Claude Web-Dev Accelerator (Phase 0 → scaffold → local viewer). Picked from the accelerator idea slate 2026-07-16.**
+
+## Client / for whom
+Steve — site #201 in minutes instead of hand-building. Productizable later as a SaaS ("spin up your own branded catalog site").
+
+## Outcome / high value
+Highest compounding leverage on the slate: turns the 200+-site DW sister-microsite fleet from **hand-built** into **generated**. Today a new microsite means hand-editing a `sites/<slug>.json`, guessing the niche keywords, and hoping the product count is right. This makes it: point → preview live against all 15k real products → emit a validated, deploy-ready config.
+
+## Shape
+A local dashboard (Express + single-page UI) that **drives the existing `dw-domain-fleet` platform — it does not reinvent it.**
+
+- Loads the fleet's real `data/catalog.json` (≈15k products) + reuses its `shared/catalog.js` (`nicheSlice`) and `shared/sort.js` (`sortProducts`) so preview logic is **byte-identical to production**.
+- Form to define a new microsite: slug, domain, siteName, tagline, hero headline/sub, niche (`pos`/`neg` keywords + product `types`), theme (accent, palette, fonts), hero mode.
+- **Live niche preview**: match count + a product grid (with the house **sort + density slider**) that updates as you edit the niche — real products, real logic.
+- **Facets helper**: real product-types + top tags pulled from the catalog so you pick from real data, not guesses.
+- **Generate**: emits `sites/<slug>.json` in the fleet's **exact schema** + next free port + collision guard (warns if slug/domain/port already used in the live fleet).
+- **Saved drafts dashboard**: admin cards, each showing **created date + time** (house rule), niche summary, live match count, status.
+
+## Data
+Read-only against `~/Projects/dw-domain-fleet/data/catalog.json` + `shared/`. $0-local, no AI, no scrape, no DB.
+
+## Constraints / gates (hard)
+- House rules: sort + density on the grid; admin cards show created date+time; `.gitignore` + gitify on scaffold; preview behind basic auth `admin / DW2024!` via `/viewer`.
+- **Never writes into the live fleet `sites/` dir; never deploys; never touches DNS.** Generated configs land in this project's own `output/` dir + a copy-command is surfaced for Steve to run. All gated.
+
+## First clickable target (this session)
+Dashboard → define niche → live match count + grid preview → generate a validated `sites/<slug>.json` → saved-drafts card. Served locally, previewable via `/viewer`.
diff --git a/README.md b/README.md
index 9c6f5b4..4dd30e3 100644
--- a/README.md
+++ b/README.md
@@ -1,19 +1,38 @@
-# Project
+# microsite-engine
 
-Scaffolded by the Claude web-dev accelerator. Storefront starter with a
-server-side-sorted product grid + density slider (both persist in localStorage).
+A config-generator dashboard that **drives the `dw-domain-fleet`** — the platform
+that runs the 40+ generic DW sister microsites (one shared Express app, one
+`sites/<slug>.json` per site, over a shared `data/catalog.json`).
+
+Instead of hand-editing a `sites/<slug>.json` and guessing the niche keywords,
+you: define the site → **preview the exact product grid live against all ~15k real
+products** (identical `nicheSlice`/`sortProducts` logic to production) → emit a
+validated, deploy-ready `sites/<slug>.json`.
+
+It **reinvents nothing** — it `require()`s the fleet's own `shared/catalog.js` and
+`shared/sort.js` and reads its `data/catalog.json`, all read-only.
 
 ## Run
 ```bash
-PORT=3900 node server.js                                # open http://localhost:3900
-BASIC_AUTH="admin:DW2024!" PORT=3900 node server.js     # gated
+node server.js                 # http://localhost:9770  (admin / DW2024!)
+FLEET_DIR=/path/to/dw-domain-fleet node server.js   # point at a different fleet checkout
+NO_AUTH=1 node server.js       # drop basic auth (local only)
 ```
 
-## Fill it in
-- `data/products.json` — array of `{ title, sku, price, hex, image }`.
-- `server.js` `sortProducts()` — add sort modes as needed.
-- `public/index.html` — the grid + controls.
+## What it does
+- **Live niche preview** — match count + a sortable, density-adjustable product grid
+  that updates as you edit the include/exclude keywords + product types.
+- **Facets helper** — real product-types + top tags pulled from the catalog, click to add.
+- **Generate** — writes `output/<slug>.json` in the fleet's **exact schema**, assigns the
+  next free port, and runs a **collision guard** (warns if slug/domain/port already
+  exists in the live fleet).
+- **Saved drafts** — admin cards, each with created date + time, niche summary, live match count.
+
+## Hard gates (by design)
+- **Never writes into the live fleet `sites/` dir. Never deploys. Never touches DNS.**
+- Generated configs land in this project's `output/`; a `cp … sites/<slug>.json` command is
+  surfaced for Steve to run (staging into the fleet is a manual, gated step).
 
-## Preview publicly
-From the accelerator dir: `scripts/preview-tunnel.sh <slug> <port>` stands up
-`<slug>.agentabrams.com` (basic-auth gated). The DNS step is Steve-gated.
+## Endpoints
+`/api/facets` · `/api/preview` (POST) · `/api/generate` (POST) · `/api/live-sites` ·
+`/api/drafts` · `/api/delete-draft` (POST) — all behind basic auth `admin / DW2024!`.
diff --git a/data/products.json b/data/products.json
deleted file mode 100644
index 8eee5f2..0000000
--- a/data/products.json
+++ /dev/null
@@ -1,6 +0,0 @@
-[
-  { "title": "Sample One", "sku": "SMP-001", "price": 129.00, "hex": "#2b3a55", "image": "" },
-  { "title": "Sample Two", "sku": "SMP-002", "price": 89.00, "hex": "#d8c3a5", "image": "" },
-  { "title": "Sample Three", "sku": "SMP-003", "price": 210.00, "hex": "#8e8d8a", "image": "" },
-  { "title": "Sample Four", "sku": "SMP-004", "price": 54.00, "hex": "#e98074", "image": "" }
-]
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..7010ba9
--- /dev/null
+++ b/package.json
@@ -0,0 +1,8 @@
+{
+  "name": "microsite-engine",
+  "version": "0.1.0",
+  "private": true,
+  "description": "Config-generator dashboard that drives the dw-domain-fleet — preview a niche live against the real catalog, emit a deploy-ready sites/<slug>.json. Never writes the live fleet, never deploys.",
+  "main": "server.js",
+  "scripts": { "start": "node server.js" }
+}
diff --git a/public/index.html b/public/index.html
index ceb7ab1..0e8f880 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1,55 +1,237 @@
-<!doctype html><html lang="en"><head><meta charset="utf-8">
-<meta name="viewport" content="width=device-width,initial-scale=1"><title>Storefront starter</title>
+<!doctype html><html lang="en"><head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Microsite Engine — DW fleet site generator</title>
 <style>
-  :root{--cols:4}
-  *{box-sizing:border-box} body{margin:0;font:15px/1.4 -apple-system,Segoe UI,Roboto,sans-serif;color:#1a1a1a;background:#fafafa}
-  header{display:flex;gap:14px;align-items:center;flex-wrap:wrap;padding:14px 20px;border-bottom:1px solid #e6e6e6;position:sticky;top:0;background:#fff;z-index:5}
-  h1{font-size:17px;margin:0;font-weight:700} .spacer{flex:1}
-  label{font-size:12px;color:#666;display:flex;gap:6px;align-items:center}
-  select,input[type=range]{font:inherit} .count{font-size:12px;color:#999}
-  .grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:14px;padding:20px}
-  .card{background:#fff;border:1px solid #e6e6e6;border-radius:8px;overflow:hidden}
-  .card img{width:100%;aspect-ratio:1/1;object-fit:cover;display:block;background:#f0f0f0}
-  .meta{padding:8px 10px} .t{font-size:13px;font-weight:600;margin:0 0 2px} .s{font-size:11px;color:#888}
-  .p{font-size:13px;margin-top:4px} .empty{padding:40px;color:#999;text-align:center}
-</style></head><body>
+  :root{ --bg:#12100d; --panel:#1b1813; --ink:#f1ece2; --dim:#9c9484; --accent:#c98a3a; --rule:#332e26; --good:#5fae6b; --warn:#d98a3a; --bad:#d15b5b;
+    --serif:'Cormorant Garamond',Georgia,serif; --sans:-apple-system,'Inter',system-ui,sans-serif; --cols:5; }
+  *{box-sizing:border-box}
+  body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 var(--sans)}
+  header{padding:18px 24px;border-bottom:1px solid var(--rule);display:flex;align-items:center;gap:16px;position:sticky;top:0;background:var(--bg);z-index:5}
+  header h1{font:600 22px var(--serif);margin:0;letter-spacing:.02em}
+  header .sub{color:var(--dim);font-size:13px}
+  .badge{margin-left:auto;font-size:12px;color:var(--dim);border:1px solid var(--rule);border-radius:20px;padding:4px 12px}
+  .wrap{display:grid;grid-template-columns:380px 1fr;gap:0;min-height:calc(100vh - 61px)}
+  .form{padding:20px 24px;border-right:1px solid var(--rule);overflow-y:auto;max-height:calc(100vh - 61px)}
+  .main{padding:20px 24px;overflow-y:auto;max-height:calc(100vh - 61px)}
+  label{display:block;font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--dim);margin:14px 0 5px}
+  input,select,textarea{width:100%;background:var(--panel);border:1px solid var(--rule);color:var(--ink);border-radius:6px;padding:8px 10px;font:14px var(--sans)}
+  textarea{resize:vertical;min-height:44px}
+  .row{display:grid;grid-template-columns:1fr 1fr;gap:10px}
+  .chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}
+  .chip{font-size:12px;padding:4px 9px;border:1px solid var(--rule);border-radius:16px;background:var(--panel);color:var(--dim);cursor:pointer;user-select:none}
+  .chip:hover{border-color:var(--accent);color:var(--ink)}
+  .chip.on{background:var(--accent);color:#191510;border-color:var(--accent);font-weight:600}
+  .chip .n{opacity:.6;margin-left:4px}
+  button{cursor:pointer;font:600 14px var(--sans);border-radius:6px;border:1px solid var(--accent);background:var(--accent);color:#191510;padding:10px 16px}
+  button.ghost{background:transparent;color:var(--ink);border-color:var(--rule)}
+  button.ghost:hover{border-color:var(--accent)}
+  .count{font:600 40px var(--serif);color:var(--accent);line-height:1}
+  .count small{font:400 14px var(--sans);color:var(--dim);display:block;text-transform:uppercase;letter-spacing:.1em;margin-top:2px}
+  .toolbar{display:flex;align-items:center;gap:14px;margin:14px 0;flex-wrap:wrap}
+  .toolbar .sp{margin-left:auto}
+  .grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:12px}
+  .card{background:var(--panel);border:1px solid var(--rule);border-radius:8px;overflow:hidden}
+  .card .img{aspect-ratio:1/1;background:#0c0a08 center/cover no-repeat}
+  .card .meta{padding:7px 9px;font-size:12px}
+  .card .t{color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+  .card .r{color:var(--dim);display:flex;align-items:center;gap:6px;margin-top:3px}
+  .dot{width:10px;height:10px;border-radius:50%;border:1px solid #0006;display:inline-block}
+  .section-title{font:600 15px var(--serif);letter-spacing:.03em;margin:26px 0 4px;border-bottom:1px solid var(--rule);padding-bottom:6px}
+  .drafts{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;margin-top:12px}
+  .draft{background:var(--panel);border:1px solid var(--rule);border-radius:8px;padding:12px 14px}
+  .draft h4{margin:0 0 2px;font:600 16px var(--serif)}
+  .draft .dom{color:var(--accent);font-size:13px}
+  .draft .when{color:var(--dim);font-size:11.5px;margin:6px 0}
+  .draft .k{color:var(--dim);font-size:12px;margin:2px 0}
+  .draft .st{display:inline-block;font-size:10.5px;text-transform:uppercase;letter-spacing:.08em;padding:2px 8px;border-radius:12px;margin-top:6px}
+  .st.draft{background:#2a3a2a;color:var(--good)} .st.conflict{background:#3a2a2a;color:var(--bad)}
+  .draft .acts{display:flex;gap:8px;margin-top:10px}
+  .draft .acts button{font-size:12px;padding:6px 10px}
+  pre{background:#0c0a08;border:1px solid var(--rule);border-radius:6px;padding:12px;overflow-x:auto;font-size:12px;color:#cfc7b6}
+  .cmd{background:#0c0a08;border:1px dashed var(--accent);border-radius:6px;padding:9px 11px;font-family:ui-monospace,monospace;font-size:12px;color:var(--accent);word-break:break-all;margin-top:8px}
+  .note{color:var(--dim);font-size:12px;margin-top:6px}
+  .banner{background:#3a2a2a;border:1px solid var(--bad);color:#f0c9c9;padding:10px 14px;border-radius:6px;margin-bottom:14px;font-size:13px}
+  dialog{background:var(--panel);color:var(--ink);border:1px solid var(--accent);border-radius:10px;max-width:640px;width:92%}
+  dialog::backdrop{background:#000a}
+</style></head>
+<body>
 <header>
-  <h1>Storefront starter</h1>
-  <label>Sort
-    <select id="sort">
-      <option value="newest">Newest</option>
-      <option value="title">Title A→Z</option>
-      <option value="sku">SKU A→Z</option>
-      <option value="price-asc">Price ↑</option>
-      <option value="price-desc">Price ↓</option>
-      <option value="light">Light → Dark</option>
-      <option value="dark">Dark → Light</option>
-    </select>
-  </label>
-  <label>Density <input id="density" type="range" min="2" max="8" value="4"></label>
-  <span class="spacer"></span><span class="count" id="count"></span>
+  <h1>Microsite Engine</h1>
+  <span class="sub">generate a DW sister-microsite config over the live catalog · drives <code>dw-domain-fleet</code></span>
+  <span class="badge" id="fleetBadge">…</span>
 </header>
-<div class="grid" id="grid"><div class="empty">Loading…</div></div>
+
+<div class="wrap">
+  <!-- ============ FORM ============ -->
+  <div class="form">
+    <div id="fleetErr"></div>
+    <label>Slug (subdomain, a–z0–9-)</label>
+    <input id="slug" placeholder="corkwallcovering" autocomplete="off">
+    <label>Domain</label>
+    <input id="domain" placeholder="corkwallcovering.com" autocomplete="off">
+    <label>Site name</label>
+    <input id="siteName" placeholder="Cork Wallcovering" autocomplete="off">
+    <label>Tagline</label>
+    <input id="tagline" placeholder="Hand-crafted cork wallcoverings." autocomplete="off">
+    <div class="row">
+      <div><label>Hero headline</label><input id="heroHeadline" placeholder="Cork Wallcovering"></div>
+      <div><label>Hero sub</label><input id="heroSub" placeholder="Explore the collection"></div>
+    </div>
+
+    <div class="section-title">Niche — which products this site carries</div>
+    <label>Include keywords <span class="note">(any-match on title+tags+type)</span></label>
+    <input id="pos" placeholder="cork, natural" autocomplete="off">
+    <div class="chips" id="tagChips"></div>
+    <label>Exclude keywords</label>
+    <input id="neg" placeholder="commercial" autocomplete="off">
+    <label>Product types</label>
+    <div class="chips" id="typeChips"></div>
+
+    <div class="section-title">Look</div>
+    <div class="row">
+      <div><label>Palette</label><select id="palette"></select></div>
+      <div><label>Font</label><select id="font"></select></div>
+    </div>
+
+    <div style="margin-top:20px;display:flex;gap:10px">
+      <button id="genBtn">Generate config →</button>
+      <button class="ghost" id="resetBtn">Reset</button>
+    </div>
+    <div class="note">Writes a deploy-ready <code>sites/&lt;slug&gt;.json</code> to this project's <code>output/</code> — never the live fleet, never deploys.</div>
+  </div>
+
+  <!-- ============ MAIN ============ -->
+  <div class="main">
+    <div class="toolbar">
+      <div><div class="count"><span id="cnt">0</span><small>products match this niche</small></div></div>
+      <div class="sp"></div>
+      <label style="margin:0">Sort</label>
+      <select id="sort" style="width:auto">
+        <option value="newest">Newest</option>
+        <option value="color">Color</option>
+        <option value="light-dark">Light → Dark</option>
+        <option value="dark-light">Dark → Light</option>
+        <option value="style">Style</option>
+        <option value="title">Title A→Z</option>
+        <option value="sku">SKU A→Z</option>
+        <option value="price-asc">Price ↑</option>
+        <option value="price-desc">Price ↓</option>
+      </select>
+      <label style="margin:0">Density</label>
+      <input id="density" type="range" min="3" max="8" value="5" style="width:120px">
+    </div>
+    <div class="grid" id="grid"></div>
+    <p class="note" id="gridNote"></p>
+
+    <div class="section-title">Saved microsite drafts</div>
+    <div class="drafts" id="drafts"></div>
+  </div>
+</div>
+
+<dialog id="genDlg">
+  <div style="padding:20px 22px">
+    <h3 style="margin:0 0 4px;font:600 20px var(--serif)" id="dlgTitle">Config generated</h3>
+    <div id="dlgClash"></div>
+    <p class="note">Emitted to <code id="dlgPath"></code> — matches <b id="dlgCount">0</b> products, port <b id="dlgPort"></b>.</p>
+    <p class="note" style="margin-top:12px">To stage into the live fleet, run (gated — you run it):</p>
+    <div class="cmd" id="dlgCmd"></div>
+    <details style="margin-top:12px"><summary class="note" style="cursor:pointer">view sites/&lt;slug&gt;.json</summary><pre id="dlgJson"></pre></details>
+    <div style="text-align:right;margin-top:16px"><button class="ghost" onclick="genDlg.close()">Close</button></div>
+  </div>
+</dialog>
+
 <script>
-// House rule: sort + density both persist in localStorage so the choice survives reloads.
-const $=s=>document.querySelector(s), esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
-const sortSel=$('#sort'), dens=$('#density');
-sortSel.value=localStorage.getItem('sort')||'newest'; dens.value=localStorage.getItem('cols')||'4';
-function applyCols(){document.documentElement.style.setProperty('--cols',dens.value);localStorage.setItem('cols',dens.value);}
-applyCols();
-async function load(){
-  const r=await fetch('/api/products?sort='+encodeURIComponent(sortSel.value)); const d=await r.json();
-  $('#count').textContent=d.count+' items';
-  $('#grid').innerHTML = d.products.length ? d.products.map(p=>`
+const $ = id => document.getElementById(id);
+let FACETS = { typeList:[], tagList:[], palettes:[], fonts:[] };
+const selTypes = new Set();
+
+async function boot(){
+  FACETS = await (await fetch('/api/facets')).json();
+  $('fleetBadge').textContent = FACETS.fleetOk ? (FACETS.total.toLocaleString()+' products · fleet linked') : 'FLEET NOT LINKED';
+  if(!FACETS.fleetOk){
+    $('fleetErr').innerHTML = '<div class="banner">Could not load dw-domain-fleet shared modules: '+(FACETS.fleetErr||'')+'<br>Preview will be empty until the fleet is reachable.</div>';
+  }
+  $('palette').innerHTML = FACETS.palettes.map(p=>`<option>${p}</option>`).join('');
+  $('font').innerHTML = FACETS.fonts.map(f=>`<option>${f}</option>`).join('');
+  // type chips
+  $('typeChips').innerHTML = FACETS.typeList.map(t=>`<span class="chip" data-type="${t.name}">${t.name}<span class="n">${t.n}</span></span>`).join('');
+  document.querySelectorAll('#typeChips .chip').forEach(c=>c.onclick=()=>{
+    const t=c.dataset.type; c.classList.toggle('on'); if(selTypes.has(t))selTypes.delete(t);else selTypes.add(t); doPreview();
+  });
+  // top tag chips (click to append to include)
+  $('tagChips').innerHTML = FACETS.tagList.slice(0,28).map(t=>`<span class="chip" data-tag="${t.name}">${t.name}<span class="n">${t.n}</span></span>`).join('');
+  document.querySelectorAll('#tagChips .chip').forEach(c=>c.onclick=()=>{
+    const cur=$('pos').value.split(',').map(s=>s.trim()).filter(Boolean);
+    const t=c.dataset.tag.toLowerCase();
+    if(cur.map(s=>s.toLowerCase()).includes(t)){cur.splice(cur.findIndex(s=>s.toLowerCase()===t),1);c.classList.remove('on');}
+    else{cur.push(c.dataset.tag);c.classList.add('on');}
+    $('pos').value=cur.join(', '); doPreview();
+  });
+  loadDrafts(); doPreview();
+}
+
+let pvTimer;
+function doPreviewDebounced(){ clearTimeout(pvTimer); pvTimer=setTimeout(doPreview,250); }
+async function doPreview(){
+  const body={ pos:$('pos').value, neg:$('neg').value, types:[...selTypes], sort:$('sort').value };
+  const r = await (await fetch('/api/preview',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})).json();
+  $('cnt').textContent = (r.count||0).toLocaleString();
+  $('grid').innerHTML = (r.cards||[]).map(c=>`
     <div class="card">
-      <img src="${esc(p.image||'')}" alt="${esc(p.title||'')}" loading="lazy">
-      <div class="meta"><p class="t">${esc(p.title||'Untitled')}</p>
-        <div class="s">${esc(p.sku||p.handle||'')}</div>
-        ${p.price?`<div class="p">$${Number(p.price).toFixed(2)}</div>`:''}
-      </div>
-    </div>`).join('') : '<div class="empty">No products yet — fill data/products.json.</div>';
+      <div class="img" style="background-image:url('${(c.image_url||'').replace(/'/g,'')}')"></div>
+      <div class="meta"><div class="t" title="${esc(c.title)}">${esc(c.title)}</div>
+      <div class="r"><span class="dot" style="background:${c.hex}"></span>${esc(c.product_type||'')}</div></div>
+    </div>`).join('');
+  $('gridNote').textContent = r.count>r.shown ? `showing first ${r.shown} of ${r.count.toLocaleString()}` : (r.count?'':'no products match — loosen the niche');
+}
+function esc(s){return String(s||'').replace(/[&<>"]/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[m]));}
+
+async function loadDrafts(){
+  const {drafts} = await (await fetch('/api/drafts')).json();
+  $('drafts').innerHTML = drafts.length ? drafts.slice().reverse().map(d=>{
+    const dt = new Date(d.created_at).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
+    const niche = [d.niche.pos.length?('+'+d.niche.pos.join('/')):'', d.niche.neg.length?('−'+d.niche.neg.join('/')):'', d.niche.types.join('/')].filter(Boolean).join(' · ')||'all products';
+    return `<div class="draft">
+      <h4>${esc(d.siteName)}</h4><div class="dom">${esc(d.domain)}</div>
+      <div class="when" title="${d.created_at}">🕓 ${dt}</div>
+      <div class="k">niche: ${esc(niche)}</div>
+      <div class="k">${(d.matchCount||0).toLocaleString()} products · port ${d.port}</div>
+      <span class="st ${d.status}">${d.status}${d.clashes&&d.clashes.length?' — '+esc(d.clashes.join(', ')):''}</span>
+      <div class="acts"><button class="ghost" data-load="${d.slug}">Load</button><button class="ghost" data-del="${d.slug}">Delete</button></div>
+    </div>`;
+  }).join('') : '<p class="note">No drafts yet — generate one to see it here.</p>';
+  document.querySelectorAll('[data-del]').forEach(b=>b.onclick=async()=>{ await fetch('/api/delete-draft',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({slug:b.dataset.del})}); loadDrafts(); });
+  document.querySelectorAll('[data-load]').forEach(b=>b.onclick=async()=>{
+    const {drafts}=await (await fetch('/api/drafts')).json(); const d=drafts.find(x=>x.slug===b.dataset.load); if(!d)return;
+    $('slug').value=d.slug;$('domain').value=d.domain;$('siteName').value=d.siteName;
+    $('pos').value=d.niche.pos.join(', ');$('neg').value=d.niche.neg.join(', ');
+    selTypes.clear(); d.niche.types.forEach(t=>selTypes.add(t));
+    document.querySelectorAll('#typeChips .chip').forEach(c=>c.classList.toggle('on',selTypes.has(c.dataset.type)));
+    doPreview(); window.scrollTo(0,0);
+  });
+}
+
+async function generate(){
+  const slug=$('slug').value.trim();
+  if(!slug){alert('slug required');return;}
+  const body={ slug, domain:$('domain').value, siteName:$('siteName').value, tagline:$('tagline').value,
+    heroHeadline:$('heroHeadline').value, heroSub:$('heroSub').value,
+    pos:$('pos').value, neg:$('neg').value, types:[...selTypes],
+    palette:$('palette').value, font:$('font').value };
+  const r = await (await fetch('/api/generate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})).json();
+  if(r.error){alert(r.error);return;}
+  $('dlgPath').textContent=r.outPath; $('dlgCount').textContent=(r.matchCount||0).toLocaleString(); $('dlgPort').textContent=r.port;
+  $('dlgCmd').textContent=r.copyCmd; $('dlgJson').textContent=JSON.stringify(r.config,null,2);
+  $('dlgClash').innerHTML = (r.clashes&&r.clashes.length)?('<div class="banner">Conflict: '+esc(r.clashes.join(', '))+'. Pick a different slug/domain before staging.</div>'):'';
+  $('genDlg').showModal(); loadDrafts();
 }
-sortSel.onchange=()=>{localStorage.setItem('sort',sortSel.value);load();};
-dens.oninput=applyCols;
-load();
-</script></body></html>
+
+['pos','neg'].forEach(id=>$(id).addEventListener('input',doPreviewDebounced));
+$('sort').addEventListener('change',doPreview);
+$('density').addEventListener('input',e=>document.documentElement.style.setProperty('--cols',e.target.value));
+$('genBtn').onclick=generate;
+$('resetBtn').onclick=()=>{['slug','domain','siteName','tagline','heroHeadline','heroSub','pos','neg'].forEach(id=>$(id).value='');selTypes.clear();document.querySelectorAll('.chip.on').forEach(c=>c.classList.remove('on'));doPreview();};
+boot();
+</script>
+</body></html>
diff --git a/server.js b/server.js
index 57f5df5..ea6f846 100644
--- a/server.js
+++ b/server.js
@@ -1,41 +1,238 @@
-// Storefront starter — zero-dep Node http. Serves data/products.json with SERVER-SIDE sort
-// (house rule: every product grid gets sort + a density slider). Optional basic auth via BASIC_AUTH.
-const http = require('http'), fs = require('fs'), path = require('path');
-const PORT = parseInt(process.env.PORT || '3900', 10);
-const BASIC_AUTH = process.env.BASIC_AUTH || ''; // "user:pass" to gate; empty = open
-const DIR = __dirname;
-
-function load() { try { return JSON.parse(fs.readFileSync(path.join(DIR, 'data', 'products.json'), 'utf8')); } catch { return []; } }
-function authed(req) {
-  if (!BASIC_AUTH) return true;
-  const m = (req.headers.authorization || '').match(/^Basic\s+(.+)$/i);
-  if (!m) return false; try { return Buffer.from(m[1], 'base64').toString() === BASIC_AUTH; } catch { return false; }
-}
-// Sort modes mirror the DW sort-skill: newest, title/sku A→Z, price ↑↓, light→dark (by dominant hex).
-function luminance(hex) { if (!/^#?[0-9a-f]{6}$/i.test(hex || '')) return 999; const h = hex.replace('#', '');
-  const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
-  return 0.2126 * r + 0.7152 * g + 0.0722 * b; }
-function sortProducts(items, mode) {
-  const a = items.slice();
-  switch (mode) {
-    case 'title': return a.sort((x, y) => (x.title || '').localeCompare(y.title || ''));
-    case 'sku': return a.sort((x, y) => (x.sku || x.handle || '').localeCompare(y.sku || y.handle || ''));
-    case 'price-asc': return a.sort((x, y) => (x.price || 0) - (y.price || 0));
-    case 'price-desc': return a.sort((x, y) => (y.price || 0) - (x.price || 0));
-    case 'light': return a.sort((x, y) => luminance(y.hex) - luminance(x.hex));
-    case 'dark': return a.sort((x, y) => luminance(x.hex) - luminance(y.hex));
-    default: return a; // 'newest' = natural order
+/**
+ * microsite-engine — config-generator dashboard that DRIVES the dw-domain-fleet.
+ *
+ * It never reinvents the fleet's render/sort/catalog logic and never writes into
+ * the live fleet or deploys. It:
+ *   - loads the fleet's REAL data/catalog.json + shared/{catalog,sort}.js so the
+ *     preview is byte-identical to what production would render,
+ *   - previews a niche live (match count + sortable/dense product grid),
+ *   - emits a deploy-ready sites/<slug>.json in the fleet's exact schema into
+ *     THIS project's own output/ dir (gated: Steve copies it to the fleet).
+ *
+ * Read-only against the fleet. $0 (local).
+ */
+'use strict';
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const { URL } = require('url');
+
+const PORT = parseInt(process.env.PORT || '9770', 10);
+const FLEET = process.env.FLEET_DIR || path.join(process.env.HOME, 'Projects', 'dw-domain-fleet');
+const OUTPUT = path.join(__dirname, 'output');
+const DRAFTS = path.join(__dirname, 'data', 'drafts.json');
+fs.mkdirSync(OUTPUT, { recursive: true });
+fs.mkdirSync(path.dirname(DRAFTS), { recursive: true });
+
+// ---- load the fleet's REAL modules + catalog (identical logic to production) ----
+let catalog, sortMod, FLEET_OK = true, FLEET_ERR = '';
+try {
+  catalog = require(path.join(FLEET, 'shared', 'catalog.js'));
+  sortMod = require(path.join(FLEET, 'shared', 'sort.js'));
+} catch (e) {
+  FLEET_OK = false;
+  FLEET_ERR = e.message;
+  console.error('[engine] could not load fleet shared modules:', e.message);
+  // graceful stubs so the dashboard still boots and reports the problem
+  catalog = { CLEAN: [], nicheSlice: () => [], normType: (t) => t || 'Other', count: 0 };
+  sortMod = { sortProducts: (l) => l, dominantHex: () => '#9a9a9a' };
+}
+
+// ---- derive real facets (product types + top tags) once ----
+function buildFacets() {
+  const types = {}, tags = {};
+  for (const p of catalog.CLEAN) {
+    const t = catalog.normType(p.product_type);
+    types[t] = (types[t] || 0) + 1;
+    for (const tag of (p.tags || [])) {
+      const k = String(tag).trim();
+      if (!k || /^AI-Analyzed/i.test(k) || k.length > 28) continue;
+      tags[k] = (tags[k] || 0) + 1;
+    }
   }
+  const typeList = Object.entries(types).sort((a, b) => b[1] - a[1]).map(([name, n]) => ({ name, n }));
+  const tagList = Object.entries(tags).sort((a, b) => b[1] - a[1]).slice(0, 120).map(([name, n]) => ({ name, n }));
+  return { typeList, tagList, total: catalog.CLEAN.length };
+}
+const FACETS = buildFacets();
+
+// ---- read the LIVE fleet's existing sites (READ-ONLY) for collision guard ----
+function liveSites() {
+  const dir = path.join(FLEET, 'sites');
+  const out = [];
+  try {
+    for (const f of fs.readdirSync(dir)) {
+      if (!f.endsWith('.json')) continue;
+      try {
+        const c = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
+        out.push({ slug: c.slug, domain: c.domain, port: c.port });
+      } catch { /* skip */ }
+    }
+  } catch { /* no fleet */ }
+  return out;
+}
+function nextFreePort(live, drafts) {
+  const used = new Set([...live, ...drafts].map(s => s.port).filter(Boolean));
+  let p = 10001;
+  while (used.has(p)) p++;
+  return p;
+}
+
+// ---- drafts store (this project only) ----
+function loadDrafts() { try { return JSON.parse(fs.readFileSync(DRAFTS, 'utf8')); } catch { return []; } }
+function saveDrafts(d) { fs.writeFileSync(DRAFTS, JSON.stringify(d, null, 2)); }
+
+// ---- the fleet's config schema (mirrors sites/<slug>.json exactly) ----
+const PALETTES = {
+  ivory:   { accent:'#7d3c00', bg:'#14110d', bgLight:'#faf7f2', ink:'#f3efe8', inkLight:'#1a1714', rule:'#d8cec0' },
+  sage:    { accent:'#5a6f3e', bg:'#10120c', bgLight:'#f3f4ee', ink:'#eef0e6', inkLight:'#1c1f17', rule:'#cfd4c2' },
+  slate:   { accent:'#3a5fb8', bg:'#0d1017', bgLight:'#eef2f8', ink:'#e7ecf5', inkLight:'#141922', rule:'#c4cede' },
+  rose:    { accent:'#a83a5a', bg:'#160e11', bgLight:'#faf0f2', ink:'#f3e6ea', inkLight:'#1c1216', rule:'#e2c9d1' },
+  charcoal:{ accent:'#8a6d3a', bg:'#0e0e0e', bgLight:'#f5f4f1', ink:'#efece6', inkLight:'#161514', rule:'#d6d2c8' },
+};
+const FONTS = {
+  cormorant: { serif:"'Cormorant Garamond','Playfair Display',Georgia,serif", sans:"-apple-system,'Inter',system-ui,sans-serif" },
+  playfair:  { serif:"'Playfair Display',Georgia,serif", sans:"-apple-system,'Inter',system-ui,sans-serif" },
+  didot:     { serif:"'GFS Didot',Didot,Georgia,serif", sans:"-apple-system,'Inter',system-ui,sans-serif" },
+};
+
+function cleanList(v) {
+  if (Array.isArray(v)) return v.map(s => String(s).trim()).filter(Boolean);
+  if (typeof v === 'string') return v.split(',').map(s => s.trim()).filter(Boolean);
+  return [];
+}
+
+function buildConfig(body, port) {
+  const slug = String(body.slug || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
+  const pal = PALETTES[body.palette] || PALETTES.ivory;
+  const fnt = FONTS[body.font] || FONTS.cormorant;
+  const name = body.siteName || slug;
+  const domain = body.domain || (slug + '.com');
+  const tagline = body.tagline || 'Designer wallcoverings for every room.';
+  return {
+    slug,
+    domain,
+    siteName: name,
+    port,
+    siteEmail: 'info@' + domain,
+    tagline,
+    heroHeadline: body.heroHeadline || name,
+    heroSub: body.heroSub || 'Explore the collection',
+    metaDesc: `${name} — ${tagline} Free memo samples, expert trade service, and hand-holding on every order from Designer Wallcoverings.`,
+    nicheLabel: body.nicheLabel || name.toLowerCase(),
+    aboutCopy: `${name} curates ${body.nicheLabel || 'designer wallcoverings'} for designers, architects, and discerning homeowners. ${tagline} Every selection is backed by Designer Wallcoverings' decades of trade experience.`,
+    niche: {
+      pos: cleanList(body.pos),
+      neg: cleanList(body.neg),
+      types: cleanList(body.types),
+    },
+    theme: {
+      accent: pal.accent, bg: pal.bg, bgLight: pal.bgLight, ink: pal.ink, inkLight: pal.inkLight, rule: pal.rule,
+      serif: fnt.serif, sans: fnt.sans,
+      palette: body.palette || 'ivory', font: body.font || 'cormorant',
+    },
+  };
 }
-http.createServer((req, res) => {
-  if (!authed(req)) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="preview"' }); return res.end('auth required'); }
-  const u = new URL(req.url, 'http://x');
-  if (u.pathname === '/api/products') {
-    const out = sortProducts(load(), u.searchParams.get('sort') || 'newest');
-    res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ count: out.length, products: out }));
+
+// ---- preview: run the REAL nicheSlice + sortProducts ----
+function preview(body) {
+  const niche = { pos: cleanList(body.pos), neg: cleanList(body.neg), types: cleanList(body.types) };
+  const all = catalog.nicheSlice({ ...niche, limit: 4000 });
+  const sorted = sortMod.sortProducts(all, body.sort || 'newest');
+  const cards = sorted.slice(0, 60).map(p => ({
+    title: p.title,
+    image_url: p.image_url,
+    product_type: p.product_type,
+    price: p.price,
+    hex: sortMod.dominantHex(p),
+  }));
+  return { count: all.length, shown: cards.length, cards, niche };
+}
+
+// ---- basic auth (house rule for the preview surface) ----
+const AUTH = 'Basic ' + Buffer.from('admin:DW2024!').toString('base64');
+function authed(req) {
+  if (process.env.NO_AUTH === '1') return true;
+  return (req.headers['authorization'] || '') === AUTH;
+}
+
+function send(res, code, type, body) { res.writeHead(code, { 'Content-Type': type }); res.end(body); }
+function json(res, code, obj) { send(res, code, 'application/json', JSON.stringify(obj)); }
+
+function readBody(req) {
+  return new Promise((resolve) => {
+    let b = '';
+    req.on('data', c => { b += c; if (b.length > 1e6) req.destroy(); });
+    req.on('end', () => { try { resolve(JSON.parse(b || '{}')); } catch { resolve({}); } });
+  });
+}
+
+const server = http.createServer(async (req, res) => {
+  const u = new URL(req.url, 'http://localhost');
+  const p = u.pathname;
+
+  if (!authed(req)) {
+    res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="microsite-engine"' });
+    return res.end('auth required');
   }
-  const f = u.pathname === '/' ? 'index.html' : u.pathname.replace(/^\//, '');
-  const fp = path.join(DIR, 'public', path.basename(f));
-  if (fs.existsSync(fp)) { res.writeHead(200, { 'Content-Type': f.endsWith('.html') ? 'text/html' : 'text/plain' }); return res.end(fs.readFileSync(fp)); }
-  res.writeHead(404); res.end('not found');
-}).listen(PORT, function () { console.log('[' + path.basename(DIR) + '] http://localhost:' + this.address().port); });
+
+  try {
+    if (p === '/' || p === '/index.html') {
+      return send(res, 200, 'text/html; charset=utf-8', fs.readFileSync(path.join(__dirname, 'public', 'index.html')));
+    }
+    if (p === '/api/facets') {
+      return json(res, 200, { ...FACETS, fleetOk: FLEET_OK, fleetErr: FLEET_ERR, palettes: Object.keys(PALETTES), fonts: Object.keys(FONTS) });
+    }
+    if (p === '/api/preview' && req.method === 'POST') {
+      return json(res, 200, preview(await readBody(req)));
+    }
+    if (p === '/api/live-sites') {
+      return json(res, 200, { sites: liveSites() });
+    }
+    if (p === '/api/drafts') {
+      return json(res, 200, { drafts: loadDrafts() });
+    }
+    if (p === '/api/generate' && req.method === 'POST') {
+      const body = await readBody(req);
+      const slug = String(body.slug || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
+      if (!slug) return json(res, 400, { error: 'slug required' });
+      const live = liveSites();
+      const drafts = loadDrafts();
+      const clashes = [];
+      if (live.some(s => s.slug === slug)) clashes.push('slug exists in live fleet');
+      if (body.domain && live.some(s => s.domain === body.domain)) clashes.push('domain exists in live fleet');
+      const port = nextFreePort(live, drafts.filter(d => d.slug !== slug));
+      const cfg = buildConfig(body, port);
+      const pv = preview(body);
+      // write ONLY to this project's output/ (never the live fleet)
+      const outPath = path.join(OUTPUT, slug + '.json');
+      fs.writeFileSync(outPath, JSON.stringify(cfg, null, 2));
+      const draft = {
+        slug, domain: cfg.domain, siteName: cfg.siteName, port,
+        matchCount: pv.count, niche: cfg.niche,
+        created_at: new Date().toISOString(),
+        outPath, status: clashes.length ? 'conflict' : 'draft', clashes,
+      };
+      const idx = drafts.findIndex(d => d.slug === slug);
+      if (idx >= 0) drafts[idx] = draft; else drafts.push(draft);
+      saveDrafts(drafts);
+      const copyCmd = `cp ${outPath} ${path.join(FLEET, 'sites', slug + '.json')}`;
+      return json(res, 200, { config: cfg, outPath, port, matchCount: pv.count, clashes, copyCmd, draft });
+    }
+    if (p === '/api/delete-draft' && req.method === 'POST') {
+      const body = await readBody(req);
+      const drafts = loadDrafts().filter(d => d.slug !== body.slug);
+      saveDrafts(drafts);
+      try { fs.unlinkSync(path.join(OUTPUT, body.slug + '.json')); } catch {}
+      return json(res, 200, { ok: true });
+    }
+    return send(res, 404, 'text/plain', 'not found');
+  } catch (e) {
+    return json(res, 500, { error: e.message });
+  }
+});
+
+server.listen(PORT, function () {
+  console.log(`[microsite-engine] http://localhost:${this.address().port}  (admin / DW2024!)`);
+  console.log(`[microsite-engine] fleet: ${FLEET}  catalog: ${FACETS.total} clean products  fleetOk=${FLEET_OK}`);
+});

← 0520612 initial scaffold (microsite-engine) via web-dev accelerator  ·  back to Microsite Engine  ·  microsite-engine: live rotating-hero preview + companion her c509c2b →