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.')}
${tr('人物空间','People space')} ${tr('共享空间','Shared space')} ${tr('个人空间','Personal space')}
${tr('避开最近 200 张的相似照片','Avoid photos similar to the last 200 selections')}${tr('网盘 OAuth 凭据 JSON(可选;仅原生网盘)','Drive OAuth credentials JSON (optional; native drives only)')}
`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared';
+ const box=document.createElement('section');box.innerHTML=`${tr('网盘 OAuth 凭据 JSON(可选;仅原生网盘)','Drive OAuth credentials JSON (optional; native drives only)')}
`;$('#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('username')}
${t('password')}
${t('photosSessionHint')}
${t('photosConnect')} `;
+ target.innerHTML=`${t('nasUsername')}
${t('nasPassword')}
${t('photosSessionHint')}
${t('photosConnect')} `;
$('#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')}
`);
+ $('#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]):''}
${t('browse')} ${t('randomPreview')} ${t('sourceJob')} ${t('edit')} ${t('remove')}
`).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]):''}
${t('browse')} ${t('randomPreview')} ${t('sourceJob')} ${t('edit')} ${t('remove')}
`).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')} `);
- $('#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)});