[object Object]

← back to Dw Yolo Loop

cycle 66: officer REVISE — rendered-layer re-measure (0/46 WCAG violations); WCAG claim retracted, downgraded P2->P3 image-SEO

c9838519298015017e2fbf91989090234c091002 · 2026-06-17 15:29:00 -0700 · Steve Abrams

Files touched

Diff

commit c9838519298015017e2fbf91989090234c091002
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jun 17 15:29:00 2026 -0700

    cycle 66: officer REVISE — rendered-layer re-measure (0/46 WCAG violations); WCAG claim retracted, downgraded P2->P3 image-SEO
---
 .../image-alt-audit/image-alt-audit-rendered.mjs   | 100 +++++++++++++++++++++
 1 file changed, 100 insertions(+)

diff --git a/scripts/image-alt-audit/image-alt-audit-rendered.mjs b/scripts/image-alt-audit/image-alt-audit-rendered.mjs
new file mode 100644
index 0000000..1907478
--- /dev/null
+++ b/scripts/image-alt-audit/image-alt-audit-rendered.mjs
@@ -0,0 +1,100 @@
+// image-alt-audit-rendered — READ-ONLY, $0. Cycle 66 RE-MEASUREMENT (officer REVISE).
+// The v1 measured product.json images[].alt = the STORED layer. The compliance officer
+// proved (live, n=2) the theme constructs alt at RENDER time: a product that is 100%
+// alt:null in products.json renders a MIX of populated / empty / missing-attribute alts.
+// So WCAG/ADA exposure must be measured on RENDERED HTML, and the three alt states
+// separated (empty alt="" is WCAG-CORRECT for decorative/zoom-pair images; only a
+// product-content image with NO usable alt is a real §1.1.1 violation).
+// METHOD: fetch rendered PDP HTML; extract <img> referencing cdn.shopify product media;
+// group by normalized image URL (collapses zoom/lazy duplicate pairs); per UNIQUE
+// content image record the BEST alt across its instances (populated > empty > missing).
+// Classify each unique content image: POPULATED / EMPTY-alt / NO-alt-attribute.
+// NEGATIVE CONTROL: print real populated alts (parser reads them) + confirm we can
+// distinguish empty="" from a missing attribute on real tags.
+// EXCLUDES Phillip Jeffries + UI chrome (svg/icons, non-/products/ media).
+const WWW='https://www.designerwallcoverings.com';
+const UA='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36';
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
+async function get(u){for(let a=0;a<4;a++){try{const r=await fetch(u,{headers:{'User-Agent':UA}});if(r.status===429){await sleep(1500*(a+1));continue;}return {status:r.status,text:await r.text()};}catch(e){await sleep(800*(a+1));}}return {status:0,text:''};}
+const PJ=/phillip[- ]?jeffries|phillip-jeffries/i;
+const norm=s=>(s||'').toLowerCase().replace(/[^a-z0-9]+/g,' ').trim();
+// normalize a shopify cdn image URL: strip size suffix (_600x, _1024x1024) + query → identity of the underlying asset
+function imgId(u){ try{ let p=new URL(u, WWW).pathname; p=p.replace(/_(\d+x\d*|\d*x\d+|small|medium|large|grande|pico|icon|thumb|compact|master)\b/gi,''); return p.toLowerCase(); }catch{ return (u||'').toLowerCase(); } }
+
+const idx=await get(`${WWW}/sitemap.xml`);
+const prodMaps=[...idx.text.matchAll(/<loc>([^<]+sitemap_products_\d+\.xml[^<]*)<\/loc>/g)].map(m=>m[1].replace(/&amp;/g,'&')).filter(u=>!/\/en-(ca|gb)\//.test(u));
+let handles=[];
+for(const sm of prodMaps){ const x=await get(sm); const hs=[...x.text.matchAll(/<loc>[^<]*\/products\/([^<\/?]+)/g)].map(m=>m[1]); const step=Math.max(1,Math.floor(hs.length/3)); for(let i=0;i<hs.length&&handles.length<120;i+=step) handles.push(hs[i]); }
+handles=[...new Set(handles)].filter(h=>!PJ.test(h)).slice(0,80);
+console.log(`=== image-alt-audit-RENDERED (READ-ONLY, $0) ===\nre-measuring ${handles.length} live PDPs at the RENDERED layer (PJ excluded)\n`);
+
+let prodEval=0, prodAllCovered=0, prodHasViolation=0, pjSkip=0, fetchErr=0;
+let uImgTotal=0, uPopulated=0, uEmpty=0, uMissingAttr=0, uMeaningful=0;
+const populatedSamples=[], emptySamples=[], missingSamples=[], problems=[];
+let done=0;
+for(const h of handles){
+  const r=await get(`${WWW}/products/${h}`);
+  if(r.status!==200){ fetchErr++; done++; continue; }
+  if(PJ.test(h)){ pjSkip++; done++; continue; }
+  // pull product title for meaningfulness check
+  const title=norm((r.text.match(/<title>([^<]*)<\/title>/i)||[])[1]||'');
+  const titleToks=title.split(' ').filter(t=>t.length>3);
+  // all <img> tags referencing shopify product CDN media (exclude svg/icons/chrome)
+  const tags=[...r.text.matchAll(/<img[^>]*>/gi)].map(m=>m[0])
+    .filter(t=>/cdn\.shopify\.com\/s\/files|\/products\//i.test(t))
+    .filter(t=>!/\.svg|icon|logo|close|chevron|arrow|spinner|loader/i.test(t));
+  if(tags.length===0){ // no parseable product-media img in rendered HTML — skip (can't measure rendered layer)
+    done++; await sleep(90); continue; }
+  prodEval++;
+  // group by underlying asset, keep BEST alt state per asset (populated > empty="" > missing-attr)
+  const byAsset=new Map(); // id -> {state:'pop'|'empty'|'missing', alt}
+  for(const t of tags){
+    // resolve the asset url from src OR data-src OR srcset first url
+    const su=(t.match(/\s(?:data-src|src)=["']([^"']+)["']/i)||[])[1] || (t.match(/srcset=["']([^"',\s]+)/i)||[])[1] || '';
+    if(!/cdn\.shopify\.com|\/products\//i.test(su)) continue;
+    const id=imgId(su);
+    const hasAltAttr=/\salt\s*=/i.test(t);
+    const altVal=hasAltAttr?((t.match(/\salt\s*=\s*["']([^"']*)["']/i)||[,''])[1]).trim():null;
+    const state = altVal && altVal.length>0 ? 'pop' : (hasAltAttr ? 'empty' : 'missing');
+    const prev=byAsset.get(id);
+    const rank={pop:3,empty:2,missing:1};
+    if(!prev || rank[state]>rank[prev.state]) byAsset.set(id,{state,alt:altVal||''});
+  }
+  if(byAsset.size===0){ done++; await sleep(90); continue; }
+  let violation=false;
+  for(const [id,info] of byAsset){
+    uImgTotal++;
+    if(info.state==='pop'){ uPopulated++; const na=norm(info.alt); if(titleToks.some(tk=>na.includes(tk))) uMeaningful++; if(populatedSamples.length<6) populatedSamples.push({h,alt:info.alt.slice(0,70)}); }
+    else if(info.state==='empty'){ uEmpty++; if(emptySamples.length<6) emptySamples.push({h,id:id.slice(-40)}); }
+    else { uMissingAttr++; violation=true; if(missingSamples.length<8) missingSamples.push({h,id:id.slice(-40)}); }
+  }
+  if(violation){ prodHasViolation++; problems.push({h,issue:`has image(s) with NO alt attribute`}); }
+  else prodAllCovered++;
+  done++;
+  if(done%20===0) console.log(`  ${done}/${handles.length} (unique content imgs: pop=${uPopulated} empty=${uEmpty} no-attr=${uMissingAttr})`);
+  await sleep(110);
+}
+
+console.log(`\n=== RESULTS (RENDERED layer, unique content images) ===`);
+console.log(`PDPs with parseable product-media imgs: ${prodEval} | PJ-skip: ${pjSkip} | fetch-err: ${fetchErr} | unparseable(skipped): ${done-prodEval-pjSkip-fetchErr}`);
+console.log(`unique content images classified: ${uImgTotal}`);
+console.log(`\n-- three alt states (separated per officer) --`);
+console.log(`  POPULATED alt: ${uImgTotal?(100*uPopulated/uImgTotal).toFixed(1):0}%  (${uPopulated})  [meaningful: ${uPopulated?(100*uMeaningful/uPopulated).toFixed(1):0}%]`);
+console.log(`  EMPTY alt="" : ${uImgTotal?(100*uEmpty/uImgTotal).toFixed(1):0}%  (${uEmpty})  [WCAG-OK if decorative/zoom-pair]`);
+console.log(`  *** NO alt ATTRIBUTE (genuine §1.1.1 risk): ${uImgTotal?(100*uMissingAttr/uImgTotal).toFixed(1):0}%  (${uMissingAttr}) ***`);
+console.log(`\n-- product-level --`);
+console.log(`  products where ALL content imgs have a usable (populated/empty) alt: ${prodAllCovered}/${prodEval}`);
+console.log(`  products with >=1 NO-alt-attribute image: ${prodHasViolation}/${prodEval}`);
+if(uImgTotal>0){
+  const p=uMissingAttr/uImgTotal, se=Math.sqrt(p*(1-p)/uImgTotal), lo=Math.max(0,p-1.96*se)*100, hi=Math.min(1,p+1.96*se)*100;
+  console.log(`\n*** RENDERED no-alt-attribute rate: ${uMissingAttr}/${uImgTotal} = ${(100*p).toFixed(1)}% (95% CI ${lo.toFixed(1)}–${hi.toFixed(1)}%, n=${uImgTotal}) ***`);
+}
+console.log(`\n-- NEGATIVE CONTROL --`);
+console.log(`  populated (parser reads value):`); populatedSamples.forEach(s=>console.log(`    ${s.h.slice(0,32)} → "${s.alt}"`));
+console.log(`  empty alt="" detected: ${uEmpty>0?'yes ('+emptySamples.length+' samples)':'none in sample'}`);
+console.log(`  no-alt-attribute detected: ${uMissingAttr>0?'yes ('+missingSamples.length+' samples)':'none in sample'}`);
+if(missingSamples.length) missingSamples.forEach(s=>console.log(`    NO-ATTR ${s.h.slice(0,40)}`));
+
+import fs from 'fs';
+fs.writeFileSync('/tmp/image-alt-audit-rendered.json',JSON.stringify({ts:new Date().toISOString(),handles:handles.length,prodEval,prodAllCovered,prodHasViolation,uImgTotal,uPopulated,uEmpty,uMissingAttr,uMeaningful,populatedSamples,emptySamples,missingSamples,problems},null,2));
+console.log('\nwrote /tmp/image-alt-audit-rendered.json');

← c740b1d cycle 66: image alt-text audit — 26.8% images empty alt (WCA  ·  back to Dw Yolo Loop  ·  cycle 67: price-at-render audit — 111 live Ralph Lauren PDPs b3c9f34 →