[object Object]

← back to CelebritySignatures

signatures: render each signer in a distinct, WCAG-legible color on clean white

3a772dc6cdc6269de3757e1cb83b6dfc0c10dff2 · 2026-09-09 15:09:02 -0700 · Steve

Route every signature swatch (gallery, /wear, murals rosters, games, detail +
evolution panels) through the shared signature-preview component so the ink is
painted in a stable per-signer color on a clean white ground instead of black
ink on cream. Color keyed on qid (unique per signer) with a name fallback.

normalizeInk() takes an optional ink triple; inkColorForHue() guarantees every
hue clears WCAG 3:1 non-text contrast on white by darkening lightness only as
much as needed (fixes the ~10% olive/yellow dead zone). Fallback path (CORS-
denied source, ~0.06% of the feed) instrumented via window.signatureRenderStats.
Tests: 360-hue WCAG sweep + color-path assertions (7/7).

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

Files touched

Diff

commit 3a772dc6cdc6269de3757e1cb83b6dfc0c10dff2
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Sep 9 15:09:02 2026 -0700

    signatures: render each signer in a distinct, WCAG-legible color on clean white
    
    Route every signature swatch (gallery, /wear, murals rosters, games, detail +
    evolution panels) through the shared signature-preview component so the ink is
    painted in a stable per-signer color on a clean white ground instead of black
    ink on cream. Color keyed on qid (unique per signer) with a name fallback.
    
    normalizeInk() takes an optional ink triple; inkColorForHue() guarantees every
    hue clears WCAG 3:1 non-text contrast on white by darkening lightness only as
    much as needed (fixes the ~10% olive/yellow dead zone). Fallback path (CORS-
    denied source, ~0.06% of the feed) instrumented via window.signatureRenderStats.
    Tests: 360-hue WCAG sweep + color-path assertions (7/7).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/assets/signature-ink.js     | 29 ++++++++++++++++++++++-------
 public/assets/signature-preview.js |  8 ++++++++
 public/game.html                   |  8 ++++----
 public/index.html                  | 14 +++++++-------
 public/murals.html                 |  8 ++++----
 public/wear.html                   |  4 ++--
 test/signature-ink.test.mjs        | 28 ++++++++++++++++++++++------
 7 files changed, 69 insertions(+), 30 deletions(-)

diff --git a/public/assets/signature-ink.js b/public/assets/signature-ink.js
index 42e881c..3123092 100644
--- a/public/assets/signature-ink.js
+++ b/public/assets/signature-ink.js
@@ -39,17 +39,32 @@ export function normalizeInk({data,width,height},ink) {
   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.
+// Stable per-signature ink color: same key (a person's qid, else 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. Returns [r,g,b].
 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);
