From f2c9459a54e242d6206196d3d002828777158040 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Sat, 3 Oct 2026 18:19:56 +0800 Subject: [PATCH] Unify Glass Stage navigation and page controls with Now Playing --- docs/glass-controls-polish.md | 18 +++++++ frontend/glass-controls.js | 69 ++++++++++++++++++++++++ frontend/glass-controls.test.js | 9 ++++ frontend/index.html | 2 + frontend/sw.js | 2 + frontend/theme-glass-controls.css | 53 +++++++++++++++++++ playwright.glass.config.js | 2 + tests/glass-controls.spec.js | 87 +++++++++++++++++++++++++++++++ tests/helpers/png-diff.js | 27 ++++++++++ 9 files changed, 269 insertions(+) create mode 100644 docs/glass-controls-polish.md create mode 100644 frontend/glass-controls.js create mode 100644 frontend/glass-controls.test.js create mode 100644 frontend/theme-glass-controls.css create mode 100644 playwright.glass.config.js create mode 100644 tests/glass-controls.spec.js create mode 100644 tests/helpers/png-diff.js diff --git a/docs/glass-controls-polish.md b/docs/glass-controls-polish.md new file mode 100644 index 0000000..1bcf11b --- /dev/null +++ b/docs/glass-controls-polish.md @@ -0,0 +1,18 @@ +# Glass Stage control consistency + +Glass navigation and browsing controls now use Now Playing's existing vocabulary: 20 px SVGs with rounded 1.9 strokes, translucent tile surfaces, 18 px button corners, muted foregrounds, coral active/destructive states, and visible focus rings. Navigation badges use the same palette with readable 11 px counts. + +The stylesheet is entirely scoped to `html[data-layout="glass-stage"]`. Existing hidden navigation SVGs are reused. A small Glass-only module draws legacy button glyphs without changing IDs, listeners or existing accessible labels; switching away restores the original glyphs and removes its temporary accessibility labels. It observes page updates only while Glass is selected. The existing Now Playing design remains the reference. + +Coverage: bottom tabs and badges, sidebar rows, search/queue/history/playlist row actions, saved-file actions, download controls, Settings navigation/buttons, notes tabs and mini-player buttons. Glass row actions reserve space so long titles remain readable. Section shortcuts sit lower-left rather than covering right-hand file actions. Downloads' bottom-tab label stays fully visible. + +Validation: + +- 115 frontend unit tests; app/module syntax; server no-bundle build. +- Browser coverage for Classic and Glass at 390 and 1440 px across seven page states; keyboard button commands, focus, theme restoration, mini-player controls, settings reachability and file-action hit testing. +- Fourteen Classic before/after screenshots compare against the same fixture with the new assets disabled. Control geometry and computed styles match exactly. PNG comparison allows at most 16 isolated browser rasterization pixels in the entire screenshot (roughly 0.005% at phone size); no layout/theme difference is tolerated. +- Batched visual review and a single confirmation of the label/shortcut corrections. The apparent clipped Settings row is within its normal scroll container; a test reaches the final row. + +Captures are local files under `/tmp/glass-polish-before-*` and `/tmp/glass-polish-after-*`, plus `/tmp/glass-polish-navigation-notes-mini.png` and `/tmp/glass-polish-settings-end.png`. Before/after fixtures suppress animations and automatic saving so playback/network timing cannot change the comparison. The installed bottom navigation is exposed in one isolated fixture because ordinary Chromium is not an installed PWA. + +Reviewer: check a real installed iPhone PWA's bottom bar, mini-player clearance and theme switching, plus keyboard/hover behavior on desktop. No playback implementation, Classic stylesheet, credentials or deployment was changed. diff --git a/frontend/glass-controls.js b/frontend/glass-controls.js new file mode 100644 index 0000000..b1c32ca --- /dev/null +++ b/frontend/glass-controls.js @@ -0,0 +1,69 @@ +/* Draw legacy control glyphs only in Glass; restore them on theme changes. */ +(function(root) { + 'use strict'; + const paths = { + add:'', close:'', + search:'', + save:'', + play:'', pause:'', + next:'', previous:'', + repeat:'', + edit:'', + lyrics:'', + share:'', + copy:'', + check:'', folder:'', + settings:'', + chapters:'', + clock:'', + shuffle:'', + down:'', right:'', back:'', more:'', + devices:'' + }; + const glyphs = {'๏ผ‹':'add','โœ•':'close','ร—':'close','โœ–':'close','๐Ÿ”':'search','๐Ÿ”Ž':'search','โฌ‡':'save','โค“':'save','๐Ÿ’พ':'save','โ–ถ':'play','โธ':'pause','โญ':'next','โฎ':'previous','โ†ป':'repeat','โ†บ':'repeat','โœŽ':'edit','โœ':'edit','๐Ÿ“':'edit','๐ŸŽค':'lyrics','โ†—':'share','โง‰':'copy','โœ“':'check','๐Ÿ“':'folder','โš™':'settings','๐Ÿ“‹':'chapters','๐Ÿ•˜':'clock','๐Ÿ”€':'shuffle','๐Ÿ“ก':'devices','โ–พ':'down','โ–ธ':'right','โ†':'back','โ‹ฏ':'more'}; + function leadingGlyph(text) { + const prefix = text.match(/^\s*/)[0], rest = text.slice(prefix.length); + const glyph = Object.keys(glyphs).find(key => rest.startsWith(key)); + if (!glyph) return null; + const length = glyph.length + (rest[glyph.length] === '\uFE0F' ? 1 : 0); + return { kind:glyphs[glyph], original:prefix+rest.slice(0,length), rest:rest.slice(length) }; + } + function mount(doc) { + const decorations = new Set(); let scheduled=false; + const selector = '#listPane button, .sidebar button, .smart-item .pl-name, .notes-tab, .notes-fold, .mini-btn'; + const svg = kind => { const icon=doc.createElementNS('http://www.w3.org/2000/svg','svg'); icon.setAttribute('viewBox','0 0 24 24');icon.setAttribute('aria-hidden','true');icon.classList.add('glass-control-icon');icon.innerHTML=paths[kind];return icon; }; + const observer=new root.MutationObserver(schedule), themeObserver=new root.MutationObserver(schedule); + function restore(record) { + record.svg.remove(); if(record.original.isConnected)record.original.replaceWith(doc.createTextNode(record.original.textContent)); + if(record.addedLabel && record.parent.isConnected)record.parent.removeAttribute('aria-label'); + } + function paint() { + scheduled=false;observer.disconnect(); + const active=doc.documentElement.dataset.layout==='glass-stage'; + for(const record of decorations)if(!active || !record.svg.isConnected){restore(record);decorations.delete(record);} + if(active) for(const parent of doc.querySelectorAll(selector)) { + // Existing SVGs already express the action; only legacy glyphs change. + if(parent.querySelector('svg:not(.glass-control-icon),.legacy-nav-icon'))continue; + for(const node of [...parent.childNodes]) { + if(node.nodeType!==3)continue;const match=leadingGlyph(node.textContent);if(!match)continue; + const icon=svg(match.kind), original=doc.createElement('span'); original.className='glass-original-glyph';original.textContent=match.original; + node.textContent=match.rest;parent.insertBefore(icon,node);parent.insertBefore(original,node); + const addedLabel=parent.tagName==='BUTTON'&&!parent.hasAttribute('aria-label')&&!parent.textContent.replace(match.original,'').trim(); + if(addedLabel)parent.setAttribute('aria-label',parent.title || ({play:'Play',pause:'Pause',close:'Close',add:'Add',save:'Save'}[match.kind] || match.kind)); + decorations.add({parent,svg:icon,original,addedLabel});break; + } + if(parent.matches('.notes-tab')&&!parent.querySelector('svg')&&paths[parent.dataset.tab==='markers'?'chapters':parent.dataset.tab]) { + const kind=parent.dataset.tab==='markers'?'chapters':parent.dataset.tab,icon=svg(kind);parent.prepend(icon); + const original=doc.createElement('span');decorations.add({parent,svg:icon,original,addedLabel:false}); + } + } + if(active)observer.observe(doc.body,{childList:true,subtree:true,characterData:true}); + } + function schedule(){if(!scheduled){scheduled=true;root.queueMicrotask(paint);}} + themeObserver.observe(doc.documentElement,{attributes:true,attributeFilter:['data-layout']});paint(); + return ()=>{observer.disconnect();themeObserver.disconnect();for(const record of decorations)restore(record);}; + } + if(typeof module!=='undefined')module.exports={leadingGlyph,paths}; + else if(root.document.readyState==='loading')root.document.addEventListener('DOMContentLoaded',()=>mount(root.document),{once:true}); + else mount(root.document); +})(typeof window!=='undefined'?window:globalThis); diff --git a/frontend/glass-controls.test.js b/frontend/glass-controls.test.js new file mode 100644 index 0000000..2638fd8 --- /dev/null +++ b/frontend/glass-controls.test.js @@ -0,0 +1,9 @@ +const test=require('node:test'),assert=require('node:assert/strict'); +const {leadingGlyph}=require('./glass-controls.js'); +test('glass icon substitution preserves leading whitespace and remaining labels',()=>{ + assert.deepEqual(leadingGlyph(' โฌ‡ Save'),{kind:'save',original:' โฌ‡',rest:' Save'}); + assert.deepEqual(leadingGlyph('โš™๏ธ Settings'),{kind:'settings',original:'โš™๏ธ',rest:' Settings'}); + assert.equal(leadingGlyph('A song with ๐Ÿ” in its title'),null); + assert.equal(leadingGlyph('Clear all'),null); + assert.equal(leadingGlyph('โธ').kind,'pause'); +}); diff --git a/frontend/index.html b/frontend/index.html index bf6108e..6767cbc 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -32,6 +32,7 @@ +
@@ -663,6 +664,7 @@ + diff --git a/frontend/sw.js b/frontend/sw.js index c1ede0f..3b6526b 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -89,6 +89,8 @@ const SHELL = [ '/offline-pages.css', '/saved-page.js', '/server-backfill.js', + '/glass-controls.js', + '/theme-glass-controls.css', '/saved-page.css', '/party-dj.js', '/lyrics-window.js', diff --git a/frontend/theme-glass-controls.css b/frontend/theme-glass-controls.css new file mode 100644 index 0000000..7f0f788 --- /dev/null +++ b/frontend/theme-glass-controls.css @@ -0,0 +1,53 @@ +/* Now Playing's stroke and frosted-tile vocabulary, scoped to Glass Stage. */ +html[data-layout="glass-stage"] .legacy-nav-icon, +html[data-layout="glass-stage"] .glass-original-glyph { display:none; } +html[data-layout="glass-stage"] :is(.classic-nav-icon,.glass-control-icon) { display:inline-block; flex:none; width:20px; height:20px; fill:none; stroke:currentColor; stroke-width:1.9; stroke-linecap:round; stroke-linejoin:round; vertical-align:middle; pointer-events:none; } +html[data-layout="glass-stage"] :is(#listPane button,.settings-section-row,.notes-tab,.mini-btn,.list-icon-btn) svg { width:20px; height:20px; fill:none; stroke:currentColor; stroke-width:1.9; stroke-linecap:round; stroke-linejoin:round; flex:none; } +html[data-layout="glass-stage"] :is(.bottom-nav-btn,.nav-item,.playlist-item) { min-height:44px; color:var(--text-2); } +html[data-layout="glass-stage"] .bottom-nav { gap:2px; padding-inline:4px; } +html[data-layout="glass-stage"] .bottom-nav-btn { border:1px solid transparent; border-radius:18px; gap:6px; min-width:44px; background:transparent; } +html[data-layout="glass-stage"] .bottom-nav-icon { display:grid; place-items:center; width:24px; height:24px; background:none; box-shadow:none; transform:none; } +html[data-layout="glass-stage"] .bottom-nav-label { font-size:11px; } +html[data-layout="glass-stage"] .bottom-nav-btn.active { color:var(--accent-bright); background:var(--tile-hi); border-color:var(--glass-line); } +html[data-layout="glass-stage"] .bottom-nav-btn.active .bottom-nav-icon { background:none; box-shadow:none; transform:none; } +html[data-layout="glass-stage"] :is(.bottom-nav-badge,.nav-badge,.pl-count) { background:var(--tile-hi); border:1px solid var(--glass-line); color:var(--text-2); border-radius:99px; font-size:11px; min-width:18px; font-variant-numeric:tabular-nums; } +html[data-layout="glass-stage"] :is(.bottom-nav-btn.active .bottom-nav-badge,.nav-item.active .nav-badge) { color:var(--accent-bright); } +html[data-layout="glass-stage"] :is(.nav-item,.playlist-item) { border:1px solid transparent; border-radius:18px; gap:10px; padding:10px 12px; } +html[data-layout="glass-stage"] :is(.nav-item.active,.playlist-item.active) { color:var(--accent-bright); border-color:var(--glass-line); background:var(--tile-hi); } +html[data-layout="glass-stage"] :is(.nav-item,.playlist-item):hover { background:var(--tile); } +html[data-layout="glass-stage"] .smart-item .pl-name { display:flex; align-items:center; gap:10px; } +html[data-layout="glass-stage"] :is(#listPane button,.sidebar .icon-btn,.sb-profile-btn,.notes-tab,.mini-btn) { min-height:44px; min-width:44px; border-radius:18px; border:1px solid var(--glass-line); background:var(--tile); color:var(--text-2); box-shadow:none; } +html[data-layout="glass-stage"] :is(#listPane button,.sidebar .icon-btn,.sb-profile-btn,.notes-tab,.mini-btn):hover { background:var(--tile-hi); color:var(--text); border-color:var(--glass-line); } +html[data-layout="glass-stage"] :is(#listPane button,.sidebar button,.notes-tab,.mini-btn,.playlist-item):focus-visible { outline:2px solid var(--accent-bright); outline-offset:3px; } +html[data-layout="glass-stage"] :is(#listPane button,.notes-tab,.mini-btn):disabled { opacity:.5; cursor:not-allowed; } +html[data-layout="glass-stage"] :is(.card-menu,.card-del,.card-act,.saved-file-action,.mini-btn) { width:44px; height:44px; padding:11px; opacity:1; display:inline-flex; align-items:center; justify-content:center; flex:none; } +html[data-layout="glass-stage"] :is(.card-del,.saved-file-action.danger,.saved-clear,.dl-act.danger) { color:var(--accent-bright); } +html[data-layout="glass-stage"] :is(.settings-section-row[aria-current="true"],.notes-tab.active) { color:var(--accent-bright); background:var(--tile-hi); border-color:var(--glass-line); } +html[data-layout="glass-stage"] .settings-section-row { margin-bottom:6px; } +html[data-layout="glass-stage"] .settings-section-row svg { color:inherit; } +html[data-layout="glass-stage"] #listActions button { height:auto; min-height:44px; font-size:12px; } +html[data-layout="glass-stage"] #listActions button::after { font-size:11px; } +html[data-layout="glass-stage"] .notes-tabs { display:flex; flex-wrap:wrap; gap:6px; padding:6px; border-radius:22px; background:var(--tile); border:1px solid var(--glass-line); } +html[data-layout="glass-stage"] .notes-tab { display:inline-flex; align-items:center; justify-content:center; gap:6px; padding:8px 10px; font-size:12px; } +html[data-layout="glass-stage"] .mini-bar-inner { gap:8px; } +html[data-layout="glass-stage"] .mini-close { color:var(--text-2); } +html[data-layout="glass-stage"] .mini-service { color:var(--accent-bright); } +html[data-layout="glass-stage"] .list-filter-bar input { min-height:44px; } +/* Reserve room for both row actions rather than squeezing the media title. */ +html[data-layout="glass-stage"] .card:not(.saved-card):not(.dl-row) { display:grid; grid-template-columns:72px minmax(0,1fr) 44px; align-items:center; gap:8px; } +html[data-layout="glass-stage"] .card:not(.saved-card):not(.dl-row):has(.card-del) { grid-template-columns:72px minmax(0,1fr) 44px 44px; } +html[data-layout="glass-stage"] .card:not(.saved-card):not(.dl-row) .thumb { grid-column:1; grid-row:1; width:72px; aspect-ratio:16/9; } +html[data-layout="glass-stage"] .card:not(.saved-card):not(.dl-row) .card-info { grid-column:2; grid-row:1; min-width:0; } +html[data-layout="glass-stage"] .card:not(.saved-card):not(.dl-row) .card-menu { grid-column:3; grid-row:1; } +html[data-layout="glass-stage"] .card:not(.saved-card):not(.dl-row) .card-del { grid-column:4; grid-row:1; } +html[data-layout="glass-stage"] .card .card-title { overflow:hidden; overflow-wrap:anywhere; } +html[data-layout="glass-stage"] .notes-fold { min-width:44px; min-height:44px; border-radius:18px; background:var(--tile); border:1px solid var(--glass-line); color:var(--text-2); } +html[data-layout="glass-stage"] .notes-count { font-size:11px; } +html[data-layout="glass-stage"] .bottom-nav-btn { padding:8px 2px; } +html[data-layout="glass-stage"] .bottom-nav-label { overflow:visible; text-overflow:clip; max-width:none; letter-spacing:0; } +/* Keep the section shortcuts away from right-hand file actions. */ +html[data-layout="glass-stage"] .sec-rail { top:auto; left:12px; right:auto; bottom:var(--rail-bottom,24px); transform:none; width:max-content; max-width:calc(100% - 100px); flex-direction:row; gap:4px; padding:6px; border-radius:22px; background:var(--glass); border-color:var(--glass-line); } +html[data-layout="glass-stage"] .sec-rail-btn { width:44px; height:44px; border-radius:18px; background:var(--tile); border:1px solid var(--glass-line); color:var(--text-2); } +html[data-layout="glass-stage"] .sec-rail-btn.active { background:var(--tile-hi); color:var(--accent-bright); border-color:var(--glass-line); } +html[data-layout="glass-stage"] .sec-rail-label { right:auto; left:0; bottom:50px; } +html[data-layout="glass-stage"] #listPane { padding-bottom:calc(var(--bottom-chrome,0px) + 88px) !important; } diff --git a/playwright.glass.config.js b/playwright.glass.config.js new file mode 100644 index 0000000..f0a9cbf --- /dev/null +++ b/playwright.glass.config.js @@ -0,0 +1,2 @@ +const { defineConfig } = require('@playwright/test'); +module.exports = defineConfig({ ...require('./playwright.classic.config'), testMatch:/glass-controls\.spec\.js/ }); diff --git a/tests/glass-controls.spec.js b/tests/glass-controls.spec.js new file mode 100644 index 0000000..78a6075 --- /dev/null +++ b/tests/glass-controls.spec.js @@ -0,0 +1,87 @@ +const {test,expect}=require('@playwright/test'); +const {differentPixels}=require('./helpers/png-diff'); +const {openClassic,showPlayingFixture}=require('./helpers/classic-fixture'); +const capture=process.env.YTP_GLASS_CAPTURE; +async function fixture(page,layout,state){ + await openClassic(page,{populated:true,settings:{autoPreload:false}}); + await page.addStyleTag({content:'*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}'}); + await page.evaluate(({layout,state})=>{ + data.settings.layout=layout;applyAppearance();SaveQueue.resumeAll=async()=>{}; + API.cacheList=async()=>({ok:true,items:data.playlists[0].videos.slice(0,3).map((v,i)=>({id:v.id,size:104857600*(i+1)})),total:629145600}); + if(state==='downloads'){ + const videos=data.playlists[0].videos.slice(0,4);downloading.clear();downloadMeta.clear();cachedIds.clear(); + localStorage.setItem('ytpSaveQueue',JSON.stringify(Object.fromEntries(videos.map((v,i)=>[v.id,{...v,at:Date.now(),status:['active','queued','paused','failed'][i],error:i===3?'Connection lost':''}])))); + videos.slice(0,2).forEach((v,i)=>{downloading.add(v.id);downloadMeta.set(v.id,v);SaveQueue.progress(v.id,i?{phase:'waiting'}:{received:10485760,total:104857600});}); + } + if(state==='search'){searchResults=data.playlists[0].videos;searchAttempted=true;} + view=state==='playlist'?{type:state,id:'studio'}:{type:state};render(); + },{layout,state}); + if(state==='playing'){await showPlayingFixture(page);await page.evaluate(()=>{data.settings.layout='glass-stage';applyAppearance();});} + await page.evaluate(async()=>{await document.fonts.ready;await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));}); + await page.locator('#listPane').scrollIntoViewIfNeeded(); + if(state==='saved')await expect(page.locator('.saved-card')).toHaveCount(3); + if(state==='settings')await expect(page.locator('.settings-section-row').first()).toBeVisible(); +} +for(const layout of ['classic','glass-stage'])for(const width of [390,1440])for(const state of ['search','queue','history','playlist','saved','downloads','settings'])test(`${layout} ${state} ${width}`,async({page})=>{ + await page.setViewportSize({width,height:900}); + let baseline, baselineStyles; + const styles = target => target.locator('.nav-item,.bottom-nav-btn,.card-menu,.card-del,.saved-file-action,.dl-act,.settings-section-row').evaluateAll(nodes=>nodes.map(el=>{const s=getComputedStyle(el),r=el.getBoundingClientRect();return [el.className,r.x,r.y,r.width,r.height,s.color,s.backgroundColor,s.borderRadius,s.fontSize,s.display];})); + if(!capture&&layout==='classic'){ + const prior=await page.context().newPage();await prior.setViewportSize({width,height:900}); + await prior.route('**/theme-glass-controls.css',r=>r.fulfill({contentType:'text/css',body:''})); + await prior.route('**/glass-controls.js',r=>r.fulfill({contentType:'application/javascript',body:''})); + await fixture(prior,layout,state);baselineStyles=await styles(prior);baseline=await prior.screenshot({path:`/tmp/glass-polish-before-${layout}-${width}-${state}.png`});await prior.close(); + } + await fixture(page,layout,state); + const path=`/tmp/glass-polish-${capture||'after'}-${layout}-${width}-${state}.png`; + const screenshot=await page.screenshot({path}); + if(!capture&&layout==='classic'){expect(await styles(page)).toEqual(baselineStyles);expect(differentPixels(screenshot,baseline)).toBeLessThanOrEqual(16);} + if(!capture&&layout==='glass-stage'){ + const svg=page.locator('#bottomNav .classic-nav-icon').first();expect(await svg.evaluate(el=>getComputedStyle(el).display)).not.toBe('none'); + const stroke=await svg.evaluate(el=>getComputedStyle(el).strokeWidth);expect(stroke).toBe('1.9px'); + const controls=await page.locator('.saved-file-action,.dl-act,.settings-section-row,.card-menu').evaluateAll(nodes=>nodes.filter(n=>n.getBoundingClientRect().width>0).map(n=>{const r=n.getBoundingClientRect();return [r.width,r.height];})); + expect(controls.every(([w,h])=>w>=44&&h>=44)).toBe(true); + } +}); +test('Now Playing Glass reference',async({page})=>{ + await page.setViewportSize({width:390,height:900});await fixture(page,'glass-stage','playing');await page.locator('#nowPlayingMeta').scrollIntoViewIfNeeded(); + await page.screenshot({path:`/tmp/glass-polish-${capture||'after'}-reference.png`}); +}); + +test('Glass navigation, notes and mini-player controls keep labels and round-trip cleanly',async({page})=>{ + await page.setViewportSize({width:390,height:900});await openClassic(page,{populated:true,settings:{autoPreload:false}});await showPlayingFixture(page); + await page.addStyleTag({content:'*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}'}); + await page.evaluate(async()=>{await document.fonts.ready;await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));}); + const markup = () => page.locator('#listPane').evaluate(el=>{const copy=el.cloneNode(true);copy.querySelectorAll('[style=""]').forEach(n=>n.removeAttribute('style'));return copy.outerHTML;}); + const before=await markup(); + const miniClass=await page.locator('#miniBar').getAttribute('class'); + await page.evaluate(()=>{data.settings.layout='glass-stage';applyAppearance();}); + await expect(page.locator('.card-menu .glass-control-icon').first()).toBeAttached(); + // Isolated installed-navigation fixture: the real app shows this bar in + // standalone portrait mode; ordinary Chromium isn't an installed PWA. + await page.addStyleTag({content:'html[data-layout="glass-stage"] #bottomNav{display:flex}html[data-layout="glass-stage"] #miniBar{display:block}'}); + await page.locator('#miniBar').evaluate(el=>el.classList.remove('hidden')); + await expect(page.locator('#miniPlayBtn svg')).toBeVisible(); + await expect(page.locator('#miniPlayBtn')).toHaveAttribute('aria-label',/Play|Pause/); + await expect(page.locator('#bottomNav [aria-label="Queue"] svg')).toBeVisible(); + expect(await page.locator('#bottomNav [aria-label="Downloads"] .bottom-nav-label').evaluate(el=>el.scrollWidth<=el.clientWidth)).toBe(true); + await page.screenshot({path:'/tmp/glass-polish-navigation-notes-mini.png'}); + await page.evaluate(()=>{data.settings.layout='classic';applyAppearance();}); + await expect(page.locator('.glass-control-icon')).toHaveCount(0); + await page.locator('#miniBar').evaluate((el,className)=>{el.className=className;},miniClass); + expect(await markup()).toBe(before); +}); +test('Glass icons preserve button commands and keyboard focus',async({page})=>{ + await page.setViewportSize({width:1440,height:900});await fixture(page,'glass-stage','queue'); + const action=page.locator('.card-menu').first();await action.focus(); + expect(await action.evaluate(el=>getComputedStyle(el).outlineStyle)).toBe('solid'); + await action.press('Enter');await expect(page.locator('#modal')).toBeVisible(); +}); + +test('Glass settings final row is reachable and file actions are not covered',async({page})=>{ + await page.setViewportSize({width:390,height:900});await fixture(page,'glass-stage','saved'); + const covered=await page.locator('.saved-file-action').evaluateAll(nodes=>nodes.some(el=>{const r=el.getBoundingClientRect();if(r.bottom>innerHeight)return false;const target=document.elementFromPoint(r.x+r.width/2,r.y+r.height/2);return !el.contains(target);}));expect(covered).toBe(false); + await page.setViewportSize({width:1440,height:900});await fixture(page,'glass-stage','settings'); + const last=page.locator('.settings-section-row').last();await last.scrollIntoViewIfNeeded();await expect(last).toBeInViewport(); + await page.screenshot({path:'/tmp/glass-polish-settings-end.png'}); +}); diff --git a/tests/helpers/png-diff.js b/tests/helpers/png-diff.js new file mode 100644 index 0000000..538f727 --- /dev/null +++ b/tests/helpers/png-diff.js @@ -0,0 +1,27 @@ +// Lossless Chromium screenshots: tolerate a few rasterization pixels while +// keeping strict geometry/style comparisons in the calling UI test. +const { inflateSync } = require('node:zlib'); +function decode(buffer) { + let width,height,channels;const chunks=[]; + for(let offset=8;offset{const p=a+b-c,da=Math.abs(p-a),db=Math.abs(p-b),dc=Math.abs(p-c);return da<=db&&da<=dc?a:db<=dc?b:c;}; + for(let y=0;y4)throw new Error('Unexpected PNG filter'); + for(let x=0;x=channels?pixels[index-channels]:0,b=y?pixels[index-stride]:0,c=y&&x>=channels?pixels[index-stride-channels]:0; + pixels[index]=(raw[position++]+[0,a,b,Math.floor((a+b)/2),paeth(a,b,c)][filter])&255; + } + } + return {width,height,channels,pixels}; +} +function differentPixels(first,second){ + const a=decode(first),b=decode(second);if(a.width!==b.width||a.height!==b.height||a.channels!==b.channels)return Infinity; + let count=0;for(let i=0;i