Keep the Glass Stage notes toolbar on one row at every player width

This commit is contained in:
Jonathan Sykes
2026-10-03 21:58:03 +08:00
parent 729e73e131
commit 49ba470740
4 changed files with 136 additions and 2 deletions

View File

@@ -0,0 +1,67 @@
# Glass Stage iPhone layout follow-up
The supplied production screenshots came from build `16c08b099a31`, before the
Task A/B changes. This follow-up preserves the Glass Stage colours, glass tiles,
stroke icons and all existing control ids/actions. Every CSS rule is scoped to
`html[data-layout="glass-stage"]`; Classic retains its original rail and labels.
## Panel shortcuts and bottom chrome
The Glass section rail was a fixed horizontal overlay positioned above the mini
player. Clearing the player did not clear a panel heading or filter underneath.
`glass-panel-layout.js` moves that same rail, with its existing listeners, into
`#listPane` before the heading. It restores the original location when leaving
Glass Stage. The rail now occupies its own row instead of covering content.
The module measures the visible fixed bottom navigation and mini player. The
navigation's measured height already contains the home-indicator safe area;
the mini player sits eight pixels above that height. Scroll clearance includes
both bars and a 16 px gap, without counting the safe area twice. Portrait uses
one shared scroller and reserves that clearance once at its tail; desktop panes
reserve it individually. The mini player's outer card clips the progress strip
inside its rounded border, rather than leaving a separate line below it.
## Bottom navigation
The uploaded rightward icon shift is reproduced by the pre-Task-A CSS from
`f2c9459`: installed-PWA padding of `4px 16px` remained on a fixed-width icon box.
Task A (`79a3c28`) already resets that padding to zero. The new regression test
runs an iPhone 13 browser profile at 390 px, including 34 px bottom safe-area
allowance, Queue/Downloads badges and an active pill. In both Chromium and
WebKit, SVG and icon-box centres match label centres within 0.6 CSS px. The old
CSS shifts every icon more than eight CSS px. No further nav CSS change was
needed; this is committed verification of the existing fix.
## Notes toolbar
The toolbar's Glass override explicitly enabled `flex-wrap:wrap`. It now keeps
one row. Container queries respond to the player card's available width, even
when a desktop player column is narrow. Spacing tightens first, the longer
Chapters & bookmarks label becomes icon/count at narrow card widths, and all
labels become icon-only on the smallest cards. Full accessible names and title
hints remain. Every action retains a minimum 44 px target, and the fold chevron
stays at the row's right end. Status text truncates within the remaining space.
Glass label wrappers and added names restore exactly when switching to Classic.
## Verification
- 125 frontend unit tests pass, including measured chrome-clearance logic.
- `node --check frontend/app.js` and `bun build --no-bundle server/server.js` pass.
- Chromium and WebKit: all five panel shortcuts at 390 and 1440 px; final list
row clears playback chrome; progress strip stays inside its card.
- Classic geometry and before/after screenshot comparisons pass in both browsers
at 390 and 1440 px, within the 32-pixel isolated rasterization tolerance.
- iPhone-profile navigation alignment/badges pass in both engines at 390 px.
- Toolbar stays one row at 320, 360, 390, 430 and 1440 px in both engines, including
long status text and chapter counts; theme round-trip restoration passes.
- Batched visual review of six WebKit captures found no overlap, alignment,
clipping or wrapping defects in the requested areas.
Run `npx playwright test -c playwright.glass-panel.config.js` for the 22 browser
cases. WebKit was installed locally for these checks; these are browser/iPhone
profile tests, not tests on physical iPhone hardware. Review the installed PWA
on an actual iPhone after the reviewer deploys, particularly when the mini
player appears/disappears and after rotating the phone.
Before/after captures are saved under
`/home/josh/deliverables/ytplayer-done9/`. No push or deployment was performed.

View File

