Keep the Glass Stage notes toolbar on one row at every player width
This commit is contained in:
67
docs/glass-stage-iphone-followup.md
Normal file
67
docs/glass-stage-iphone-followup.md
Normal 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.
|
||||||
@@ -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});
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user