import {browser} from './browser.mjs'; import {mkdir,writeFile,readFile} from 'node:fs/promises'; import {fileURLToPath} from 'node:url'; import assert from 'node:assert/strict'; const out=new URL('../evidence/',import.meta.url), downloads=new URL('downloads/',out); await mkdir(downloads,{recursive:true}); const report={observedAt:new Date().toISOString(),checks:[],renderedContrast:[]}; const b=await browser(); const url=new URL('../index.html',import.meta.url).href; const change=async(id,value)=>b.evaluate(`(()=>{const e=document.getElementById(${JSON.stringify(id)});e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('change',{bubbles:true}))})()`); const mode=async value=>b.evaluate(`document.querySelector('[name=mode][value=${value}]').click()`); const screenshot=async(section,name,width=1440)=>{ await b.viewport(width,1100); await b.evaluate(`document.getElementById('${section}').scrollIntoView();new Promise(requestAnimationFrame)`); await b.screenshot(new URL(name,out)); }; try{ await b.viewport(1440,1100);await b.navigate(url); // Computed foreground/background values on rendered text, not just token formulas. for(const p of await b.evaluate('Brand.palettes.map(p=>p.id)')) for(const m of ['light','dim','dark']){ await change('palette',p);await mode(m); const result=await b.evaluate(`(()=>{ const hex=c=>{const n=c.match(/[\\d.]+/g);return '#'+n.slice(0,3).map(v=>Math.round(+v).toString(16).padStart(2,'0')).join('')}; const bg=e=>{for(let n=e;n;n=n.parentElement){const c=getComputedStyle(n).backgroundColor;if(c!=='rgba(0, 0, 0, 0)'&&c!=='transparent')return hex(c)}return '#ffffff'}; const walker=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT);let n, checks=[]; while(n=walker.nextNode()){ const e=n.parentElement;if(!n.textContent.trim()||e.closest('script,style,noscript,.sr-only,button:disabled,option'))continue; const rect=e.getBoundingClientRect();if(!rect.width||!rect.height)continue; const style=getComputedStyle(e);if(style.visibility==='hidden'||style.display==='none')continue; const fg=hex(style.color),back=bg(e),ratio=Brand.contrast(fg,back); const large=parseFloat(style.fontSize)>=24||(parseFloat(style.fontSize)>=18.67 && parseFloat(style.fontWeight)>=700); checks.push({text:n.textContent.trim().slice(0,65),fg,bg:back,ratio,minimum:large?3:4.5}); } for(const e of document.querySelectorAll('input:not([type=radio]),textarea,select')){ const style=getComputedStyle(e),fg=hex(style.color),back=bg(e);checks.push({text:e.id,fg,bg:back,ratio:Brand.contrast(fg,back),minimum:4.5}); } return {count:checks.length,min:Math.min(...checks.map(x=>x.ratio)),failures:checks.filter(x=>x.ratio{const e=document.activeElement,r=e.getBoundingClientRect();return {text:e.textContent,top:r.top,bottom:r.bottom,viewport:innerHeight,style:getComputedStyle(e).outlineStyle}})()`); assert(focus.top>=0&&focus.bottom<=focus.viewport,JSON.stringify(focus)); await b.screenshot(new URL('focus-short-desktop.png',out)); report.checks.push({name:'Sidebar keyboard focus visible at 768 px viewport height',result:'pass',focus}); const ax=await b.call('Accessibility.getFullAXTree'); const missingNames=ax.nodes.filter(n=>!n.ignored&&['button','combobox','textbox','radio','link'].includes(n.role?.value)&&!n.name?.value); assert.equal(missingNames.length,0); report.checks.push({name:'Chromium accessibility tree gives interactive elements accessible names',result:'pass',namedControls:ax.nodes.filter(n=>!n.ignored&&['button','combobox','textbox','radio','link'].includes(n.role?.value)).length}); await b.call('Browser.setDownloadBehavior',{behavior:'allow',downloadPath:fileURLToPath(downloads)}); await b.evaluate('document.getElementById("review-notes").value="Prefer Relay. Please refine the small-size gaps.";document.getElementById("download-review").click();document.getElementById("export").click()'); // Wait only for these two local browser downloads, bounded at five seconds. let saved; for(let n=0;n<50;n++){ try{saved=[await readFile(new URL('mosaic-brand-review.txt',downloads),'utf8'),await readFile(new URL('mosaic-harbor-tokens.json',downloads),'utf8')];break;}catch{await new Promise(r=>setTimeout(r,100));} } assert(saved,'Local downloads did not complete'); assert(saved[0].includes('Prefer Relay.')); assert.deepEqual(Object.keys(JSON.parse(saved[1]).modes),['light','dim','dark']); report.checks.push({name:'Review notes and all-three-mode palette JSON downloaded and content-verified',result:'pass'}); // Double every computed text size, including authored pixel sizes. await b.viewport(320,1000); await b.evaluate(`(()=>{const sizes=[...document.querySelectorAll('body *')].map(e=>[e,parseFloat(getComputedStyle(e).fontSize)]);for(const[e,size]of sizes)e.style.fontSize=size*2+'px'})()`); let expanded=await b.evaluate('({width:innerWidth,scroll:document.documentElement.scrollWidth})'); assert(expanded.scroll<=expanded.width,JSON.stringify(expanded)); await b.screenshot(new URL('board-all-text-200-320.png',out)); report.checks.push({name:'Every computed text size doubled at 320 px',result:'pass',measurement:expanded}); // Font failure and storage denial are injected only into this isolated test browser. await b.call('Network.enable');await b.call('Network.setCacheDisabled',{cacheDisabled:true}); await b.call('Network.setBlockedURLs',{urls:['*woff2*']}); await b.call('Page.addScriptToEvaluateOnNewDocument',{source:'Object.defineProperty(window,"localStorage",{get(){throw new DOMException("Unavailable","SecurityError")}})'}); await b.navigate(url);await change('palette','carmine');await mode('dim'); assert.equal(await b.evaluate('document.documentElement.dataset.palette'),'carmine'); expanded=await b.evaluate('({width:innerWidth,scroll:document.documentElement.scrollWidth})'); assert(expanded.scroll<=expanded.width,JSON.stringify(expanded)); assert(!(await b.evaluate('document.fonts.check(\'16px "DM Sans"\')'))); await b.screenshot(new URL('board-font-storage-fallback-320.png',out)); report.checks.push({name:'Blocked fonts and denied storage: controls still work and fallback reflows',result:'pass',measurement:expanded}); }catch(error){report.failure=error.stack;} finally{await b.close();} await writeFile(new URL('detail-verification.json',out),JSON.stringify(report,null,2)+'\n'); console.log(JSON.stringify(report,null,2)); if(report.failure)process.exitCode=1;