[object Object]

← back to CelebritySignatures

wear: buyer-adjustable signature ink + Node-ported color for the printed art

08f3529b1eb29472c95589ebbe998fc3a157f777 · 2026-09-09 15:33:35 -0700 · Steve

UI: a SIGNATURE INK control on the /wear product — Auto (the signer's own color,
white on dark cloth) + 7 curated inks + a Custom… full picker. The pick drives
the live shirt preview (inkHexFor honors the override) and rides the cart →
checkout → order item (validated server-side).

Print: ported inkColorForHue to server.js (identical FNV+HSL+WCAG-3:1 output,
verified against the client), generalized recolorSignatureToLight →
recolorSignatureInk(url, hex), and the POD draft now bakes the resolved ink into
the printed art — buyer's pick, else white on dark / signer color on light —
matching the preview. Client ink is strictly validated (#rrggbb) before it can
reach ImageMagick -fill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 08f3529b1eb29472c95589ebbe998fc3a157f777
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Sep 9 15:33:35 2026 -0700

    wear: buyer-adjustable signature ink + Node-ported color for the printed art
    
    UI: a SIGNATURE INK control on the /wear product — Auto (the signer's own color,
    white on dark cloth) + 7 curated inks + a Custom… full picker. The pick drives
    the live shirt preview (inkHexFor honors the override) and rides the cart →
    checkout → order item (validated server-side).
    
    Print: ported inkColorForHue to server.js (identical FNV+HSL+WCAG-3:1 output,
    verified against the client), generalized recolorSignatureToLight →
    recolorSignatureInk(url, hex), and the POD draft now bakes the resolved ink into
    the printed art — buyer's pick, else white on dark / signer color on light —
    matching the preview. Client ink is strictly validated (#rrggbb) before it can
    reach ImageMagick -fill.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/wear.html | 49 ++++++++++++++++++++++++++++++++++++++++++++++---
 server.js        | 55 ++++++++++++++++++++++++++++++++++++++++++++++---------
 2 files changed, 92 insertions(+), 12 deletions(-)

diff --git a/public/wear.html b/public/wear.html
index 5c75421..8d34ff5 100644
--- a/public/wear.html
+++ b/public/wear.html
@@ -101,6 +101,8 @@
   .chip.sel { border-color:var(--ink); background:var(--ink); color:#fff; }
   .swatch { width:30px; height:30px; border-radius:50%; border:2px solid #fff; box-shadow:0 0 0 1px var(--line); cursor:pointer; }
   .swatch.sel { box-shadow:0 0 0 2px var(--ink); }
+  .ink-auto.sel { box-shadow:0 0 0 2px var(--green); }
+  .chip.ink-custom { padding:5px 12px; font-size:12px; }
   .price { font:600 22px 'Playfair Display',serif; margin:10px 0; }
   .buy input[type=email] { width:100%; padding:10px 12px; border:1px solid var(--line); border-radius:8px; font:inherit; }
   .btn { display:inline-block; width:100%; text-align:center; margin-top:12px; padding:12px 20px; border:0; border-radius:999px; background:var(--ink); color:#fff; font:inherit; cursor:pointer; }
@@ -175,6 +177,7 @@
       <div class="who" id="pCat"></div>
       <div class="row"><div class="lab">Product</div><div class="chips" id="garments"></div></div>
       <div class="row"><div class="lab">Color</div><div class="chips" id="colors"></div></div>
+      <div class="row"><div class="lab">Signature ink</div><div class="chips" id="inkChips"></div><input type="color" id="inkCustom" hidden></div>
       <div class="row"><div class="lab">Size</div><div class="chips" id="sizes"></div><div class="fine" id="sizeNote"></div></div>
       <div class="price" id="price"></div>
       <div class="row"><input id="email" type="email" placeholder="your@email.com"></div>
@@ -201,7 +204,19 @@
 </div>
 
 <script>
-const state = { data:null, sig:null, garment:null, color:null, size:null };
+const state = { data:null, sig:null, garment:null, color:null, size:null, inkColor:null };
+// Curated signature-ink swatches (buyer override; null state.inkColor = Auto =
+// the signer's own color on light cloth / white on dark). "Custom…" opens the
+// full picker. Every preset is legible on white AND on a light garment.
+const INK_PRESETS = [
+  { id:'black',    label:'Black',    hex:'#1a1a1a' },
+  { id:'navy',     label:'Navy',     hex:'#1f3a5f' },
+  { id:'burgundy', label:'Burgundy', hex:'#6e1f2e' },
+  { id:'forest',   label:'Forest',   hex:'#234a34' },
+  { id:'gold',     label:'Gold',     hex:'#8a6a1e' },
+  { id:'sepia',    label:'Sepia',    hex:'#5a4632' },
+  { id:'white',    label:'White',    hex:'#ffffff' },
+];
 // Top-level HTML-attribute escape (the autocomplete's own `esc` is scoped to
 // that block, so the grid renderer needs its own).
 const escA = s => String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
@@ -258,7 +273,7 @@ document.getElementById('cartCheckout').onclick = async ()=>{
   const btn = document.getElementById('cartCheckout'); btn.disabled=true; btn.textContent='Starting checkout…';
   try{
     const r = await fetch('/api/wear-checkout',{method:'POST',headers:{'Content-Type':'application/json'},
-      body:JSON.stringify({ email, items: cart.map(i=>({ qid:i.qid, garment:i.garment, color:i.color, size:i.size })) })}).then(r=>r.json());
+      body:JSON.stringify({ email, items: cart.map(i=>({ qid:i.qid, garment:i.garment, color:i.color, size:i.size, inkColor:i.inkColor })) })}).then(r=>r.json());
     if(r.ok && r.url){ localStorage.removeItem(CART_KEY); location.href = r.url; return; }
     if(r.comingSoon){ msg.style.color='#2e7d32'; msg.textContent = `You're on the list — we'll email ${email} the moment sales go live.`; btn.textContent='On the list ✓'; return; }
     msg.textContent = r.error || 'Checkout unavailable.';
@@ -318,6 +333,7 @@ function render(){
 function openProduct(qid){
   const s = state.data.signatures.find(x=>x.qid===qid); if(!s) return;
   state.sig = s;
+  state.inkColor = null;                    // each signer starts on Auto (its own color)
   state.garment = state.data.garments[0];
   state.color = state.garment.colors[0];
   state.size = state.garment.sizes[Math.floor(state.garment.sizes.length/2)] || state.garment.sizes[0];
@@ -345,6 +361,7 @@ function buildOptions(){
   });
   C.innerHTML = state.garment.colors.map(c=>`<span class="swatch ${c.id===state.color.id?'sel':''}" data-c="${c.id}" title="${c.label}" style="background:${c.hex}"></span>`).join('');
   C.querySelectorAll('.swatch').forEach(el=>el.onclick=()=>{ state.color = state.garment.colors.find(c=>c.id===el.dataset.c); buildOptions(); draw(); });
+  buildInkChips();
   Z.innerHTML = state.garment.sizes.map(z=>`<span class="chip ${z===state.size?'sel':''}" data-z="${z}">${z}</span>`).join('');
   Z.querySelectorAll('.chip').forEach(el=>el.onclick=()=>{ state.size=el.dataset.z; buildOptions(); });
   const _up = (state.garment.sizeUpchargeUsd && state.garment.sizeUpchargeUsd[state.size]) || 0;
@@ -395,10 +412,35 @@ function recolorInk(img, hex){
 // be invisible. `window.__signerInk` is provided by a module bridge (below);
 // fall back to a legible dark ink if it hasn't loaded yet.
 function inkHexFor(col){
-  if (luminance(col.hex) < 0.4) return '#ffffff';                 // dark cloth → white
+  if (state.inkColor) return state.inkColor;                      // buyer's override wins
+  if (luminance(col.hex) < 0.4) return '#ffffff';                 // auto: white on dark cloth
+  const key = state.sig && (state.sig.qid || state.sig.full_name);
+  return (window.__signerInk && key) ? window.__signerInk(key) : '#1a1a1a';  // auto: signer's color
+}
+// The signer's own auto color (for the "Auto" swatch preview).
+function autoInkHex(){
   const key = state.sig && (state.sig.qid || state.sig.full_name);
   return (window.__signerInk && key) ? window.__signerInk(key) : '#1a1a1a';
 }
+// Render the SIGNATURE INK chips: Auto (signer's color) + curated presets +
+// Custom…, reflecting the current state.inkColor selection.
+function buildInkChips(){
+  const I = document.getElementById('inkChips'); if (!I) return;
+  const cur = state.inkColor;
+  const isPreset = cur && INK_PRESETS.some(p=>p.hex.toLowerCase()===cur.toLowerCase());
+  let html = `<span class="swatch ink-auto ${cur===null?'sel':''}" data-ink="auto" title="Auto — this signer's color (white on dark cloth)" style="background:${autoInkHex()}"></span>`;
+  html += INK_PRESETS.map(p=>`<span class="swatch ${cur&&cur.toLowerCase()===p.hex.toLowerCase()?'sel':''}" data-ink="${p.hex}" title="${p.label}" style="background:${p.hex}"></span>`).join('');
+  html += `<span class="chip ink-custom ${cur&&!isPreset?'sel':''}" data-ink="custom">${cur&&!isPreset?cur.toUpperCase():'Custom…'}</span>`;
+  I.innerHTML = html;
+  I.querySelectorAll('[data-ink]').forEach(el=>el.onclick=()=>{
+    const v = el.dataset.ink;
+    if (v==='auto'){ state.inkColor=null; buildOptions(); draw(); }
+    else if (v==='custom'){ const p=document.getElementById('inkCustom'); p.value = (state.inkColor||autoInkHex()); p.click(); }
+    else { state.inkColor=v; buildOptions(); draw(); }
+  });
+  const picker=document.getElementById('inkCustom');
+  if (picker && !picker._wired){ picker._wired=true; picker.oninput=()=>{ state.inkColor=picker.value; buildOptions(); draw(); }; }
+}
 
 // ---- 3D viewer (Three.js) — classic-tee only ----------------------------
 // Free MIT-licensed crew-neck tee model (Starklord17/threejs-t-shirt, itself
@@ -773,6 +815,7 @@ document.getElementById('buy').onclick = async ()=>{
       qid: state.sig.qid, signature_name: state.sig.full_name,
       garment: state.garment.id, garment_label: state.garment.label,
       color: state.color.id, color_label: state.color.label, size: state.size,
+      inkColor: state.inkColor,
       unitPriceUsd: cartUnitPrice(state.garment, state.size), thumb: state.sig.signature_image_url,
     });
     msg.style.color='#2e7d32'; msg.textContent = 'Added to cart ✓';
diff --git a/server.js b/server.js
index d8c0006..358abce 100644
--- a/server.js
+++ b/server.js
@@ -241,6 +241,37 @@ const wearLuminance = hex => {
   const n = parseInt(hex.slice(1), 16), r = (n>>16)&255, g = (n>>8)&255, b = n&255;
   return (0.299*r + 0.587*g + 0.114*b) / 255;
 };
+// ---- per-signer ink color (Node port of public/assets/signature-ink.js) -----
+// MUST stay in lockstep with the client inkColorForHue: same FNV-1a hash, same
+// HSL, same WCAG-3:1-on-white contrast guarantee — so the color baked into the
+// PRINTED art matches what the buyer saw in the on-shirt preview and the swatch.
+const _hslToRgb = (h, s, l) => {
+  if (s === 0) { const v = Math.round(l*255); return [v,v,v]; }
+  const q = l<0.5 ? l*(1+s) : l+s-l*s, p = 2*l-q;
+  const hk = t => { t=(t%1+1)%1;
+    if (t<1/6) return p+(q-p)*6*t;
+    if (t<1/2) return q;
+    if (t<2/3) return p+(q-p)*(2/3-t)*6;
+    return p; };
+  return [Math.round(hk(h+1/3)*255), Math.round(hk(h)*255), Math.round(hk(h-1/3)*255)];
+};
+const _relLuminance = ([r,g,b]) => {
+  const f = c => { c/=255; return c<=0.03928 ? c/12.92 : ((c+0.055)/1.055)**2.4; };
+  return 0.2126*f(r)+0.7152*f(g)+0.0722*f(b);
+};
+const INK_MAX_LUMINANCE = 0.26;
+const _rgbToHex = ([r,g,b]) => '#'+[r,g,b].map(v=>v.toString(16).padStart(2,'0')).join('');
+function inkColorHexFor(key) {
+  let h = 2166136261; const s = String(key||'');
+  for (let i=0;i<s.length;i++){ h^=s.charCodeAt(i); h=Math.imul(h,16777619); }
+  const hue = (h>>>0)%360;
+  let l = 0.42, rgb = _hslToRgb(hue/360, 0.62, l);
+  while (_relLuminance(rgb) > INK_MAX_LUMINANCE && l > 0.14) { l -= 0.02; rgb = _hslToRgb(hue/360, 0.62, l); }
+  return _rgbToHex(rgb);
+}
+// Sanitize a client-supplied ink color before it can reach ImageMagick's -fill;
+// only a literal #rrggbb is allowed, anything else falls back to the auto color.
+const safeInkHex = v => (typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v)) ? v.toLowerCase() : null;
 const RECOLOR_DIR = join(ROOT, 'public', 'assets', 'recolored-signatures');
 const WEAR_SITE_ORIGIN = 'https://celebsignatures.com';
 // Real print fix for the KNOWN ISSUE noted in wear-templates.json: Printify
@@ -254,9 +285,10 @@ const WEAR_SITE_ORIGIN = 'https://celebsignatures.com';
 // to the original image rather than blocking order/draft creation — a
 // slightly-low-contrast print is a quality issue, not a reason to lose the
 // customer's order record.
-async function recolorSignatureToLight(imageUrl) {
+async function recolorSignatureInk(imageUrl, hex) {
+  const ink = safeInkHex(hex) || '#f5f3ee';   // fall back to the old light ink
   try {
-    const hash = createHash('sha256').update(imageUrl).digest('hex').slice(0, 24);
+    const hash = createHash('sha256').update(imageUrl + '|' + ink).digest('hex').slice(0, 24);
     const outPath = join(RECOLOR_DIR, `${hash}.png`);
     // FULLY QUALIFIED — this URL gets set as design_image_url on the draft,
     // which flows straight into the Printify order payload as images[0].src;
@@ -272,7 +304,7 @@ async function recolorSignatureToLight(imageUrl) {
       // (convert), not the IMv7 'magick' unified CLI — verified via SSH.
       // 'convert' also exists on Mac2 (IMv7's back-compat shim, just prints a
       // deprecation warning to stderr, which this doesn't treat as failure).
-      const proc = spawn('convert', ['-background', 'none', '-', '-channel', 'RGB', '-fill', '#f5f3ee', '-colorize', '100%', 'png:-']);
+      const proc = spawn('convert', ['-background', 'none', '-', '-channel', 'RGB', '-fill', ink, '-colorize', '100%', 'png:-']);
       const chunks = []; let errText = '';
       const timer = setTimeout(() => { proc.kill(); reject(new Error('magick timeout')); }, 8000);
       proc.stdout.on('data', c => chunks.push(c));
@@ -285,7 +317,7 @@ async function recolorSignatureToLight(imageUrl) {
     await writeFile(outPath, out);
     return outUrl;
   } catch (e) {
-    console.error('recolorSignatureToLight failed, using original image:', e.message);
+    console.error('recolorSignatureInk failed, using original image:', e.message);
     return imageUrl;
   }
 }
@@ -934,7 +966,8 @@ ${paid ? `<div class="ok">✓</div><h1>Order confirmed</h1>
         const amountCents = Math.round((baseUsd + upchargeUsd) * 100);
         resolvedItems.push({ qid: qidOf(sig), signature_name: sig.full_name, garment: garment.id, garment_label: garment.label,
           color: color.id, color_label: color.label, size, placement: garment.placement || tpl.placement || 'left_chest',
-          placementLabel: garment.placementLabel || 'Signature printed at the left chest.', amountCents });
+          placementLabel: garment.placementLabel || 'Signature printed at the left chest.',
+          inkColor: safeInkHex(it.inkColor), amountCents });   // buyer's chosen ink (validated; null = auto)
       }
       const u = await currentUser(req);
       const list = await load('wear-orders.json', []);
@@ -1020,13 +1053,17 @@ ${paid ? `<div class="ok">✓</div><h1>Order confirmed</h1>
                   if (alreadyDrafted.has(compositeId)) continue;
                   const sig = sigsAll.find(x => qidOf(x) === it.qid);
                   let designUrl = sig ? sig.signature_image_url : null;
-                  // Recolor to a light ink on a dark garment — see
-                  // recolorSignatureToLight()'s comment for why this matters
-                  // (Printify prints the source art's own colors as-is).
+                  // Bake the ink color into the printed art (Printify prints the
+                  // source pixels as-is): the buyer's chosen color if they picked
+                  // one, else the auto rule — white on a dark garment (a color
+                  // would vanish), the signer's own distinct color on a light one.
+                  // Matches the on-shirt preview's inkHexFor() exactly.
                   if (designUrl) {
                     const g = (draftTpl.garments || []).find(x => x.id === it.garment);
                     const c = g && (g.colors || []).find(x => x.id === it.color);
-                    if (c && wearLuminance(c.hex) < 0.4) designUrl = await recolorSignatureToLight(designUrl);
+                    const darkCloth = !!(c && wearLuminance(c.hex) < 0.4);
+                    const ink = safeInkHex(it.inkColor) || (darkCloth ? '#ffffff' : inkColorHexFor(it.qid));
+                    designUrl = await recolorSignatureInk(designUrl, ink);
                   }
                   const draft = { draftedAt: new Date().toISOString(), orderId: compositeId, status: 'DRAFT_UNSENT', provider,
                     recipient_email: order.email, recipient: order.recipient, garment: it.garment, color: it.color, size: it.size,

← b5ebc16 signature-size: make the mobile grid slider actually resize  ·  back to CelebritySignatures  ·  5x sweep 1: record M3 false-negative (ink control is modal-g ec820c7 →