@@ -31,7 +31,7 @@
return { kind:glyphs[glyph], original:prefix+rest.slice(0,length), rest:rest.slice(length) }; return { kind:glyphs[glyph], original:prefix+rest.slice(0,length), rest:rest.slice(length) };
} }
function mount(doc) { function mount(doc) {
const decorations = new Set(); let scheduled=false; const decorations = new Set(), tabLabels = new Map(); let scheduled=false;
const selector = '#listPane button, .sidebar button, .smart-item .pl-name, .notes-tab, .notes-fold, .mini-btn, .sidebar .playlist-item:not(.smart-item), .pl-offline-badge.all, .home-pl-offline.all, .home-pl-play, .home-pl-thumb-fallback'; const selector = '#listPane button, .sidebar button, .smart-item .pl-name, .notes-tab, .notes-fold, .mini-btn, .sidebar .playlist-item:not(.smart-item), .pl-offline-badge.all, .home-pl-offline.all, .home-pl-play, .home-pl-thumb-fallback';
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 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); const observer=new root.MutationObserver(schedule), themeObserver=new root.MutationObserver(schedule);
@@ -39,9 +39,14 @@
record.svg.remove(); if(record.original.isConnected)record.original.replaceWith(doc.createTextNode(record.original.textContent)); 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'); if(record.addedLabel && record.parent.isConnected)record.parent.removeAttribute('aria-label');
} }
function restoreTab(parent, record) {
for(const label of record.labels)if(label.isConnected)label.replaceWith(doc.createTextNode(label.textContent));
for(const [name,value] of Object.entries(record.attributes)) { if(value===null)parent.removeAttribute(name);else parent.setAttribute(name,value); }
}
function paint() { function paint() {
scheduled=false;observer.disconnect(); scheduled=false;observer.disconnect();
const active=doc.documentElement.dataset.layout==='glass-stage'; const active=doc.documentElement.dataset.layout==='glass-stage';
for(const [parent,record] of tabLabels)if(!active || !parent.isConnected){restoreTab(parent,record);tabLabels.delete(parent);}
for(const record of decorations)if(!active || !record.svg.isConnected){restore(record);decorations.delete(record);} for(const record of decorations)if(!active || !record.svg.isConnected){restore(record);decorations.delete(record);}
if(active) for(const parent of doc.querySelectorAll(selector)) { if(active) for(const parent of doc.querySelectorAll(selector)) {
// Existing SVGs already express the action; only legacy glyphs change. // Existing SVGs already express the action; only legacy glyphs change.
@@ -65,11 +70,22 @@
const original=doc.createElement('span');decorations.add({parent,svg:icon,original,addedLabel:false}); const original=doc.createElement('span');decorations.add({parent,svg:icon,original,addedLabel:false});
} }
} }
if(active)for(const parent of doc.querySelectorAll('.notes-tab')) {
if(tabLabels.has(parent))continue;
const record={labels:[],attributes:{'aria-label':parent.getAttribute('aria-label'),title:parent.getAttribute('title')}};
const name=parent.dataset.tab==='markers'?'Chapters & bookmarks':parent.dataset.tab==='lyrics'?'Lyrics':'Search';
if(!parent.hasAttribute('aria-label'))parent.setAttribute('aria-label',name);
if(!parent.hasAttribute('title'))parent.setAttribute('title',name);
for(const node of [...parent.childNodes])if(node.nodeType===3 && node.textContent.trim()) {
const label=doc.createElement('span');label.className='glass-tab-label';node.replaceWith(label);label.append(node);record.labels.push(label);
}
tabLabels.set(parent,record);
}
if(active)observer.observe(doc.body,{childList:true,subtree:true,characterData:true}); if(active)observer.observe(doc.body,{childList:true,subtree:true,characterData:true});
} }
function schedule(){if(!scheduled){scheduled=true;root.queueMicrotask(paint);}} function schedule(){if(!scheduled){scheduled=true;root.queueMicrotask(paint);}}
themeObserver.observe(doc.documentElement,{attributes:true,attributeFilter:['data-layout']});paint(); themeObserver.observe(doc.documentElement,{attributes:true,attributeFilter:['data-layout']});paint();
return ()=>{observer.disconnect();themeObserver.disconnect();for(const record of decorations)restore(record);}; return ()=>{observer.disconnect();themeObserver.disconnect();for(const record of decorations)restore(record);for(const [parent,record] of tabLabels)restoreTab(parent,record);};
} }
if(typeof module!=='undefined')module.exports={leadingGlyph,paths}; if(typeof module!=='undefined')module.exports={leadingGlyph,paths};
else if(root.document.readyState==='loading')root.document.addEventListener('DOMContentLoaded',()=>mount(root.document),{once:true}); else if(root.document.readyState==='loading')root.document.addEventListener('DOMContentLoaded',()=>mount(root.document),{once:true});

View File

