diff --git a/frontend/glass-panel-layout.js b/frontend/glass-panel-layout.js
new file mode 100644
index 0000000..86b2c6d
--- /dev/null
+++ b/frontend/glass-panel-layout.js
@@ -0,0 +1,42 @@
+/* Glass Stage section shortcuts participate in layout; Classic keeps its rail. */
+(function (root) {
+ 'use strict';
+ function bottomClearance(viewport, boxes, gap = 16) {
+ const occupied = boxes.reduce((height, b) => b.fixed && b.height > 0 && b.top < viewport && b.bottom > 0 ? Math.max(height, viewport - b.top) : height, 0);
+ return Math.ceil(occupied + (occupied ? gap : 0));
+ }
+ function mount(doc) {
+ const win = doc.defaultView, html = doc.documentElement, rail = doc.getElementById('sectionRail'), panel = doc.getElementById('listPane');
+ if (!rail || !panel) return;
+ const original = doc.createComment('section rail original position'); rail.before(original);
+ let frame = 0;
+ function active() { return html.dataset.layout === 'glass-stage'; }
+ function schedule() { if (!frame) frame = win.requestAnimationFrame(measure); }
+ function set(name, value) { if (html.style.getPropertyValue(name) !== value) html.style.setProperty(name, value); }
+ function box(el) { const r = el.getBoundingClientRect(); return { top: r.top, bottom: r.bottom, height: el.classList.contains('hidden') ? 0 : r.height, fixed: win.getComputedStyle(el).position === 'fixed' }; }
+ function measure() {
+ frame = 0; if (!active()) return;
+ const nav = doc.getElementById('bottomNav'), mini = doc.getElementById('miniBar');
+ const navBox = box(nav);
+ set('--glass-nav-height', `${Math.ceil(navBox.height)}px`);
+ // Read again after nav-height changes: the mini player's bottom depends on it.
+ set('--glass-bottom-clearance', `${bottomClearance(win.innerHeight, [box(nav), box(mini)])}px`);
+ }
+ function sync() {
+ if (active()) { if (rail.parentElement !== panel) panel.prepend(rail); schedule(); }
+ else {
+ if (rail.parentNode !== original.parentNode) original.after(rail);
+ html.style.removeProperty('--glass-nav-height'); html.style.removeProperty('--glass-bottom-clearance');
+ }
+ }
+ const observer = new MutationObserver(sync); observer.observe(html, { attributes: true, attributeFilter: ['data-layout'] });
+ const chrome = [doc.getElementById('bottomNav'), doc.getElementById('miniBar')].filter(Boolean);
+ const changes = new MutationObserver(schedule); chrome.forEach(el => changes.observe(el, { attributes: true, attributeFilter: ['class', 'style'] }));
+ const resize = typeof win.ResizeObserver === 'function' ? new win.ResizeObserver(schedule) : null; chrome.forEach(el => resize?.observe(el));
+ win.addEventListener('resize', schedule); win.addEventListener('orientationchange', schedule); win.visualViewport?.addEventListener('resize', schedule);
+ sync();
+ }
+ if (typeof module !== 'undefined') module.exports = { bottomClearance };
+ 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-panel-layout.test.js b/frontend/glass-panel-layout.test.js
new file mode 100644
index 0000000..bae7779
--- /dev/null
+++ b/frontend/glass-panel-layout.test.js
@@ -0,0 +1,10 @@
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const { bottomClearance } = require('./glass-panel-layout.js');
+test('clearance covers stacked floating chrome and includes its viewport inset once', () => {
+ assert.equal(bottomClearance(844, [{ fixed: true, top: 740, bottom: 844, height: 104 }, { fixed: true, top: 668, bottom: 732, height: 64 }]), 192);
+ assert.equal(bottomClearance(844, [{ fixed: true, top: 740, bottom: 844, height: 104 }]), 120);
+});
+test('hidden, nonfixed and offscreen elements do not reserve chrome space', () => {
+ assert.equal(bottomClearance(844, [{ fixed: true, top: 740, bottom: 844, height: 0 }, { fixed: false, top: 668, bottom: 732, height: 64 }, { fixed: true, top: 900, bottom: 964, height: 64 }]), 0);
+});
diff --git a/frontend/index.html b/frontend/index.html
index adacb2a..bdfdad1 100755
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -34,6 +34,7 @@
+
@@ -670,6 +671,7 @@
+
diff --git a/frontend/sw.js b/frontend/sw.js
index 37532de..6c63f65 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -59,6 +59,8 @@ const SHELL = [
'/service-ui.css',
'/refresh.css',
'/theme-glass.css',
+ '/theme-glass-panel.css',
+ '/glass-panel-layout.js',
'/theme-classic.css',
'/recommendations.css',
'/flag-ui.js',
diff --git a/frontend/theme-glass-panel.css b/frontend/theme-glass-panel.css
new file mode 100644
index 0000000..183bb43
--- /dev/null
+++ b/frontend/theme-glass-panel.css
@@ -0,0 +1,37 @@
+/* In-flow section shortcuts cannot cover a heading, filter or list row. */
+html[data-layout="glass-stage"] #listPane > .sec-rail {
+ position:static; inset:auto; transform:none; display:flex !important;
+ align-self:flex-start; flex:none; flex-direction:row; flex-wrap:nowrap;
+ width:max-content; max-width:100%; margin:0 0 12px; z-index:auto;
+}
+html[data-layout="glass-stage"] #listPane > .sec-rail.hidden { display:none !important; }
+html[data-layout="glass-stage"] #listPane > .sec-rail .sec-rail-btn { flex:none; }
+html[data-layout="glass-stage"] #listPane > .sec-rail .sec-rail-label { z-index:1; }
+/* Keep the progress strip inside the same rounded card as the controls. */
+html[data-layout="glass-stage"] .mini-bar {
+ bottom:calc(var(--glass-nav-height, 66px + env(safe-area-inset-bottom)) + 8px);
+ border-radius:18px; overflow:hidden; border:1px solid var(--glass-line);
+ background:var(--glass); -webkit-backdrop-filter:blur(24px); backdrop-filter:blur(24px);
+}
+html[data-layout="glass-stage"] .mini-bar-inner {
+ border:0; border-radius:0; background:transparent; box-shadow:none;
+ -webkit-backdrop-filter:none; backdrop-filter:none;
+}
+html[data-layout="glass-stage"] .mini-progress { margin:0; height:3px; overflow:hidden; }
+html[data-layout="glass-stage"] .player-pane {
+ padding-bottom:var(--glass-bottom-clearance, 32px);
+ scroll-padding-bottom:var(--glass-bottom-clearance, 32px);
+}
+html[data-layout="glass-stage"] #listPane {
+ padding-bottom:var(--glass-bottom-clearance, 32px) !important;
+ scroll-padding-bottom:var(--glass-bottom-clearance, 32px);
+}
+@media (max-width:860px) {
+ /* Portrait uses one shared scroller: reserve chrome once, at its tail. */
+ html[data-layout="glass-stage"] .body {
+ padding-bottom:var(--glass-bottom-clearance, calc(82px + env(safe-area-inset-bottom)));
+ scroll-padding-bottom:var(--glass-bottom-clearance, calc(82px + env(safe-area-inset-bottom)));
+ }
+ html[data-layout="glass-stage"] .player-pane { padding-bottom:14px; }
+ html[data-layout="glass-stage"] #listPane { padding-bottom:16px !important; }
+}
diff --git a/playwright.glass-panel.config.js b/playwright.glass-panel.config.js
new file mode 100644
index 0000000..6f493c8
--- /dev/null
+++ b/playwright.glass-panel.config.js
@@ -0,0 +1,2 @@
+const { defineConfig } = require('@playwright/test');
+module.exports = defineConfig({ testDir: './tests', testMatch: /glass-panel\.spec\.js/, timeout: 30000, workers: 1, projects: [{ name: 'chromium', use: { browserName: 'chromium' } }, { name: 'webkit', use: { browserName: 'webkit' } }], use: { baseURL: 'http://localhost:8096', serviceWorkers: 'block' }, webServer: { command: 'npx serve frontend -l 8096 --no-clipboard', port: 8096, reuseExistingServer: false } });
diff --git a/tests/glass-panel.spec.js b/tests/glass-panel.spec.js
new file mode 100644
index 0000000..b79fbdb
--- /dev/null
+++ b/tests/glass-panel.spec.js
@@ -0,0 +1,71 @@
+const { test, expect } = require('@playwright/test');
+const { readFileSync } = require('node:fs');
+const { openClassic, showPlayingFixture } = require('./helpers/classic-fixture');
+const before = process.env.CAPTURE_BEFORE === '1';
+const phoneCss = readFileSync('frontend/styles.css', 'utf8').replaceAll('(display-mode: standalone)', '(min-width: 0px)').replaceAll('env(safe-area-inset-bottom)', '34px');
+async function setup(page, width, layout = 'glass-stage') {
+ await page.setViewportSize({ width, height: 844 });
+ if (width < 900) await page.route('**/styles.css', r => r.fulfill({ contentType: 'text/css', body: phoneCss }));
+ await openClassic(page, { populated: true, standalone: width < 900, 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(layout => {
+ data.settings.layout = layout; applyAppearance();
+ document.getElementById('notesPanel').classList.remove('hidden');
+ relatedVideos = data.history.slice(1, 4); renderRelated();
+ document.getElementById('miniTitle').textContent = current.meta.title;
+ document.getElementById('miniChannel').textContent = current.meta.channel;
+ document.getElementById('miniProgressFill').style.width = '25%';
+ SectionRail.refresh();
+ }, layout);
+ await page.evaluate(async () => { await document.fonts.ready; await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); });
+}
+for (const width of [390, 1440]) test(`panel rail and bottom clearance at ${width}`, async ({ page }, info) => {
+ await setup(page, width);
+ // The desktop rail was hidden in the old build; expose it for baseline evidence.
+ if (before && width === 1440) await page.addStyleTag({ content: 'html[data-layout="glass-stage"] #sectionRail{display:flex!important}' });
+ for (const section of ['player', 'meta', 'notes', 'related', 'list']) {
+ await page.locator(`#sectionRail [data-sec="${section}"]`).click();
+ await page.evaluate(() => { document.getElementById('miniBar').classList.remove('hidden'); updateBottomChrome(); });
+ await page.waitForTimeout(100);
+ if (!before) {
+ const metrics = await page.evaluate(() => {
+ const rail = document.getElementById('sectionRail'), header = document.querySelector('#listPane .list-header');
+ const r = rail.getBoundingClientRect(), h = header.getBoundingClientRect();
+ const m = document.getElementById('miniBar').getBoundingClientRect(), p = document.getElementById('miniProgress').getBoundingClientRect(), nav = document.getElementById('bottomNav').getBoundingClientRect();
+ return { parent: rail.parentElement.id, railBottom: r.bottom, headingTop: h.top, miniBottom: m.bottom, progressBottom: p.bottom, navTop: nav.height ? nav.top : innerHeight, overflow: getComputedStyle(document.getElementById('miniBar')).overflow };
+ });
+ expect(metrics.parent).toBe('listPane');
+ expect(metrics.railBottom).toBeLessThanOrEqual(metrics.headingTop);
+ expect(metrics.miniBottom).toBeLessThanOrEqual(metrics.navTop);
+ expect(metrics.progressBottom).toBeLessThanOrEqual(metrics.miniBottom);
+ expect(metrics.overflow).toBe('hidden');
+ }
+ await page.screenshot({ path: `/tmp/done9-${before ? 'before' : 'after'}-${info.project.name}-${width}-${section}.png` });
+ }
+ if (!before) {
+ await page.evaluate(() => {
+ const body = document.querySelector('.body'); body.scrollTop = body.scrollHeight;
+ const cards = document.getElementById('cards'); cards.scrollTop = cards.scrollHeight;
+ document.getElementById('miniBar').classList.remove('hidden'); updateBottomChrome();
+ });
+ await page.waitForTimeout(100);
+ const tail = await page.locator('#cards .card').last().boundingBox(), mini = await page.locator('#miniBar').boundingBox();
+ expect(tail.y + tail.height).toBeLessThanOrEqual(mini.y - 8);
+ }
+});
+module.exports = { setup };
+
+for (const width of [390, 1440]) test(`Classic panel layout unchanged at ${width}`, async ({ page, context }, info) => {
+ const baseline = await context.newPage();
+ await baseline.route('**/theme-glass-panel.css', r => r.fulfill({ contentType: 'text/css', body: '' }));
+ await baseline.route('**/glass-panel-layout.js', r => r.fulfill({ contentType: 'text/javascript', body: '' }));
+ await setup(baseline, width, 'classic'); await setup(page, width, 'classic');
+ const geometry = p => p.locator('#listPane,#playerPane,#sectionRail,#miniBar,.notes-tabs').evaluateAll(nodes => nodes.map(el => { const r = el.getBoundingClientRect(), s = getComputedStyle(el); return [el.id, r.x,r.y,r.width,r.height,s.padding,s.position,s.overflow]; }));
+ expect(await geometry(page)).toEqual(await geometry(baseline));
+ const a = await baseline.screenshot({ path: `/tmp/done9-classic-before-${info.project.name}-${width}.png` });
+ const b = await page.screenshot({ path: `/tmp/done9-classic-after-${info.project.name}-${width}.png` });
+ const { differentPixels } = require('./helpers/png-diff');
+ expect(differentPixels(a, b)).toBeLessThanOrEqual(32);
+ await baseline.close();
+});