[object Object]

← back to Wallco Ai

wallco.ai · 3 more pro functions inline · (1) /api/designs/by-color?hex=%23c16d74&n=12&exclude= — CIELAB ΔE ranked neighbor endpoint (sRGB→linear→XYZ-D65→Lab pipeline, returns sorted by Δ rounded to .1), useful for 'more like this in this palette' rails; verified target=#c16d74 → exact match ΔE=0 on #161, then ΔE 20.2 / 21.6 near-pinks; bad hex → 400 · (2) PDP wall-size calculator (collapsible details block above CTA group) — W×H ft inputs → panel count + total yards + sq-ft + material estimate (low/high range), reads chosen panel width (24/27/36 in) from existing material picker localStorage, 15% match-waste buffer, 27 ft standard panel length, $6.50–$11.50/sq-ft pricing tier · (3) /brief?ids=72,161,139&project=&client= — multi-design proposal/brief HTML (print-to-PDF), cover page w/ project+client+date+3-thumb mosaic, per-design tearsheet sections w/ page-break-after, empty /brief → landing page explaining usage; verified 3 ids → 12.7KB / empty → 778B / project=Living Room shows in <title> and <h1>

17991988d1eb443a6fb8f19e27ce0583d49f070a · 2026-05-12 08:55:20 -0700 · SteveStudio2

Files touched

Diff

commit 17991988d1eb443a6fb8f19e27ce0583d49f070a
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 08:55:20 2026 -0700

    wallco.ai · 3 more pro functions inline · (1) /api/designs/by-color?hex=%23c16d74&n=12&exclude= — CIELAB ΔE ranked neighbor endpoint (sRGB→linear→XYZ-D65→Lab pipeline, returns sorted by Δ rounded to .1), useful for 'more like this in this palette' rails; verified target=#c16d74 → exact match ΔE=0 on #161, then ΔE 20.2 / 21.6 near-pinks; bad hex → 400 · (2) PDP wall-size calculator (collapsible details block above CTA group) — W×H ft inputs → panel count + total yards + sq-ft + material estimate (low/high range), reads chosen panel width (24/27/36 in) from existing material picker localStorage, 15% match-waste buffer, 27 ft standard panel length, $6.50–$11.50/sq-ft pricing tier · (3) /brief?ids=72,161,139&project=&client= — multi-design proposal/brief HTML (print-to-PDF), cover page w/ project+client+date+3-thumb mosaic, per-design tearsheet sections w/ page-break-after, empty /brief → landing page explaining usage; verified 3 ids → 12.7KB / empty → 778B / project=Living Room shows in <title> and <h1>
---
 server.js | 201 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 201 insertions(+)

diff --git a/server.js b/server.js
index 1aa7343..b7d53d2 100644
--- a/server.js
+++ b/server.js
@@ -346,6 +346,48 @@ app.get('/api/designs/search', (req, res) => {
   });
 });
 
