← 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
A public/assets/signature-ink.jsA public/assets/signature-preview.cssA public/assets/signature-preview.jsM public/index.htmlA test/signature-ink.test.mjsA verification/signature-viewer.e2e.cjs
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 →