refactor(ui): unify semantic presentation and table hierarchy

This commit is contained in:
Joseph Doherty
2026-11-13 19:06:18 -05:00
parent 2d30323ae8
commit 0c41b613ac
97 changed files with 13829 additions and 167 deletions
+42
View File
@@ -0,0 +1,42 @@
"""Bounded actual-component presentation matrix, loopback synthetic values only."""
from pathlib import Path
import json,re,sys
from playwright.sync_api import sync_playwright
sys.path.insert(0,'/Users/dohertj2/.codex/worktrees/scada-ui-consistency/scadaproj/components/ui-theme/validation/theme-1.0.0/canary')
from browser_zoom import zoom_browser,set_browser_zoom
out=Path('docs/ui-consistency/evidence/sb07-09/browser');out.mkdir(parents=True,exist_ok=True)
def contrast(fg,bg):
def parts(s):return [float(v) for v in re.findall(r'[\d.]+',s)]
def lum(a):
a=[v/255 for v in a[:3]];a=[v/12.92 if v<=.04045 else ((v+.055)/1.055)**2.4 for v in a];return sum(x*y for x,y in zip(a,[.2126,.7152,.0722]))
a,b=parts(fg),parts(bg)
if len(a)==4:a=[a[i]*a[3]+b[i]*(1-a[3]) for i in range(3)]
lo,hi=sorted([lum(a),lum(b)]);return round((hi+.05)/(lo+.05),3)
results=[]
with sync_playwright() as p:
with zoom_browser(p,width=1440,height=1000) as (context,worker):
cases=[('presentation',w,s,1) for w in [1440,1024,390] for s in ['light','dark']]+[('presentation',1440,s,2) for s in ['light','dark']]+[(kind,w,s,1) for kind in ['dashboard','sms','smtp'] for w in [1440,390] for s in ['light','dark']]
for kind,width,scheme,zoom in cases:
page=context.new_page();page.set_viewport_size({'width':width,'height':1000});page.emulate_media(reduced_motion='reduce');errors=[];page.on('pageerror',lambda e:errors.append(str(e)))
route='/presentation' if kind=='presentation' else '/batch-two?sample=dashboard' if kind=='dashboard' else '/form-samples?view='+kind
page.goto('http://127.0.0.1:5201'+route,wait_until='networkidle');page.evaluate('s=>window.sbTheme.set(s)',scheme)
if kind in ['sms','smtp']:page.get_by_role('button',name='Add '+kind.upper()+' configuration',exact=True).click();page.locator('input[type=password]').wait_for()
record={'kind':kind,'width':width,'scheme':scheme,'zoom':zoom,'checks':{}}
if zoom==2:record['browserZoom']=set_browser_zoom(worker,page,2)
record['layout']=page.evaluate('({innerWidth,scrollWidth:document.documentElement.scrollWidth,DPR:devicePixelRatio})');record['checks']['noDocumentOverflow']=record['layout']['innerWidth']==record['layout']['scrollWidth'];record['checks']['onePageHeading']=page.locator('h1').count()==1
if kind=='presentation':
record['pairs']=page.evaluate('''()=>[...document.querySelectorAll('.fg-pill,.ft-pill,#utility-samples span')].map(e=>{let c=getComputedStyle(e),p=e,bg;while(p){let b=getComputedStyle(p).backgroundColor;if(b!=='rgba(0, 0, 0, 0)'&&b!=='transparent'){bg=b;break;}p=p.parentElement;}return {text:e.textContent.trim(),class:e.className,color:c.color,background:bg};})''')
for pair in record['pairs']:pair['contrast']=contrast(pair['color'],pair['background'])
record['checks']['readableStateAndUtilityText']=all(pair['contrast']>=4.5 for pair in record['pairs'])
page.locator('.ft-row').first.focus();page.keyboard.press('Tab');record['focus']=page.locator('.ft-row').nth(1).evaluate('e=>{let c=getComputedStyle(e);return {active:e===document.activeElement,visible:e.matches(":focus-visible"),outlineWidth:c.outlineWidth,outlineStyle:c.outlineStyle,outlineColor:c.outlineColor}}');record['checks']['traceKeyboardFocus']=record['focus']['active'] and record['focus']['visible'] and record['focus']['outlineWidth']=='2px' and record['focus']['outlineStyle']=='solid'
region=page.get_by_role('region',name='Synthetic records',exact=True);record['table']=region.evaluate('e=>({width:e.clientWidth,scrollWidth:e.scrollWidth,tabindex:e.tabIndex})')
if record['table']['scrollWidth']>record['table']['width']:
region.focus();page.keyboard.press('ArrowRight');page.wait_for_function('document.querySelector("[aria-label=\\"Synthetic records\\"]").scrollLeft>0')
record['checks']['tableKeyboardRegion']=record['table']['tabindex']==0
record['rowHeader']=region.locator('th[scope=row]').first.evaluate('e=>({position:getComputedStyle(e).position,textTransform:getComputedStyle(e).textTransform})');record['checks']['rowHeadersKeepSemantics']=record['rowHeader']=={'position':'static','textTransform':'none'}
search=page.get_by_label('Search',exact=True);search.fill('no-match');page.wait_for_function('document.querySelector(".table-responsive + .text-muted")?.textContent.trim()==="0 items" || [...document.querySelectorAll(".text-muted.small")].some(e=>e.textContent.trim()==="0 items")');record['checks']['emptyCount']=page.get_by_text('0 items',exact=True).count()==1;page.get_by_role('button',name='Clear search',exact=True).click();page.wait_for_function('document.querySelector("[aria-label=\\"Synthetic records\\"] tbody").textContent.includes("Plant A")')
else:record['headingStyle']=page.locator('h1').evaluate('e=>({font:getComputedStyle(e).fontFamily,size:getComputedStyle(e).fontSize})');record['checks']['sharedHeadingScale']=record['headingStyle']['size']=='20px'
record['errors']=errors;record['checks']['noBrowserError']=not errors;page.screenshot(path=str(out/f'{kind}-{scheme}-{width}-zoom{zoom}.png'),full_page=zoom!=2);results.append(record)
if zoom==2:set_browser_zoom(worker,page,1)
page.close()
(out/'results.json').write_text(json.dumps({'results':results,'passed':all(all(r['checks'].values()) for r in results)},indent=2)+'\n');print([(r['kind'],r['width'],r['scheme'],r['zoom'],all(r['checks'].values())) for r in results])
+20
View File
@@ -0,0 +1,20 @@
"""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])
+18
View File
@@ -0,0 +1,18 @@
"""Focused long unbroken identifier case for the actual DataTable region."""
import json,sys
from pathlib import Path
from playwright.sync_api import sync_playwright
sys.path.insert(0,'/Users/dohertj2/.codex/worktrees/scada-ui-consistency/scadaproj/components/ui-theme/validation/theme-1.0.0/canary')
from browser_zoom import zoom_browser,set_browser_zoom
results=[]
with sync_playwright() as p:
with zoom_browser(p,width=1440,height=1000) as (context,worker):
for width,zoom in [(390,1),(1440,2)]:
for scheme in ['light','dark']:
page=context.new_page();page.set_viewport_size({'width':width,'height':1000});page.emulate_media(reduced_motion='reduce');page.goto('http://127.0.0.1:5201/presentation',wait_until='networkidle');page.evaluate('s=>window.sbTheme.set(s)',scheme)
if zoom==2:set_browser_zoom(worker,page,2)
region=page.get_by_role('region',name='Synthetic records',exact=True);region.focus();page.keyboard.press('ArrowRight');page.wait_for_function('selector=>document.querySelector(selector).scrollLeft>0',arg='[aria-label="Synthetic records"]')
result=region.evaluate('e=>({width:e.clientWidth,scrollWidth:e.scrollWidth,scrollLeft:e.scrollLeft,focused:e===document.activeElement,payloadLength:e.querySelector("tbody td span").textContent.length,font:getComputedStyle(e.querySelector("tbody td span")).fontFamily})');result.update(page.evaluate('({innerWidth,documentScrollWidth:document.documentElement.scrollWidth,DPR:devicePixelRatio})'));result.update({'scheme':scheme,'physicalWidth':width,'zoom':zoom});result['passed']=result['scrollWidth']>result['width'] and result['scrollLeft']>0 and result['focused'] and result['innerWidth']==result['documentScrollWidth'] and result['payloadLength']==221;results.append(result)
if zoom==2:set_browser_zoom(worker,page,1)
page.close()
Path('docs/ui-consistency/evidence/sb07-09/focused-table-overflow.json').write_text(json.dumps({'results':results,'passed':all(r['passed'] for r in results)},indent=2)+'\n');print(results)