+// ── /api/designs/by-color?hex=%23c16d74&n=12&exclude=72
+// Returns N closest-in-color designs to a target hex via CIELAB ΔE distance.
+// Useful for "more like this in this palette" rails on the PDP. Read-only, no DB.
+function _hexToLab(hex) {
+  let h = String(hex || '').replace('#','').trim();
+  if (h.length === 3) h = h.split('').map(c => c+c).join('');
+  if (!/^[0-9a-f]{6}$/i.test(h)) return null;
+  let r = parseInt(h.slice(0,2),16)/255, g = parseInt(h.slice(2,4),16)/255, b = parseInt(h.slice(4,6),16)/255;
+  // sRGB → linear
+  [r,g,b] = [r,g,b].map(v => v <= 0.04045 ? v/12.92 : Math.pow((v+0.055)/1.055, 2.4));
+  // linear → XYZ (D65)
+  const X = (r*0.4124564 + g*0.3575761 + b*0.1804375) / 0.95047;
+  const Y = (r*0.2126729 + g*0.7151522 + b*0.0721750) / 1.00000;
+  const Z = (r*0.0193339 + g*0.1191920 + b*0.9503041) / 1.08883;
+  // XYZ → Lab
+  const f = (t) => t > 0.008856 ? Math.cbrt(t) : (7.787*t + 16/116);
+  const fx = f(X), fy = f(Y), fz = f(Z);
+  return [ 116*fy - 16, 500*(fx-fy), 200*(fy-fz) ];
+}
+function _dE(a, b) {
+  const dL = a[0]-b[0], dA = a[1]-b[1], dB = a[2]-b[2];
+  return Math.sqrt(dL*dL + dA*dA + dB*dB);
+}
+app.get('/api/designs/by-color', (req, res) => {
+  const hex = String(req.query.hex || '').trim();
+  const n = Math.max(1, Math.min(50, parseInt(req.query.n || '12', 10)));
+  const exclude = new Set(String(req.query.exclude || '').split(',').map(s => parseInt(s.trim(),10)).filter(x => x>0));
+  const target = _hexToLab(hex);
+  if (!target) return res.status(400).json({ ok:false, error:'invalid hex (try ?hex=%23c16d74 or hex=c16d74)' });
+  const ranked = [];
+  for (const d of DESIGNS) {
+    if (exclude.has(d.id)) continue;
+    if (!d.dominant_hex) continue;
+    const lab = _hexToLab(d.dominant_hex);
+    if (!lab) continue;
+    ranked.push({ id: d.id, title: d.title, category: d.category, handle: d.handle, image_url: d.image_url, dominant_hex: d.dominant_hex, room_mockups: d.room_mockups || [], delta_e: _dE(target, lab), url: `/design/${d.id}` });
+  }
+  ranked.sort((a,b) => a.delta_e - b.delta_e);
+  res.setHeader('Cache-Control', 'public, max-age=300, must-revalidate');
+  res.json({ hex, n, count: ranked.length, exclude: [...exclude], results: ranked.slice(0, n).map(r => ({ ...r, delta_e: Math.round(r.delta_e * 10)/10 })) });
+});
+
 // ── /api/designs/random?n=12&category=floral&seed=42
 // Returns N random designs. Useful for "Surprise me" rails + empty-state discovery.
 // Optional ?category= filter. Optional ?seed= for reproducible shuffles (otherwise time-seeded).
@@ -3912,6 +3954,57 @@ ${htmlHeader('/designs')}
       })();
       </script>
 
