[object Object]

← back to CelebritySignatures

signature-viewer: fix ink-collision crash + add user-adjustable signature size

abc8f51dd1aff894e5da2b44c1aa6a743f63775c · 2026-09-09 14:44:41 -0700 · Steve

- signature-ink.js: rename the pixel counter (was `let ink` shadowing the
  `ink` color param) → hard SyntaxError that broke the whole preview module.
  The broken build had shipped to prod; unit test now 5/5, module chain parses.
- New size controls (both surfaces): a "Signature size" slider resizes gallery
  tile signatures (--sig-h); a −/+/Reset stepper resizes the popup preview
  (--ap-sig-h). Both persist in localStorage prefs; null keeps the responsive
  default. Verified headless on desktop + mobile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ikfY9DhpMDXkpNRpb5ufG

Files touched

Diff

commit abc8f51dd1aff894e5da2b44c1aa6a743f63775c
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Sep 9 14:44:41 2026 -0700

    signature-viewer: fix ink-collision crash + add user-adjustable signature size
    
    - signature-ink.js: rename the pixel counter (was `let ink` shadowing the
      `ink` color param) → hard SyntaxError that broke the whole preview module.
      The broken build had shipped to prod; unit test now 5/5, module chain parses.
    - New size controls (both surfaces): a "Signature size" slider resizes gallery
      tile signatures (--sig-h); a −/+/Reset stepper resizes the popup preview
      (--ap-sig-h). Both persist in localStorage prefs; null keeps the responsive
      default. Verified headless on desktop + mobile.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_014ikfY9DhpMDXkpNRpb5ufG
---
 public/assets/signature-ink.js        |  72 +++++++++++++++++++++++
 public/assets/signature-preview.css   |  30 ++++++++++
 public/assets/signature-preview.js    | 108 ++++++++++++++++++++++++++++++++++
 public/index.html                     |  98 +++++++++++++++++++++++++-----
 test/signature-ink.test.mjs           |  43 ++++++++++++++
 verification/signature-viewer.e2e.cjs |  64 ++++++++++++++++++++
 6 files changed, 401 insertions(+), 14 deletions(-)

