← 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
A colorwheel/index.htmlA colorwheel/server.jsA colorwheel/upload-12-images.js
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 →