+      <!-- Wall-size calculator: enter W×H, get panel count + total yardage + estimated cost -->
+      <details class="wall-calc" style="margin:14px 0 0;padding:0;border:1px solid var(--line,#ddd);border-radius:6px;background:var(--card-bg,#faf8f3)">
+        <summary style="cursor:pointer;padding:10px 14px;font:12px var(--sans,system-ui);text-transform:uppercase;letter-spacing:.08em;color:var(--ink-soft,#555);list-style:none">
+          <span aria-hidden="true">📐</span> Wall-size calculator <span style="color:var(--ink-faint,#999);font-size:11px;text-transform:none;letter-spacing:0">— estimate panels, yardage, cost</span>
+        </summary>
+        <div style="padding:6px 14px 14px;display:grid;grid-template-columns:1fr 1fr auto;gap:10px;align-items:end">
+          <label style="font:11px var(--sans);color:var(--ink-soft);text-transform:uppercase;letter-spacing:.06em">Wall width (ft)<br><input id="wc-w" type="number" min="1" max="200" step="0.5" value="10" style="margin-top:4px;width:100%;padding:6px 8px;font:14px var(--sans);border:1px solid var(--line,#ddd);border-radius:4px"></label>
+          <label style="font:11px var(--sans);color:var(--ink-soft);text-transform:uppercase;letter-spacing:.06em">Wall height (ft)<br><input id="wc-h" type="number" min="1" max="40" step="0.5" value="9" style="margin-top:4px;width:100%;padding:6px 8px;font:14px var(--sans);border:1px solid var(--line,#ddd);border-radius:4px"></label>
+          <button type="button" id="wc-calc" style="padding:8px 16px;background:var(--ink,#111);color:var(--bg,#fff);border:0;border-radius:4px;cursor:pointer;font:13px var(--sans);height:fit-content">Calculate</button>
+        </div>
+        <div id="wc-result" style="padding:0 14px 14px;font:13px/1.5 var(--sans);color:var(--ink,#111);display:none"></div>
+      </details>
+      <script>
+      (function(){
+        var btn = document.getElementById('wc-calc');
+        if (!btn) return;
+        function compute() {
+          var w = parseFloat(document.getElementById('wc-w').value);
+          var h = parseFloat(document.getElementById('wc-h').value);
+          var box = document.getElementById('wc-result');
+          if (!isFinite(w) || !isFinite(h) || w<=0 || h<=0) { box.style.display='block'; box.innerHTML = '<span style="color:#b03a2e">Enter both width and height in feet.</span>'; return; }
+          // Read user's chosen panel width (24/27/36) from the existing material picker if present.
+          var savedWidth = 24;
+          try { var s = JSON.parse(localStorage.getItem('wallco.material') || 'null'); if (s && parseInt(s.w,10)) savedWidth = parseInt(s.w,10); } catch(e){}
+          var panelWidthInches = savedWidth; // panel width in inches
+          var panelLengthFt = 27; // standard roll length in ft (a typical 27 sq-ft single roll)
+          var wallSqFt = w * h;
+          var panelCoverageFt2 = (panelWidthInches/12) * panelLengthFt; // each panel covers this many sq-ft
+          // Match-waste factor: AI-original tiles are 24×24 in seamless so most patterns repeat cleanly. Use a 15% waste buffer.
+          var withWaste = wallSqFt * 1.15;
+          var panels = Math.ceil(withWaste / panelCoverageFt2);
+          var totalYards = +((panels * panelLengthFt) / 3).toFixed(1);
+          // Rough pricing tier: $7.50/sq-ft installed material list (typical mid-range custom wall, AI-original)
+          var lowEnd = Math.round(panels * panelCoverageFt2 * 6.5);
+          var highEnd = Math.round(panels * panelCoverageFt2 * 11.5);
+          box.style.display='block';
+          box.innerHTML =
+            '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:10px;margin-top:6px">' +
+              '<div><strong style="display:block;font:22px var(--serif,Georgia,serif);color:var(--ink,#111)">' + panels + '</strong><span style="font:11px var(--sans);color:var(--ink-faint,#999);text-transform:uppercase;letter-spacing:.06em">panels (' + panelWidthInches + '" wide)</span></div>' +
+              '<div><strong style="display:block;font:22px var(--serif,Georgia,serif);color:var(--ink,#111)">' + totalYards + ' yd</strong><span style="font:11px var(--sans);color:var(--ink-faint,#999);text-transform:uppercase;letter-spacing:.06em">total length</span></div>' +
+              '<div><strong style="display:block;font:22px var(--serif,Georgia,serif);color:var(--ink,#111)">' + wallSqFt.toFixed(0) + ' ft²</strong><span style="font:11px var(--sans);color:var(--ink-faint,#999);text-transform:uppercase;letter-spacing:.06em">wall area</span></div>' +
+              '<div><strong style="display:block;font:22px var(--serif,Georgia,serif);color:var(--ink,#111)">~$' + lowEnd.toLocaleString() + '–$' + highEnd.toLocaleString() + '</strong><span style="font:11px var(--sans);color:var(--ink-faint,#999);text-transform:uppercase;letter-spacing:.06em">material estimate</span></div>' +
+            '</div>' +
+            '<p style="margin:10px 0 0;font:11px/1.4 var(--sans);color:var(--ink-faint,#999)">Estimate uses ' + panelWidthInches + '" panel width, 27 ft panel length, 15% match-waste buffer. Final quote depends on substrate + installation. Sample first.</p>';
+        }
+        btn.addEventListener('click', compute);
+        document.getElementById('wc-w').addEventListener('keyup', function(e){ if (e.key==='Enter') compute(); });
+        document.getElementById('wc-h').addEventListener('keyup', function(e){ if (e.key==='Enter') compute(); });
+      })();
+      </script>
+
       <!-- Sample CTA (DW standing rule — non-negotiable) -->
       <div class="cta-group">
         <a id="cta-sample-link" href="/samples?design=${design.id}" class="btn-primary cta-sample">Request Free Sample</a>
