"""Focused normal/reduced-motion and predicted-ghost text proof; no screenshot loop.""" import json,re from pathlib import Path from playwright.sync_api import sync_playwright def rgb(s):return [float(v) for v in re.findall(r'[\d.]+',s)][:3] def mix(f,b,a):return [f[i]*a+b[i]*(1-a) for i in range(3)] def ratio(a,b): def lum(v): v=[x/255 for x in v];v=[x/12.92 if x<=.04045 else ((x+.055)/1.055)**2.4 for x in v];return sum(x*y for x,y in zip(v,[.2126,.7152,.0722])) lo,hi=sorted([lum(a),lum(b)]);return round((hi+.05)/(lo+.05),3) results=[] with sync_playwright() as p: browser=p.chromium.launch(channel='chromium') for scheme in ['light','dark']: for motion in ['no-preference','reduce']: page=browser.new_page(reduced_motion=motion);page.goto('http://127.0.0.1:5201/presentation',wait_until='networkidle');page.evaluate('s=>window.sbTheme.set(s)',scheme) record=page.evaluate('''()=>{const p=document.querySelector('.fg-node.pending .fg-pill'),g=document.querySelector('.fg-node.ghost'),r=g.querySelector('.fg-node-box'),lane=document.querySelector('.fg-lane-bg.alt');const pc=getComputedStyle(p),gc=getComputedStyle(g),rc=getComputedStyle(r),lc=getComputedStyle(lane);return {pending:{opacity:pc.opacity,animations:p.getAnimations().length,color:pc.color,background:pc.backgroundColor},ghost:{opacity:gc.opacity,titleColor:getComputedStyle(g.querySelector('.fg-title')).color,captionColor:getComputedStyle(g.querySelector('.fg-eyebrow')).color,boxFill:rc.fill,boxFillOpacity:rc.fillOpacity,strokeDasharray:rc.strokeDasharray,laneFill:lc.fill,laneFillOpacity:lc.fillOpacity,wrapBackground:getComputedStyle(document.querySelector('.fg-wrap')).backgroundColor}};}''');record.update({'scheme':scheme,'motion':motion});ghost=record['ghost'];base=mix(rgb(ghost['laneFill']),rgb(ghost['wrapBackground']),float(ghost['laneFillOpacity']));background=mix(rgb(ghost['boxFill']),base,float(ghost['boxFillOpacity']));ghost['effectiveBackground']=background;ghost['titleContrast']=ratio(rgb(ghost['titleColor']),background);ghost['captionContrast']=ratio(rgb(ghost['captionColor']),background);record['pending']['contrast']=ratio(rgb(record['pending']['color']),rgb(record['pending']['background']));record['passed']=record['pending']['opacity']=='1' and record['pending']['animations']==0 and ghost['opacity']=='1' and min(ghost['titleContrast'],ghost['captionContrast'],record['pending']['contrast'])>=4.5;results.append(record);page.close() browser.close() Path('docs/ui-consistency/evidence/sb07-09/flow-text-opacity-after.json').write_text(json.dumps({'results':results,'passed':all(r['passed'] for r in results)},indent=2)+'\n');print([(r['scheme'],r['motion'],r['passed'],r['ghost']['titleContrast'],r['ghost']['captionContrast']) for r in results])