diff --git a/docs/UI_LAYOUT_AUDIT.md b/docs/UI_LAYOUT_AUDIT.md new file mode 100644 index 0000000..b33921c --- /dev/null +++ b/docs/UI_LAYOUT_AUDIT.md @@ -0,0 +1,36 @@ +# UI layout audit + +The Mock-only `scripts/ui-layout-audit.cjs` checks all 11 navigation pages, +local/cloud gallery layouts and six modal states at 1440, 768 and 390 CSS pixels, +in Chinese/English and light/dark themes. It checks page horizontal overflow and +at least 8 pixels between visible input/select/textarea/button rectangles, and +44-pixel control heights on mobile. +Closed details, background content behind dialogs and hidden fields are excluded. +Photos authentication is intercepted with fixture credentials; no NAS login or +frame write occurs. Populated task/history fixtures remain in disposable Mock data. + +This audit found and corrected missing upload preview spacing, nested task field +spacing, small mobile action gaps and inconsistent dynamically inserted form +spacing. NAS source forms now hide irrelevant cloud credential fields. Native +drive forms expose their credential field and hide local-only quality controls. + +Synology Photos has a dedicated connection card on the Photo Sources page, with +status, an explanation of affected automation and a direct NAS sign-in dialog. +Folder settings and person selection remain separate. Credentials are not saved; +the existing protected session API is used. Successful login does not run a job +or change frame content. Passwords are cleared from the dialog before submission. + +Run against an isolated Mock server with the existing Playwright runtime: + +```sh +FRAME_FLOW_URL=http://127.0.0.1:18789 \ +FRAME_FLOW_ENV_FILE=/path/to/mock.env \ +FRAME_FLOW_TEST_USERNAME=test-ui \ +FRAME_FLOW_TEST_PASSWORD=test-only-password \ +node scripts/ui-layout-audit.cjs +``` + +Set `NODE_PATH` to the installed Playwright runtime if needed. Optional +`FRAME_FLOW_SCREENSHOT_DIR` saves fixture-only screenshots for visual review. +The full audit passed 217 checks with no console errors. This complements +`ui-smoke.cjs` and `ui-integrations.cjs`; it does not replace functional tests. diff --git a/pigway_frame_flow/web/extensions.js b/pigway_frame_flow/web/extensions.js index 6fd77fb..023ac27 100644 --- a/pigway_frame_flow/web/extensions.js +++ b/pigway_frame_flow/web/extensions.js @@ -47,7 +47,7 @@ async function officialWidgetControls(info,data){ const send=cover_id=>confirmDialog(tr('此操作会改变官方订阅和画框内容,是否继续?','This changes the official subscription and frame content. Continue?'),async()=>{const args=cover_id?Object.fromEntries([...document.querySelectorAll('[data-widget-arg]')].filter(x=>x.value).map(x=>[x.dataset.widgetArg,x.value])):{};const r=await api('/api/v1/devices/'+state.device+'/widgets/selection',{method:'POST',body:{cover_id,args},headers:{'Idempotency-Key':'widget-'+clientUUID()}});await waitOperation(r.operation_id);await navigate('templates');});if(!disabled){$('#official-widget-use').onclick=busy(()=>send(Number($('#official-widget-choice').value)));$('#official-widget-cancel').onclick=busy(()=>send(null));} } function addPhotoQualityFields(source){ - const box=document.createElement('section');box.innerHTML=`

${tr('照片筛选','Photo filtering')}

${tr('按小图清晰度、亮度和相似度筛选,不改动或删除 NAS 原照片;无法保证识别闭眼。建议先用低阈值。个人空间还需要将相应目录只读挂载到 /photos。','Filters thumbnail sharpness, brightness and similarity without modifying or deleting originals. Closed-eye detection is not guaranteed. Start with low thresholds. Personal space also requires mounting its folder read-only at /photos.')}

`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared'; + const box=document.createElement('section');box.innerHTML=`

${tr('照片筛选','Photo filtering')}

