[object Object]

← back to CelebritySignatures

Murals storefront: /murals sales page + wall-placement studio + order endpoint + 150dpi 24x12ft TIF print-master generator (verified)

9111112bc80730725e5b3fa29cb1b289c7b3f66a · 2026-06-18 18:15:42 -0700 · Steve

Files touched

Diff

commit 9111112bc80730725e5b3fa29cb1b289c7b3f66a
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jun 18 18:15:42 2026 -0700

    Murals storefront: /murals sales page + wall-placement studio + order endpoint + 150dpi 24x12ft TIF print-master generator (verified)
---
 .gitignore               |   2 +
 data/murals-catalog.json |  13 ++
 public/murals.html       | 345 +++++++++++++++++++++++++++++++++++++++++++++++
 scripts/build-tifs.py    | 188 ++++++++++++++++++++++++++
 server.js                |  21 ++-
 5 files changed, 568 insertions(+), 1 deletion(-)

diff --git a/.gitignore b/.gitignore
index 2f40e25..fe8c64e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,5 @@ tmp_*.log
 tmp_collage_cache/
 
 .venv/
+data/mural-orders.json
+output/*.tif
diff --git a/data/murals-catalog.json b/data/murals-catalog.json
new file mode 100644
index 0000000..5d67bdc
--- /dev/null
+++ b/data/murals-catalog.json
@@ -0,0 +1,13 @@
+{
+  "pricePerSqFt": 24,
+  "size": { "widthFt": 24, "heightFt": 12, "dpi": 150, "pxW": 43200, "pxH": 21600, "sqft": 288 },
+  "material": "Archival matte non-woven wallcovering, printed to order",
+  "murals": [
+    { "code": "d", "slug": "declaration-of-independence", "title": "Declaration of Independence", "blurb": "All 56 signers of the 1776 Declaration — the safest, fully public-domain edition.", "sigs": 56 },
+    { "code": "p", "slug": "politics", "title": "Politics & Statecraft", "blurb": "Presidents, premiers and world leaders across two centuries.", "sigs": 74 },
+    { "code": "s", "slug": "sports", "title": "Sports Legends", "blurb": "Hall-of-fame athletes, every signature openly licensed.", "sigs": 42 },
+    { "code": "h", "slug": "hollywood", "title": "Hollywood", "blurb": "Classic and iconic screen stars of the silver age.", "sigs": 96 },
+    { "code": "c", "slug": "movies-classic", "title": "Movies — Classic", "blurb": "Golden-age film greats, hand-verified signatures.", "sigs": 97 },
+    { "code": "t", "slug": "tv", "title": "Television", "blurb": "Beloved small-screen icons.", "sigs": 14 }
+  ]
+}
diff --git a/public/murals.html b/public/murals.html
new file mode 100644
index 0000000..e3bdf6c
--- /dev/null
+++ b/public/murals.html
@@ -0,0 +1,345 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Celebrity Signature Murals — made to order</title>
+<style>
+  :root{
+    --ink:#1a1a1a; --muted:#6b6b6b; --line:#e6e3dc; --bg:#f7f5f0; --card:#fff;
+    --accent:#1d7a36; --gold:#9a6b00; --wall:#efe9df; --floor:#d9cfbf;
+  }
+  *{box-sizing:border-box}
+  body{margin:0;font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:var(--bg)}
+  h1,h2,h3{font-family:Georgia,"Times New Roman",serif;font-weight:600;letter-spacing:.01em}
+  a{color:var(--accent)}
+  header{padding:24px 30px;border-bottom:1px solid var(--line);background:#fff;position:sticky;top:0;z-index:30;display:flex;align-items:baseline;gap:16px;flex-wrap:wrap}
+  header h1{margin:0;font-size:24px}
+  header .sub{color:var(--muted);font-size:13px}
+  header nav{margin-left:auto;display:flex;gap:18px;font-size:14px}
+  header nav a{color:var(--muted);text-decoration:none}
+  header nav a:hover{color:var(--ink)}
+  main{max-width:1180px;margin:0 auto;padding:30px 30px 80px}
+  .intro{max-width:760px;margin-bottom:30px}
+  .intro h2{font-size:30px;margin:0 0 10px}
+  .intro p{color:#3c3c3c;margin:0 0 8px}
+  .spec{display:flex;gap:26px;flex-wrap:wrap;margin-top:16px;padding:14px 18px;background:#fff;border:1px solid var(--line);border-radius:12px}
+  .spec div{font-size:13px}
+  .spec b{display:block;font-size:18px;font-family:Georgia,serif}
+  /* gallery */
+  .gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:22px;margin-top:10px}
+  .m-card{background:var(--card);border:1px solid var(--line);border-radius:14px;overflow:hidden;display:flex;flex-direction:column}
+  .m-card .pic{background:#fff;border-bottom:1px solid var(--line);padding:14px;display:flex;align-items:center;justify-content:center;aspect-ratio:2/1;overflow:hidden}
+  .m-card .pic img{max-width:100%;max-height:100%;object-fit:contain}
+  .m-card .body{padding:14px 16px 16px;display:flex;flex-direction:column;gap:8px;flex:1}
+  .m-card h3{margin:0;font-size:19px}
+  .m-card .blurb{color:var(--muted);font-size:13.5px;flex:1}
+  .m-card .facts{display:flex;gap:8px;flex-wrap:wrap;font-size:11.5px;color:#555}
+  .pill{padding:3px 10px;border-radius:999px;background:#f0ece3;border:1px solid var(--line)}
+  .m-card .foot{display:flex;align-items:center;gap:10px;margin-top:4px}
+  .price{font-family:Georgia,serif;font-size:22px}
+  .btn{appearance:none;border:1px solid var(--ink);background:var(--ink);color:#fff;font:inherit;font-size:14px;padding:9px 16px;border-radius:9px;cursor:pointer;text-decoration:none;display:inline-block}
+  .btn.ghost{background:#fff;color:var(--ink)}
+  .btn:hover{opacity:.9}
+  .btn.full{width:100%;text-align:center}
+  /* studio */
+  .studio{margin-top:46px;border-top:1px solid var(--line);padding-top:34px;scroll-margin-top:90px}
+  .studio h2{font-size:26px;margin:0 0 4px}
+  .studio .lead{color:var(--muted);margin:0 0 18px;max-width:680px}
+  .stage-wrap{background:#fff;border:1px solid var(--line);border-radius:14px;padding:18px}
+  .ctrls{display:flex;gap:22px;flex-wrap:wrap;align-items:center;margin-bottom:16px}
+  .ctrls .f{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--muted)}
+  .ctrls select,.ctrls input[type=range]{font:inherit}
+  .ctrls select{padding:7px 10px;border:1px solid var(--line);border-radius:8px;background:#fff;color:var(--ink);font-size:14px}
+  .ctrls output{font-family:Georgia,serif;color:var(--ink);font-size:15px}
+  .scene{position:relative;width:100%;background:linear-gradient(#f4eee4,#efe9df);border:1px solid var(--line);border-radius:10px;overflow:hidden;user-select:none;touch-action:none}
+  .floor{position:absolute;left:0;right:0;bottom:0;background:var(--floor);border-top:2px solid #c9bda9}
+  .baseboard{position:absolute;left:0;right:0;background:#fff;border-top:1px solid #cfc4b2;border-bottom:1px solid #cfc4b2}
+  .human{position:absolute;bottom:0;width:14px;background:#cdbfa9;border-radius:7px 7px 0 0;opacity:.85}
+  .human::after{content:"6 ft";position:absolute;top:-18px;left:50%;transform:translateX(-50%);font-size:10px;color:#8a7c63;white-space:nowrap}
+  .mural{position:absolute;cursor:grab;border:1px solid rgba(0,0,0,.18);box-shadow:0 6px 22px rgba(60,50,30,.22);background-size:cover;background-position:center}
+  .mural.drag{cursor:grabbing;box-shadow:0 10px 30px rgba(60,50,30,.32)}
+  .mural .grip{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#fff;font-size:12px;letter-spacing:.08em;text-shadow:0 1px 3px rgba(0,0,0,.5);opacity:0;transition:.15s}
+  .mural:hover .grip{opacity:.9}
+  .dims{position:absolute;font-size:11px;color:#8a7c63;pointer-events:none}
+  .readout{display:flex;gap:24px;flex-wrap:wrap;margin-top:14px;font-size:13px}
+  .readout b{font-family:Georgia,serif;font-weight:600;font-size:15px}
+  .hint{font-size:12px;color:var(--muted);margin-top:8px}
+  /* order */
+  .order{margin-top:40px;background:#fff;border:1px solid var(--line);border-radius:14px;padding:22px 24px;max-width:680px}
+  .order h2{font-size:22px;margin:0 0 6px}
+  .order p{color:var(--muted);margin:0 0 16px;font-size:14px}
+  .order form{display:grid;grid-template-columns:1fr 1fr;gap:14px}
+  .order label{display:flex;flex-direction:column;gap:5px;font-size:12px;color:var(--muted)}
+  .order label.wide{grid-column:1/3}
+  .order input,.order textarea{font:inherit;padding:9px 11px;border:1px solid var(--line);border-radius:8px;background:#fff}
+  .order textarea{min-height:64px;resize:vertical}
+  .order .summary{grid-column:1/3;background:var(--bg);border:1px dashed var(--line);border-radius:10px;padding:12px 14px;font-size:13px}
+  .order .summary b{font-family:Georgia,serif}
+  .msg{grid-column:1/3;font-size:14px}
+  .msg.ok{color:var(--accent)} .msg.err{color:#b3261e}
+  footer{max-width:1180px;margin:0 auto;padding:0 30px 50px;color:var(--muted);font-size:12.5px;line-height:1.7}
+  @media(max-width:620px){.order form{grid-template-columns:1fr}.order label.wide,.order .summary,.msg{grid-column:1}}
+</style>
+</head>
+<body>
+<header>
+  <h1>Celebrity Signature Murals</h1>
+  <span class="sub">made to order · Designer Wallcoverings</span>
+  <nav>
+    <a href="/">Signature archive</a>
+    <a href="#studio">Wall studio</a>
+    <a href="#order">Order</a>
+  </nav>
+</header>
+
+<main>
+  <section class="intro">
+    <h2>A wall of history, in their own hand.</h2>
+    <p>Each mural is a curated wall of authentic signatures — every name traced to a
+       public-domain or openly licensed source, no signature invented. Printed to order
+       as a single seamless piece for a full feature wall.</p>
+    <div class="spec" id="spec"><!-- filled by JS --></div>
+  </section>
+
+  <div class="gallery" id="gallery"></div>
+
+  <!-- WALL PLACEMENT STUDIO -->
+  <section class="studio" id="studio">
+    <h2>Place it on your wall</h2>
+    <p class="lead">Pick a mural, set your wall width, then <b>drag the mural</b> to exactly
+       where you want it. The 6&nbsp;ft figure is there for scale.</p>
+    <div class="stage-wrap">
+      <div class="ctrls">
+        <div class="f">Mural
+          <select id="selMural"></select>
+        </div>
+        <div class="f">Wall width — <output id="wwOut"></output>
+          <input id="wallW" type="range" min="24" max="60" step="1" value="32">
+        </div>
+        <div class="f">Wall height — <output id="whOut"></output>
+          <input id="wallH" type="range" min="12" max="20" step="1" value="14">
+        </div>
+        <div class="f">&nbsp;
+          <button class="btn ghost" id="btnCenter" type="button">Center on wall</button>
+        </div>
+      </div>
+      <div class="scene" id="scene"></div>
+      <div class="readout" id="readout"></div>
+      <div class="hint">Mural is fixed at 24&nbsp;ft wide × 12&nbsp;ft tall (150&nbsp;DPI print master). Drag to set horizontal &amp; vertical placement; it can't leave the wall.</div>
+    </div>
+  </section>
+
+  <!-- ORDER -->
+  <section class="order" id="order">
+    <h2>Order this mural</h2>
+    <p>Tell us your wall and we'll prepare your 150&nbsp;DPI print master and a quote.</p>
+    <form id="orderForm">
+      <label>Your name<input name="name" required></label>
+      <label>Email<input name="email" type="email" required></label>
+      <label>Wall width (ft)<input name="wall_w" type="number" min="1" step="0.5" required></label>
+      <label>Wall height (ft)<input name="wall_h" type="number" min="1" step="0.5" required></label>
+      <label class="wide">Notes (room, install date, anything)<textarea name="notes"></textarea></label>
+      <div class="summary" id="orderSummary"></div>
+      <div style="grid-column:1/3"><button class="btn" type="submit">Request this mural</button></div>
+      <div class="msg" id="orderMsg"></div>
+    </form>
+  </section>
+</main>
+
+<footer>
+  Every signature on these murals is sourced from Wikimedia Commons under a public-domain or
+  open license and is rendered at uniform size — no signature is emphasized over another.
+  Living-celebrity signatures are excluded (right of publicity). Print masters are produced to
+  order at 150&nbsp;DPI; final clarity scales with viewing distance for large-format installs.
+</footer>
+
+<script>
+const FT = " ft";
+let CAT = null;          // catalog
+let cur = null;          // current mural in studio
+let pos = {x:0, y:0};    // mural top-left in FEET on the wall
+
+const money = n => "$" + Math.round(n).toLocaleString();
+const $ = s => document.querySelector(s);
+
+async function boot(){
+  CAT = await (await fetch('/api/murals-catalog')).json();
+  const {widthFt,heightFt,dpi,pxW,pxH,sqft} = CAT.size;
+  const price = CAT.pricePerSqFt * sqft;
+
+  $('#spec').innerHTML = `
+    <div><b>${heightFt} × ${widthFt} ft</b>height × width</div>
+    <div><b>${dpi} DPI</b>${pxH.toLocaleString()} × ${pxW.toLocaleString()} px master</div>
+    <div><b>${sqft} sq ft</b>seamless feature wall</div>
+    <div><b>${money(price)}</b>from, ${CAT.material.toLowerCase()}</div>`;
+
+  // gallery
+  $('#gallery').innerHTML = CAT.murals.map(m => `
+    <div class="m-card">
+      <div class="pic"><img loading="lazy" src="/output/collage-${m.slug}.png" alt="${m.title} signature mural"></div>
+      <div class="body">
+        <h3>${m.title}</h3>
+        <div class="blurb">${m.blurb}</div>
+        <div class="facts">
+          <span class="pill">${m.sigs} signatures</span>
+          <span class="pill">${heightFt}×${widthFt} ft</span>
+          <span class="pill">${dpi} DPI TIF</span>
+        </div>
+        <div class="foot">
+          <span class="price">${money(price)}</span>
+          <button class="btn ghost" data-place="${m.code}" type="button">Place on wall →</button>
+        </div>
+      </div>
+    </div>`).join('');
+
+  // mural selector
+  $('#selMural').innerHTML = CAT.murals.map(m => `<option value="${m.code}">${m.title}</option>`).join('');
+
+  $('#gallery').addEventListener('click', e => {
+    const code = e.target.getAttribute?.('data-place');
+    if(!code) return;
+    $('#selMural').value = code;
+    selectMural(code);
+    $('#studio').scrollIntoView({behavior:'smooth'});
+  });
+  $('#selMural').addEventListener('change', e => selectMural(e.target.value));
+  $('#wallW').addEventListener('input', drawScene);
+  $('#wallH').addEventListener('input', drawScene);
+  $('#btnCenter').addEventListener('click', centerMural);
+  window.addEventListener('resize', drawScene);
+
+  selectMural(CAT.murals[0].code);
+  wireOrder();
+}
+
+function muralByCode(c){ return CAT.murals.find(m => m.code === c); }
+
+function selectMural(code){
+  cur = muralByCode(code);
+  centerMural();           // also draws
+}
+
+function wallDims(){ return { W:+$('#wallW').value, H:+$('#wallH').value }; }
+
+function centerMural(){
+  const {W,H} = wallDims();
+  const mw = CAT.size.widthFt, mh = CAT.size.heightFt;
+  pos.x = Math.max(0,(W-mw)/2);
+  pos.y = Math.max(0,(H-mh)/2);
+  drawScene();
+}
+
+function drawScene(){
+  if(!cur) return;
+  const scene = $('#scene');
+  const {W,H} = wallDims();
+  const mw = CAT.size.widthFt, mh = CAT.size.heightFt;
+  // scale: fit wall width to scene width; cap scene height sensibly
+  const sw = scene.clientWidth || 900;
+  let pxPerFt = sw / W;
+  const sceneH = H * pxPerFt;
+  scene.style.height = sceneH + "px";
+
+  // clamp position to wall
+  pos.x = Math.min(Math.max(0,pos.x), Math.max(0,W-mw));
+  pos.y = Math.min(Math.max(0,pos.y), Math.max(0,H-mh));
+
+  $('#wwOut').textContent = W + FT;
+  $('#whOut').textContent = H + FT;
+
+  const floorH = Math.min(sceneH*0.10, pxPerFt*1.0);
+  scene.innerHTML = `
+    <div class="floor" style="height:${floorH}px"></div>
+    <div class="baseboard" style="bottom:${floorH}px;height:${Math.max(6,pxPerFt*0.4)}px"></div>
+    <div class="human" style="left:${pxPerFt*1.2}px;height:${6*pxPerFt - floorH}px;bottom:${floorH}px"></div>
+    <div class="mural" id="muralEl"
+         style="width:${mw*pxPerFt}px;height:${mh*pxPerFt}px;
+                left:${pos.x*pxPerFt}px;top:${(H-mh-pos.y)*pxPerFt}px;
+                background-image:url('/output/collage-${cur.slug}.png')">
+      <div class="grip">⟵ drag ⟶</div>
+    </div>`;
+  enableDrag(pxPerFt);
+
+  const floorGap = pos.y;                       // ft from mural bottom to floor
+  const leftGap = pos.x;                        // ft from left wall to mural
+  const rightGap = Math.max(0, W - mw - pos.x);
+  $('#readout').innerHTML = `
+    <div>From left wall: <b>${leftGap.toFixed(1)}${FT}</b></div>
+    <div>From right wall: <b>${rightGap.toFixed(1)}${FT}</b></div>
+    <div>Bottom off floor: <b>${floorGap.toFixed(1)}${FT}</b></div>
+    <div>Mural: <b>${mh}×${mw}${FT}</b></div>`;
+  syncOrderSummary();
+}
+
+function enableDrag(pxPerFt){
+  const el = $('#muralEl');
+  let start=null;
+  el.addEventListener('pointerdown', e => {
+    start = {mx:e.clientX, my:e.clientY, px:pos.x, py:pos.y};
+    el.setPointerCapture(e.pointerId); el.classList.add('drag');
+  });
+  el.addEventListener('pointermove', e => {
+    if(!start) return;
+    const dxFt = (e.clientX-start.mx)/pxPerFt;
+    const dyFt = (e.clientY-start.my)/pxPerFt;
+    pos.x = start.px + dxFt;
+    pos.y = start.py - dyFt;       // up = increase floor gap
+    const {W,H}=wallDims(), mw=CAT.size.widthFt, mh=CAT.size.heightFt;
+    pos.x = Math.min(Math.max(0,pos.x), Math.max(0,W-mw));
+    pos.y = Math.min(Math.max(0,pos.y), Math.max(0,H-mh));
+    el.style.left = pos.x*pxPerFt + "px";
+    el.style.top  = (H-mh-pos.y)*pxPerFt + "px";
+    liveReadout();
+  });
+  const end = e => { if(start){ start=null; el.classList.remove('drag'); syncOrderSummary(); } };
+  el.addEventListener('pointerup', end);
+  el.addEventListener('pointercancel', end);
+}
+
+function liveReadout(){
+  const {W}=wallDims(), mw=CAT.size.widthFt;
+  $('#readout').children[0].querySelector('b').textContent = pos.x.toFixed(1)+FT;
+  $('#readout').children[1].querySelector('b').textContent = Math.max(0,W-mw-pos.x).toFixed(1)+FT;
+  $('#readout').children[2].querySelector('b').textContent = pos.y.toFixed(1)+FT;
+}
+
+function syncOrderSummary(){
+  if(!cur) return;
+  const {W,H}=wallDims();
+  const price = CAT.pricePerSqFt * CAT.size.sqft;
+  $('#orderSummary').innerHTML =
+    `<b>${cur.title}</b> · ${CAT.size.heightFt}×${CAT.size.widthFt}${FT} · ${money(price)}<br>
+     Placement on a ${W}×${H}${FT} wall: ${pos.x.toFixed(1)}${FT} from left, ${pos.y.toFixed(1)}${FT} off floor.`;
+  // mirror into order form if blank
+  const f = $('#orderForm');
+  if(!f.wall_w.value) f.wall_w.value = W;
+  if(!f.wall_h.value) f.wall_h.value = H;
+}
+
+function wireOrder(){
+  $('#orderForm').addEventListener('submit', async e => {
+    e.preventDefault();
+    const msg = $('#orderMsg'); msg.className='msg'; msg.textContent='Sending…';
+    const f = e.target;
+    const payload = {
+      mural: cur?.slug, mural_title: cur?.title,
+      name: f.name.value, email: f.email.value,
+      wall_w: +f.wall_w.value, wall_h: +f.wall_h.value,
+      placement: { from_left_ft:+pos.x.toFixed(2), off_floor_ft:+pos.y.toFixed(2) },
+      size: CAT.size, price: CAT.pricePerSqFt*CAT.size.sqft,
+      notes: f.notes.value
+    };
+    try{
+      const r = await fetch('/api/mural-order',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
+      const j = await r.json();
+      if(j.ok){ msg.className='msg ok'; msg.textContent='Thank you — request #'+j.id+' received. We’ll email you a proof and quote.'; f.reset(); syncOrderSummary(); }
+      else { msg.className='msg err'; msg.textContent = j.error || 'Something went wrong.'; }
+    }catch(err){ msg.className='msg err'; msg.textContent='Network error — please email steve@designerwallcoverings.com.'; }
+  });
+}
+
+boot();
+</script>
+</body>
+</html>
diff --git a/scripts/build-tifs.py b/scripts/build-tifs.py
new file mode 100644
index 0000000..d62229f
--- /dev/null
+++ b/scripts/build-tifs.py
@@ -0,0 +1,188 @@
+#!/usr/bin/env python3
+"""Build 150-DPI TIF PRINT MASTERS of the signature murals at 24 ft wide × 12 ft
+tall — the file a client orders for a full feature wall.
+
+  24 ft × 150 dpi = 43,200 px wide
+  12 ft × 150 dpi = 21,600 px tall   → ~933 megapixels, 2:1
+
+The mural is RE-RENDERED at print scale (not upscaled from the small web PNG):
+every signature is fetched as a large raster straight from Wikimedia's SVG
+thumbnailer, so the ink stays crisp. Output is LZW-compressed TIF with the DPI
+tag pinned to 150 — because the art is mostly white, the files stay modest
+despite the pixel count.
+
+Honest resolution note: signatures are vector SVGs but Commons caps the raster
+width it will render, so very large cells are mild upscales of a ~2–3k px raster.
+For a 12-ft wall viewed at room distance this is well past visually-sharp; for
+true vector-perfect output we'd rasterize the SVGs locally (needs cairosvg).
+
+Usage (via the venv that has Pillow + qrcode):
+  .venv/bin/python scripts/build-tifs.py tv          # one category (by slug)
+  .venv/bin/python scripts/build-tifs.py all         # every usable category
+Set PUBLIC_BASE_URL to match the deployed QR host (defaults to the live site).
+"""
+
+import json, os, re, sys, time, urllib.parse, urllib.request
+from io import BytesIO
+from PIL import Image, ImageDraw
+import qrcode
+
+Image.MAX_IMAGE_PIXELS = None   # 933 MP is intentional, not a decompression bomb
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+DATA = os.path.join(ROOT, "data", "celebrity_signatures.json")
+CACHE = os.path.join(ROOT, "tmp_collage_cache")
+OUT = os.path.join(ROOT, "output")
+UA = "CelebritySignatures-research/1.0 (steve@designerwallcoverings.com)"
+COMMONS = "https://commons.wikimedia.org/w/api.php"
+BASE_URL = os.environ.get("PUBLIC_BASE_URL", "https://celebrity.designerwallcoverings.com").rstrip("/")
+
+DPI = 150
+W_FT, H_FT = 24, 12
+TARGET_W, TARGET_H = W_FT * 12 * DPI, H_FT * 12 * DPI   # 43200 x 21600
+MARGIN = 90            # white gutter inside each cell (px @150dpi ≈ 0.6")
+FETCH_W = 2600         # raster width requested per signature from Commons
+QR_IN = 9             # printed QR size in inches on the wall
+CODE = {"declaration-of-independence": "d", "politics": "p", "sports": "s",
+        "hollywood": "h", "movies-classic": "c", "tv": "t"}
+os.makedirs(CACHE, exist_ok=True)
+os.makedirs(OUT, exist_ok=True)
+
+
+def file_title(url):
+    return "File:" + urllib.parse.unquote(url.rstrip("/").split("/")[-1])
+
+
+def resolve_thumbs(titles, width):
+    out = {}
+    for i in range(0, len(titles), 50):
+        q = urllib.parse.urlencode({
+            "action": "query", "format": "json", "prop": "imageinfo",
+            "iiprop": "url", "iiurlwidth": str(width), "titles": "|".join(titles[i:i+50]),
+        })
+        try:
+            j = json.load(urllib.request.urlopen(
+                urllib.request.Request(COMMONS + "?" + q, headers={"User-Agent": UA}), timeout=30))
+        except Exception as e:
+            print("  api batch failed:", e); time.sleep(1); continue
+        norm = {n["to"]: n["from"] for n in j.get("query", {}).get("normalized", [])}
+        for p in j.get("query", {}).get("pages", {}).values():
+            ii = (p.get("imageinfo") or [{}])[0]
+            out[norm.get(p.get("title"), p.get("title"))] = ii.get("thumburl") or ii.get("url")
+        time.sleep(0.2)
+    return out
+
+
+def fetch_image(url):
+    h = re.sub(r"[^a-zA-Z0-9]", "_", url)[-80:] + f"_{FETCH_W}"
+    cp = os.path.join(CACHE, h + ".png")
+    if os.path.exists(cp):
+        try: return Image.open(cp).convert("RGBA")
+        except Exception: pass
+    for attempt in range(6):
+        try:
+            raw = urllib.request.urlopen(
+                urllib.request.Request(url, headers={"User-Agent": UA}), timeout=60).read()
+            im = Image.open(BytesIO(raw)).convert("RGBA")
+            im.save(cp); time.sleep(0.35); return im
+        except urllib.error.HTTPError as e:
+            if e.code == 429:
+                w = 8 * (attempt + 1); print(f"  429 backoff {w}s"); time.sleep(w); continue
+            print("  img fail:", url[:60], e); return None
+        except Exception as e:
+            print("  img fail:", url[:60], e); return None
+    return None
+
+
+def signature_like(im):
+    rgb = Image.alpha_composite(Image.new("RGBA", im.size, (255,)*4), im).convert("RGB")
+    s = rgb.resize((72, 72)); g = list(s.convert("L").getdata()); n = len(g)
+    light = sum(1 for v in g if v >= 200) / n
+    dark = sum(1 for v in g if v <= 90) / n
+    sat = sum(p[1] for p in s.convert("HSV").getdata()) / n
+    return sat < 60 and light >= 0.30 and dark <= 0.30
+
+
+def fit_cell(im, cw, ch):
+    """Flatten onto white, scale to fit (cw,ch) preserving aspect (upscaling ok)."""
+    im = Image.alpha_composite(Image.new("RGBA", im.size, (255,)*4), im).convert("RGB")
+    r = min(cw / im.width, ch / im.height)
+    return im.resize((max(1, int(im.width*r)), max(1, int(im.height*r))), Image.LANCZOS)
+
+
+def make_qr(data, px):
+    qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M, box_size=10, border=2)
+    qr.add_data(data); qr.make(fit=True)
+    img = qr.make_image(fill_color="black", back_color="white").convert("RGB")
+    modules = qr.modules_count + 2 * qr.border
+    box = max(4, round(px / modules))
+    return img.resize((box * modules, box * modules), Image.NEAREST)
+
+
+def build(category, rows, slug):
+    rows = [r for r in rows if r["usable_in_commercial_collage"] == "yes"]
+    n = len(rows)
+    if not n:
+        print(f"[{category}] no usable rows"); return None
+    cols = min(10, max(1, round(n ** 0.5)))
+    rows_ct = (n + cols - 1) // cols
+    cw, ch = TARGET_W // cols, TARGET_H // rows_ct
+    print(f"[{category}] {n} sigs · {cols}×{rows_ct} grid · cell {cw}×{ch}px · canvas {TARGET_W}×{TARGET_H}")
+
+    titles = [file_title(r["signature_image_url"]) for r in rows]
+    thumbs = resolve_thumbs(titles, FETCH_W)
+
+    canvas = Image.new("RGB", (TARGET_W, TARGET_H), (255, 255, 255))
+    placed = 0
+    for r in rows:
+        url = thumbs.get(file_title(r["signature_image_url"])) or r["signature_image_url"]
+        im = fetch_image(url)
+        if not im or not signature_like(im):
+            continue
+        sig = fit_cell(im, cw - 2*MARGIN, ch - 2*MARGIN)
+        cx = (placed % cols) * cw
+        cy = (placed // cols) * ch
+        canvas.paste(sig, (cx + (cw - sig.width)//2, cy + (ch - sig.height)//2))
+        placed += 1
+        if placed % 20 == 0:
+            print(f"   placed {placed}/{n}")
+
+    # centred print QR — same "scan to reveal names" feature, sized for the wall
+    qpx = QR_IN * DPI
+    qr = make_qr(f"{BASE_URL}/k/{CODE.get(slug, slug)}", qpx)
+    qx, qy = (TARGET_W - qr.width)//2, (TARGET_H - qr.height)//2
+    pad = int(0.6 * DPI)
+    ImageDraw.Draw(canvas).rectangle(
+        [qx-pad, qy-pad, qx+qr.width+pad, qy+qr.height+pad], fill="white", outline=(150,146,138), width=6)
+    canvas.paste(qr, (qx, qy))
+
+    path = os.path.join(OUT, f"mural-{slug}-24x12ft-150dpi.tif")
+    print(f"   encoding LZW TIF ({placed} sigs placed)…")
+    canvas.save(path, format="TIFF", compression="tiff_lzw", dpi=(DPI, DPI))
+    mb = os.path.getsize(path) / 1e6
+    print(f"[{category}] ✅ {os.path.basename(path)}  ({mb:.1f} MB, {TARGET_W}×{TARGET_H} @ {DPI}dpi)\n")
+    return path
+
+
+def main():
+    which = (sys.argv[1] if len(sys.argv) > 1 else "all").lower()
+    data = json.load(open(DATA))
+    by = {}
+    for r in data:
+        if r["usable_in_commercial_collage"] == "yes":
+            by.setdefault(r["category"], []).append(r)
+    order = ["Declaration of Independence", "Politics", "Sports", "Hollywood", "Movies (Classic)", "TV"]
+    print(f"Print masters → {W_FT}ft × {H_FT}ft @ {DPI}dpi  ({TARGET_W}×{TARGET_H}px)   QR host: {BASE_URL}\n")
+    made = []
+    for cat in order:
+        slug = re.sub(r"[^a-z0-9]+", "-", cat.lower()).strip("-")
+        if which not in ("all", slug):
+            continue
+        if cat in by:
+            p = build(cat, sorted(by[cat], key=lambda x: x["rank"]), slug)
+            if p: made.append(p)
+    print(f"Built {len(made)} TIF print master(s) in output/")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/server.js b/server.js
index 0812131..d3d6f97 100644
--- a/server.js
+++ b/server.js
@@ -1,7 +1,7 @@
 #!/usr/bin/env node
 // Zero-dependency static server for the CelebritySignatures grid.
 import { createServer } from 'node:http';
-import { readFile } from 'node:fs/promises';
+import { readFile, writeFile } from 'node:fs/promises';
 import { extname, join } from 'node:path';
 import { fileURLToPath } from 'node:url';
 
@@ -14,11 +14,30 @@ const TYPES = {
   '.webp': 'image/webp', '.gif': 'image/gif', '.ico': 'image/x-icon',
 };
 
+const ORDERS = join(ROOT, 'data', 'mural-orders.json');
+
 createServer(async (req, res) => {
   try {
     let path = decodeURIComponent(req.url.split('?')[0]);
+
+    // POST: capture a made-to-order mural request → append to data/mural-orders.json
+    if (req.method === 'POST' && path === '/api/mural-order') {
+      let raw = '';
+      for await (const c of req) { raw += c; if (raw.length > 1e5) { res.writeHead(413).end('too large'); return; } }
+      let body; try { body = JSON.parse(raw || '{}'); } catch { res.writeHead(400, { 'Content-Type': 'application/json' }).end('{"ok":false,"error":"bad json"}'); return; }
+      if (!body.name || !body.email) { res.writeHead(400, { 'Content-Type': 'application/json' }).end('{"ok":false,"error":"name and email required"}'); return; }
+      let list = []; try { list = JSON.parse(await readFile(ORDERS, 'utf8')); } catch {}
+      const id = (list.at(-1)?.id || 1000) + 1;
+      list.push({ id, at: new Date().toISOString(), ...body });
+      await writeFile(ORDERS, JSON.stringify(list, null, 2));
+      res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true, id }));
+      return;
+    }
+
     if (path === '/') path = '/public/index.html';
+    if (path === '/murals') path = '/public/murals.html';
     if (path === '/api/signatures') path = '/data/celebrity_signatures.json';
+    if (path === '/api/murals-catalog') path = '/data/murals-catalog.json';
     // Short QR target: /k/<code> -> the named "key" mural. Single-letter codes keep
     // the encoded URL tiny so the centre QR stays low-density and scans cleanly off a
     // printed wall. Full-slug (/k/<cat>) still works as a fallback.

← 84da440 Mural QR: B+C — functional-size centre QR + short /k/<code>  ·  back to CelebritySignatures  ·  murals: add 5 consensus components (graphic-designer ∩ four- 86bfd8c →