@@ -35,3 +35,22 @@ html[data-layout="glass-stage"] #listPane {
html[data-layout="glass-stage"] .player-pane { padding-bottom:14px; } html[data-layout="glass-stage"] .player-pane { padding-bottom:14px; }
html[data-layout="glass-stage"] #listPane { padding-bottom:16px !important; } html[data-layout="glass-stage"] #listPane { padding-bottom:16px !important; }
} }
/* Keep the notes toolbar one row, even when the player is a narrow desktop pane. */
html[data-layout="glass-stage"] #notesPanel { container-type:inline-size; container-name:glass-notes; min-width:0; }
html[data-layout="glass-stage"] .notes-tabs { flex-wrap:nowrap; min-width:0; align-items:center; }
html[data-layout="glass-stage"] .notes-tab { flex:0 1 auto; min-width:44px; min-height:44px; white-space:nowrap; letter-spacing:.04em; }
html[data-layout="glass-stage"] .glass-tab-label { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
html[data-layout="glass-stage"] .notes-status { flex:1 1 0; min-width:0; margin-left:0; font-size:11px; }
html[data-layout="glass-stage"] .notes-count { flex:none; margin-left:0; }
html[data-layout="glass-stage"] .notes-fold { flex:none; width:44px; height:44px; margin-left:auto; }
@container glass-notes (max-width:560px) {
html[data-layout="glass-stage"] .notes-tabs { gap:4px; padding:6px; }
html[data-layout="glass-stage"] .notes-tab { padding:8px; letter-spacing:0; font-size:11px; }
}
@container glass-notes (max-width:440px) {
html[data-layout="glass-stage"] .notes-tab[data-tab="markers"] .glass-tab-label { display:none; }
}
@container glass-notes (max-width:340px) {
html[data-layout="glass-stage"] .notes-tab .glass-tab-label { display:none; }
}

View File

@@ -103,3 +103,35 @@ for (const width of [390]) test(`iPhone bottom icons centered above labels with
await page.locator('#bottomNav').screenshot({ path: `/tmp/done9-nav-after-${info.project.name}-390.png` }); await page.locator('#bottomNav').screenshot({ path: `/tmp/done9-nav-after-${info.project.name}-390.png` });
await context.close(); await context.close();
}); });
for (const width of [320, 360, 390, 430, 1440]) test(`Now Playing toolbar remains one row at ${width}`, async ({ page }, info) => {
await setup(page, width);
await page.evaluate(() => { document.getElementById('notesStatus').textContent = 'Verse · shared lyrics'; document.getElementById('markersCount').textContent = '12'; });
await page.locator('#notesPanel').scrollIntoViewIfNeeded();
if (!before) {
const metrics = await page.locator('.notes-tabs').evaluate(bar => {
const b = bar.getBoundingClientRect(), buttons = [...bar.querySelectorAll('button')].map(el => { const r = el.getBoundingClientRect(); return { top: r.top, bottom: r.bottom, left: r.left, right: r.right, width: r.width, height: r.height, label: el.getAttribute('aria-label') || el.getAttribute('title') }; });
return { bar: { left: b.left, right: b.right }, buttons, overflow: bar.scrollWidth - bar.clientWidth };
});
expect(Math.max(...metrics.buttons.map(b => b.top)) - Math.min(...metrics.buttons.map(b => b.top))).toBeLessThan(1);
expect(metrics.overflow).toBeLessThanOrEqual(1);
for (const b of metrics.buttons) { expect(b.width).toBeGreaterThanOrEqual(44); expect(b.height).toBeGreaterThanOrEqual(44); expect(b.left).toBeGreaterThanOrEqual(metrics.bar.left); expect(b.right).toBeLessThanOrEqual(metrics.bar.right); expect(b.label).toBeTruthy(); }
expect(metrics.buttons.at(-1).right).toBeGreaterThan(metrics.bar.right - 12);
}
await page.locator('#notesPanel .notes-tabs').screenshot({ path: `/tmp/done9-toolbar-${before ? 'before' : 'after'}-${info.project.name}-${width}.png` });
});
test('compact toolbar labels and accessible names restore on leaving Glass Stage', async ({ page }) => {
await setup(page, 390, 'classic');
const original = await page.locator('.notes-tab').evaluateAll(nodes => nodes.map(n => ({ html: n.innerHTML, label: n.getAttribute('aria-label'), title: n.getAttribute('title') })));
for (let i = 0; i < 2; i++) {
await page.evaluate(() => { data.settings.layout = 'glass-stage'; applyAppearance(); });
await expect(page.locator('.glass-tab-label')).toHaveCount(3);
for (const name of ['Lyrics', 'Chapters & bookmarks', 'Search']) await expect(page.getByRole('tab', { name, exact: true })).toHaveCount(1);
await page.evaluate(() => { data.settings.layout = 'classic'; applyAppearance(); });
await expect(page.locator('.glass-tab-label')).toHaveCount(0);
expect(await page.locator('.notes-tab').evaluateAll(nodes => nodes.map(n => ({ html: n.innerHTML, label: n.getAttribute('aria-label'), title: n.getAttribute('title') })))).toEqual(original);
await expect(page.locator('#sectionRail')).not.toHaveJSProperty('parentElement', null);
expect(await page.locator('#sectionRail').evaluate(n => n.parentElement.tagName)).toBe('BODY');
}
});