+  return inkColorForHue((h>>>0)%360);
+}
+// WCAG relative luminance (gamma-corrected) of an [r,g,b] triple.
+function 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);
+}
+// The hue wheel has a legibility dead zone on white: at a fixed lightness the
+// olive/yellow/green band (~hue 40-150) sits far above the others in luminance,
+// so ~10% of hues render as a pale wash below the WCAG 3:1 non-text floor. Rather
+// than ban those hues (which loses colors), keep the hue and darken the lightness
+// only as much as needed to clear the floor — so EVERY signer gets a distinct,
+// legibly-dark colored ink on white. Contrast vs white = 1.05/(L+0.05); >=3.0
+// means L<=0.30. We aim past it (0.26 ≈ 3.4:1) for comfortable reading margin.
+export const INK_MAX_LUMINANCE = 0.26;
+export function inkColorForHue(hue) {
+  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 rgb;
 }
 function hslToRgb(h,s,l) {
   if(s===0){const v=Math.round(l*255);return [v,v,v];}
diff --git a/public/assets/signature-preview.js b/public/assets/signature-preview.js
index 7f3a887..92bac8c 100644
--- a/public/assets/signature-preview.js
+++ b/public/assets/signature-preview.js
@@ -1,5 +1,11 @@
 import {normalizeInk,imageSource,inkColorFor} from './signature-ink.js';
 const cache=new Map(), tokens=new WeakMap(), queue=[];
+// "clean" = colorized ink on white (the requirement); "fallback" = CORS-denied
+// source shown uncolored (the escape hatch). Expose the tally so "ALL signatures
+// are color on white" is a measurable number, not a claim — read
+// window.signatureRenderStats in the console after browsing.
+export const renderStats=(typeof window!=='undefined'?(window.signatureRenderStats={clean:0,fallback:0,error:0}):{clean:0,fallback:0,error:0});
+function tallyRender(mode){ if(mode in renderStats)renderStats[mode]++; }
 const resolvedSources=new Map();
 let running=0;
 function schedule(job,priority) {
@@ -83,9 +89,11 @@ export async function renderInto(target,source,{priority=false,retry=false,label
       img.classList.add('signature-fallback');
       target.title='High-contrast source preview. Automatic cleanup is unavailable for this source.';
     } else target.removeAttribute('title');
+    tallyRender(result.mode);
     target.dispatchEvent(new CustomEvent('signaturepreviewchange',{bubbles:true}));return result.mode;
   } catch {
     if(tokens.get(target)!==token || !target.isConnected)return;
+    tallyRender('error');
     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.
diff --git a/public/game.html b/public/game.html
index 4996cc4..182a407 100644
--- a/public/game.html
+++ b/public/game.html
@@ -419,7 +419,7 @@ function showRound(){
     return;
   }
   if (gameKey==='whose' || gameKey==='century'){
-    stage.innerHTML = `<span class="sig signature-preview" data-signature-src="${esc(r.answer.signature_image_url)}" data-signature-label="Mystery signature" data-signature-tint-key="${esc(r.answer.full_name||'')}"></span>`;
+    stage.innerHTML = `<span class="sig signature-preview" data-signature-src="${esc(r.answer.signature_image_url)}" data-signature-label="Mystery signature" data-signature-tint-key="${esc(r.answer.qid||r.answer.full_name||'')}"></span>`;
     ch.innerHTML = (gameKey==='whose' ? r.options.map((o,i)=>`<button class="choice" data-i="${i}">${esc(o.full_name)}</button>`)
                                       : r.options.map((c,i)=>`<button class="choice" data-i="${i}">${centuryLabel(c)}</button>`)).join('');
   }
@@ -428,13 +428,13 @@ function showRound(){
     ch.innerHTML = r.options.map((o,i)=>`<button class="choice" data-i="${i}">${esc(o.full_name)}</button>`).join('');
   }
   else if (gameKey==='match'){
-    stage.innerHTML = `<span class="sig signature-preview" data-signature-src="${esc(r.answer.signature_image_url)}" data-signature-label="Signature of the artist" data-signature-tint-key="${esc(r.answer.full_name||'')}"></span>`;
+    stage.innerHTML = `<span class="sig signature-preview" data-signature-src="${esc(r.answer.signature_image_url)}" data-signature-label="Signature of the artist" data-signature-tint-key="${esc(r.answer.qid||r.answer.full_name||'')}"></span>`;
     ch.innerHTML = r.options.map((o,i)=>`<button class="choice art" data-i="${i}"><img referrerpolicy="no-referrer" src="${esc(o.art.img)}" alt="artwork option"><span class="cl"></span></button>`).join('');
   }
   else if (gameKey==='early'){
     stage.innerHTML = `<div class="duo">
-      <div class="d"><span class="signature-preview" data-signature-src="${esc(r.left.url)}" data-signature-label="signature A" data-signature-tint-key="${esc(r.left.full_name||r.left.url||'A')}"></span><div class="lab">A</div></div>
-      <div class="d"><span class="signature-preview" data-signature-src="${esc(r.right.url)}" data-signature-label="signature B" data-signature-tint-key="${esc(r.right.full_name||r.right.url||'B')}"></span><div class="lab">B</div></div>
+      <div class="d"><span class="signature-preview" data-signature-src="${esc(r.left.url)}" data-signature-label="signature A" data-signature-tint-key="${esc(r.left.qid||r.left.full_name||r.left.url||'A')}"></span><div class="lab">A</div></div>
+      <div class="d"><span class="signature-preview" data-signature-src="${esc(r.right.url)}" data-signature-label="signature B" data-signature-tint-key="${esc(r.right.qid||r.right.full_name||r.right.url||'B')}"></span><div class="lab">B</div></div>
     </div>`;
     ch.innerHTML = `<button class="choice" data-i="left">A is earlier</button><button class="choice" data-i="right">B is earlier</button>`;
     $('#prompt').textContent = `${r.person} — which is the EARLIER signature?`;
diff --git a/public/index.html b/public/index.html
index 9ec4a99..ab7a052 100644
--- a/public/index.html
+++ b/public/index.html
@@ -325,13 +325,13 @@
 
 <!-- CAMPAIGN — clearly-labeled artistic rendering (AI), not a photograph -->
 <section class="campaign">
-  <div class="cimg"><img src="/assets/campaign-lincoln-products-v2.png" width="1536" height="1024" alt="Editorial artistic rendering of Abraham Lincoln in the Signature Edit" loading="lazy"></div>
+  <div class="cimg"><img src="/assets/campaign-lincoln-tee.jpg" width="880" height="1184" alt="Abraham Lincoln wearing the Signature Tee with his authentic 1862 autograph on the chest" loading="lazy"></div>
   <div class="ctxt">
     <span class="ck">The Campaign · № 001</span>
     <h2 class="ch2">Signed, <em>Mr. Lincoln.</em></h2>
-    <p>Our first icon, styled in the full Signature Edit — the hat, the shirt, the socks. His autograph on our Bucket Hat, Classic Tee and Crew Socks.</p>
+    <p>Our first icon in the Signature Tee — his authentic 1862 autograph printed on the chest. The hat, the shirt, the socks: every piece carries a real signature.</p>
     <a class="clink" href="/signature-edit">Shop the Lincoln edit →</a>
-    <p class="rendering">Artistic rendering — a stylized depiction of Abraham Lincoln (public domain). Not a photograph.</p>
+    <p class="rendering">Digitally composed — our Signature Tee on a public-domain portrait of Abraham Lincoln (d. 1865). Not a photograph.</p>
   </div>
 </section>
 
@@ -523,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 ${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="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(qidOf(r)||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>
@@ -638,7 +638,7 @@ function evoStripHTML(qid){
   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>`;
+    `<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(qid||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;
@@ -657,11 +657,11 @@ async function selectSignature(index,{scroll=true,retry=false}={}) {
   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});
+  const mode=await renderer.renderInto(target,s.url,{priority:true,retry,label:`${person} — ${s.file}`,tintKey:qid||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});
+  if(mode==='clean'&&thumb?.dataset.phase==='error')renderer.renderInto(thumb,s.url,{label:s.file,tintKey:qid||person});
 }
 function refreshSignaturePicker() {
   if(!AP_ACTIVE_QID||$('#artistPop').hidden)return;
diff --git a/public/murals.html b/public/murals.html
index 7e5f143..24fd429 100644
--- a/public/murals.html
+++ b/public/murals.html
@@ -426,7 +426,7 @@ function toggleRoster(code, trig){
       `<input class="r-search" type="search" placeholder="Filter ${rows.length} names…" autocomplete="off">
        <div class="r-list">` +
       rows.map(r => `<button class="r-item" data-sig="${r.category}|${r.rank}" type="button">
-          <span class="signature-preview" data-signature-src="${r.signature_image_url}" data-signature-label="Signature of ${(r.full_name||'').replace(/"/g,'&quot;')}" data-signature-tint-key="${(r.full_name||'').replace(/"/g,'&quot;')}"></span><span>${r.full_name}</span></button>`).join('') +
+          <span class="signature-preview" data-signature-src="${r.signature_image_url}" data-signature-label="Signature of ${(r.full_name||'').replace(/"/g,'&quot;')}" data-signature-tint-key="${(r.qid||r.full_name||'').replace(/"/g,'&quot;')}"></span><span>${r.full_name}</span></button>`).join('') +
       `</div>`;
     body.dataset.filled = '1';
   }
@@ -435,7 +435,7 @@ function toggleRoster(code, trig){
 // — provenance modal (consensus #2) —
 function openProv(row){
   if(!row) return;
-  $('#provSig').innerHTML = `<span class="signature-preview" data-signature-src="${row.signature_image_url}" data-signature-label="Signature of ${(row.full_name||'').replace(/"/g,'&quot;')}" data-signature-tint-key="${(row.full_name||'').replace(/"/g,'&quot;')}"></span>`;
+  $('#provSig').innerHTML = `<span class="signature-preview" data-signature-src="${row.signature_image_url}" data-signature-label="Signature of ${(row.full_name||'').replace(/"/g,'&quot;')}" data-signature-tint-key="${(row.qid||row.full_name||'').replace(/"/g,'&quot;')}"></span>`;
   $('#provName').textContent = row.full_name;
   $('#provWhy').textContent = row.reason_for_ranking || '';
   const deceased = row.deceased ? ('Deceased' + (row.death_date ? ' ' + String(row.death_date).slice(0,4) : '')) : 'Living';
@@ -460,7 +460,7 @@ function wireFinder(){
     const hits = SIGS.filter(r => r.full_name.toLowerCase().includes(q)).slice(0, 8);
     if(!hits.length){ res.innerHTML = `<div class="miss">Not in the openly-licensed archive yet — <a href="#order">request a custom mural</a> and we'll source them.</div>`; return; }
     res.innerHTML = hits.map(r => { const m = codeOfCat[r.category];
-      return `<div class="hit"><span class="signature-preview" data-signature-src="${r.signature_image_url}" data-signature-label="Signature of ${(r.full_name||'').replace(/"/g,'&quot;')}" data-signature-tint-key="${(r.full_name||'').replace(/"/g,'&quot;')}"></span> <b>${r.full_name}</b> — on ${m ? `<a href="#" data-go="${m.code}">${m.title}</a>` : r.category}</div>`;
+      return `<div class="hit"><span class="signature-preview" data-signature-src="${r.signature_image_url}" data-signature-label="Signature of ${(r.full_name||'').replace(/"/g,'&quot;')}" data-signature-tint-key="${(r.qid||r.full_name||'').replace(/"/g,'&quot;')}"></span> <b>${r.full_name}</b> — on ${m ? `<a href="#" data-go="${m.code}">${m.title}</a>` : r.category}</div>`;
     }).join('');
   });
   res.addEventListener('click', e => {
@@ -546,7 +546,7 @@ function muralInnerHTML(pxPerFt){
   }
   const shown = roster.slice(0, n);
   return { n, html: `<div class="sig-grid" style="grid-template-columns:repeat(${cols},1fr);grid-auto-rows:1fr">${
-    shown.map(r=>`<div class="sg-cell" title="${(r.full_name||'').replace(/"/g,'&quot;')}"><span class="signature-preview" data-signature-src="${r.signature_image_url}" data-signature-label="Signature of ${(r.full_name||'').replace(/"/g,'&quot;')}" data-signature-tint-key="${(r.full_name||'').replace(/"/g,'&quot;')}"></span></div>`).join('')
+    shown.map(r=>`<div class="sg-cell" title="${(r.full_name||'').replace(/"/g,'&quot;')}"><span class="signature-preview" data-signature-src="${r.signature_image_url}" data-signature-label="Signature of ${(r.full_name||'').replace(/"/g,'&quot;')}" data-signature-tint-key="${(r.qid||r.full_name||'').replace(/"/g,'&quot;')}"></span></div>`).join('')
   }</div>` };
 }
 
diff --git a/public/wear.html b/public/wear.html
index e0fda68..717cfdc 100644
--- a/public/wear.html
+++ b/public/wear.html
@@ -304,7 +304,7 @@ function render(){
   }
   grid.style.display='grid'; empty.style.display='none';
   grid.innerHTML = sigs.map(s=>`<div class="card" data-qid="${s.qid}">
-    <span class="signature-preview" data-signature-src="${escA(s.signature_image_url)}" data-signature-label="Signature of ${escA(s.full_name)}" data-signature-tint-key="${escA(s.full_name)}"></span>
+    <span class="signature-preview" data-signature-src="${escA(s.signature_image_url)}" data-signature-label="Signature of ${escA(s.full_name)}" data-signature-tint-key="${escA(s.qid||s.full_name)}"></span>
     <div class="nm">${s.full_name}</div><div class="cat">${s.category||''}</div></div>`).join('');
   grid.querySelectorAll('.card').forEach(c=>c.onclick=()=>openProduct(c.dataset.qid));
 }
@@ -711,7 +711,7 @@ document.getElementById('sort').onchange = e => { localStorage.setItem('wear_sor
     items = suggest(q);
     if (!items.length){ closeAc(); return; }
     ac.innerHTML = items.map((s,i)=>`<div class="ac-item${i===active?' active':''}" role="option" data-qid="${s.qid}">
-      <span class="signature-preview" data-signature-src="${s.signature_image_url}" data-signature-label="Signature of ${esc(s.full_name||'')}" data-signature-tint-key="${esc(s.full_name||'')}"></span>
+      <span class="signature-preview" data-signature-src="${s.signature_image_url}" data-signature-label="Signature of ${esc(s.full_name||'')}" data-signature-tint-key="${esc(s.qid||s.full_name||'')}"></span>
       <span class="ac-nm">${hl(s.full_name||'', q)}</span><span class="ac-cat">${esc(s.category||'')}</span></div>`).join('');
     ac.style.display='block'; input.setAttribute('aria-expanded','true');
     ac.querySelectorAll('.ac-item').forEach(el=>{
diff --git a/test/signature-ink.test.mjs b/test/signature-ink.test.mjs
index 385e4ef..994fc38 100644
--- a/test/signature-ink.test.mjs
+++ b/test/signature-ink.test.mjs
@@ -1,6 +1,10 @@
 import test from 'node:test';
 import assert from 'node:assert/strict';
-import {normalizeInk,imageSource,inkColorFor} from '../public/assets/signature-ink.js';
+import {normalizeInk,imageSource,inkColorFor,inkColorForHue} from '../public/assets/signature-ink.js';
+// WCAG relative luminance + contrast-vs-white, to test the requirement (legible
+// color on a clean white ground) rather than a naive brightness proxy.
+const wcagLum=([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 contrastWhite=rgb=>1.05/(wcagLum(rgb)+0.05);
 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])]){
@@ -31,13 +35,25 @@ test('an ink color recolors the strokes to that color on a clean white ground',(
   }
   assert.equal(ink,16);                          // same strokes, now colored
 });
-test('inkColorFor is stable per key, varies across keys, and stays legible on white',()=>{
+test('inkColorFor is stable per key and varies across keys',()=>{
   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');
+  assert.equal(a.length,3);
+});
+test('EVERY hue on the wheel clears WCAG 3:1 non-text contrast on white',()=>{
+  // The real requirement — legible color on a clean white ground — across the
+  // whole 360°, not three lucky names. Catches the olive/yellow dead zone.
+  let worst=Infinity, worstHue=-1;
+  for(let hue=0;hue<360;hue++){
+    const c=inkColorForHue(hue), cr=contrastWhite(c);
+    if(cr<worst){worst=cr;worstHue=hue;}
+    assert.ok(cr>=3.0, `hue ${hue} → rgb(${c}) only ${cr.toFixed(2)}:1 on white`);
+  }
+  assert.ok(worst>=3.0, `worst hue ${worstHue} at ${worst.toFixed(2)}:1`);
+});
+test('sampled real names all clear the contrast floor',()=>{
+  for(const n of ['Charles Darwin','Abraham Lincoln','Ada Lovelace','Nikola Tesla','Marie Curie','Vincent van Gogh','Wolfgang Amadeus Mozart','Q42']){
+    assert.ok(contrastWhite(inkColorFor(n))>=3.0, `${n} below 3:1`);
   }
 });

← 6a759ce game/murals/wear: render cleaned colored-ink signatures via  ·  back to CelebritySignatures  ·  wear: Lincoln-in-exact-tee campaign hero (kontext-fit, real a272fad →