${tr('按小图清晰度、亮度和相似度筛选,不改动或删除 NAS 原照片;无法保证识别闭眼。建议先用低阈值。个人空间还需要将相应目录只读挂载到 /photos。','Filters thumbnail sharpness, brightness and similarity without modifying or deleting originals. Closed-eye detection is not guaranteed. Start with low thresholds. Personal space also requires mounting its folder read-only at /photos.')}

`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared'; } async function aiImagePanel(){ diff --git a/pigway_frame_flow/web/features.js b/pigway_frame_flow/web/features.js index 2a853b7..2f31230 100644 --- a/pigway_frame_flow/web/features.js +++ b/pigway_frame_flow/web/features.js @@ -1,7 +1,9 @@ 'use strict'; // Page features share the same authenticated API and display operation pipeline. async function currentPanel(container){ + const epoch=state.epoch; const content=await api('/api/v1/devices/'+state.device+'/current-content'); + if(epoch!==state.epoch||!container.isConnected)return content; state.currentDisplay=content; const known=content.source_known&&content.source?.type==='template'; container.innerHTML=`

${t('currentContent')}

#${esc(content.image_id??'—')} · ${t(content.synced?'synced':'syncing')}

${known?`${t('currentTemplate')}: ${esc(content.source.template)}`:content.source_known?t('sourceKnown'):t('sourceUnknown')}

${known?buttons('edit-current-template','editCurrent'):''}${content.overrides.length?`
${t('temporaryDisplay')}${content.overrides.map(x=>`

#${esc(x.image_id)} · ${t('priority')}: ${x.priority} · ${fmt(x.expires_at)}

`).join('')}
`:''}`; @@ -110,7 +112,7 @@ async function photosPeoplePicker(selected){ const status=await api(path); if(!status.configured){target.innerHTML=`

${t('photosConfigure')}

`;return;} if(!status.connected){ - target.innerHTML=`

${t('photosSessionHint')}

`; + target.innerHTML=`

${t('photosSessionHint')}

`; $('#photos-connect').onclick=busy(async()=>{const password=$('#photos-password').value;$('#photos-password').value='';await api(path,{method:'POST',body:{username:$('#photos-username').value,password}});await photosPeoplePicker(selected);});return; } const people=await api(path+'/people?space='+encodeURIComponent($('#source-people-space')?.value||'shared')); @@ -119,10 +121,24 @@ async function photosPeoplePicker(selected){ $('#people-search').oninput=render;$('#people-unnamed').onchange=render;$('#photos-disconnect').onclick=busy(async()=>{await api(path,{method:'DELETE'});await photosPeoplePicker(selected);});render(); } +async function photosConnectionPanel(){ + const status=await api('/api/v1/integrations/synology-photos'); + const box=$('#photos-connection');if(!box)return; + box.innerHTML=`

Synology Photos

${t('photosConnectionHelp')}

${t(status.connected?'photosConnected':'photosDisconnected')}

${t(!status.configured?'photosSetupHelp':status.connected?'photosConnectedHelp':'photosReconnectHelp')}

${buttons('photos-login',status.connected?'photosReconnect':'photosConnect',true)}${buttons('photos-status-refresh','refresh')}
`; + $('#photos-login').disabled=!status.configured; + bind('photos-login',()=>photosLoginDialog()); + bind('photos-status-refresh',()=>photosConnectionPanel()); +} +function photosLoginDialog(){ + showModal(`

${t('photosConnect')}

${t('photosLoginHelp')}

${t('photosSessionHint')}

`); + $('#photos-login-form').onsubmit=busy(async e=>{e.preventDefault();const password=$('#photos-login-password').value;$('#photos-login-password').value='';try{await api('/api/v1/integrations/synology-photos',{method:'POST',body:{username:$('#photos-login-user').value,password}});$('#modal').close();await photosConnectionPanel();toast(t('photosConnectedHelp'));}catch(error){const box=$('#photos-login-error');box.hidden=false;box.className='notice warning';box.textContent=error.message;}}); +} + async function photoSourcesPanel(){ const section=document.createElement('section');section.className='card photo-sources-panel';$('#main').append(section); const sources=await api('/api/v1/photo-sources');if(!section.isConnected)return; - section.innerHTML=`

