diff --git a/frontend/settings-sections.css b/frontend/settings-sections.css
index bbf652b..56f6696 100644
--- a/frontend/settings-sections.css
+++ b/frontend/settings-sections.css
@@ -1,4 +1,27 @@
.settings-browser { display: block; width: 100%; min-width: 0; }
+.settings-browser, .settings-grid, .settings-overview, .settings-detail, .settings-section,
+.settings-browser .settings, .settings-browser .set-group, .settings-browser .dsp-settings,
+.settings-browser .dsp-subsection, .settings-browser .dsp-param-list {
+ box-sizing: border-box;
+ min-width: 0;
+ max-width: 100%;
+}
+/* Settings is a workspace, not a sidebar: give the section browser the full
+ content area on desktop instead of leaving it inside the fixed 452px list. */
+.body:has(#listPane .cards[data-view="settings"]) .player-pane { display: none; }
+.body:has(#listPane .cards[data-view="settings"]) #listPane {
+ flex: 1 1 0;
+ width: 100%;
+ min-width: 0;
+ max-width: 100%;
+ border-left: 0;
+}
+.body:has(#listPane .cards[data-view="settings"]) #listPane .cards {
+ width: 100%;
+ min-width: 0;
+ max-width: 100%;
+ overflow-x: hidden;
+}
.settings-browser [hidden] { display: none !important; }
.settings-search { margin-bottom: 12px; }
.settings-search input { width: 100%; min-height: 44px; background: var(--bg-2); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; font: inherit; }
@@ -11,10 +34,81 @@
.settings-section-row:hover, .settings-section-row[aria-current="true"] { background: var(--bg-3); border-radius: 8px; }
.settings-browser :is(button,input,select,textarea):focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.settings-back { min-height: 44px; padding: 8px 12px; margin-bottom: 12px; background: var(--bg-2); color: var(--text); border: 1px solid var(--line); border-radius: 8px; font: inherit; cursor: pointer; }
-.settings-browser .set-row { min-height: 44px; }
+.settings-browser .set-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, min(40%, 260px));
+ align-items: center;
+ gap: 12px;
+ min-height: 44px;
+ min-width: 0;
+ max-width: 100%;
+}
+.settings-browser .set-row > :is(span, input, select, button, output) { min-width: 0; max-width: 100%; }
+.settings-browser .set-row > :is(input, select, button, output) { box-sizing: border-box; }
+.settings-browser .set-control-wrap { display: block; width: 100%; min-width: 0; max-width: 100%; overflow: hidden; }
+#listPane #cards .settings-browser .set-row select,
+#listPane #cards .settings-browser .set-control-wrap select,
+.settings-browser .set-row select,
+.settings-browser .set-control-wrap select {
+ box-sizing: border-box;
+ appearance: none;
+ -webkit-appearance: none;
+ width: 100%;
+ min-width: 0;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ padding-right: 28px;
+ background-color: var(--bg-3);
+ background-image: linear-gradient(45deg, transparent 50%, var(--text-dim) 50%), linear-gradient(135deg, var(--text-dim) 50%, transparent 50%);
+ background-position: calc(100% - 14px) 50%, calc(100% - 9px) 50%;
+ background-size: 5px 5px;
+ background-repeat: no-repeat;
+ color: var(--text);
+ border-color: var(--line);
+}
+.settings-browser .set-row > span:first-child { overflow-wrap: anywhere; }
+.settings-browser .set-row:has(> span:only-child) { grid-template-columns: minmax(0, 1fr); }
.settings-browser .set-row small { font-size: 12px; max-width: 100%; }
.settings-browser .set-row > span { min-width: 0; }
-.settings-browser .set-row input[type="range"] { max-width: 42%; }
+.settings-browser .set-row input[type="range"] { width: calc(100% - 16px); max-width: calc(100% - 16px); min-width: 0; }
+.settings-browser .set-row input[type="checkbox"] {
+ appearance: none;
+ -webkit-appearance: none;
+ justify-self: end;
+ width: 46px;
+ height: 28px;
+ min-width: 46px;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ background: radial-gradient(circle at 14px 50%, var(--text) 0 9px, transparent 10px), var(--bg-3);
+ cursor: pointer;
+ transition: background .16s ease, border-color .16s ease;
+}
+.settings-browser .set-row input[type="checkbox"]:checked {
+ border-color: var(--accent);
+ background: radial-gradient(circle at 32px 50%, var(--on-accent, #fff) 0 9px, transparent 10px), var(--accent);
+}
+.settings-browser .set-row input[type="checkbox"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
+.dsp-file-control {
+ position: relative;
+ display: grid;
+ grid-template-columns: max-content minmax(0, 1fr);
+ align-items: center;
+ gap: 9px;
+ width: 100%;
+ min-width: 0;
+ max-width: 100%;
+}
+.dsp-file-control input[type="file"] { position: absolute; width: 1px; height: 1px; min-width: 0; max-width: 1px; padding: 0; border: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
+.dsp-file-control:focus-within { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 8px; }
+.dsp-file-button { display: inline-flex; align-items: center; justify-content: center; min-height: 40px; padding: 7px 12px; border: 1px solid var(--line); border-radius: 9px; background: var(--bg-3); color: var(--text); font: 600 12px var(--ui); white-space: nowrap; }
+.dsp-file-name { display: block; min-width: 0; overflow: hidden; color: var(--text-2); text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
+.dsp-subsection .sp-note { min-width: 0; max-width: 100%; overflow-wrap: anywhere; }
+.dsp-subsection > .btn { min-width: 0; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.dsp-actions { min-width: 0; max-width: 100%; }
+.dsp-param-list { grid-template-columns: repeat(auto-fit, minmax(min(190px, 100%), 1fr)); }
+.dsp-param-row input[type="range"] { width: calc(100% - 16px); min-width: 0; max-width: calc(100% - 16px); }
.settings-browser .settings-detail { display: none; min-width: 0; }
.settings-browser[data-detail="1"] .settings-overview { display: none; }
.settings-browser[data-detail="1"] .settings-detail { display: block; }
@@ -22,7 +116,7 @@
.settings-grid { display: grid; grid-template-columns: minmax(160px, .8fr) minmax(0, 1.4fr); gap: 16px; }
.settings-browser .settings-overview, .settings-browser[data-detail="1"] .settings-overview, .settings-browser .settings-detail { display: block; }
.settings-back { display: none; }
- .settings-browser .set-row { flex-wrap: wrap; gap: 12px; }
+ .settings-browser .set-row { flex-wrap: nowrap; gap: 12px; }
}
.download-job { padding: 14px 0; border-bottom: 1px solid var(--line); display: grid; gap: 8px; }
.download-job b { overflow-wrap: anywhere; }
diff --git a/frontend/views-core.js b/frontend/views-core.js
index 7d0936b..dd87874 100644
--- a/frontend/views-core.js
+++ b/frontend/views-core.js
@@ -741,9 +741,9 @@ async function renderSettings() {
const dspRange = (key, label, min, max, step = 1, unit = '') => ``;
const dspBandRows = dspBands.map((hz, i) => dspRange(`eq.band${i}`, `${hz >= 1000 ? (hz / 1000) + ' kHz' : hz + ' Hz'}`, -12, 12, 0.5, ' dB')).join('');
- const sel = (id, val, opts) => ``;
const dspBuiltinIRSelect = `
${jamesDsp.irRef?.name ? `Loaded: ${jamesDsp.irRef.name}` : 'No impulse response loaded.'}
@@ -1196,8 +1196,10 @@ async function renderSettings() { try{const ref=await EQ.loadDspSavedIR(name);if($('dspIrStatus'))$('dspIrStatus').textContent=`Loaded saved response: ${name}`;$('setDspConvolver').checked=true;await refreshDspIrList();} catch(error){toast(`Saved response failed: ${error.message}`);} }); + if ($('dspIrChooseFile')) $('dspIrChooseFile').addEventListener('click', e => { e.preventDefault(); $('dspIrFile')?.click(); }); if ($('dspIrFile')) $('dspIrFile').addEventListener('change',async e=>{ const file=e.target.files?.[0];if(!file)return; + if($('dspIrFileName'))$('dspIrFileName').textContent=file.name; try{const ref=await EQ.loadDspIR(file);if($('dspIrStatus'))$('dspIrStatus').textContent=`Loaded ${ref?.name||file.name}${ref?' and saved it for this device':' for this session only'}.`;$('setDspConvolver').checked=true;await refreshDspIrList();} catch(error){toast(`Impulse response failed: ${error.message}`);} finally{e.target.value='';} diff --git a/perf/README.md b/perf/README.md index 92fdc5f..35f859e 100644 --- a/perf/README.md +++ b/perf/README.md @@ -489,3 +489,10 @@ remote toggles against a mocked relay. Transport previews and remote screenshots are saved in ignored `plans/transport-shots/`. Run `node perf/ui-geometry.mjs` for the 320–430px search-bar and Saved-row geometry matrix. OS lock-screen buttons remain platform-rendered through the existing MediaSession handlers. + +Run `node perf/settings-layout.mjs` for the Settings workspace and JamesDSP +control matrix: Chromium and WebKit, all four layouts, dark/light/contrast, and +390/768/1280/1440/1920px widths. It checks horizontal overflow, responsive row +geometry, themed selects and switches, the clipped native file picker, and +button wrapping; desktop screenshots and measurements are written to +`plans/settings-shots/`. diff --git a/perf/settings-layout.mjs b/perf/settings-layout.mjs new file mode 100644 index 0000000..69b28a6 --- /dev/null +++ b/perf/settings-layout.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +// Settings layout regression checks across responsive widths, layouts and themes. +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { readFile, mkdir } from 'node:fs/promises'; +import { resolve, extname } from 'node:path'; +import { chromium, webkit } from 'playwright'; + +const root = resolve('frontend'), shots = resolve('plans/settings-shots'); +await mkdir(shots, { recursive: true }); +const mime = { '.html':'text/html', '.js':'text/javascript', '.mjs':'text/javascript', '.css':'text/css', '.json':'application/json', '.woff2':'font/woff2', '.svg':'image/svg+xml', '.png':'image/png' }; +const server = createServer(async (req, res) => { + const url = new URL(req.url, 'http://localhost'); + const path = resolve(root, '.' + (url.pathname === '/' ? '/index.html' : url.pathname)); + if (!path.startsWith(root + '/')) { res.writeHead(403).end(); return; } + try { const body = await readFile(path); res.writeHead(200, { 'Content-Type':mime[extname(path)] || 'application/octet-stream' }).end(body); } + catch { res.writeHead(404).end(); } +}); +await new Promise(r => server.listen(0, '127.0.0.1', r)); +const origin = `http://127.0.0.1:${server.address().port}`; +const layouts = ['classic','f7-swipe','glass-stage','bento-hub'], themes = ['dark','light','contrast'], widths = [390,768,1280,1440,1920]; +const failures = [], results = []; +try { + for (const [engineName, engine] of Object.entries({ chromium, webkit })) { + const browser = await engine.launch(); + try { + const context = await browser.newContext({ viewport:{width:1280,height:1000}, serviceWorkers:'block' }); + await context.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.abort()); + await context.addInitScript(() => localStorage.setItem('_ytpdata', JSON.stringify({ + playlists:[], history:[], queue:[], settings:{layout:'glass-stage',theme:'dark',quality:'auto',jamesDsp:{enabled:true,downloadForOffline:false,preset:'warm',params:{'convolver.enabled':1,'spatial.enabled':1},irRef:{name:'Small room'}}} + }))); + const page = await context.newPage(); + page.on('pageerror', error => failures.push(`${engineName}: pageerror ${error.message}`)); + await page.goto(origin); + await page.waitForFunction(() => typeof data !== 'undefined' && typeof Lazy !== 'undefined'); + await page.waitForFunction(() => performance.getEntriesByName('ytp:boot').length > 0); + for (const css of ['theme-classic.css','theme-f7.css','theme-glass.css','theme-glass-panel.css','theme-glass-controls.css','theme-bento-hub.css']) await page.addStyleTag({ path:resolve(root,css) }); + for (const layout of layouts) { + for (const theme of themes) for (const width of widths) { + await page.setViewportSize({ width, height:1000 }); + await page.evaluate(({theme,layout}) => { data.settings.theme=theme; data.settings.layout=layout; data.settings.jamesDsp.enabled=true; document.documentElement.dataset.theme=theme; document.documentElement.dataset.layout=layout; if(typeof EQ!=='undefined')EQ.listDspIRs=async()=>[]; view={type:'settings'}; render(); }, {theme,layout}); + await page.waitForSelector('.settings-browser .settings-section-row[data-section="playback"]',{state:'attached'}); + await page.evaluate(()=>{const host=document.querySelector('.settings-browser');host.dataset.detail='1';document.querySelectorAll('.settings-section').forEach(panel=>panel.hidden=panel.id!=='settings-section-playback');document.getElementById('jamesDspControls').hidden=false;}); + await page.waitForSelector('#setDspSpatialMode',{state:'attached'}); + const measured = await page.evaluate(() => { + const rect = e => { const r=e.getBoundingClientRect(); return {x:r.x,y:r.y,width:r.width,height:r.height,right:r.right}; }; + const selectors=['.settings-browser','.settings-grid','.settings-detail','#listPane .cards','#jamesDspSettings','.dsp-subsection']; + const boxes=Object.fromEntries(selectors.map(s=>{const e=document.querySelector(s);return [s,e?{clientWidth:e.clientWidth,scrollWidth:e.scrollWidth,rect:rect(e)}:null];})); + const rows=[...document.querySelectorAll('#jamesDspSettings .set-row')].map(row=>{ + const label=row.querySelector(':scope > span:first-child'),control=row.querySelector(':scope > input,:scope > select,:scope > .set-control-wrap,:scope > .dsp-file-control'); + return {text:row.textContent.trim().slice(0,90),label:label?rect(label):null,control:control?{...rect(control),scrollWidth:control.scrollWidth,clientWidth:control.clientWidth,overflow:getComputedStyle(control).overflow,select:control.querySelector('select')?{...rect(control.querySelector('select')),scrollWidth:control.querySelector('select').scrollWidth,clientWidth:control.querySelector('select').clientWidth}:null}:null,row:rect(row)}; + }); + const file=document.querySelector('#dspIrFile'),checkbox=document.querySelector('#setDspConvolver'),buttons=[...document.querySelectorAll('.dsp-subsection > button')]; + const selects=[...document.querySelectorAll('.settings-browser select')].map(s=>({id:s.id,appearance:getComputedStyle(s).appearance,backgroundImage:getComputedStyle(s).backgroundImage,backgroundColor:getComputedStyle(s).backgroundColor})); + const overflows=[...document.querySelectorAll('#jamesDspSettings *')].filter(e=>e.scrollWidth>e.clientWidth+1).map(e=>({tag:e.tagName,id:e.id,cls:e.className,text:e.textContent.trim().slice(0,100),scroll:e.scrollWidth,client:e.clientWidth,rect:rect(e)})); + return {boxes,rows,file:{clipPath:getComputedStyle(file).clipPath,rect:rect(file),parent:rect(file.parentElement)},checkbox:{appearance:getComputedStyle(checkbox).appearance,width:rect(checkbox).width},selects,buttons:buttons.map(b=>({whiteSpace:getComputedStyle(b).whiteSpace,scrollWidth:b.scrollWidth,clientWidth:b.clientWidth,rect:rect(b)})),overflows,page:{scrollWidth:document.documentElement.scrollWidth,clientWidth:document.documentElement.clientWidth}}; + }); + const key=`${engineName}/${layout}/${theme}/${width}`; + for (const [selector,box] of Object.entries(measured.boxes)) if(box) { + if(box.scrollWidth>box.clientWidth+1) failures.push(`${key}: ${selector} horizontal overflow ${box.scrollWidth}/${box.clientWidth}`); + } + if(measured.page.scrollWidth>measured.page.clientWidth+1) failures.push(`${key}: document horizontal overflow ${measured.page.scrollWidth}/${measured.page.clientWidth}`); + for(const row of measured.rows) if(row.label&&row.control&&row.control.x