From 25ae8104630e342d157d36e84e58597c56d1a730 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Sun, 4 Oct 2026 02:17:48 +0800 Subject: [PATCH] Glass Stage: float the section rail again, centred above the mini player --- frontend/glass-panel-layout.js | 11 +++++----- frontend/theme-glass-panel.css | 16 +++++++------- playwright.glass-rail.config.js | 2 ++ tests/glass-panel.spec.js | 37 +-------------------------------- tests/glass-rail-float.spec.js | 32 ++++++++++++++++++++++++++++ 5 files changed, 48 insertions(+), 50 deletions(-) create mode 100644 playwright.glass-rail.config.js create mode 100644 tests/glass-rail-float.spec.js diff --git a/frontend/glass-panel-layout.js b/frontend/glass-panel-layout.js index 86b2c6d..7cf3175 100644 --- a/frontend/glass-panel-layout.js +++ b/frontend/glass-panel-layout.js @@ -1,4 +1,6 @@ -/* Glass Stage section shortcuts participate in layout; Classic keeps its rail. */ +/* Glass Stage: measure the bottom chrome so panels keep clear of it. The + section rail stays a fixed floating bar (body-level); never move it into a + scrolling container or it scrolls with the content. */ (function (root) { 'use strict'; function bottomClearance(viewport, boxes, gap = 16) { @@ -6,9 +8,7 @@ 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); + const win = doc.defaultView, html = doc.documentElement; let frame = 0; function active() { return html.dataset.layout === 'glass-stage'; } function schedule() { if (!frame) frame = win.requestAnimationFrame(measure); } @@ -23,9 +23,8 @@ set('--glass-bottom-clearance', `${bottomClearance(win.innerHeight, [box(nav), box(mini)])}px`); } function sync() { - if (active()) { if (rail.parentElement !== panel) panel.prepend(rail); schedule(); } + if (active()) schedule(); else { - if (rail.parentNode !== original.parentNode) original.after(rail); html.style.removeProperty('--glass-nav-height'); html.style.removeProperty('--glass-bottom-clearance'); } } diff --git a/frontend/theme-glass-panel.css b/frontend/theme-glass-panel.css index 154bd89..edad0ee 100644 --- a/frontend/theme-glass-panel.css +++ b/frontend/theme-glass-panel.css @@ -1,12 +1,12 @@ -/* 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; +/* Section shortcuts float, centred above the mini player and bottom nav + (bottom comes from --rail-bottom, measured by SectionRail.placeRail()). */ +html[data-layout="glass-stage"] .sec-rail, +html[data-layout="glass-stage"] body:has(#miniBar:not(.hidden)) .sec-rail { + position:fixed; top:auto; right:auto; left:50%; transform:translateX(-50%); + flex-direction:row; flex-wrap:nowrap; width:max-content; max-width:calc(100% - 24px); z-index:60; } -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; } +html[data-layout="glass-stage"] .sec-rail-label { left:50%; transform:translateX(-50%); } + /* 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); diff --git a/playwright.glass-rail.config.js b/playwright.glass-rail.config.js new file mode 100644 index 0000000..bba448b --- /dev/null +++ b/playwright.glass-rail.config.js @@ -0,0 +1,2 @@ +const { defineConfig } = require('@playwright/test'); +module.exports = defineConfig({ testDir: './tests', testMatch: /glass-rail-float\.spec\.js/, timeout: 30000, workers: 1, projects: [{ name: 'chromium', use: { browserName: 'chromium' } }, { name: 'webkit', use: { browserName: 'webkit' } }], use: { baseURL: 'http://localhost:8097', serviceWorkers: 'block' }, webServer: { command: 'npx serve frontend -l 8097 --no-clipboard', port: 8097, reuseExistingServer: false } }); diff --git a/tests/glass-panel.spec.js b/tests/glass-panel.spec.js index 1c122b2..fd04736 100644 --- a/tests/glass-panel.spec.js +++ b/tests/glass-panel.spec.js @@ -20,42 +20,7 @@ async function setup(page, width, layout = 'glass-stage') { }, 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 }; - +// The rail-in-panel test was removed: the Glass rail floats again (see glass-rail-float.spec.js). 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: '' })); diff --git a/tests/glass-rail-float.spec.js b/tests/glass-rail-float.spec.js new file mode 100644 index 0000000..a4bc30f --- /dev/null +++ b/tests/glass-rail-float.spec.js @@ -0,0 +1,32 @@ +const { test, expect } = require('@playwright/test'); +const { readFileSync } = require('node:fs'); +const { openClassic, showPlayingFixture } = require('./helpers/classic-fixture'); +const phoneCss = readFileSync('frontend/styles.css', 'utf8').replaceAll('(display-mode: standalone)', '(min-width: 0px)').replaceAll('env(safe-area-inset-bottom)', '34px'); + +// Glass Stage section rail floats centred above the mini player and stays put while the page scrolls. +test('glass rail floats above the mini player and ignores scroll', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.route('**/styles.css', r => r.fulfill({ contentType: 'text/css', body: phoneCss })); + await openClassic(page, { populated: true, standalone: true, settings: { autoPreload: false } }); + await showPlayingFixture(page); + await page.evaluate(() => { + data.settings.layout = 'glass-stage'; applyAppearance(); + document.getElementById('miniBar').classList.remove('hidden'); updateBottomChrome(); SectionRail.refresh(); + }); + await page.waitForTimeout(300); + const at = () => page.evaluate(() => { + const r = document.getElementById('sectionRail').getBoundingClientRect(), m = document.getElementById('miniBar').getBoundingClientRect(); + return { top: r.top, bottom: r.bottom, center: (r.left + r.right) / 2, miniTop: m.top, parent: document.getElementById('sectionRail').parentElement.id, pos: getComputedStyle(document.getElementById('sectionRail')).position }; + }); + const a = await at(); + expect(a.pos).toBe('fixed'); + expect(a.parent).not.toBe('listPane'); + expect(Math.abs(a.center - 195)).toBeLessThan(2); + expect(a.bottom).toBeLessThanOrEqual(a.miniTop); + await page.screenshot({ path: process.env.SHOT_DIR ? `${process.env.SHOT_DIR}/rail-a.png` : undefined }); + await page.evaluate(() => { for (const el of [document.scrollingElement, ...document.querySelectorAll('.body,#listPane,.player-pane')]) el && (el.scrollTop += 600); }); + await page.waitForTimeout(200); + const b = await at(); + expect(Math.abs(b.top - a.top)).toBeLessThan(1); + await page.screenshot({ path: process.env.SHOT_DIR ? `${process.env.SHOT_DIR}/rail-b.png` : undefined }); +});