← 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
A scripts/image-alt-audit/image-alt-audit-rendered.mjs
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(/&/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 →