${t('photoSources')}

${t('sourceHint')}

${buttons('add-photo-source','addSource')}
${sources.length?sources.map(s=>`

${esc(s.name)}

${esc(s.kind)} · ${t(s.enabled?'enabled':'disabled')}${s.people_filter?' · '+t('peopleFilter'):''}${s.orientation?' · '+t({auto:'orientationAuto',landscape:'orientationLandscape',portrait:'orientationPortrait'}[s.orientation]):''}

`).join(''):`

${t('noSources')}

`}
`; + section.innerHTML=`

${t('photoSources')}

${t('sourceHint')}

${buttons('add-photo-source','addSource')}
${sources.length?sources.map(s=>`

${esc(s.name)}

${esc(s.kind)} · ${t(s.enabled?'enabled':'disabled')}${s.people_filter?' · '+t('peopleFilter'):''}${s.orientation?' · '+t({auto:'orientationAuto',landscape:'orientationLandscape',portrait:'orientationPortrait'}[s.orientation]):''}

`).join(''):`

${t('noSources')}

`}
`; + await photosConnectionPanel(); bind('add-photo-source',()=>photoSourceForm()); section.querySelectorAll('[data-source-edit]').forEach(b=>b.onclick=()=>photoSourceForm(sources.find(s=>s.id===b.dataset.sourceEdit))); section.querySelectorAll('[data-source-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/photo-sources/'+b.dataset.sourceDelete,{method:'DELETE'});await navigate('photo-sources');})); @@ -132,7 +148,7 @@ async function photoSourcesPanel(){ } async function photoSourceForm(source=null){ showModal(`

${t(source?'edit':'addSource')}

${t('sourceLocationHint')}

${t('sourceCredentialHint')}

${t('orientationHint')}

${t('folderCaptionHint')}

${t('peopleHint')}

${t('peopleSelected')}${(source?.person_ids||[]).length}

