From a497180352bdfd85e05b1fec5bf19ce495b887c5 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Fri, 9 Oct 2026 14:32:17 +0800 Subject: [PATCH] Verify mobile geometry and themed media icons across browsers --- .gitignore | 3 ++ frontend/media-icons.test.js | 19 +++++++ perf/README.md | 12 +++++ perf/ui-fixture.mjs | 36 +++++++++++++ perf/ui-geometry.mjs | 98 ++++++++++++++++++++++++++++++++++++ perf/ui-icons.mjs | 96 +++++++++++++++++++++++++++++++++++ plans/ui-fixes-report.md | 25 +++++++++ plans/ui-icon-inventory.md | 71 ++++++++++++++++++++++++++ 8 files changed, 360 insertions(+) create mode 100644 frontend/media-icons.test.js create mode 100644 perf/ui-fixture.mjs create mode 100644 perf/ui-geometry.mjs create mode 100644 perf/ui-icons.mjs create mode 100644 plans/ui-fixes-report.md create mode 100644 plans/ui-icon-inventory.md diff --git a/.gitignore b/.gitignore index c9390e1..db90696 100755 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ plans/codex-report*.md # performance harness artifacts and temp files perf/fixtures/ perf/.tmp/ + +# UI regression screenshots and local measurements +plans/ui-shots/ diff --git a/frontend/media-icons.test.js b/frontend/media-icons.test.js new file mode 100644 index 0000000..bd8b90a --- /dev/null +++ b/frontend/media-icons.test.js @@ -0,0 +1,19 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { readdirSync, readFileSync } = require('node:fs'); +const { join } = require('node:path'); + +test('download and offline icons never use emoji or text glyphs', () => { + // Arrow keys are prose, not action icons. Exact lines prevent new exceptions. + const allow = new Map([ + ['index.html', new Set(['
↑↓Volume up / down
'])], + ['admin.html', new Set(['Shortcuts: Space stamp (Tap-sync) · S stamp selected · K play · ↑↓ select · ⌘/Ctrl S save'])], + ]); + const failures=[]; + for (const file of readdirSync(__dirname).filter(f=>/\.(js|html|css)$/.test(f) && !f.endsWith('.test.js'))) { + readFileSync(join(__dirname,file),'utf8').split('\n').forEach((line,i)=>{ + if (/[⬇⤓⇩💾📥↓]/u.test(line) && !allow.get(file)?.has(line.trim())) failures.push(`${file}:${i+1}`); + }); + } + assert.deepEqual(failures,[]); +}); diff --git a/perf/README.md b/perf/README.md index 0b64796..ee6941b 100644 --- a/perf/README.md +++ b/perf/README.md @@ -335,3 +335,15 @@ No product behavior changes are involved in these measurement corrections. Keep browser/build/test processes idle during full timing measurements. WebKit's zero long-task field means unavailable, not zero work. Autoplay-blocked/media-ready null results do not establish playback performance or iPhone audio continuity. + +## UI geometry and media icons + +```sh +node perf/ui-geometry.mjs +node perf/ui-icons.mjs +node --test frontend/media-icons.test.js +``` + +Both harnesses run Chromium and WebKit at mobile dimensions with stubbed SpeechRecognition and local Saved/Queue metadata. They serve only the frontend, block service workers and external requests, and never download media. Geometry tests cold and switched layouts, six widths (320–430), mic visible/hidden and update icon visible/hidden: center spread ≤1px, touch targets ≥40px, Saved image/text gap ≥8px, no horizontal overflow or clipped car control. `--quick` limits widths and speech cases; `--browser` selects an engine; `--frontend` selects historical fixtures. `--measure-only --label before` records known broken geometry without exiting nonzero. + +Icon checks switch the same page through four layouts × dark/light/contrast, assert SVG definitions, inherited colour, stroke family and save/progress/offline state, and save Queue/Now Playing/navigation shots. Glass Stage intentionally hides bottom navigation, so its full page is recorded instead. Any assertion failure exits nonzero. Shots and raw measurements live in git-ignored plans/ui-shots/. Full production inventory: plans/ui-icon-inventory.md. Native speech recognition, installed-PWA safe-area insets and iPhone rendering still need device verification. diff --git a/perf/ui-fixture.mjs b/perf/ui-fixture.mjs new file mode 100644 index 0000000..a02e21e --- /dev/null +++ b/perf/ui-fixture.mjs @@ -0,0 +1,36 @@ +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { resolve, extname } from 'node:path'; + +export const layouts = ['classic', 'f7-swipe', 'glass-stage', 'bento-hub']; +export const themes = ['dark', 'light', 'contrast']; +export async function serve(frontend = 'frontend') { + const root = resolve(frontend); + 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 pathname = new URL(req.url, 'http://localhost').pathname; + const path = resolve(root, '.' + (pathname === '/' ? '/index.html' : 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)); + return { origin:`http://127.0.0.1:${server.address().port}`, close:() => new Promise(r => server.close(r)) }; +} +export async function contextFor(browser, origin, layout = 'classic', theme = 'dark') { + const context = await browser.newContext({ viewport:{width:414,height:896}, deviceScaleFactor:2, isMobile:true, hasTouch:true, serviceWorkers:'block', reducedMotion:'reduce' }); + await context.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.abort()); + await context.addInitScript(({layout,theme}) => { + localStorage.setItem('_ytpdata', JSON.stringify({ playlists:[], history:[], queue:[], settings:{layout,theme,reduceMotion:true} })); + window.SpeechRecognition = window.webkitSpeechRecognition = function() {}; + Object.defineProperty(navigator.connection || navigator, 'saveData', {value:true,configurable:true}); + }, {layout,theme}); + return context; +} +export async function appearance(page, layout, theme = 'dark') { + await page.evaluate(async ({layout,theme}) => { + if (window.Lazy) await Lazy.load('layout:' + layout); + data.settings.layout = layout; data.settings.theme = theme; applyAppearance(); + await document.fonts.ready; + }, {layout,theme}); +} diff --git a/perf/ui-geometry.mjs b/perf/ui-geometry.mjs new file mode 100644 index 0000000..5b23804 --- /dev/null +++ b/perf/ui-geometry.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node +// Local static shell, real renderSaved(), deterministic offline-file metadata. +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { readFile, mkdir, writeFile } from 'node:fs/promises'; +import { resolve, extname } from 'node:path'; +import { chromium, webkit } from 'playwright'; +const args = process.argv.slice(2); +const option = (key, fallback) => args.includes(key) ? args[args.indexOf(key) + 1] : fallback; +const root = resolve(option('--frontend', 'frontend')); +const label = option('--label', 'after'); +const measureOnly = args.includes('--measure-only'); +const shotDir = resolve('plans/ui-shots'); +await mkdir(shotDir, { 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 path = resolve(root, '.' + (new URL(req.url, 'http://localhost').pathname === '/' ? '/index.html' : new URL(req.url, 'http://localhost').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 widths = args.includes('--quick') ? [414] : [320, 360, 375, 390, 414, 430]; +const layouts = ['classic', 'f7-swipe', 'glass-stage', 'bento-hub']; +const results = [], failures = []; +const check = (ok, message) => { if (!ok) failures.push(message); }; +try { + for (const [name, engine] of Object.entries({ chromium, webkit }).filter(([name])=>option('--browser','all')==='all'||option('--browser','all')===name)) { + const browser = await engine.launch(); + try { + for (const layout of layouts) for (const speech of (args.includes('--quick') ? [true] : [true, false])) for (const entry of ['cold', 'switched']) { + const context = await browser.newContext({ viewport:{width:414,height:896}, deviceScaleFactor:2, isMobile:true, hasTouch:true, serviceWorkers:'block' }); + await context.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.abort()); + await context.addInitScript(({ layout, speech }) => { + localStorage.setItem('_ytpdata', JSON.stringify({ playlists:[], history:[], queue:[], settings:{ layout } })); + window.SpeechRecognition = window.webkitSpeechRecognition = speech ? function() {} : undefined; + window.addEventListener('unhandledrejection', e=>console.error('rejection:', String(e.reason))); + // Prevent idle warming from concealing the eager/lazy CSS regression. + Object.defineProperty(navigator.connection || navigator, 'saveData', { value:true, configurable:true }); + }, { layout:entry==='cold'?layout:(layout==='classic'?'f7-swipe':'classic'), speech }); + const page = await context.newPage(); + page.on("pageerror", e => console.error(name, layout, e.message)); + page.on("console", m=>{if(m.text().startsWith("rejection:")) console.error(m.text());}); + await page.goto(origin); + await page.waitForFunction(() => document.activeElement.id === 'searchInput'); + // Historical shell's optional integrations can delay toolbar setup on + // a static server; run its original initializer with inert callbacks. + await page.evaluate(() => { if (!document.querySelector('[aria-label="Car mode"]')) CarMode.configure({ input:document.getElementById('searchInput'), search(){}, toast(){}, state:()=>({paused:true}), action(){} }); }); + await page.evaluate(() => { document.querySelector('[aria-label="Car mode"]').id='carModeBtn'; const mic=document.querySelector('[aria-label="Voice search"]'); if(mic)mic.id='voiceSearchBtn'; }); + await page.waitForSelector('#carModeBtn'); + if (speech) await page.waitForSelector('#voiceSearchBtn'); + await page.evaluate(async layout => { if(window.Lazy) await Lazy.load('layout:' + layout); data.settings.layout=layout; applyAppearance(); await document.fonts.ready; }, layout); + // Seed the actual Saved renderer, without media downloads or server access. + await page.evaluate(async () => { + const videos = ['Revealing Lilith (origins of abortion, ancient goddesses, & the final chapter)', 'How my Trouble Has Always Been a Divine Setup | Perry Stone', 'Official live video ' + 'UnbrokenLongTitle'.repeat(12)].map((title,i) => ({id:'geometry00'+i,title,thumbnail:'/icons/icon-192.png',channel:'Geometry fixture'})); + data.history = videos; + API.cacheList = async () => ({ok:true,items:videos.map((v,i)=>({id:v.id,size:(3-i)*100000000})),total:600000000}); + view = {type:'saved'}; render(); + }); + await page.waitForSelector('.saved-card .card-title'); + await page.waitForTimeout(550); // card entrance animation + for (const width of widths) { + await page.setViewportSize({width,height:896}); + for (const update of [false, true]) { + await page.evaluate(show => document.querySelector('#updateIconBtn')?.classList.toggle('hidden', !show), update); + const geometry = await page.evaluate(() => { + const rect = n => { const r=n.getBoundingClientRect();return {x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,cy:r.y+r.height/2}; }; + const ids=['#sidebarToggle','#searchInput','#voiceSearchBtn','.search-btn','#carModeBtn','#updateIconBtn']; + const top = Object.fromEntries(ids.map(id=>[id,document.querySelector(id)]).filter(([,n])=>n&&n.getBoundingClientRect().width).map(([id,n])=>[id,rect(n)])); + const rows=[...document.querySelectorAll('.saved-card')].map(n=>({thumb:rect(n.querySelector('.thumb')),image:rect(n.querySelector('.thumb img')),info:rect(n.querySelector('.card-info')),title:rect(n.querySelector('.card-title'))})); + return {top,rows,scrollWidth:document.documentElement.scrollWidth}; + }); + const key=`${name}/${layout}/${entry}/${width}/speech=${speech}/update=${update}`; + const centers=Object.values(geometry.top).map(r=>r.cy); + geometry.centerSpread=Math.max(...centers)-Math.min(...centers); + geometry.gaps=geometry.rows.map(r=>r.info.x-Math.max(r.thumb.right,r.image.right)); + check(geometry.centerSpread<=1,`${key}: center spread ${geometry.centerSpread}`); + for (const id of ['#voiceSearchBtn','#carModeBtn']) if(geometry.top[id]) check(geometry.top[id].width>=40&&geometry.top[id].height>=40,`${key}: cramped ${id}`); + for (const gap of geometry.gaps) check(gap>=8,`${key}: thumbnail/title gap ${gap}`); + check(geometry.top['#searchInput'].width>=24,`${key}: search input collapsed`); + check(geometry.top['#carModeBtn'].right<=width-8,`${key}: car clipped at edge`); + check(geometry.scrollWidth<=width,`${key}: horizontal page overflow`); + results.push({browser:name,layout,entry,width,speech,update,...geometry}); + if(width===414&&speech&&!update) await page.screenshot({path:resolve(shotDir,`${label}-${name}-${layout}-${entry}.png`)}); + } + } + // Loading the optional feature must not resize or move the toolbar. + + await context.close(); + } + } finally { await browser.close(); } + } +} finally { await new Promise(r=>server.close(r)); } +await writeFile(resolve(shotDir,`${label}-measurements.json`),JSON.stringify({results,failures},null,2)); +console.log(`${label}: ${results.length} cases; ${failures.length} failed checks; measurements/screenshots in plans/ui-shots/`); +if(failures.length) console.log(failures.slice(0,12).join('\n')); +if(!measureOnly) assert.equal(failures.length,0,'UI geometry checks failed'); diff --git a/perf/ui-icons.mjs b/perf/ui-icons.mjs new file mode 100644 index 0000000..91192d3 --- /dev/null +++ b/perf/ui-icons.mjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { chromium, webkit } from 'playwright'; +import { serve, layouts, themes, contextFor, appearance } from './ui-fixture.mjs'; + +const args = process.argv.slice(2); +const option = (key, fallback) => args.includes(key) ? args[args.indexOf(key)+1] : fallback; +const shots = resolve('plans/ui-shots'); +await mkdir(shots, {recursive:true}); +const host = await serve(option('--frontend','frontend')); +const results = []; +try { + for (const [name,engine] of Object.entries({chromium,webkit}).filter(([name]) => option('--browser','all') === 'all' || name === option('--browser','all'))) { + const browser = await engine.launch(); + try { + const context = await contextFor(browser,host.origin); + const page = await context.newPage(); + const errors = []; + page.on('pageerror', e => errors.push(e.message)); + await page.goto(host.origin); + await page.waitForFunction(() => document.activeElement.id === 'searchInput'); + await page.evaluate(() => { + CarMode.configure({input:els.searchInput,search(){},toast(){},state:()=>({paused:true}),action(){}}); + const videos = ['Saved offline fixture','Downloading fixture','Streaming fixture'].map((title,i) => ({id:'iconcheck0'+i,title,channel:'Icon fixture',thumbnail:'/icons/icon-192.png',duration:240})); + data.queue = videos; data.history = videos; + cachedIds.add(videos[0].id); downloading.add(videos[1].id); + current = {meta:videos[0]}; view = {type:'queue'}; render(); + els.playerPane.classList.remove('empty','home-over'); + els.placeholder.classList.add('hidden'); els.controls.classList.remove('hidden'); + document.querySelector('#nowPlayingMeta').classList.remove('hidden'); + updateNowPlayingActions(); + }); + await page.waitForSelector('.card.cached .saved-badge'); + // Reuse this live page: layout changes must repaint the same SVG instances. + for (const layout of layouts) for (const theme of themes) { + await appearance(page,layout,theme); + await page.addStyleTag({content:'*,*::before,*::after { animation:none !important; transition:none !important; }'}); + const measurements = await page.evaluate(() => { + const glyph = /[⬇⤓⇩💾📥↓]/u; + const bad = [...document.querySelectorAll('button,a,.saved-badge,.empty-icon,.pl-offline-badge,.home-pl-offline')].filter(n => glyph.test(n.textContent)).map(n=>n.outerHTML); + const selectors = ['.card.cached .saved-badge','.card.downloading .saved-badge','#saveBtn','#exportBtn','.bottom-nav-btn[data-view="saved"]','.bottom-nav-btn[data-view="downloads"]','.nav-item[data-view="saved"]','.nav-item[data-view="downloads"]']; + const icons = selectors.map(selector => { + const el=document.querySelector(selector), svg=el.querySelector('svg'); + const active=[...el.querySelectorAll('svg')].find(n=>getComputedStyle(n).display!=='none') || svg; + const style=getComputedStyle(active), parent=getComputedStyle(el); + const uses=[...active.querySelectorAll('use')].filter(n=>getComputedStyle(n).display!=='none'); + return {selector,hasSvg:!!svg,text:active.textContent,stroke:style.stroke,color:style.color,parentColor:parent.color,weight:style.strokeWidth,uses:uses.map(n=>n.getAttribute('href')),resolved:uses.every(n=>!!document.querySelector(n.getAttribute('href')))}; + }); + const shapes = [...document.querySelectorAll('#media-download .media-shape')].filter(n=>getComputedStyle(n).display!=='none').map(n=>n.classList[1]); + const probe=document.createElement('span');probe.style.color='var(--accent-bright)';document.body.appendChild(probe); + const accent=getComputedStyle(probe).color;probe.remove(); + return {bad,icons,shapes,accent}; + }); + assert.deepEqual(measurements.bad,[],`${name}/${layout}/${theme}: glyph icon`); + assert.deepEqual(measurements.shapes,[`media-shape-${layout}`],`${layout}: wrong layout drawing`); + for (const icon of measurements.icons) { + assert(icon.hasSvg && icon.resolved && !icon.text.trim(),`${icon.selector}: invalid SVG`); + assert.equal(icon.stroke,icon.color,`${icon.selector}: ignores currentColor`); + assert.equal(icon.color,icon.parentColor,`${icon.selector}: ignores control colour`); + assert.equal(parseFloat(icon.weight),({'classic':1.8,'f7-swipe':2,'glass-stage':1.9,'bento-hub':1.8})[layout],`${icon.selector}: wrong stroke family`); + } + if(layout!=='bento-hub') assert.equal(measurements.icons[0].color,measurements.accent,'badge ignores theme token'); + assert.deepEqual(measurements.icons[0].uses,['#media-offline']); + assert.deepEqual(measurements.icons[1].uses,['#media-progress']); + const label=`icons-${name}-${layout}-${theme}`; + await page.locator('#listPane').screenshot({path:resolve(shots,`${label}-queue.png`)}); + // Bento Hub shows either the list or player on phones. + await page.evaluate(() => document.querySelector('#listPane').classList.add('hidden')); + await page.locator('.np-actions').screenshot({path:resolve(shots,`${label}-playing.png`)}); + // Glass Stage replaces bottom navigation with its floating chrome. + const nav = page.locator('.bottom-nav'); + if (await nav.isVisible()) await nav.screenshot({path:resolve(shots,`${label}-nav.png`)}); + else await page.screenshot({path:resolve(shots,`${label}-nav-hidden.png`)}); + // State updates must keep the SVG and reveal the corresponding concept. + const states=await page.evaluate(() => { + const id=current.meta.id; + const visible=()=>{ els.saveBtn.getBoundingClientRect(); return [...els.saveBtn.querySelectorAll('use')].filter(n=>getComputedStyle(n).display!=='none').map(n=>n.getAttribute('href')); }; + cachedIds.delete(id);updateNowPlayingActions();const normal=visible(); + downloading.add(id);updateNowPlayingActions();const progress=visible(); + downloading.delete(id);cachedIds.add(id);updateNowPlayingActions();const done=visible(); + markCardCacheState('iconcheck02','downloading');markCardCacheState('iconcheck02','cached'); + return {normal,progress,done}; + }); + assert.deepEqual(states,{normal:['#media-download'],progress:['#media-progress'],done:['#media-offline']},`${name}/${layout}/${theme}: save states`); + await page.evaluate(() => document.querySelector('#listPane').classList.remove('hidden')); + results.push({browser:name,layout,theme,...measurements}); + } + assert.deepEqual(errors,[],'script errors'); + await context.close(); + } finally { await browser.close(); } + } +} finally { await host.close(); } +await writeFile(resolve(shots,'icon-measurements.json'),JSON.stringify(results,null,2)); +console.log(`Media icons: ${results.length} layout/theme cases passed; Queue, Now Playing and navigation screenshots in plans/ui-shots/`); diff --git a/plans/ui-fixes-report.md b/plans/ui-fixes-report.md new file mode 100644 index 0000000..5363479 --- /dev/null +++ b/plans/ui-fixes-report.md @@ -0,0 +1,25 @@ +# iPhone UI fixes — 2026-10-09 + +Work confined to wt-ui/codex/ui-fixes. Retained interrupted commits 3de4cba (A) and 66bbda5 (B). No loading, service-worker, boot or media behavior changed. + +## A: toolbar + +Phase 2 measurements have ≤1px center spread. Phase 3 measurements rise to 10px (classic), 8px (F7), 9px (Glass), while Bento stays aligned. Commit 5d6a154 moved car-mode.css out of the eager path, including the mic's min-height/min-width rule; absent search-form align-items:center left it at the top. The 26px car target was already small in the old shell. Shared eager styles now provide align-items:center, 44px targets, 22px icons, mobile gaps and safe-area padding. Relevant rules: frontend/styles.css:276 and :4260; obsolete feature-only mic sizing removed from frontend/car-mode.css. + +## B: Saved + +The existing 72px grid track could contain a theme-widened thumbnail/image; later-loaded layout rules made that visible. Phase 2 minimum measured image/text gap was 12px; Phase 3 or subsequent layout switching produced -4px. The original weak rule comes from ca836e0; Phase 3 dynamic stylesheet ordering exposed it. frontend/saved-page.css:5 now bounds thumbnail and image to the grid track with specific selectors, overflow containment and fixed participation; the text track remains minmax(0,1fr) with min-width:0 and clamped/ellipsized long titles. Final gap is 12px across all tested layouts and widths. + +## C: all media icons + +Legacy text/emoji download/save icons predate Phases 3–5. frontend/download-actions.js:4 supplies shared SVG concepts (download, offline check, progress), with layout-specific shapes switched by CSS, currentColor and theme tokens. Consumers include Queue/card badges, Now Playing save/export, Saved actions, Downloads/empty states, sidebar/home offline badges, bottom navigation, playlist offline actions, Settings imports, P2P export, Share/External exports and admin LRCLIB/JSON export. Accessible text, IDs and handlers remain intact. Hard-coded done/download badge colours now use accent tokens. Full file:line inventory: [ui-icon-inventory.md](ui-icon-inventory.md). Keyboard-arrow prose is the only narrow glyph allowlist; manifest/app logos have no media-action glyphs. + +## Verification + +Commands: node perf/ui-geometry.mjs; node perf/ui-icons.mjs; node --test frontend/*.test.js; (cd server && bun install && bun run test). + +Icons: 24 layout/theme cases passed across both engines, including runtime shape/colour switching and save/downloading/saved state changes. The glyph guard passed. Frontend suite: 199 passed. Server: 124 passed; only the two permitted worker failures (missing Python yt_dlp). + +Geometry: 384 cases, zero failed checks on Chromium and WebKit; four layouts, widths 320/360/375/390/414/430, mic on/off, update on/off, cold and switched layouts. Final center spread 0px, mic/car targets 44×44, image/text gaps 12px, no clipped car or horizontal overflow. Recorded screenshots and raw phase comparisons are git-ignored under plans/ui-shots/. Inspected old and new classic plus the owner's F7 screenshot. Classic Saved-heading and title-region pixel comparisons are identical; full application-wide pixel identity was not tested. + +Native iPhone follow-up: installed-PWA safe-area/keyboard sizing, real speech permission and voice search, touch targets at 320px, runtime layout switching, and light/contrast appearance. Browser fixtures stub speech capability and media metadata, not native speech or OPFS. diff --git a/plans/ui-icon-inventory.md b/plans/ui-icon-inventory.md new file mode 100644 index 0000000..01e15d4 --- /dev/null +++ b/plans/ui-icon-inventory.md @@ -0,0 +1,71 @@ +# Download and offline icon inventory + +All production references after conversion, including SVG definitions, consumers and CSS. Generated from the final frontend; tests excluded. + +| File:line | Reference | +| --- | --- | +| `frontend/admin.html:315` | `.media-shape { display:none; } .media-shape-classic { display:inline; } ` | +| `frontend/admin.html:316` | `.media-icon { width:18px; height:18px; vertical-align:middle; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; } ` | +| `frontend/admin.html:412` | ` ` | +| `frontend/admin.html:513` | ` Export all (JSON) ` | +| `frontend/app.js:3562` | `${isUploadId(st.videoId) ? '' : } ` | +| `frontend/app.js:3804` | ` ` | +| `frontend/app.js:6994` | `exportBtn.innerHTML = MediaIcons.svg('download') + ' Save to gallery'; ` | +| `frontend/app.js:7008` | `saveBtn.innerHTML = MediaIcons.svg(isSaved ? 'offline' : 'download') + (isSaved ? ' Saved offline — remove' : ' Save for offline'); ` | +| `frontend/app.js:7235` | `if (iconOnly) { setListIcon(btn, 'offlineDone'); btn.classList.add('done'); } else btn.innerHTML = MediaIcons.svg('offline') + ' Available offline'; ` | +| `frontend/app.js:7240` | `if (iconOnly) { btn.innerHTML = MediaIcons.svg('progress') + ${stats.cached}/${stats.total}; btn.classList.add('busy'); } else btn.innerHTML = MediaIcons.svg('progress') + Saving ${stats.cached}/${stats.total}…; ` | +| `frontend/app.js:7245` | `if (iconOnly) setListIcon(btn, 'offline'); else btn.innerHTML = MediaIcons.svg('download') + ' Make available offline'; ` | +| `frontend/app.js:7262` | `btn.innerHTML = MediaIcons.svg('progress') + ${stats.cached}/${stats.total}; ` | +| `frontend/download-actions.js:29` | `const svg = (kind, extra = '') => ; ` | +| `frontend/download-actions.js:35` | `defs.innerHTML = '' + Object.keys(mediaPaths).map(kind => ${Object.entries(variants).map(([layout, paths]) => ${paths[kind]}).join('')}).join('') + ''; ` | +| `frontend/download-actions.js:37` | `root.MediaIcons = media; ` | +| `frontend/downloads-page.js:122` | ` ` | +| `frontend/downloads.js:15` | `root.SettingsSections.register({ id: 'downloads-storage', title: 'Downloads & storage', cluster: 'Library & storage', description: 'Active queued paused failed saves, disk usage and playlist sizes', icon: '', summary: () => ${adapter.slots()} at once · ${bytes(total)}, render(container) { ` | +| `frontend/glass-controls.js:9` | `save:'', ` | +| `frontend/index.html:50` | ` ` | +| `frontend/index.html:51` | ` ` | +| `frontend/index.html:271` | ` ` | +| `frontend/index.html:272` | ` ` | +| `frontend/index.html:341` | ` ` | +| `frontend/index.html:601` | ` ` | +| `frontend/index.html:605` | ` ` | +| `frontend/saved-page.js:6` | `export: '', ` | +| `frontend/saved-page.js:13` | `b.innerHTML = ; ` | +| `frontend/share-external.js:74` | `
${MediaIcons.svg('download')} Download ` | +| `frontend/share-external.js:101` | `
${MediaIcons.svg('download')} Download ` | +| `frontend/share-external.js:179` | `body.innerHTML = ; ` | +| `frontend/share-external.js:196` | ` ` | +| `frontend/share-external.js:197` | `${inList ? : ''} ` | +| `frontend/shell-core.js:94` | `offlineBadge = ${MediaIcons.svg('offline')}; ` | +| `frontend/shell-core.js:144` | `offlineHtml = ${MediaIcons.svg('offline')}; ` | +| `frontend/styles.css:1113` | `.saved-badge .media-icon { width:16px; height:16px; filter:drop-shadow(0 1px 2px #000); } ` | +| `frontend/styles.css:4337` | `.media-icon { display:inline-block; width:20px; height:20px; flex:none; vertical-align:middle; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; pointer-events:none; } ` | +| `frontend/styles.css:4339` | `.empty-icon .media-icon { width:48px; height:48px; } ` | +| `frontend/styles.css:4340` | `:is(.pl-offline-badge,.home-pl-offline) .media-icon { width:14px; height:14px; } ` | +| `frontend/styles.css:4341` | `html[data-layout="classic"] .media-icon { stroke-width:1.8; } ` | +| `frontend/styles.css:4342` | `html[data-layout="f7-swipe"] .media-icon { stroke-width:2; } ` | +| `frontend/styles.css:4343` | `html[data-layout="glass-stage"] .media-icon { stroke-width:1.9; } ` | +| `frontend/styles.css:4344` | `html[data-layout="bento-hub"] .media-icon { stroke-width:1.8; } ` | +| `frontend/styles.css:4348` | `:is([data-media-icon="progress"], .np-btn.saving .media-icon) { animation:mediaSpin 1s linear infinite; transform-origin:center; } ` | +| `frontend/styles.css:4350` | `@media(prefers-reduced-motion:reduce) { :is([data-media-icon="progress"], .np-btn.saving .media-icon) { animation:none; } } ` | +| `frontend/styles.css:4351` | `.media-shape { display:none; } ` | +| `frontend/styles.css:4352` | `.media-shape-classic { display:inline; } ` | +| `frontend/styles.css:4353` | `html[data-layout="f7-swipe"] .media-shape-classic, ` | +| `frontend/styles.css:4354` | `html[data-layout="glass-stage"] .media-shape-classic, ` | +| `frontend/styles.css:4355` | `html[data-layout="bento-hub"] .media-shape-classic { display:none; } ` | +| `frontend/styles.css:4356` | `html[data-layout="f7-swipe"] .media-shape-f7-swipe, ` | +| `frontend/styles.css:4357` | `html[data-layout="glass-stage"] .media-shape-glass-stage, ` | +| `frontend/styles.css:4358` | `html[data-layout="bento-hub"] .media-shape-bento-hub { display:inline; } ` | +| `frontend/views-core.js:47` | `els.listActions.innerHTML = groups.length ? : ''; ` | +| `frontend/views-core.js:133` | `offline: '', ` | +| `frontend/views-core.js:134` | `offlineDone: '', ` | +| `frontend/views-core.js:419` | `row.innerHTML = 📡; ` | +| `frontend/views-core.js:520` | ` ` | +| `frontend/views-core.js:553` | `
${MediaIcons.svg('offline')}
` | +| `frontend/views-core.js:569` | `
${MediaIcons.svg('offline')}
` | +| `frontend/views-core.js:571` | `

Use ${MediaIcons.svg('download')} Save while playing, or add videos to a playlist to keep them offline.

; ` | +| `frontend/views-core.js:906` | ` ` | +| `frontend/views-core.js:978` | ` ` | +| `frontend/views-core.js:1339` | `${MediaIcons.svg('offline', 'badge-offline')}${MediaIcons.svg('progress', 'badge-progress')} ` | + +The only remaining requested arrow glyphs are keyboard shortcut prose in index.html and admin.html, explicitly allowlisted by media-icons.test.js. Webmanifest/shortcut/icon images contain no download/save action glyph; generic app logos stay unchanged. Modal titles that previously prefixed a glyph now retain their accessible plain-text name.