[object Object]

← back to Dw Marketing Reels

shop-by-color motion wheel (local preview :9851, proxies live color-index endpoint) + drafted 12-image Shopify upload script (Steve-run/gated)

5ca3fe3ea37ad326b3296fd676d2b3052cf038d3 · 2026-07-16 10:26:23 -0700 · Steve Abrams

Files touched

Diff

commit 5ca3fe3ea37ad326b3296fd676d2b3052cf038d3
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 16 10:26:23 2026 -0700

    shop-by-color motion wheel (local preview :9851, proxies live color-index endpoint) + drafted 12-image Shopify upload script (Steve-run/gated)
---
 colorwheel/index.html          | 171 +++++++++++++++++++++++++++++++++++++++++
 colorwheel/server.js           |  41 ++++++++++
 colorwheel/upload-12-images.js |  74 ++++++++++++++++++
 3 files changed, 286 insertions(+)

diff --git a/colorwheel/index.html b/colorwheel/index.html
new file mode 100644
index 0000000..82ae038
--- /dev/null
+++ b/colorwheel/index.html
@@ -0,0 +1,171 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Designer Wallcoverings — Shop by Color</title>
+<style>
+  :root{ --ink:#1a1a1a; --paper:#faf8f4; --line:#e6e0d6; --gold:#b9892f; }
+  *{box-sizing:border-box}
+  body{margin:0;background:radial-gradient(1200px 800px at 50% -10%, #fff, var(--paper));
+    color:var(--ink);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;
+    min-height:100vh}
+  header{text-align:center;padding:34px 16px 8px}
+  header h1{margin:0;font-weight:300;letter-spacing:.18em;text-transform:uppercase;font-size:22px}
+  header p{margin:6px 0 0;color:#7a7266;font-size:13px;letter-spacing:.04em}
+  .stage{display:flex;gap:40px;align-items:center;justify-content:center;flex-wrap:wrap;padding:22px 16px 8px}
+  .wheelwrap{position:relative;width:340px;height:340px;flex:0 0 auto}
+  #wheel{width:100%;height:100%;border-radius:50%;cursor:crosshair;
+    box-shadow:0 10px 40px rgba(0,0,0,.18), inset 0 0 0 1px rgba(0,0,0,.06);
+    animation:spin 40s linear infinite}
+  .wheelwrap.dragging #wheel{animation-play-state:paused}
+  @keyframes spin{to{transform:rotate(360deg)}}
+  /* selector ring sits ABOVE the spinning wheel and does not rotate */
+  #pick{position:absolute;width:34px;height:34px;border-radius:50%;border:3px solid #fff;
+    box-shadow:0 2px 10px rgba(0,0,0,.35), 0 0 0 1px rgba(0,0,0,.25);transform:translate(-50%,-50%);
+    left:50%;top:14%;pointer-events:none;transition:box-shadow .2s}
+  .hub{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:118px;height:118px;
+    border-radius:50%;background:#fff;box-shadow:0 6px 22px rgba(0,0,0,.16), inset 0 0 0 1px var(--line);
+    display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center}
+  .hub .sw{width:52px;height:52px;border-radius:50%;box-shadow:inset 0 0 0 1px rgba(0,0,0,.12);
+    transition:background .15s;background:#8aa}
+  .hub .hex{font-size:12px;letter-spacing:.08em;color:#6b6357;margin-top:8px;font-variant-numeric:tabular-nums}
+  .hub .cnt{font-size:11px;color:var(--gold);margin-top:2px;min-height:14px}
+  .side{max-width:300px}
+  .side h2{font-weight:400;font-size:15px;letter-spacing:.06em;text-transform:uppercase;margin:0 0 8px}
+  .side p{color:#7a7266;font-size:13px;line-height:1.6;margin:0 0 14px}
+  .chips{display:flex;flex-wrap:wrap;gap:8px}
+  .chip{width:30px;height:30px;border-radius:8px;cursor:pointer;box-shadow:inset 0 0 0 1px rgba(0,0,0,.12);
+    transition:transform .15s}
+  .chip:hover{transform:translateY(-3px) scale(1.08)}
+  .grid{max-width:1180px;margin:16px auto 60px;padding:0 18px;
+    display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:18px}
+  .card{position:relative;text-decoration:none;color:inherit;background:#fff;border:1px solid var(--line);
+    border-radius:12px;overflow:hidden;opacity:0;transform:translateY(14px) scale(.98);
+    animation:pop .5s forwards;box-shadow:0 1px 0 rgba(0,0,0,.03)}
+  @keyframes pop{to{opacity:1;transform:none}}
+  .card:hover{box-shadow:0 12px 30px rgba(0,0,0,.12)}
+  .card .im{aspect-ratio:1/1;width:100%;object-fit:cover;display:block;background:#f2efe9}
+  .card .cap{padding:10px 12px;font-size:12.5px;line-height:1.35}
+  .card .cap i{display:block;color:#9a9276;font-style:normal;font-size:11px;margin-top:3px}
+  .card .dot{position:absolute;top:8px;right:8px;width:14px;height:14px;border-radius:50%;
+    box-shadow:0 0 0 2px #fff, inset 0 0 0 1px rgba(0,0,0,.15)}
+  .empty{grid-column:1/-1;text-align:center;color:#9a9276;padding:40px;font-size:14px}
+  .loading{grid-column:1/-1;text-align:center;color:var(--gold);padding:40px;font-size:13px;letter-spacing:.1em}
+  .loading:after{content:'';display:inline-block;width:0;overflow:hidden;vertical-align:bottom;
+    animation:dots 1.2s steps(4,end) infinite}
+  @keyframes dots{to{width:1.2em}}
+</style>
+</head>
+<body>
+  <header>
+    <h1>Shop by Color</h1>
+    <p>Spin the wheel · click a hue · discover every wallcovering in that color</p>
+  </header>
+
+  <div class="stage">
+    <div class="wheelwrap" id="ww">
+      <canvas id="wheel" width="680" height="680"></canvas>
+      <div id="pick"></div>
+      <div class="hub">
+        <div class="sw" id="sw"></div>
+        <div class="hex" id="hex">#5F7060</div>
+        <div class="cnt" id="cnt"></div>
+      </div>
+    </div>
+    <div class="side">
+      <h2>Curated by hue</h2>
+      <p>Every color on the wheel maps to the actual dominant colors extracted from our
+         full catalog. Pick a shade and we surface the closest perceptual matches — the
+         same color intelligence behind each product page.</p>
+      <div class="chips" id="chips"></div>
+    </div>
+  </div>
+
+  <div class="grid" id="grid"></div>
+
+<script>
+const cv=document.getElementById('wheel'), ctx=cv.getContext('2d'), R=cv.width/2;
+// paint the hue/saturation wheel (angle=hue, radius=saturation, fixed lightness)
+(function paint(){
+  const img=ctx.createImageData(cv.width,cv.height), d=img.data;
+  for(let y=0;y<cv.height;y++)for(let x=0;x<cv.width;x++){
+    const dx=x-R,dy=y-R,r=Math.sqrt(dx*dx+dy*dy),i=(y*cv.width+x)*4;
+    if(r>R){d[i+3]=0;continue;}
+    let h=Math.atan2(dy,dx)*180/Math.PI; if(h<0)h+=360;
+    const s=Math.min(1,r/R), l=0.5;
+    const [rr,gg,bb]=hsl2rgb(h,s,l);
+    d[i]=rr;d[i+1]=gg;d[i+2]=bb;d[i+3]=255;
+  }
+  ctx.putImageData(img,0,0);
+})();
+function hsl2rgb(h,s,l){h/=360;let r,g,b;if(s===0){r=g=b=l;}else{
+  const q=l<.5?l*(1+s):l+s-l*s,p=2*l-q;const t=(a)=>{if(a<0)a+=1;if(a>1)a-=1;
+    if(a<1/6)return p+(q-p)*6*a;if(a<1/2)return q;if(a<2/3)return p+(q-p)*(2/3-a)*6;return p;};
+  r=t(h+1/3);g=t(h);b=t(h-1/3);}return[Math.round(r*255),Math.round(g*255),Math.round(b*255)];}
+function rgbHex(r,g,b){return '#'+[r,g,b].map(x=>x.toString(16).padStart(2,'0')).join('');}
+
+const ww=document.getElementById('ww'), pick=document.getElementById('pick'),
+  sw=document.getElementById('sw'), hexEl=document.getElementById('hex'),
+  cnt=document.getElementById('cnt'), grid=document.getElementById('grid');
+let curHue=140, curSat=0.45;
+
+function place(hue,sat){ // position the (non-rotating) selector ring
+  const ang=(hue)*Math.PI/180, rad=sat*48; // 48% of half-size
+  pick.style.left=(50+Math.cos(ang)*rad)+'%';
+  pick.style.top=(50+Math.sin(ang)*rad)+'%';
+  const [r,g,b]=hsl2rgb(hue,sat,0.5), hx=rgbHex(r,g,b);
+  pick.style.background=hx; sw.style.background=hx; hexEl.textContent=hx.toUpperCase();
+  return hx;
+}
+function fromEvent(e){
+  const rect=cv.getBoundingClientRect();
+  const cx=e.touches?e.touches[0].clientX:e.clientX, cy=e.touches?e.touches[0].clientY:e.clientY;
+  const dx=cx-(rect.left+rect.width/2), dy=cy-(rect.top+rect.height/2);
+  let h=Math.atan2(dy,dx)*180/Math.PI; if(h<0)h+=360;
+  const s=Math.min(1,Math.sqrt(dx*dx+dy*dy)/(rect.width/2));
+  curHue=h; curSat=Math.max(.12,s); return place(curHue,curSat);
+}
+let t=null;
+function selected(hx){ clearTimeout(t); t=setTimeout(()=>load(hx),120); }
+ww.addEventListener('pointerdown',e=>{ww.classList.add('dragging');const hx=fromEvent(e);selected(hx);
+  const mv=ev=>selected(fromEvent(ev)); const up=()=>{ww.classList.remove('dragging');
+  window.removeEventListener('pointermove',mv);window.removeEventListener('pointerup',up);};
+  window.addEventListener('pointermove',mv);window.addEventListener('pointerup',up);});
+
+// quick-pick chips (curated hues)
+const CHIPS=[[0,.6],[25,.6],[45,.55],[90,.45],[140,.45],[175,.5],[205,.55],[230,.5],[275,.45],[320,.5],[0,0.03],[0,0.0]];
+const chips=document.getElementById('chips');
+CHIPS.forEach(([h,s])=>{const c=document.createElement('div');c.className='chip';
+  const[r,g,bl]=hsl2rgb(h, s, s<0.06?0.85:0.5);c.style.background=rgbHex(r,g,bl);
+  c.onclick=()=>{curHue=h;curSat=Math.max(.12,s);const hx=place(h,s);load(hx);};chips.appendChild(c);});
+
+async function load(hex){
+  grid.innerHTML='<div class="loading">Finding wallcoverings in this color</div>';
+  cnt.textContent='';
+  try{
+    const r=await fetch('/api/colors',{method:'POST',headers:{'Content-Type':'application/json'},
+      body:JSON.stringify({hex,k:48})});
+    const j=await r.json(); const res=(j.results||[]);
+    cnt.textContent=(j.total_in_tolerance!=null? j.total_in_tolerance+' matches':'');
+    if(!res.length){grid.innerHTML='<div class="empty">No close matches for this shade yet — try a neighboring hue.</div>';return;}
+    grid.innerHTML='';
+    res.forEach((p,i)=>{
+      if(!p.handle||!p.image)return;
+      const a=document.createElement('a');a.className='card';a.href='https://designerwallcoverings.com/products/'+encodeURIComponent(p.handle);
+      a.target='_blank';a.rel='noopener';a.style.animationDelay=(i*18)+'ms';
+      const im=document.createElement('img');im.className='im';im.loading='lazy';im.src=p.image;
+      im.onerror=()=>a.remove();
+      const dot=document.createElement('span');dot.className='dot';dot.style.background=p.hex||hex;
+      const cap=document.createElement('div');cap.className='cap';
+      cap.textContent=(p.title||'').replace(/\s*\|\s*.*$/,'');
+      if(p.vendor){const v=document.createElement('i');v.textContent=p.vendor;cap.appendChild(v);}
+      a.append(im,dot,cap);grid.appendChild(a);
+    });
+  }catch(e){grid.innerHTML='<div class="empty">Could not load right now. '+e.message+'</div>';}
+}
+// initial
+const hx0=place(curHue,curSat); load(hx0);
+</script>
+</body>
+</html>
diff --git a/colorwheel/server.js b/colorwheel/server.js
new file mode 100644
index 0000000..08adeaf
--- /dev/null
+++ b/colorwheel/server.js
@@ -0,0 +1,41 @@
+#!/usr/bin/env node
+// Tiny zero-dep server for the Shop-by-Color motion wheel. Serves index.html and
+// proxies POST /api/colors → the live color-index endpoint (server-side, so the
+// browser never hits a CORS wall).
+import http from 'node:http';
+import https from 'node:https';
+import { readFile } from 'node:fs/promises';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+const ROOT = dirname(fileURLToPath(import.meta.url));
+const PORT = Number(process.env.PORT || 9851);
+const ENDPOINT = 'https://photo.designerwallcoverings.com/apps/color-index';
+
+function proxy(body) {
+  return new Promise((resolve, reject) => {
+    const u = new URL(ENDPOINT);
+    const req = https.request({ hostname: u.hostname, path: u.pathname, method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'Origin': 'https://designerwallcoverings.com',
+        'Content-Length': Buffer.byteLength(body) } },
+      (r) => { let d = ''; r.on('data', c => d += c); r.on('end', () => resolve(d)); });
+    req.on('error', reject); req.write(body); req.end();
+  });
+}
+
+http.createServer(async (req, res) => {
+  try {
+    if (req.method === 'POST' && req.url === '/api/colors') {
+      let b = ''; req.on('data', c => b += c);
+      req.on('end', async () => {
+        try { const out = await proxy(b || '{}');
+          res.writeHead(200, { 'content-type': 'application/json' }).end(out); }
+        catch (e) { res.writeHead(502, { 'content-type': 'application/json' })
+          .end(JSON.stringify({ results: [], error: e.message })); }
+      });
+      return;
+    }
+    const html = await readFile(join(ROOT, 'index.html'));
+    res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(html);
+  } catch (e) { res.writeHead(500).end('err: ' + e.message); }
+}).listen(PORT, () => console.log(`Shop-by-Color wheel → http://127.0.0.1:${PORT}`));
diff --git a/colorwheel/upload-12-images.js b/colorwheel/upload-12-images.js
new file mode 100644
index 0000000..e7e0736
--- /dev/null
+++ b/colorwheel/upload-12-images.js
@@ -0,0 +1,74 @@
+#!/usr/bin/env node
+// Attach the 12 sourced images as featured media on their live Shopify products.
+// Flow per product: stagedUploadsCreate → multipart POST to the staged target →
+// productCreateMedia(originalSource=resourceUrl). APPROVED gated write (2026-07-16).
+import https from 'node:https';
+import { readFile } from 'node:fs/promises';
+import { extname } from 'node:path';
+
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const STORE = 'designer-laboratory-sandbox.myshopify.com', API = '2024-10';
+const DIR = '/Users/macstudio3/Projects/dw-marketing-reels/sourced-images/imageless-14';
+const ITEMS = [
+  ['EUR-71130', 'gid://shopify/Product/7515289845811', 'W7814-01__w7814-01_n6lger3vo8gdn9ly.webp'],
+  ['EUR-71059', 'gid://shopify/Product/7515288109107', 'W7682-01__w7682-01_ff8svfx2jzq6e1ez.webp'],
+  ['EUR-70743', 'gid://shopify/Product/7515278704691', 'W7024-03__w7024-03.webp'],
+  ['EUR-70486', 'gid://shopify/Product/7514199457843', 'W6541-02__4591.webp'],
+  ['EUR-70279', 'gid://shopify/Product/7514192871475', 'W5223-03__w5223-03.webp'],
+  ['EUR-70883', 'gid://shopify/Product/7515283554355', 'W7334-1__w7334-01.webp'],
+  ['EUR-70328', 'gid://shopify/Product/7514194444339', 'EUR-70328__5314.webp'],
+  ['EUR-70402', 'gid://shopify/Product/7514196901939', 'EUR-70402__5053.webp'],
+  ['EUR-70351', 'gid://shopify/Product/7514195066931', 'EUR-70351__5223.webp'],
+  ['EUR-80361', 'gid://shopify/Product/7513505431603', 'NCW4352-05_bonnelles.jpg'],
+  ['EUR-80261', 'gid://shopify/Product/7513501499443', 'NCW4270-03__coromandel-offwhite.webp'],
+  ['EUR-80263', 'gid://shopify/Product/7513501564979', 'NCW4270-05__coromandel-offwhite.webp'],
+];
+const mime = f => { const e = extname(f).toLowerCase(); return e === '.jpg' || e === '.jpeg' ? 'image/jpeg' : e === '.png' ? 'image/png' : 'image/webp'; };
+
+function gql(query, variables) {
+  const body = JSON.stringify({ query, variables });
+  return new Promise((resolve, reject) => {
+    const req = https.request({ hostname: STORE, path: `/admin/api/${API}/graphql.json`, method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': TOKEN, 'Content-Length': Buffer.byteLength(body) } },
+      r => { let d = ''; r.on('data', c => d += c); r.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(new Error(d.slice(0, 200))); } }); });
+    req.on('error', reject); req.write(body); req.end();
+  });
+}
+function uploadToTarget(target, fileBuf, filename, mimeType) {
+  return new Promise((resolve, reject) => {
+    const boundary = '----dw' + Date.now();
+    const parts = [];
+    for (const p of target.parameters) parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="${p.name}"\r\n\r\n${p.value}\r\n`);
+    parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${mimeType}\r\n\r\n`);
+    const body = Buffer.concat([Buffer.from(parts.join(''), 'utf8'), fileBuf, Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8')]);
+    const u = new URL(target.url);
+    const req = https.request({ hostname: u.hostname, path: u.pathname + u.search, method: 'POST',
+      headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': body.length } },
+      res => { let d = ''; res.on('data', c => d += c); res.on('end', () => (res.statusCode >= 200 && res.statusCode < 300) ? resolve() : reject(new Error('upload ' + res.statusCode + ' ' + d.slice(0, 160)))); });
+    req.on('error', reject); req.write(body); req.end();
+  });
+}
+
+(async () => {
+  if (!TOKEN) { console.error('Missing SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
+  let ok = 0, fail = 0;
+  for (const [sku, pid, file] of ITEMS) {
+    try {
+      const buf = await readFile(`${DIR}/${file}`);
+      const mt = mime(file);
+      const sr = await gql(`mutation($input:[StagedUploadInput!]!){ stagedUploadsCreate(input:$input){ stagedTargets{ url resourceUrl parameters{ name value } } userErrors{ message } } }`,
+        { input: [{ filename: file, mimeType: mt, resource: 'IMAGE', httpMethod: 'POST', fileSize: String(buf.length) }] });
+      const tgt = sr.data?.stagedUploadsCreate?.stagedTargets?.[0];
+      const se = sr.data?.stagedUploadsCreate?.userErrors;
+      if (!tgt) throw new Error('staged: ' + JSON.stringify(se || sr.errors));
+      await uploadToTarget(tgt, buf, file, mt);
+      const cr = await gql(`mutation($id:ID!,$media:[CreateMediaInput!]!){ productCreateMedia(productId:$id, media:$media){ media{ status } mediaUserErrors{ message } } }`,
+        { id: pid, media: [{ originalSource: tgt.resourceUrl, mediaContentType: 'IMAGE' }] });
+      const me = cr.data?.productCreateMedia?.mediaUserErrors;
+      if (me && me.length) throw new Error('attach: ' + JSON.stringify(me));
+      console.log(`OK  ${sku}  ${cr.data.productCreateMedia.media[0].status}  ${file}`);
+      ok++;
+    } catch (e) { console.log(`ERR ${sku}: ${e.message}`); fail++; }
+  }
+  console.log(`\nUploaded ${ok}/${ITEMS.length}, ${fail} failed.`);
+})();

← 51d3d22 Source real product images for 12/14 imageless DW products v  ·  back to Dw Marketing Reels  ·  colorwheel: widen to 15% ΔE band (big data set), fix spin/cl 8098337 →