diff --git a/public/assets/signature-ink.js b/public/assets/signature-ink.js
new file mode 100644
index 0000000..42e881c
--- /dev/null
+++ b/public/assets/signature-ink.js
@@ -0,0 +1,72 @@
+// Display-only cleanup. Never redraw handwriting or modify the archive source.
+// `ink` optionally recolors the extracted strokes to a per-signature hue (RGB
+// triple, 0-255) rendered on a clean white ground; omit it for the classic
+// near-black. The archive pixels are never touched — this only decides what
+// color the display copy paints the already-thresholded ink.
+export function normalizeInk({data,width,height},ink) {
+  const ir=ink?ink[0]:26, ig=ink?ink[1]:26, ib=ink?ink[2]:26;
+  if (!width || !height || data.length !== width*height*4 || width*height>4000000) throw new Error('Invalid image');
+  const gray=new Uint8Array(width*height), hist=new Uint32Array(256), border=[];
+  for(let y=0;y<height;y++) for(let x=0;x<width;x++) {
+    const p=y*width+x,i=p*4,a=data[i+3]/255;
+    const g=Math.round((.2126*data[i]+.7152*data[i+1]+.0722*data[i+2])*a+255*(1-a));
+    gray[p]=g;
+    if(x===0||y===0||x===width-1||y===height-1) border.push(g);
+  }
+  border.sort((a,b)=>a-b);
+  // A uniform dark background with light ink is displayed in reverse polarity.
+  const invert=border[Math.floor(border.length*.5)]<100 && border[Math.floor(border.length*.9)]-border[Math.floor(border.length*.1)]<28;
+  for(let p=0;p<gray.length;p++) { if(invert)gray[p]=255-gray[p];hist[gray[p]]++; }
+  let total=0;for(let i=0;i<256;i++)total+=i*hist[i];
+  let weight=0,sum=0,best=-1,threshold=0;
+  for(let i=0;i<255;i++) {
+    weight+=hist[i];sum+=i*hist[i];
+    if(!weight||weight===gray.length)continue;
+    const between=weight*(gray.length-weight)*(sum/weight-(total-sum)/(gray.length-weight))**2;
+    if(between>best){best=between;threshold=i;}
+  }
+  if(best<=0)throw new Error('No distinct ink found');
+  // Otsu separates even faded ink from aged paper without a fixed cutoff.
+  // Keep the original frame: automatic cropping could discard a flourish.
+  const out=new Uint8ClampedArray(data.length);let inkPixels=0;
+  for(let p=0;p<gray.length;p++) {
+    const isInk=gray[p]<=threshold; if(isInk)inkPixels++;
+    const i=p*4;
+    if(isInk){out[i]=ir;out[i+1]=ig;out[i+2]=ib;} else {out[i]=out[i+1]=out[i+2]=255;}
+    out[i+3]=255;
+  }
+  if(!inkPixels||inkPixels===gray.length)throw new Error('No distinct ink found');
+  return {data:out,width,height,threshold,inverted:invert};
+}
+
+// Stable per-signature ink color: same key (a person's name) always maps to the
+// same hue, so every version of one signer shares a color and the gallery reads
+// as a lively spread rather than a wall of black. Fixed saturation/lightness are
+// tuned to stay legible on a white ground (dark + saturated enough to read as
+// real colored ink, never a pale wash). Returns an [r,g,b] triple.
+export function inkColorFor(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;
+  return hslToRgb(hue/360, 0.62, 0.40);
+}
+function 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)];
+}
+
+export function imageSource(value,base='https://celebsignatures.com') {
+  try {
+    const u=new URL(value,base);
+    if(!['https:','http:'].includes(u.protocol)||u.username||u.password)return null;
+    if(u.protocol==='http:' && u.hostname!=='localhost' && u.hostname!=='127.0.0.1')u.protocol='https:';
+    return u.href;
+  } catch {return null;}
+}
diff --git a/public/assets/signature-preview.css b/public/assets/signature-preview.css
new file mode 100644
index 0000000..0776c25
--- /dev/null
+++ b/public/assets/signature-preview.css
@@ -0,0 +1,30 @@
+/* Pure white grounds in every gallery view, including float/ledger/wall. */
+body[data-layout] .card .sig, .signature-preview, .ap-sig { background:#fff; }
+.signature-preview { display:flex; align-items:center; justify-content:center; width:100%; height:100%; min-width:0; }
+.signature-preview .signature-image { display:block; max-width:100%; max-height:100%; object-fit:contain; background:#fff; }
+.signature-fallback { filter:grayscale(1) contrast(3) brightness(1.15); }
+.signature-message { padding:8px; font:12px/1.4 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; color:#555; text-align:center; }
+.signature-message button { font:inherit; color:#1a1a1a; border:0; border-bottom:1px solid; background:#fff; padding:2px; cursor:pointer; }
+.ap-sig { position:relative; padding:16px 0 12px; }
+.ap-sig .ap-portrait { position:absolute; left:0; bottom:12px; width:38px; height:38px; margin:0; }
+.ap-sig-main { height:var(--ap-sig-h,230px); }
+.ap-sig-main .signature-image { max-height:var(--ap-sig-h,230px); max-width:100%; }
+.ap-sig-size { display:flex; align-items:center; justify-content:center; gap:8px; margin:2px 0 6px; }
+.ap-sig-size button { font:600 15px/1 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; min-width:30px; height:28px; padding:0 9px; border:1px solid #d3d0c8; border-radius:7px; background:#fff; color:#333; cursor:pointer; }
+.ap-sig-size button:last-child { font-size:12px; font-weight:500; color:#666; }
+.ap-sig-size button:hover { border-color:#234334; }
+.ap-sig-size button:focus-visible { outline:2px solid #234334; outline-offset:2px; }
+.ap-sig-size .ap-sz-val { font-size:11px; color:#888; min-width:44px; text-align:center; }
+.ap-selected-caption { margin:8px 0 12px; color:#555; font-size:12px; text-align:center; }
+.ap-selected-caption a { color:#333; text-underline-offset:3px; }
+.evo-strip { display:grid; grid-template-columns:repeat(auto-fill,minmax(125px,1fr)); gap:10px; }
+.evo-cell { display:flex; flex-direction:column; align-items:stretch; gap:5px; width:100%; min-height:102px; padding:7px; border:1px solid #dedbd5; border-radius:8px; background:#fff; cursor:pointer; color:#333; font:inherit; }
+.evo-cell .signature-preview { height:65px; }
+.evo-cell .signature-image { border:0; border-radius:0; padding:0; max-height:65px; max-width:100%; }
+.evo-cell .y { font-size:11px; line-height:1.35; }
+.evo-cell[aria-pressed=true] { border:2px solid #234334; padding:6px; box-shadow:0 0 0 2px #dce8e0; }
+.evo-cell:hover { border-color:#234334; }
+.evo-cell:focus-visible { outline:3px solid #234334; outline-offset:3px; }
+.ap-picker-help { font-size:12px; color:#666; margin:4px 0 10px; }
+.ap-preview-status { font-size:12px; color:#555; min-height:18px; text-align:center; }
+@media(max-width:700px){ :root{--ap-sig-h:185px;} .evo-strip{grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;} .evo-cell{min-height:95px;padding:5px;} .evo-cell[aria-pressed=true]{padding:4px;} }
diff --git a/public/assets/signature-preview.js b/public/assets/signature-preview.js
new file mode 100644
index 0000000..7f3a887
--- /dev/null
+++ b/public/assets/signature-preview.js
@@ -0,0 +1,108 @@
+import {normalizeInk,imageSource,inkColorFor} from './signature-ink.js';
+const cache=new Map(), tokens=new WeakMap(), queue=[];
+const resolvedSources=new Map();
+let running=0;
+function schedule(job,priority) {
+  return new Promise((resolve,reject)=>{const item={job,resolve,reject};priority?queue.unshift(item):queue.push(item);pump();});
+}
+function pump() {
+  while(running<4 && queue.length){const {job,resolve,reject}=queue.shift();running++;Promise.resolve().then(job).then(resolve,reject).finally(()=>{running--;pump();});}
+}
+function load(url,cors=true) {
+  return new Promise((resolve,reject)=>{
+    const img=new Image();if(cors)img.crossOrigin='anonymous';img.referrerPolicy='no-referrer';
+    const timer=setTimeout(()=>finish(new Error('Image timed out')),12000);
+    function finish(error){clearTimeout(timer);img.onload=img.onerror=null;error?reject(error):resolve(img);}
+    img.onload=()=>finish();img.onerror=()=>finish(new Error('Image unavailable'));img.src=url;
+  });
+}
+async function resolveSource(url) {
+  const u=new URL(url),match=u.pathname.match(/^\/wiki\/(?:Special:FilePath\/|File:)(.+)$/i);
+  if(u.hostname!=='commons.wikimedia.org'||!match)return url;
+  const title='File:'+decodeURIComponent(match[1]);
+  if(!resolvedSources.has(title)){
+    const request=(async()=>{
+      const params=new URLSearchParams({action:'query',format:'json',origin:'*',prop:'imageinfo',iiprop:'url',iiurlwidth:'1200',titles:title});
+      const response=await fetch('https://commons.wikimedia.org/w/api.php?'+params,{signal:AbortSignal.timeout(10000)});
+      if(!response.ok)throw new Error('Source lookup unavailable');
+      const data=await response.json(),info=Object.values(data.query?.pages||{})[0]?.imageinfo?.[0];
+      const direct=imageSource(info?.thumburl||info?.url);
+      if(!direct||!['upload.wikimedia.org','thumb.wikimedia.org'].includes(new URL(direct).hostname))throw new Error('No source image');
+      return direct;
+    })();
+    resolvedSources.set(title,request);request.catch(()=>resolvedSources.delete(title));
+    if(resolvedSources.size>192)resolvedSources.delete(resolvedSources.keys().next().value);
+  }
+  return resolvedSources.get(title);
+}
+async function convert(source,ink) {
+  let url=source;try{url=await resolveSource(source);}catch{}
+  let img;
+  try {img=await load(url);} catch {
+    // Some archives deny CORS even though their image is publicly viewable.
+    await load(url,false);return {url,mode:'fallback'};
+  }
+  try {
+    const scale=Math.min(1,1600/Math.max(img.naturalWidth,img.naturalHeight));
+    const canvas=document.createElement('canvas');canvas.width=Math.max(1,Math.round(img.naturalWidth*scale));canvas.height=Math.max(1,Math.round(img.naturalHeight*scale));
+    const ctx=canvas.getContext('2d',{willReadFrequently:true});ctx.drawImage(img,0,0,canvas.width,canvas.height);
+    const result=normalizeInk(ctx.getImageData(0,0,canvas.width,canvas.height),ink);
+    ctx.putImageData(new ImageData(result.data,result.width,result.height),0,0);
+    return {url:canvas.toDataURL('image/png'),mode:'clean'};
+  } catch { return {url,mode:'fallback'}; }
+}
+function preview(url,ink,priority,retry) {
+  // Ink color is part of the identity of the cleaned image, so it keys the cache.
+  const key=url+'|'+(ink?ink.join(','):'-');
+  if(retry)cache.delete(key);
+  if(cache.has(key))return cache.get(key);
+  const p=schedule(()=>convert(url,ink),priority);cache.set(key,p);
+  p.catch(()=>{if(cache.get(key)===p)cache.delete(key);});
+  // Bound retained pixel previews, including long gallery browsing sessions.
+  if(cache.size>96)cache.delete(cache.keys().next().value);
+  return p;
+}
+export async function renderInto(target,source,{priority=false,retry=false,label='Signature',tintKey=null}={}) {
+  const token={};tokens.set(target,token);
+  target.dataset.phase='loading';target.replaceChildren();
+  const waiting=document.createElement('span');waiting.className='signature-message';waiting.textContent='Loading signature…';target.append(waiting);
+  const url=imageSource(source,location.href);
+  // A signature's distinct ink color is stable per signer. Prefer an explicit
+  // tint key (the person's name) so every version of one signer shares a color;
+  // fall back to the label, then the source URL.
+  const ink=inkColorFor(tintKey || (label||'').replace(/^Signature of\s+/i,'') || url || source);
+  try {
+    if(!url)throw new Error('Unsupported image source');
+    const result=await preview(url,ink,priority,retry);
+    if(tokens.get(target)!==token || !target.isConnected)return;
+    const img=new Image();img.alt=label;img.className='signature-image';img.src=result.url;
+    await img.decode();
+    if(tokens.get(target)!==token || !target.isConnected)return;
+    target.replaceChildren(img);target.dataset.phase=result.mode;target.dataset.source=url;
+    if(result.mode==='fallback'){
+      img.classList.add('signature-fallback');
+      target.title='High-contrast source preview. Automatic cleanup is unavailable for this source.';
+    } else target.removeAttribute('title');
+    target.dispatchEvent(new CustomEvent('signaturepreviewchange',{bubbles:true}));return result.mode;
+  } catch {
+    if(tokens.get(target)!==token || !target.isConnected)return;
+    target.dataset.phase='error';target.removeAttribute('data-source');
+    const box=document.createElement('span');box.className='signature-message';box.textContent='Preview unavailable. ';
+    // Do not nest buttons inside the variant selector buttons.
+    if(!target.closest('button')){
+      const button=document.createElement('button');button.type='button';button.textContent='Try again';
+      button.onclick=e=>{e.stopPropagation();renderInto(target,source,{priority:true,retry:true,label,tintKey:target.dataset.signatureTintKey});};box.append(button);
+    } else box.append('Select to retry.');
+    target.replaceChildren(box);target.dispatchEvent(new CustomEvent('signaturepreviewchange',{bubbles:true}));return 'error';
+  }
+}
+const observer=new IntersectionObserver(entries=>{
+  for(const e of entries)if(e.isIntersecting){observer.unobserve(e.target);if(e.target.isConnected)renderInto(e.target,e.target.dataset.signatureSrc,{label:e.target.dataset.signatureLabel,tintKey:e.target.dataset.signatureTintKey});}
+},{rootMargin:'120px'});
+export function watch(root=document) {
+  for(const el of root.querySelectorAll('[data-signature-src]:not([data-preview-watched])')){el.dataset.previewWatched='true';observer.observe(el);}
+}
+watch();
+new MutationObserver(records=>{for(const r of records)for(const node of r.addedNodes)if(node.nodeType===1){if(node.matches('[data-signature-src]')){node.dataset.previewWatched='true';observer.observe(node);}watch(node);}
+  for(const r of records)for(const node of r.removedNodes)if(node.nodeType===1){observer.unobserve(node);node.querySelectorAll('[data-signature-src]').forEach(el=>observer.unobserve(el));}
+}).observe(document.body,{childList:true,subtree:true});
diff --git a/public/index.html b/public/index.html
index e54aa11..9ec4a99 100644
--- a/public/index.html
+++ b/public/index.html
@@ -37,7 +37,7 @@
   main { padding:22px 28px 60px; }
   .grid { display:grid; grid-template-columns:repeat(var(--cols), 1fr); gap:10px; }
   .card { background:#fffef9; border:1px solid #ece8df; border-radius:4px; overflow:hidden; display:flex; flex-direction:column; box-shadow:0 1px 3px rgba(0,0,0,.06), 0 4px 16px rgba(0,0,0,.04); }
-  .sig { height:88px; display:flex; align-items:center; justify-content:center; padding:8px; background:#fff; border-bottom:1px solid var(--line); cursor:pointer; }
+  .sig { height:var(--sig-h,88px); display:flex; align-items:center; justify-content:center; padding:8px; background:#fff; border-bottom:1px solid var(--line); cursor:pointer; }
   .sig img { max-width:100%; max-height:100%; object-fit:contain; }
   .meta { padding:6px 8px 8px; display:flex; flex-direction:column; gap:3px; flex:1; }
   .name { font-weight:600; font-size:13px; line-height:1.25; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
@@ -290,6 +290,7 @@
     .campaign .rendering { color:rgba(244,241,234,.5); }
   }
 </style>
+<link rel="stylesheet" href="/assets/signature-preview.css">
 <!-- Google Analytics 4 (G-2HEVP6TD0J) -->
 <script async src="https://www.googletagmanager.com/gtag/js?id=G-2HEVP6TD0J"></script>
 <script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','G-2HEVP6TD0J');</script>
@@ -428,6 +429,9 @@
     <div class="density"><label>Density</label>
       <input id="density" type="range" min="3" max="9" value="5">
     </div>
+    <div class="density"><label>Signature size</label>
+      <input id="sigSize" type="range" min="56" max="220" step="4" value="88" aria-label="Signature size across the gallery">
+    </div>
     <div><label>View</label></div>
     <div class="chips layout-chips" id="layoutChips"></div>
   </div>
@@ -448,8 +452,9 @@
 </div>
 
 <script>
+const signaturePreview = import('/assets/signature-preview.js');
 const LS = 'celebsig.prefs';
-const prefs = Object.assign({ cat:'All', use:'all', sort:'cat', density:5, view:'grid' }, JSON.parse(localStorage.getItem(LS)||'{}'));
+const prefs = Object.assign({ cat:'All', use:'all', sort:'cat', density:5, view:'grid', gridSigH:null, apSigH:null }, JSON.parse(localStorage.getItem(LS)||'{}'));
 let DATA = [];
 const $ = (s) => document.querySelector(s);
 const notability = (r) => { const m=(r.reason_for_ranking||'').match(/(\d+) language/); return m?+m[1]:0; };
@@ -518,7 +523,7 @@ function render() {
     const qid = (r.wikidata||'').split('/').pop();
     const evoN = (EVO[qid]?.sigs?.length) || 0;
     return `<div class="card" data-qid="${qid}">
-      <div class="sig" role="button" tabindex="0" aria-label="Open details for ${r.full_name}"><img loading="lazy" src="${r.signature_image_url}" alt="Signature of ${r.full_name}" onerror="this.parentNode.innerHTML='<span style=color:#bbb;font-size:12px>image unavailable</span>'"></div>
+      <div class="sig" role="button" tabindex="0" aria-label="Open details for ${esc(r.full_name)}"><span class="signature-preview" data-signature-src="${esc(r.signature_image_url)}" data-signature-label="Signature of ${esc(r.full_name)}" data-signature-tint-key="${esc(r.full_name)}"></span></div>
       <div class="meta">
         <div class="name" title="${esc(r.full_name)}">${r.full_name}</div>
         <a class="info-chip" data-qid="${qid}" href="/a/${qid}">${PORTRAITS[qid]?`<img class="chip-face" loading="lazy" referrerpolicy="no-referrer" src="${esc(PORTRAITS[qid])}" alt="" onerror="this.remove()">`:''}Details${evoN>1?` · ${evoN} signatures`:''}</a>
@@ -535,6 +540,23 @@ function buildChips() {
     document.querySelectorAll('.chip').forEach(x=>x.classList.toggle('on', x.dataset.c===c)); render(); };
 }
 $('#density').oninput = (e) => { prefs.density=+e.target.value; save(); render(); };
+// User-adjustable signature size: grid tiles (--sig-h) and the popup preview
+// (--ap-sig-h). Both persist in prefs; a null value keeps the responsive default.
+function applySigSizes() {
+  const root=document.documentElement.style;
+  if(prefs.gridSigH){ root.setProperty('--sig-h', prefs.gridSigH+'px'); } else { root.removeProperty('--sig-h'); }
+  if(prefs.apSigH){ root.setProperty('--ap-sig-h', prefs.apSigH+'px'); } else { root.removeProperty('--ap-sig-h'); }
+  const s=$('#sigSize'); if(s) s.value=prefs.gridSigH||88;
+}
+$('#sigSize').oninput = (e) => { prefs.gridSigH=+e.target.value; save(); document.documentElement.style.setProperty('--sig-h', prefs.gridSigH+'px'); };
+const AP_SIG_DEFAULT = () => matchMedia('(max-width:700px)').matches?185:230;
+function apSizeStep(dir){
+  if(dir===0){ prefs.apSigH=null; document.documentElement.style.removeProperty('--ap-sig-h'); }
+  else { const base=prefs.apSigH||AP_SIG_DEFAULT(); const v=Math.min(520,Math.max(120,base+dir*30)); prefs.apSigH=v; document.documentElement.style.setProperty('--ap-sig-h', v+'px'); }
+  save(); updateApSizeLabel();
+}
+function updateApSizeLabel(){ const el=$('#apSizeVal'); if(el) el.textContent=(prefs.apSigH||AP_SIG_DEFAULT())+'px'; }
+applySigSizes();
 // ── left drawer (Amazon-style browse panel) ──
 function openDrawer(){ $('#drawer').classList.add('open'); $('#dback').hidden=false; $('#burger').setAttribute('aria-expanded','true'); }
 function closeDrawer(){ $('#drawer').classList.remove('open'); $('#dback').hidden=true; $('#burger').setAttribute('aria-expanded','false'); }
@@ -601,12 +623,51 @@ document.querySelector('.tabs').onclick = (e) => { const v=e.target.dataset.v; i
 // ── artist details popup: all metadata + signature evolution + works from the
 // Met Open Access API (free, PD images, CORS-open) loaded on demand ──
 let EVO = {}, BY_QID = {}, WORKS_CACHE = {}, PORTRAITS = {};
+let AP_ACTIVE_QID=null, AP_SELECTION={}, AP_REQUEST=0;
+document.addEventListener('signaturepreviewchange',event=>{
+  if(event.target.id!=='apSignature')return;
+  const mode=event.target.dataset.phase;
+  $('#apPreviewStatus').textContent=mode==='clean'?'Signature ink on white':mode==='fallback'?'High-contrast source preview · automatic cleanup unavailable':'Source preview unavailable. Try again or open the original source.';
+});
+function signatureVariants(qid) {
+  const r=DATA.find(x=>qidOf(x)===qid); if(!r)return [];
+  return [{url:r.signature_image_url,file:'Main signature',license:r.image_license,primary:true,sourceURL:r.backup_source},
+    ...(EVO[qid]?.sigs||[]).map(s=>({...s,sourceURL:s.url}))];
+}
 function evoStripHTML(qid){
-  const e=EVO[qid];
-  if(!e||!e.sigs||e.sigs.length<2) return '';
-  return `<div class="ap-sec">✍ Signature over time (${e.sigs.length})</div><div class="evo-strip">${
-    e.sigs.map(s=>`<div class="evo-cell"><img loading="lazy" src="${esc(s.url)}" alt="Signature of ${esc(e.name)}${s.year?' ('+s.year+')':''}" onerror="this.closest('.evo-cell').remove()"><div class="y">${s.year||'undated'}</div></div>`).join('')
-  }</div>`;
+  const variants=signatureVariants(qid);
+  const person=EVO[qid]?.name||DATA.find(r=>qidOf(r)===qid)?.full_name||qid;
+  return `<div class="ap-sec">Choose a signature (${variants.length})</div><p class="ap-picker-help">Select any version to see it above. Archival pages remain complete; original sources are preserved.</p><div class="evo-strip" aria-label="Signature versions">${variants.map((s,i)=>
+    `<button class="evo-cell" type="button" data-variant="${i}" aria-pressed="false" aria-controls="apSignature" aria-label="Select signature ${i+1}: ${esc(s.file)}" title="${esc(s.file)}"><span class="signature-preview" data-signature-src="${esc(s.url)}" data-signature-label="${esc(s.file)}" data-signature-tint-key="${esc(person)}"></span><span class="y">${s.primary?'Main signature':`Version ${i}${s.year?' · file label '+esc(s.year):' · undated'}`}</span></button>`).join('')}</div>`;
+}
+async function selectSignature(index,{scroll=true,retry=false}={}) {
+  const qid=AP_ACTIVE_QID,variants=signatureVariants(qid),s=variants[index];if(!s)return;
+  const request=++AP_REQUEST;AP_SELECTION[qid]=s.url;
+  const target=$('#apSignature');if(!target)return;
+  target.dataset.variant=String(index);
+  document.querySelectorAll('.evo-cell').forEach(b=>b.setAttribute('aria-pressed',String(+b.dataset.variant===index)));
+  $('#apSelectedCaption').textContent=`${s.primary?'Main signature':`Version ${index}`} · ${index+1} of ${variants.length}`;
+  $('#apSelectedLicense').textContent=s.license||'License not recorded for this source';
+  $('#apCatalogUse').hidden=!s.primary;
+  const source=$('#apSourceLink');
+  try {const u=new URL(s.sourceURL||s.url,location.href);source.href=['http:','https:'].includes(u.protocol)?u.href:'#';} catch {source.removeAttribute('href');}
+  source.title=s.file;
+  $('#apPreviewStatus').textContent='Loading selected signature…';
+  if(scroll)document.querySelector('.ap-card').scrollTo({top:0,behavior:'instant'});
+  const renderer=await signaturePreview;
+  if(request!==AP_REQUEST || qid!==AP_ACTIVE_QID)return;
+  const person=EVO[qid]?.name||DATA.find(r=>qidOf(r)===qid)?.full_name||qid;
+  const mode=await renderer.renderInto(target,s.url,{priority:true,retry,label:`${person} — ${s.file}`,tintKey:person});
+  if(request!==AP_REQUEST || qid!==AP_ACTIVE_QID)return;
+  $('#apPreviewStatus').textContent=mode==='clean'?'Signature ink on white':mode==='fallback'?'High-contrast source preview · automatic cleanup unavailable':'Source preview unavailable. Try again or open the original source.';
+  const thumb=document.querySelector(`.evo-cell[data-variant="${index}"] .signature-preview`);
+  if(mode==='clean'&&thumb?.dataset.phase==='error')renderer.renderInto(thumb,s.url,{label:s.file,tintKey:person});
+}
+function refreshSignaturePicker() {
+  if(!AP_ACTIVE_QID||$('#artistPop').hidden)return;
+  $('#apVariants').innerHTML=evoStripHTML(AP_ACTIVE_QID);
+  const variants=signatureVariants(AP_ACTIVE_QID),index=variants.findIndex(s=>s.url===AP_SELECTION[AP_ACTIVE_QID]);
+  selectSignature(Math.max(0,index),{scroll:false});
 }
 // Works loader — free open-museum APIs, tried in order until we have 6:
 // The Met (Open Access) → Art Institute of Chicago (PD only) → Cleveland
@@ -685,21 +746,24 @@ async function loadBooks(qid, name){
 function openArtist(qid, push=true){
   const r = BY_QID[qid] || DATA.find(x=>(x.wikidata||'').endsWith('/'+qid));
   if (!r) return;
+  AP_ACTIVE_QID=qid;
   const ub=r.usable_in_commercial_collage;
   const uClass=ub==='yes'?'use-yes':ub==='permission-needed'?'use-perm':'use-review';
   const uLabel=ub==='yes'?'Free to use':ub==='permission-needed'?'Permission needed':'Verify first';
   const isArtist = r.category==='Artists';
   const portrait = PORTRAITS[qid];
   $('#apBody').innerHTML = `<div class="ap-inner">
-    <div class="ap-sig">${portrait?`<img class="ap-portrait" referrerpolicy="no-referrer" src="${esc(portrait)}" alt="Portrait of ${esc(r.full_name)}" onerror="this.remove()">`:''}<img src="${r.signature_image_url}" alt="Signature of ${esc(r.full_name)}"></div>
+    <div class="ap-sig">${portrait?`<img class="ap-portrait" referrerpolicy="no-referrer" src="${esc(portrait)}" alt="Portrait of ${esc(r.full_name)}" onerror="this.remove()">`:''}<div class="signature-preview ap-sig-main" id="apSignature"></div></div>
+    <div class="ap-sig-size" role="group" aria-label="Signature size"><button type="button" class="ap-sz" data-sz="-1" aria-label="Decrease signature size">−</button><span class="ap-sz-val" id="apSizeVal" aria-live="polite"></span><button type="button" class="ap-sz" data-sz="1" aria-label="Increase signature size">+</button><button type="button" class="ap-sz" data-sz="0" aria-label="Reset signature size">Reset</button></div>
+    <div class="ap-selected-caption" id="apSelectedCaption"></div><div class="ap-preview-status" id="apPreviewStatus" role="status" aria-live="polite"></div>
     <div class="ap-name">${esc(r.full_name)}</div>
     <div class="ap-shop">${signatureShopLinks(r)}</div>
     <div class="ap-life">${r.deceased==='yes'?'† '+esc(r.death_date||''):'● living'} · ${esc((r.reason_for_ranking||'').replace('Cross-wiki notability: ',''))}</div>
-    <div class="ap-rowline"><span class="tag">${drill('cat',r.category)}</span><span class="badge ${uClass}">${drill('use',ub,uLabel)}</span></div>
+    <div class="ap-rowline"><span class="tag">${drill('cat',r.category)}</span><span id="apCatalogUse" class="badge ${uClass}">${drill('use',ub,uLabel)}</span></div>
     ${Array.isArray(r.museums)&&r.museums.length?`<div class="ap-sec">In the collections of</div><div class="ap-sub">${esc(r.museums.join(' · '))}</div>`:''}
     <div class="ap-sec">License & sources</div>
-    <div class="ap-sub">${esc(r.image_license||'')} · <a href="${esc(r.wikidata)}" target="_blank" rel="noopener noreferrer">Wikidata</a> · <a href="${esc(r.backup_source)}" target="_blank" rel="noopener noreferrer">signature source</a></div>
-    ${evoStripHTML(qid)}
+    <div class="ap-sub"><span id="apSelectedLicense"></span> · <a href="${esc(r.wikidata)}" target="_blank" rel="noopener noreferrer">Wikidata</a> · <a id="apSourceLink" target="_blank" rel="noopener noreferrer">Open original source</a></div>
+    <div id="apVariants"></div>
     ${isArtist
       ? `<div class="ap-sec">Works — open museum collections</div><div class="works" id="apWorks"><span class="ap-sub">loading works…</span></div>`
       : `${portrait ? `<div class="ap-sec">Portrait</div><div class="ap-figure"><img referrerpolicy="no-referrer" src="${esc(portrait.replace('width=120','width=520'))}" alt="Portrait of ${esc(r.full_name)}" onerror="this.closest('.ap-figure').remove()"><div class="ap-sub">Public-domain / CC portrait via Wikimedia Commons</div></div>` : ''}<div class="ap-sec">Books</div><div class="works" id="apWorks"><span class="ap-sub">loading books…</span></div>`}
@@ -707,6 +771,9 @@ function openArtist(qid, push=true){
   <div class="ap-cta">${signatureShopLinks(r)}</div>`;
   const pop=$('#artistPop');
   pop.hidden = false;
+  document.querySelector('.ap-card').scrollTop=0;
+  updateApSizeLabel();
+  refreshSignaturePicker();
   pop.setAttribute('role','dialog'); pop.setAttribute('aria-modal','true');
   pop.setAttribute('aria-label', r.full_name + ' — signature details');
   POP_RETURN_FOCUS = document.activeElement;
@@ -714,7 +781,7 @@ function openArtist(qid, push=true){
   document.body.style.overflow = 'hidden';
   if (push) { const u=new URLSearchParams(location.search); u.set('artist',qid); history.pushState({},'', '?'+u.toString()); }
   const renderTiles = (ws, emptyHtml) => {
-    const el=$('#apWorks'); if(!el) return;
+    const el=$('#apWorks'); if(!el||AP_ACTIVE_QID!==qid) return;
     el.innerHTML = ws.length ? ws.map(w=>`<a href="${esc(w.url)}" target="_blank" rel="noopener noreferrer"><img loading="lazy" referrerpolicy="no-referrer" src="${esc(w.img)}" alt="${esc(w.title)}" onerror="this.closest('a').remove()"><span class="wt">${esc(w.title)}${w.date?' · '+esc(w.date):''}${w.src?' · '+esc(w.src):''}</span></a>`).join('')
       : emptyHtml;
   };
@@ -727,11 +794,14 @@ let POP_RETURN_FOCUS = null;
 function closeArtist(push=true){
   if ($('#artistPop').hidden) return;
   $('#artistPop').hidden = true;
+  AP_ACTIVE_QID=null;AP_REQUEST++;
   document.body.style.overflow = '';
   if (POP_RETURN_FOCUS && POP_RETURN_FOCUS.focus) { POP_RETURN_FOCUS.focus(); POP_RETURN_FOCUS = null; }
   if (push) { const u=new URLSearchParams(location.search); u.delete('artist'); const q=u.toString(); history.pushState({},'', q?('?'+q):location.pathname); }
 }
 document.addEventListener('click', (e)=>{
+  const sz=e.target.closest('.ap-sz'); if(sz){ apSizeStep(+sz.dataset.sz); return; }
+  const variant=e.target.closest('.evo-cell');if(variant){selectSignature(+variant.dataset.variant,{retry:variant.querySelector('[data-phase=error]')!==null});return;}
   const c=e.target.closest('a.info-chip'); if(c){ e.preventDefault(); openArtist(c.dataset.qid); return; }
   // clicking the signature itself opens the same details popup (artwork/books)
   const s=e.target.closest('.card .sig'); if(s){ const card=s.closest('.card'); if(card&&card.dataset.qid){ openArtist(card.dataset.qid); return; } }
@@ -744,7 +814,7 @@ document.addEventListener('keydown', (e)=>{
 function maybeOpenFromURL(){ const q=new URLSearchParams(location.search).get('artist'); if(q&&DATA.length) openArtist(q,false); else if(!q) closeArtist(false); }
 
 fetch('/api/signatures').then(r=>r.json()).then(d => { DATA=d; readURL(); buildChips(); render(); setView(prefs.view); maybeOpenFromURL(); });
-fetch('/api/signature-evolution').then(r=>r.ok?r.json():{}).then(e=>{ EVO=e||{}; if(DATA.length) render(); }).catch(()=>{});
+fetch('/api/signature-evolution').then(r=>r.ok?r.json():{}).then(e=>{ EVO=e||{}; if(DATA.length) render(); refreshSignaturePicker(); }).catch(()=>{});
 fetch('/api/portraits').then(r=>r.ok?r.json():{}).then(p=>{ PORTRAITS=p||{}; if(DATA.length) render(); }).catch(()=>{});
 addEventListener('popstate', maybeOpenFromURL);
 </script>
diff --git a/test/signature-ink.test.mjs b/test/signature-ink.test.mjs
new file mode 100644
index 0000000..385e4ef
--- /dev/null
+++ b/test/signature-ink.test.mjs
@@ -0,0 +1,43 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {normalizeInk,imageSource,inkColorFor} from '../public/assets/signature-ink.js';
+function fixture(paper,ink){const data=new Uint8ClampedArray(32*16*4);for(let p=0;p<512;p++)data.set(p>=240&&p<256?ink:paper,p*4);return {data,width:32,height:16};}
+test('aged paper, colored ink and faded ink become only dark ink and opaque white',()=>{
+  for(const f of [fixture([226,205,151,255],[60,75,100,255]),fixture([245,241,230,255],[195,185,181,255]),fixture([0,0,0,0],[0,0,0,255])]){
+    const out=normalizeInk(f);let dark=0;
+    for(let i=0;i<out.data.length;i+=4){const c=out.data[i];assert.ok(c===26||c===255);assert.equal(out.data[i+1],c);assert.equal(out.data[i+2],c);assert.equal(out.data[i+3],255);if(c===26)dark++;}
+    assert.equal(dark,16);assert.equal(out.width,f.width);assert.equal(out.height,f.height);
+  }
+});
+test('light ink on a uniform dark ground is inverted without cutting the image frame',()=>{
+  const f=fixture([12,12,12,255],[230,230,230,255]);const out=normalizeInk(f);
+  assert.equal(out.inverted,true);assert.equal(out.data[240*4],26);assert.equal(out.data[0],255);
+});
+test('blank images fail explicitly and source URLs cannot inject scripts or credentials',()=>{
+  assert.throws(()=>normalizeInk(fixture([255,255,255,255],[255,255,255,255])));
+  assert.equal(imageSource('javascript:alert(1)'),null);assert.equal(imageSource('https://user:pass@example.com/a'),null);
+  assert.equal(imageSource('http://commons.wikimedia.org/a'),'https://commons.wikimedia.org/a');
+});
+
+test('an ink color recolors the strokes to that color on a clean white ground',()=>{
+  const f=fixture([245,241,230,255],[60,75,100,255]);
+  const out=normalizeInk(f,[180,40,60]);let ink=0;
+  for(let i=0;i<out.data.length;i+=4){
+    const r=out.data[i],g=out.data[i+1],b=out.data[i+2];
+    const isInk=(r===180&&g===40&&b===60), isPaper=(r===255&&g===255&&b===255);
+    assert.ok(isInk||isPaper);                 // only the tint or clean white
+    assert.equal(out.data[i+3],255);           // fully opaque
+    if(isInk)ink++;
+  }
+  assert.equal(ink,16);                          // same strokes, now colored
+});
+test('inkColorFor is stable per key, varies across keys, and stays legible on white',()=>{
+  const a=inkColorFor('Charles Darwin'), a2=inkColorFor('Charles Darwin');
+  assert.deepEqual(a,a2);                        // deterministic
+  assert.notDeepEqual(a,inkColorFor('Abraham Lincoln'));
+  for(const c of [a,inkColorFor('Ada Lovelace'),inkColorFor('Nikola Tesla')]){
+    assert.equal(c.length,3);
+    const lum=0.2126*c[0]+0.7152*c[1]+0.0722*c[2];
+    assert.ok(lum<200,'ink must be dark enough to read on white');
+  }
+});
diff --git a/verification/signature-viewer.e2e.cjs b/verification/signature-viewer.e2e.cjs
new file mode 100644
index 0000000..5bef6f5
--- /dev/null
+++ b/verification/signature-viewer.e2e.cjs
@@ -0,0 +1,64 @@
+const assert=require('node:assert/strict'),fs=require('node:fs'),os=require('node:os'),path=require('node:path');
+const {chromium}=require('/Users/macstudio3/Projects/Designer-Wallcoverings/node_modules/playwright');
+const base=process.env.STORE_PROOF_URL||'http://127.0.0.1:19557';
+const artifacts=fs.mkdtempSync(path.join(os.tmpdir(),'tk10286-signature-viewer-'));
+async function settled(page){await page.waitForFunction(()=>['clean','fallback','error'].includes(document.querySelector('#apSignature')?.dataset.phase),null,{timeout:60000});}
+async function pixelCheck(page,selector){return page.locator(selector).evaluate(async el=>{
+ const img=el.querySelector('img');if(!img)return {phase:el.dataset.phase};await img.decode();
+ const c=document.createElement('canvas');c.width=img.naturalWidth;c.height=img.naturalHeight;const ctx=c.getContext('2d');ctx.drawImage(img,0,0);
+ const data=ctx.getImageData(0,0,c.width,c.height).data;let ink=0,white=0,other=0;
+ for(let i=0;i<data.length;i+=4){if(data[i]===26&&data[i+1]===26&&data[i+2]===26&&data[i+3]===255)ink++;else if(data[i]===255&&data[i+1]===255&&data[i+2]===255&&data[i+3]===255)white++;else other++;}
+ return {phase:el.dataset.phase,ink,white,other};
+});}
+(async()=>{
+ const browser=await chromium.launch({channel:'chrome'}),checks=[];
+ try {
+  for(const [device,viewport] of [['desktop',{width:1280,height:900}],['mobile',{width:390,height:844}]]){
+   const context=await browser.newContext({viewport});await context.route('**/*',r=>['GET','HEAD'].includes(r.request().method())?r.continue():r.abort());
+   const page=await context.newPage(),errors=[];page.on('pageerror',e=>errors.push(e.message));
+   await page.goto(base+'/?cat=Politics&artist=Q23',{waitUntil:'domcontentloaded'});
+   await page.waitForFunction(()=>document.querySelectorAll('.evo-cell').length===19,null,{timeout:30000});
+   await settled(page);const variants=[];
+   const count=await page.locator('.evo-cell').count();
+   for(let i=0;i<count;i++){
+    await page.locator('.evo-cell').nth(i).click();await settled(page);
+    assert.equal(await page.locator('#apSignature').getAttribute('data-variant'),String(i));
+    assert.equal(await page.locator('.evo-cell[aria-pressed=true]').count(),1);
+    assert.ok(await page.locator('.ap-card').evaluate(el=>el.scrollTop<5));
+    const phase=await page.locator('#apSignature').getAttribute('data-phase');
+    if(phase==='clean'){const px=await pixelCheck(page,'#apSignature');assert.ok(px.ink>0&&px.white>0);assert.equal(px.other,0);}
+    variants.push({index:i,phase,source:await page.locator('#apSourceLink').getAttribute('href')});
+    assert.equal(await page.locator('.evo-cell').count(),count,'no failing version is silently deleted');
+   }
+   // Keyboard selection, remembered selection and person changes.
+   await page.locator('.evo-cell').nth(2).focus();await page.keyboard.press('Enter');await settled(page);
+   assert.equal(await page.locator('#apSignature').getAttribute('data-variant'),'2');
+   await page.keyboard.press('Escape');assert.ok(await page.locator('#artistPop').evaluate(el=>el.hidden));
+   await page.evaluate(()=>openArtist('Q23'));await settled(page);assert.equal(await page.locator('#apSignature').getAttribute('data-variant'),'2');
+   await page.evaluate(()=>{openArtist('Q91');openArtist('Q23');selectSignature(1);selectSignature(10);});await settled(page);
+   assert.equal(await page.locator('#apSignature').getAttribute('data-variant'),'10');
+   assert.ok((await page.locator('#apSignature img').getAttribute('alt')).includes('George Washington'));
+   await page.locator('.ap-card').screenshot({path:path.join(artifacts,device+'-washington.png')});
+   const coverage=await page.evaluate(()=>{const visible=Object.entries(EVO).filter(([qid])=>DATA.some(r=>qidOf(r)===qid));return {archivePeople:Object.keys(EVO).length,visiblePeople:visible.length,alternates:visible.reduce((n,[,e])=>n+e.sigs.length,0),outsideCurrentCatalog:Object.keys(EVO).filter(qid=>!DATA.some(r=>qidOf(r)===qid)),missing:visible.filter(([qid,e])=>signatureVariants(qid).length!==e.sigs.length+1).map(([qid])=>qid)};});
+   assert.deepEqual(coverage.missing,[],'every archived alternate is offered for its person');
+   assert.ok(await page.locator('#apCatalogUse').evaluate(el=>el.hidden),'primary-signature usage badge does not imply clearance for an alternate');
+   assert.deepEqual(errors,[]);checks.push({device,variants,coverage,pageErrors:errors,verdict:variants.every(x=>x.phase==='clean')?'PASS':'PARTIAL'});
+   await context.close();
+  }
+  // Controlled failure/retry and late-response race at the real image boundary.
+  const context=await browser.newContext(),page=await context.newPage();
+  await page.goto(base+'/?cat=Politics&artist=Q23',{waitUntil:'domcontentloaded'});await settled(page);
+  await page.route('**/controlled-signature.svg',r=>r.abort());
+  const failure=await page.evaluate(async()=>{const m=await import('/assets/signature-preview.js');return m.renderInto(document.querySelector('#apSignature'),'/controlled-signature.svg',{priority:true});});
+  assert.equal(failure,'error');assert.equal(await page.locator('#apSignature img').count(),0);assert.ok(await page.getByRole('button',{name:'Try again',exact:true}).isVisible());
+  const svg='<svg xmlns="http://www.w3.org/2000/svg" width="160" height="60"><rect width="160" height="60" fill="#dccda4"/><path d="M15 45 Q50 3 70 40 T145 15" fill="none" stroke="#284568" stroke-width="4"/></svg>';
+  await page.unroute('**/controlled-signature.svg');await page.route('**/controlled-signature.svg',r=>r.fulfill({contentType:'image/svg+xml',body:svg}));
+  await page.getByRole('button',{name:'Try again',exact:true}).click();await settled(page);assert.equal((await pixelCheck(page,'#apSignature')).other,0);assert.equal(await page.locator('#apPreviewStatus').textContent(),'Dark ink on white');
+  await page.route('**/slow-signature.svg',async r=>{await new Promise(ok=>setTimeout(ok,800));await r.fulfill({contentType:'image/svg+xml',body:svg});});
+  await page.evaluate(async()=>{const m=await import('/assets/signature-preview.js'),el=document.querySelector('#apSignature');await Promise.all([m.renderInto(el,'/slow-signature.svg',{priority:true}),m.renderInto(el,'/controlled-signature.svg',{priority:true})]);});
+  assert.ok((await page.locator('#apSignature').getAttribute('data-source')).endsWith('/controlled-signature.svg'));
+  await context.close();checks.push({boundary:'failure, retry and late-image race',verdict:'PASS'});
+ }finally{await browser.close();}
+ const result={base,artifacts,checks,externalMutations:0};fs.writeFileSync(path.join(artifacts,'result.json'),JSON.stringify(result,null,2));console.log(JSON.stringify(result,null,2));
+ if(checks.some(c=>c.verdict!=='PASS'))process.exitCode=2;
+})().catch(e=>{console.error('Artifacts:',artifacts,e);process.exitCode=1;});

← 6140aaa auto-data-snapshot: 2026-09-09T13:56:45 (1 data files) — ver  ·  back to CelebritySignatures  ·  signatures: permanently exclude Q352 (brand safety) from eve 5499131 →