← 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
M public/wear.htmlM server.js
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=>({'&':'&','<':'<','>':'>','"':'"'}[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 →