Unify Glass Stage navigation and page controls with Now Playing

This commit is contained in:
Jonathan Sykes
2026-10-03 18:19:56 +08:00
parent 8ea92c1aac
commit f2c9459a54
9 changed files with 269 additions and 0 deletions

View File

@@ -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.

View File

@@ -0,0 +1,69 @@
/* Draw legacy control glyphs only in Glass; restore them on theme changes. */
(function(root) {
'use strict';
const paths = {
add:'<path d="M12 5v14M5 12h14"/>', close:'<path d="m6 6 12 12M18 6 6 18"/>',
search:'<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>',
save:'<path d="M12 3v12m-5-5 5 5 5-5M4 17v4h16v-4"/>',
play:'<path d="m8 5 11 7-11 7z"/>', pause:'<path d="M8 5v14M16 5v14"/>',
next:'<path d="m5 5 10 7-10 7zM19 5v14"/>', previous:'<path d="m19 5-10 7 10 7zM5 5v14"/>',
repeat:'<path d="m17 2 4 4-4 4M3 11V8a2 2 0 0 1 2-2h16M7 22l-4-4 4-4m14-1v3a2 2 0 0 1-2 2H3"/>',
edit:'<path d="m14 5 5 5M4 20l5-1L21 7l-4-4L5 15z"/>',
lyrics:'<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3M8 22h8"/>',
share:'<path d="M12 16V3m-4 4 4-4 4 4M5 12v9h14v-9"/>',
copy:'<rect x="8" y="8" width="12" height="13" rx="2"/><path d="M16 8V3H3v13h5"/>',
check:'<path d="m5 12 4 4L19 6"/>', folder:'<path d="M3 7V5h7l2 3h9v12H3z"/>',
settings:'<path d="M4 6h16M4 12h16M4 18h16"/><circle cx="8" cy="6" r="2"/><circle cx="16" cy="12" r="2"/><circle cx="10" cy="18" r="2"/>',
chapters:'<rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 7h8M8 12h8M8 17h5"/>',
clock:'<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
shuffle:'<path d="m18 3 3 3-3 3M3 6h3c5 0 7 12 12 12h3m-3-3 3 3-3 3M3 18h3c2 0 4-3 6-6s4-6 6-6h3"/>',
down:'<path d="m6 9 6 6 6-6"/>', right:'<path d="m9 6 6 6-6 6"/>', back:'<path d="M20 12H4m6-6-6 6 6 6"/>', more:'<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
devices:'<rect x="3" y="4" width="14" height="12" rx="2"/><path d="M7 20h6M10 16v4"/><rect x="17" y="10" width="5" height="11" rx="1"/>'
};
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);

View File

@@ -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');
});

View File

@@ -32,6 +32,7 @@
<link rel="stylesheet" href="car-mode.css" /> <link rel="stylesheet" href="car-mode.css" />
<link rel="stylesheet" href="offline-pages.css" /> <link rel="stylesheet" href="offline-pages.css" />
<link rel="stylesheet" href="saved-page.css" /> <link rel="stylesheet" href="saved-page.css" />
<link rel="stylesheet" href="theme-glass-controls.css" />
</head> </head>
<body> <body>
<div class="app"> <div class="app">
@@ -663,6 +664,7 @@
<script src="downloads-page.js"></script> <script src="downloads-page.js"></script>
<script src="saved-page.js"></script> <script src="saved-page.js"></script>
<script src="server-backfill.js"></script> <script src="server-backfill.js"></script>
<script src="glass-controls.js"></script>
<script src="party-dj.js"></script> <script src="party-dj.js"></script>
<script src="lyrics-window.js"></script> <script src="lyrics-window.js"></script>
<script src="midi.js"></script> <script src="midi.js"></script>

View File

@@ -89,6 +89,8 @@ const SHELL = [
'/offline-pages.css', '/offline-pages.css',
'/saved-page.js', '/saved-page.js',
'/server-backfill.js', '/server-backfill.js',
'/glass-controls.js',
'/theme-glass-controls.css',
'/saved-page.css', '/saved-page.css',
'/party-dj.js', '/party-dj.js',
'/lyrics-window.js', '/lyrics-window.js',

View File

@@ -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; }

View File

@@ -0,0 +1,2 @@
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({ ...require('./playwright.classic.config'), testMatch:/glass-controls\.spec\.js/ });

View File

@@ -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'});
});

27
tests/helpers/png-diff.js Normal file
View File

@@ -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<buffer.length;){
const length=buffer.readUInt32BE(offset),type=buffer.toString('ascii',offset+4,offset+8),data=buffer.subarray(offset+8,offset+8+length);offset+=length+12;
if(type==='IHDR'){width=data.readUInt32BE(0);height=data.readUInt32BE(4);channels=data[9]===6?4:3;if(data[8]!==8||![2,6].includes(data[9])||data[12])throw new Error('Expected an 8-bit RGB Chromium PNG');}
if(type==='IDAT')chunks.push(data);
}
const raw=inflateSync(Buffer.concat(chunks)),stride=width*channels,pixels=Buffer.alloc(stride*height);let position=0;
const paeth=(a,b,c)=>{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;y<height;y++){
const filter=raw[position++];if(filter>4)throw new Error('Unexpected PNG filter');
for(let x=0;x<stride;x++){
const index=y*stride+x,a=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<a.pixels.length;i+=a.channels)if(!a.pixels.subarray(i,i+a.channels).equals(b.pixels.subarray(i,i+a.channels)))count++;
return count;
}
module.exports={differentPixels};