`); - $('#source-orientation').value=source?(source.orientation||''):'auto';const selectedPeople=new Set(source?.person_ids||[]);$('#source-person-match').value=source?.person_match||'any';$('#source-people-load').onclick=busy(async()=>{await photosPeoplePicker(selectedPeople);});$('#source-kind').value=source?.kind||'local';const update=()=>{const dav=$('#source-kind').value!=='local';$('#source-orientation').disabled=dav;$('#source-credential').disabled=!dav;$('#source-recursive').disabled=$('#source-kind').value==='webdav';$('#source-synology-previews').disabled=dav;$('#source-organized').disabled=dav;$('#source-caption').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').disabled=dav;};update();$('#source-kind').onchange=update;addPhotoQualityFields(source);$('#source-people-space').onchange=()=>{selectedPeople.clear();$('#source-people-panel').innerHTML='';}; + $('#source-orientation').value=source?(source.orientation||''):'auto';const selectedPeople=new Set(source?.person_ids||[]);$('#source-person-match').value=source?.person_match||'any';$('#source-people-load').onclick=busy(async()=>{await photosPeoplePicker(selectedPeople);});$('#source-kind').value=source?.kind||'local';const update=()=>{const dav=$('#source-kind').value!=='local';$('#source-orientation').disabled=dav;$('#source-credential').disabled=!dav;$('#source-credential').closest('.field').hidden=$('#source-kind').value!=='webdav';const oauth=$('#source-oauth');if(oauth)oauth.closest('.field').hidden=!['google-drive','alipan','baidu'].includes($('#source-kind').value);const quality=$('#source-local-quality');if(quality)quality.hidden=dav;$('#source-recursive').disabled=$('#source-kind').value==='webdav';$('#source-synology-previews').disabled=dav;$('#source-organized').disabled=dav;$('#source-caption').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').disabled=dav;};addPhotoQualityFields(source);update();$('#source-kind').onchange=update;$('#source-people-space').onchange=()=>{selectedPeople.clear();$('#source-people-panel').innerHTML='';}; $('#photo-source-form').onsubmit=busy(async e=>{e.preventDefault();const oauth=$('#source-oauth').value;const sourceId=$('#source-id').value;const native=['google-drive','alipan','baidu'].includes($('#source-kind').value);if(oauth&&native)JSON.parse(oauth);const dav=$('#source-kind').value!=='local';await api('/api/v1/photo-sources/'+$('#source-id').value,{method:'PUT',body:{name:$('#source-name').value,kind:$('#source-kind').value,location:$('#source-location').value,recursive:$('#source-kind').value!=='webdav'&&$('#source-recursive').checked,synology_previews:!dav&&$('#source-synology-previews').checked,catalog_limit:$('#source-organized').checked?100000:(source?.catalog_limit||10000),organized_only:!dav&&$('#source-organized').checked,folder_caption:!dav&&$('#source-caption').checked,orientation:dav?null:($('#source-orientation').value||null),people_filter:!dav&&$('#source-people-filter').checked,person_ids:[...selectedPeople],person_match:$('#source-person-match').value,people_space:$('#source-people-space').value,minimum_sharpness:Number($('#source-sharpness').value),minimum_brightness:Number($('#source-bright-min').value),maximum_brightness:Number($('#source-bright-max').value),avoid_duplicates:$('#source-dedup').checked,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});if(oauth&&native){$('#source-oauth').value='';await api('/api/v1/photo-sources/'+sourceId+'/credentials',{method:'PUT',body:{key:oauth}});}$('#modal').close();await navigate('photo-sources');}); } async function browseSource(id,offset=0,refresh=false){ diff --git a/pigway_frame_flow/web/i18n.js b/pigway_frame_flow/web/i18n.js index 62b0654..99d3c39 100644 --- a/pigway_frame_flow/web/i18n.js +++ b/pigway_frame_flow/web/i18n.js @@ -116,3 +116,11 @@ Object.assign(window.MESSAGES.en,{ }); Object.assign(window.MESSAGES.zh,{studio:'拼版与海报',connections:'连接中心'}); Object.assign(window.MESSAGES.en,{studio:'Layout Studio',connections:'Connections'}); + +Object.assign(MESSAGES.zh,{photosConnectionHelp:'NAS 照片登录与文件夹设置分开管理。人物筛选和 Photos 预览需要保持连接。',photosConnected:'已连接',photosDisconnected:'未连接',photosReconnect:'重新登录',photosReconnectHelp:'尚未登录或会话已失效。依赖 Synology Photos 的自动化会失败,请先登录 NAS 账号。无需填写配置变量。',photosConnectedHelp:'连接已保存。相关自动化将在下次计划时间使用照片,不会立即切换画框。',photosSetupHelp:'服务尚未配置 Photos 地址和证书。请管理员完成部署设置后再登录;这里不需要填写网盘变量。',photosLoginHelp:'使用登录群晖 NAS / Synology Photos 的账号密码,不是 Frame Flow 账号,也不是 KoKonna Key。',nasUsername:'NAS 用户名',nasPassword:'NAS 密码'}); +Object.assign(MESSAGES.en,{photosConnectionHelp:'Manage NAS sign-in separately from photo folders. People filtering and Photos previews require a connection.',photosConnected:'Connected',photosDisconnected:'Not connected',photosReconnect:'Sign in again',photosReconnectHelp:'Not signed in or the session expired. Automation using Synology Photos will fail until you sign in. No configuration variable is required here.',photosConnectedHelp:'Connection saved. Automation will use photos at its next scheduled time; the frame is not changed now.',photosSetupHelp:'The Photos address and certificate are not configured. Ask the administrator to finish deployment setup. Drive variables are not needed here.',photosLoginHelp:'Use your Synology NAS / Photos account, not your Frame Flow account or KoKonna key.',nasUsername:'NAS username',nasPassword:'NAS password'}); +Object.assign(MESSAGES.zh,{sourceHint:'管理 NAS 照片目录、WebDAV 和网盘来源。浏览与生成预览不会改变画框。'}); +Object.assign(MESSAGES.en,{sourceHint:'Manage NAS folders, WebDAV and cloud drives. Browsing and generating previews do not change the frame.'}); + +Object.assign(MESSAGES.zh,{choosePeople:'选择人物'}); +Object.assign(MESSAGES.en,{choosePeople:'Choose people'}); diff --git a/pigway_frame_flow/web/style.css b/pigway_frame_flow/web/style.css index a753313..5aa8643 100644 --- a/pigway_frame_flow/web/style.css +++ b/pigway_frame_flow/web/style.css @@ -75,3 +75,28 @@ dialog.image-viewer h2.image-viewer-name{font-size:16px;min-width:0;flex:1} @media(max-width:600px){.image-viewer-footer{flex-wrap:wrap;gap:8px}.image-viewer-name{flex-basis:100%!important}.image-viewer-actions{width:100%;justify-content:flex-end;gap:6px}.image-viewer-actions button{padding:8px 10px}} .studio-assets{max-height:240px;overflow:auto;margin:18px 0}.studio-canvas{position:relative;width:100%;background:white;margin-bottom:20px}.studio-canvas img{position:absolute;object-fit:cover;cursor:move;touch-action:none;border:2px solid var(--accent)}.region-fields{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}.region-fields input{width:100%}#client-secret,#hook-secret{overflow-wrap:anywhere} + +/* Shared control rhythm for static forms and dynamically inserted panels. */ +[hidden]{display:none!important} +form:not(#login-form){display:flex;flex-direction:column;gap:18px} +form>.field,form>.check-row,form>.hint,form>.notice{margin:0} +.field{display:flex;flex-direction:column;gap:10px} +.field>label,.field>.hint{margin:0} +.field>:is(input,select,textarea)+:is(input,select,textarea){margin-top:0} +.field>.check-row{margin:0} +.form-grid>.field{margin-bottom:0} +.actions,.row-actions,.image-viewer-actions{gap:12px} +#source-people-panel:not(:empty){margin:18px 0;display:flex;flex-direction:column;gap:16px} +#source-people-panel>.field,#source-people-panel>.hint{margin:0} +#source-people-panel>button{align-self:flex-start} +.photos-connection{margin:24px 0;padding:24px;border:1px solid var(--border);border-radius:14px;background:var(--soft)} +.photos-connection .section-heading{margin-bottom:16px} +.photos-connection .notice{margin:0 0 18px} +#source-local-quality{margin-bottom:20px} +@media(max-width:720px){.photos-connection{padding:18px}.actions,.row-actions,.image-viewer-actions{gap:10px}} + +.card>.form-grid+button{margin-top:18px} +:not(form)>.field+.form-grid{margin-top:18px} +.photos-connection .badge.warning{color:var(--bad);background:transparent;border:1px solid var(--bad)} +form>.modal-actions{margin-top:8px} +@media(max-width:720px){button,input:not([type=checkbox]):not([type=radio]),select{min-height:44px}.image-card button,.row-actions button,.history-actions button{font-size:14px;padding:9px 12px}.check-row{min-height:44px;align-items:center}} diff --git a/scripts/ui-layout-audit.cjs b/scripts/ui-layout-audit.cjs new file mode 100644 index 0000000..b0a8c86 --- /dev/null +++ b/scripts/ui-layout-audit.cjs @@ -0,0 +1,34 @@ +/* Full-page and modal layout audit in an isolated Mock service only. */ +const assert=require('assert');const fs=require('fs');const {chromium}=require('playwright'); +(async()=>{ + const base=process.env.FRAME_FLOW_URL;assert(base);const token=fs.readFileSync(process.env.FRAME_FLOW_ENV_FILE,'utf8').split('\n').find(x=>x.startsWith('FRAME_FLOW_TOKEN=')).slice(17); + assert((await (await fetch(base+'/api/v1/settings',{headers:{Authorization:'Bearer '+token}})).json()).mock===true); + const browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_EXECUTABLE||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'}); + try{ + const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));let connected=false;let logins=0; + await page.route('**/api/v1/integrations/synology-photos',async route=>{if(route.request().method()==='POST'){assert.deepStrictEqual(route.request().postDataJSON(),{username:'fixture-nas',password:'fixture-only'});connected=true;logins++;}await route.fulfill({json:{configured:true,connected}});}); + await page.route('**/api/v1/photo-sources',async route=>{assert(route.request().method()==='GET');await route.fulfill({json:[{id:'fixture-source',name:'NAS photos / NAS 照片',kind:'local',location:'.',enabled:false,people_filter:true,orientation:'auto'}]});}); + await page.goto(base);await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor(); + let checks=0;const failures=[];const out=process.env.FRAME_FLOW_SCREENSHOT_DIR;if(out)fs.mkdirSync(out,{recursive:true}); + async function inspect(label){ + const result=await page.evaluate(()=>{ + const root=document.querySelector('dialog[open]')||document.querySelector('#main');const controls=[...root.querySelectorAll('button,select,input:not([type=checkbox]):not([type=radio]):not([type=file]),textarea')].filter(e=>e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})&&!e.closest('[hidden]')).map(e=>({id:e.id||e.textContent.trim().slice(0,24),r:e.getBoundingClientRect()}));const close=[]; + for(let i=0;i1&&dy<7.5)||(y>1&&dx<7.5))close.push([controls[i].id,controls[j].id]);} + return {overflow:document.documentElement.scrollWidth>innerWidth,close,small:innerWidth<=720?controls.filter(e=>e.r.height<43.5).map(e=>e.id):[]}; + });checks++;if(result.overflow||result.close.length||result.small.length)failures.push({label,...result}); + } + for(const width of [1440,768,390])for(const lang of ['zh','en'])for(const theme of ['light','dark']){ + await page.setViewportSize({width,height:900});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme}); + for(const view of ['dashboard','gallery','photo-sources','upload','templates','automation','playlists','history','settings','studio','connections']){await page.evaluate(view=>navigate(view),view);await inspect(`${view}/${width}/${lang}/${theme}`);} + await page.evaluate(()=>navigate('photo-sources'));await page.locator('#photos-login').click();await inspect(`Photos login/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click(); + await page.locator('#add-photo-source').click();assert(await page.locator('#source-credential').isHidden());assert(await page.locator('#source-oauth').isHidden());await inspect(`NAS source/${width}/${lang}/${theme}`);await page.locator('#source-kind').selectOption('google-drive');assert(await page.locator('#source-oauth').isVisible());assert(await page.locator('#source-local-quality').isHidden());await inspect(`Drive source/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click(); + await page.evaluate(()=>navigate('automation'));await page.locator('#new-job').click();await inspect(`Job/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click(); + await page.evaluate(()=>navigate('playlists'));await page.locator('#new-playlist').click();await inspect(`Playlist/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click(); + await page.evaluate(()=>displayDialog({type:'image',image_id:1}));await inspect(`Display options/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click(); + await page.evaluate(()=>{state.tab='local';return navigate('gallery');});await inspect(`Local gallery/${width}/${lang}/${theme}`);await page.evaluate(()=>{state.tab='cloud';}); + if(out&&lang==='zh'&&theme==='dark'){await page.evaluate(()=>navigate('photo-sources'));await page.screenshot({path:out+`/photos-${width}.png`,fullPage:true});} + } + await page.evaluate(()=>navigate('photo-sources'));await page.locator('#photos-login').click();await page.locator('#photos-login-user').fill('fixture-nas');await page.locator('#photos-login-password').fill('fixture-only');await page.locator('#photos-login-form button[type=submit]').click();await page.locator('#photos-login').filter({hasText:'Sign in again'}).waitFor();assert(logins===1);checks++; + assert.deepStrictEqual(errors,[]);assert.deepStrictEqual(failures,[],JSON.stringify(failures));console.log('Layout audit:',checks,'passed; 11 pages plus local gallery, 6 modal states, 12 viewport/language/theme combinations; console errors:',errors.length); + }finally{await browser.close();} +})().catch(e=>{console.error(e.message);process.exit(1)});