@@ -5384,6 +5477,114 @@ app.post('/api/basket/submit', (req, res) => {
   res.json({ ok: true, request_id });
 });
 
+// ── /brief?ids=72,161,139 — multi-design proposal/brief PDF (print-friendly)
+// Stacks tearsheets for up to 6 designs with a cover page. Print to PDF and send to a client.
+app.get('/brief', (req, res) => {
+  const rawIds = String(req.query.ids || '').split(',').map(s => parseInt(s.trim(), 10)).filter(n => Number.isFinite(n) && n > 0).slice(0, 6);
+  const designs = rawIds.map(id => DESIGNS.find(d => d.id === id)).filter(Boolean);
+  const projectName = String(req.query.project || '').slice(0, 80);
+  const clientName = String(req.query.client || '').slice(0, 80);
+  const esc = (s) => String(s||'').replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));
+  if (!designs.length) {
+    return res.type('html').send(`<!doctype html><html><head><meta charset="utf-8"><title>Brief — wallco.ai</title>
+<style>body{font:14px/1.5 -apple-system,system-ui,sans-serif;max-width:600px;margin:60px auto;padding:24px;color:#333}a{color:#111}</style></head><body>
+<h1 style="font:300 32px/1.1 Georgia,serif">Build a design brief</h1>
+<p>Add up to 6 wallco.ai designs to a single proposal PDF — perfect for emailing to a client.</p>
+<p><strong>Usage:</strong> <code>/brief?ids=72,161,139&amp;client=Smith Residence&amp;project=Living Room</code></p>
+<p>Or visit any design page and add it to your <a href="/me/saved">Saved Designs</a>, then click <strong>Build proposal PDF</strong>.</p>
+<p>Alternatively, start a <a href="/compare">/compare</a>, then export the comparison as a brief.</p>
+</body></html>`);
+  }
+  const today = new Date().toISOString().slice(0, 10);
+  const tearsheet = (d) => {
+    const roomKey = (d.room_mockups || []).includes('living_room') ? 'living_room'
+                  : (d.room_mockups || []).includes('bedroom') ? 'bedroom'
+                  : (d.room_mockups || []).includes('office')  ? 'office'
+                  : (d.room_mockups || [])[0];
+    const roomImg = roomKey ? `/designs/room/design_${d.id}_${roomKey}.png` : null;
+    const motifs = (d.motifs || []).slice(0, 6).join(' · ');
+    return `<section class="brief-page" style="page-break-after:always;padding:36px 28px;border-bottom:1px dashed #ccc">
+      <div style="display:flex;justify-content:space-between;align-items:baseline;border-bottom:1px solid #1a1a1a;padding-bottom:8px;margin-bottom:16px">
+        <div style="font:12px/1 -apple-system,sans-serif;letter-spacing:.1em;text-transform:uppercase">wallco.ai</div>
+        <div style="font:11px/1 -apple-system,sans-serif;color:#666">Design #${d.id} · ${today}</div>
+      </div>
+      <h2 style="font:300 30px/1.1 Georgia,serif;margin:0 0 4px">${esc(d.title || 'Design #' + d.id)}</h2>
+      <div style="font:13px/1.4 -apple-system,sans-serif;color:#555;margin-bottom:14px">${esc(d.category || 'mixed')}${motifs ? ' · ' + esc(motifs) : ''}</div>
+      <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:14px">
+        <div style="aspect-ratio:1/1;background:#f4f1ea url('${esc(d.image_url)}') center/cover;border:1px solid #d8d2c4;border-radius:4px"></div>
+        ${roomImg
+          ? `<div style="aspect-ratio:1/1;background:#f4f1ea url('${esc(roomImg)}') center/cover;border:1px solid #d8d2c4;border-radius:4px"></div>`
+          : `<div style="aspect-ratio:1/1;background:#f4f1ea;border:1px solid #d8d2c4;border-radius:4px;display:flex;align-items:center;justify-content:center;font:11px -apple-system,sans-serif;color:#888;text-align:center;padding:20px">Room preview on request</div>`
+        }
+      </div>
+      <div style="padding:10px 14px;background:#faf8f3;border:1px solid #ebe5d6;border-radius:4px;margin-bottom:12px;display:flex;align-items:center;gap:10px;font:12px -apple-system,sans-serif">
+        <div style="width:24px;height:24px;border-radius:50%;background:${esc(d.dominant_hex || '#888')};border:1px solid rgba(0,0,0,.15);flex-shrink:0"></div>
+        <div><strong>Dominant color:</strong> ${esc(d.dominant_hex || 'n/a')}</div>
+      </div>
+      <dl style="display:grid;grid-template-columns:110px 1fr;gap:4px 12px;font:11px/1.5 -apple-system,sans-serif;margin:0">
+        <dt style="color:#666;text-transform:uppercase;letter-spacing:.06em;font-size:9px;padding-top:2px">SKU</dt><dd style="margin:0;color:#1a1a1a;font-weight:500">${esc(d.handle || 'wallco-' + String(d.id).padStart(4,'0'))}</dd>
+        <dt style="color:#666;text-transform:uppercase;letter-spacing:.06em;font-size:9px;padding-top:2px">Tile</dt><dd style="margin:0">24×24 in seamless · 150 DPI</dd>
+        <dt style="color:#666;text-transform:uppercase;letter-spacing:.06em;font-size:9px;padding-top:2px">Substrate</dt><dd style="margin:0">Type II vinyl · nonwoven · peel-and-stick · canvas mural</dd>
+        <dt style="color:#666;text-transform:uppercase;letter-spacing:.06em;font-size:9px;padding-top:2px">Fire</dt><dd style="margin:0">Class A available (ASTM E84)</dd>
+        <dt style="color:#666;text-transform:uppercase;letter-spacing:.06em;font-size:9px;padding-top:2px">Cleanability</dt><dd style="margin:0">Scrubbable (vinyl) · Spongeable (paper)</dd>
+        <dt style="color:#666;text-transform:uppercase;letter-spacing:.06em;font-size:9px;padding-top:2px">Lead Time</dt><dd style="margin:0">Sample 3–5 days · Production ~2 weeks</dd>
+        <dt style="color:#666;text-transform:uppercase;letter-spacing:.06em;font-size:9px;padding-top:2px">URL</dt><dd style="margin:0;font:11px ui-monospace,Menlo,monospace;word-break:break-all">wallco.ai/design/${d.id}</dd>
+      </dl>
+    </section>`;
+  };
+  res.type('html').send(`<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><title>${esc(projectName || clientName || 'Wallpaper Brief')} — wallco.ai</title>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<meta name="robots" content="noindex,nofollow">
+<style>
+  @page { size: letter portrait; margin: 0.5in; }
+  * { box-sizing: border-box; }
+  body { font-family: Georgia, 'Times New Roman', serif; color:#1a1a1a; margin:0; padding:0; background:#fff; }
+  .brief-cover { padding:60px 40px 40px; min-height:9in; display:flex; flex-direction:column; justify-content:space-between; page-break-after:always }
+  .brief-cover h1 { font:300 56px/1.05 Georgia, serif; margin:0 0 10px }
+  .brief-cover .meta { font:13px/1.6 -apple-system, system-ui, sans-serif; color:#444; margin-top:18px }
+  .brief-cover .meta strong { color:#1a1a1a }
+  .brief-cover .thumbs { display:grid; grid-template-columns: repeat(${Math.min(designs.length, 3)}, 1fr); gap:10px; margin:40px 0 }
+  .brief-cover .thumbs > div { aspect-ratio:1/1; background:#f4f1ea center/cover no-repeat; border:1px solid #d8d2c4; border-radius:4px }
+  .brief-cover .footer { font:11px/1.5 -apple-system, system-ui, sans-serif; color:#555; border-top:1px solid #ddd; padding-top:14px; margin-top:auto }
+  .no-print { padding: 16px 40px; background:#faf8f3; border-bottom:1px solid #eee }
+  .no-print button { padding:10px 20px; font:13px -apple-system, system-ui, sans-serif; background:#1a1a1a; color:#fff; border:0; border-radius:4px; cursor:pointer; margin-right:8px }
+  .no-print a { padding:10px 20px; font:13px -apple-system, system-ui, sans-serif; background:#fff; color:#1a1a1a; text-decoration:none; border:1px solid #1a1a1a; border-radius:4px; display:inline-block }
+  @media print { .no-print { display:none } }
+</style></head><body>
+<div class="no-print">
+  <button onclick="window.print()">Print or Save as PDF</button>
+  <a href="/me/saved">← Back to saved</a>
+</div>
+<section class="brief-cover">
+  <div>
+    <div style="font:11px/1 -apple-system, system-ui, sans-serif; letter-spacing:.14em; text-transform:uppercase; color:#666; margin-bottom:30px">wallco.ai · Design Brief</div>
+    <h1>${esc(projectName || 'Wallpaper Proposal')}</h1>
+    ${clientName ? `<div style="font:18px/1.3 Georgia,serif;color:#555;margin-top:8px">Prepared for ${esc(clientName)}</div>` : ''}
+    <div class="meta">
+      <strong>${designs.length} design${designs.length === 1 ? '' : 's'}</strong> ·
+      ${today} ·
+      Designer Wallcoverings, Sherman Oaks CA
+    </div>
+    <div class="thumbs">
+      ${designs.slice(0, 3).map(d => `<div style="background-image:url('${esc(d.image_url)}')" aria-label="${esc(d.title)}"></div>`).join('')}
+    </div>
+  </div>
+  <div class="footer">
+    <strong>Designer Wallcoverings</strong> · 15442 Ventura Bl #102, Sherman Oaks CA 91403<br>
+    <strong>info@designerwallcoverings.com</strong> · designerwallcoverings.com<br>
+    AI-original wallpaper, hand-curated. Free trade samples on request.
+  </div>
+</section>
+${designs.map(tearsheet).join('')}
+<section style="padding:40px;text-align:center;font:12px/1.6 -apple-system, system-ui, sans-serif;color:#555">
+  <p><strong>Next step</strong></p>
+  <p>Request samples for any of the ${designs.length} designs in this brief —<br>email <strong>info@designerwallcoverings.com</strong> or call to speak with our trade team.</p>
+  <p style="margin-top:18px;color:#888">${esc(designs.map(d => d.handle || 'wallco-' + d.id).join(' · '))}</p>
+</section>
+</body></html>`);
+});
+
 // ── MURALS
 app.get('/murals', (req, res) => {
   const q = String(req.query.q || '').toLowerCase().trim();

← 6db3b6b wallco.ai · trade login + saved designs — magic-link auth (n  ·  back to Wallco Ai  ·  wallco.ai · /design/:id/tearsheet now sends Cache-Control:pu 59a2afc →