Verify transport geometry, playback states and themed icons across both engines

This commit is contained in:
Jonathan Sykes
2026-10-09 16:12:28 +08:00
parent 1e83951972
commit a1048f1c30
6 changed files with 6243 additions and 1 deletions

View File

@@ -8,6 +8,7 @@ test('transport symbols cover all actions and layouts with monochrome SVG',()=>{
for(const kind of ['play','pause','previous','next','back10','forward10','back15','forward']){ for(const kind of ['play','pause','previous','next','back10','forward10','back15','forward']){
assert.match(transport.svg(kind),new RegExp(`href="#transport-${kind}"`)); assert.match(transport.svg(kind),new RegExp(`href="#transport-${kind}"`));
assert.match(transport.svg(kind),/stroke="currentColor"/); assert.match(transport.svg(kind),/stroke="currentColor"/);
assert.equal((transport.svg(kind).match(/<use /g)||[]).length,4,'layout selection lives on visible use elements');
for(const layout of ['classic','f7-swipe','glass-stage','bento-hub'])assert.ok(transport.variants[layout][kind]); for(const layout of ['classic','f7-swipe','glass-stage','bento-hub'])assert.ok(transport.variants[layout][kind]);
} }
assert.throws(()=>transport.svg('unknown'),/Unknown transport/); assert.throws(()=>transport.svg('unknown'),/Unknown transport/);
@@ -38,3 +39,10 @@ test('runtime sources never render legacy transport glyphs',()=>{
} }
assert.deepEqual(failures,[]); assert.deepEqual(failures,[]);
}); });
test('state changes preserve named SVG nodes used by editor controls',()=>{
let icon={id:'playIcon'};
const button={querySelector(){return icon;},set innerHTML(value){this.markup=value;icon={id:''};},setAttribute(name,value){this[name]=value;}};
transport.set(button,'pause');
assert.equal(icon.id,'playIcon');assert.equal(button['aria-label'],'Pause');assert.match(button.markup,/#transport-pause-f7-swipe/);
});

View File

@@ -474,3 +474,13 @@ check Save before playing / Audio-only / Auto-save settings, open a fresh video,
add it to a playlist, and inspect Downloads failure details and Saved after a add it to a playlist, and inspect Downloads failure details and Saved after a
relaunch/offline playback. Verify available storage and whether iOS evicted relaunch/offline playback. Verify available storage and whether iOS evicted
previous copies. A failing iPhone sequence is needed to continue the bisect. previous copies. A failing iPhone sequence is needed to continue the bisect.
Transport controls use the same shared-definition approach as media/save icons.
`node perf/ui-icons.mjs` also checks all four layouts × dark/light/contrast in
Chromium and WebKit: shared monochrome symbols, accessible labels, visible
44px targets, F7 filled glyphs, runtime layout changes, main/stage/mini/service
play-pause state swaps, fullscreen and Car mode refreshes, and actual paired
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.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import {resolve} from 'node:path';
import {mkdir} from 'node:fs/promises';
import {contextFor,appearance} from './ui-fixture.mjs';
export async function transportChecks(page,engine,layout,theme) {
const shots=resolve('plans/transport-shots');await mkdir(shots,{recursive:true});
const result=await page.evaluate(async()=>{
const ids=['playBtn','prevBtn','nextBtn','back10Btn','fwd10Btn','stgBack','stgPlay','stgFwd','miniPlayBtn','servicePrevBtn','servicePlayBtn','serviceNextBtn','rvPrev','rvToggle','rvNext'];
const legacy=/[▶⏸⏮⏭⏪⏩⏯◀❚]/u;
const bad=[...document.querySelectorAll('button,a,.home-pl-play,.brand-mark,.ph-logo,.empty-icon')].filter(n=>legacy.test(n.textContent)).map(n=>n.outerHTML);
const inspect=el=>{
const svg=el.querySelector('.transport-icon'),style=getComputedStyle(svg),parent=getComputedStyle(el),box=el.getBoundingClientRect();
return {id:el.id,kind:svg?.dataset.transportIcon,href:[...svg.querySelectorAll('use')].find(n=>getComputedStyle(n).display!=='none')?.getAttribute('href'),fill:style.fill,stroke:style.stroke,color:style.color,parentColor:parent.color,legacy:!!el.querySelector('.fill,.legacy-nav-icon'),label:el.getAttribute('aria-label'),width:box.width,height:box.height,visible:box.width>0&&box.height>0};
};
const icons=ids.map(id=>inspect(document.getElementById(id)));
const previous=document.querySelector('#prevBtn svg');
const previousBox=[...previous.querySelectorAll('use')].find(n=>getComputedStyle(n).display!=='none').getBBox();
const previousX=previousBox.x;
const shapes=[...document.querySelectorAll('#playBtn .transport-shape')].filter(n=>getComputedStyle(n).display!=='none').map(n=>n.classList[1]);
const old=Object.getOwnPropertyDescriptor(Player.master,'paused');let paused=true;
Object.defineProperty(Player.master,'paused',{configurable:true,get:()=>paused});
const states=[];
for(const state of [true,false,true]){paused=state;updatePlayBtn();states.push(['playBtn','stgPlay','miniPlayBtn','servicePlayBtn'].map(id=>inspect(document.getElementById(id))));}
// Exercise the real fullscreen HUD and Car mode's own refresh callbacks.
const stage=document.querySelector('.player-stage'); const request=stage.requestFullscreen;stage.requestFullscreen=undefined;
await playerFullscreen.enter();paused=false;await new Promise(r=>setTimeout(r,300));
const hudPlaying=inspect(document.querySelector('.fullscreen-controls button'));
paused=true;await new Promise(r=>setTimeout(r,300));const hudPaused=inspect(document.querySelector('.fullscreen-controls button'));playerFullscreen.leave();stage.requestFullscreen=request;
CarMode.configure({input:els.searchInput,search(){},toast(){},state:()=>({paused,title:'Transport fixture'}),action(){}});CarMode.open();
const car=[...document.querySelectorAll('.car-controls button')].map(inspect);
paused=false;await new Promise(r=>setTimeout(r,550));const carPlaying=inspect(document.querySelectorAll('.car-controls button')[1]);CarMode.close();
if(old)Object.defineProperty(Player.master,'paused',old);else delete Player.master.paused;updatePlayBtn();
// A preview strip uses the very same symbols to make all actions visible together.
const strip=document.createElement('section');strip.id='transportPreview';strip.style.cssText='position:fixed;left:8px;right:8px;top:120px;z-index:30000;padding:20px;background:var(--bg-1);color:var(--text);border:1px solid var(--line);border-radius:20px;display:flex;flex-wrap:wrap;gap:12px';
for(const kind of ['previous','back10','play','pause','forward10','next']){const b=document.createElement('button');b.className=kind==='play'||kind==='pause'?'ctrl play':'ctrl';b.title=kind;if(kind==='play'||kind==='pause'){const s=getComputedStyle(document.getElementById('playBtn'));for(const prop of ['width','height','borderRadius','background','color','border','boxShadow','backdropFilter'])b.style[prop]=s[prop];}b.innerHTML=TransportIcons.svg(kind);strip.append(b);}document.body.append(strip);
return {bad,icons,previousX,shapes,states,hudPlaying,hudPaused,car,carPlaying};
});
const label=`${engine}/${layout}/${theme}`;
assert.deepEqual(result.bad,[],label+': legacy transport glyph');
assert.deepEqual(result.shapes,[`transport-shape-${layout}`],label+': wrong drawing');
if(result.icons.find(n=>n.id==='prevBtn').visible)assert.equal(result.previousX,layout==='f7-swipe'?4:5,label+': rendered geometry must switch');
for(const icon of [...result.icons,...result.car,result.hudPlaying,result.hudPaused,result.carPlaying]){
assert(icon.kind&&!icon.legacy&&icon.href===`#transport-${icon.kind}${layout==='classic'?'':'-'+layout}`,label+': nonshared icon');
assert(icon.label,label+': missing accessible name');
assert.equal(icon.stroke,icon.color,label+': nonmonochrome stroke');
if(layout==='f7-swipe'&&['play','pause','previous','next','forward'].includes(icon.kind))assert.equal(icon.fill,icon.color,label+': F7 glyph must be filled '+JSON.stringify(icon));assert.equal(icon.color,icon.parentColor,label+': detached control colour');
if(icon.visible)assert(icon.width>=44&&icon.height>=44,`${label}/${icon.id||icon.kind}: target ${icon.width}x${icon.height}`);
}
for(const [i,icons] of result.states.entries())for(const icon of icons){assert.equal(icon.kind,i===1?'pause':'play');assert.equal(icon.label,i===1?'Pause':'Play');}
assert.equal(result.hudPlaying.kind,'pause');assert.equal(result.hudPaused.kind,'play');assert.equal(result.carPlaying.kind,'pause');
await page.locator('#transportPreview').screenshot({path:resolve(shots,`${engine}-${layout}-${theme}-symbols.png`)});
await page.evaluate(()=>document.getElementById('transportPreview').remove());
return result;
}
export async function remoteTransportFixture(browser,origin,engine) {
const context=await contextFor(browser,origin);
await context.route('**/api/remote/pair',route=>route.fulfill({json:{ok:true,roomId:'fixture',remoteId:'phone',token:'fixture',hostName:'Transport fixture',name:'Phone'}}));
await context.routeWebSocket('**/ws/remote**',ws=>{setTimeout(()=>ws.send(JSON.stringify({type:'hello',hostName:'Transport fixture',state:{paused:true,cur:0,dur:240,volume:1,v:{id:'iconcheck00',title:'Transport fixture',channel:'Fixture'}}})),100);});
const page=await context.newPage();const errors=[];page.on('pageerror',error=>errors.push(error.message));
await page.goto(origin+'/?pair=123456');await page.waitForSelector('#rvControl:not(.hidden)');
await page.waitForFunction(()=>document.getElementById('rvTitle').textContent==='Transport fixture');
return {
async check(layout,theme){
await appearance(page,layout,theme);
const states=[];
for(let i=0;i<3;i++){
states.push(await page.evaluate(()=>['rvPrev','rvToggle','rvNext'].map(id=>{const b=document.getElementById(id),svg=b.querySelector('.transport-icon'),r=b.getBoundingClientRect();return {kind:svg.dataset.transportIcon,href:[...svg.querySelectorAll('use')].find(n=>getComputedStyle(n).display!=='none').getAttribute('href'),label:b.getAttribute('aria-label'),width:r.width,height:r.height};})));
if(i<2)await page.locator('#rvToggle').click();
}
for(const [i,icons] of states.entries()){
assert.equal(icons[1].kind,i===1?'pause':'play');assert.equal(icons[1].label,i===1?'Pause':'Play');
for(const icon of icons){assert.equal(icon.href,'#transport-'+icon.kind+(layout==='classic'?'':'-'+layout));assert(icon.width>=44&&icon.height>=44);}
}
await page.locator('.rv-transport').screenshot({path:resolve('plans/transport-shots',`${engine}-${layout}-${theme}-remote.png`)});
assert.deepEqual(errors,[],engine+': remote script errors');
return states;
},close:()=>context.close()
};
}

View File

@@ -4,6 +4,7 @@ import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { chromium, webkit } from 'playwright'; import { chromium, webkit } from 'playwright';
import { serve, layouts, themes, contextFor, appearance } from './ui-fixture.mjs'; import { serve, layouts, themes, contextFor, appearance } from './ui-fixture.mjs';
import {transportChecks,remoteTransportFixture} from './transport-fixture.mjs';
const args = process.argv.slice(2); const args = process.argv.slice(2);
const option = (key, fallback) => args.includes(key) ? args[args.indexOf(key)+1] : fallback; const option = (key, fallback) => args.includes(key) ? args[args.indexOf(key)+1] : fallback;
@@ -17,6 +18,7 @@ try {
try { try {
const context = await contextFor(browser,host.origin); const context = await contextFor(browser,host.origin);
const page = await context.newPage(); const page = await context.newPage();
const remote=await remoteTransportFixture(browser,host.origin,name);
const errors = []; const errors = [];
page.on('pageerror', e => errors.push(e.message)); page.on('pageerror', e => errors.push(e.message));
await page.goto(host.origin); await page.goto(host.origin);
@@ -85,9 +87,12 @@ try {
}); });
assert.deepEqual(states,{normal:['#media-download'],progress:['#media-progress'],done:['#media-offline']},`${name}/${layout}/${theme}: save states`); 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')); await page.evaluate(() => document.querySelector('#listPane').classList.remove('hidden'));
results.push({browser:name,layout,theme,...measurements}); const transport=await transportChecks(page,name,layout,theme);
const remoteStates=await remote.check(layout,theme);
results.push({browser:name,layout,theme,...measurements,transport,remoteStates});
} }
assert.deepEqual(errors,[],'script errors'); assert.deepEqual(errors,[],'script errors');
await remote.close();
await context.close(); await context.close();
} finally { await browser.close(); } } finally { await browser.close(); }
} }

74
plans/transport-icons.md Normal file
View File

@@ -0,0 +1,74 @@
# Transport icon reconciliation
Branch `codex/transport-icons` starts at completed integration commit `0a5b113`.
The previous shell mixed filled inline SVGs (main/stage/fullscreen) with Unicode
play/pause/previous/next glyphs (mini, service, remote, gesture feedback, queue and
playlist actions). All rendered transport glyphs now use shared currentColor SVG
symbols defined in the already-eager `download-actions.js`; no new runtime file,
loader or worker change is required. The core/playback/queue bodies remain guarded
by their existing byte hashes. `updatePlayBtn` now has a behavioural state/handler
regression test instead of an obsolete byte-identity assertion.
| Location | Change |
|---|---|
| `frontend/download-actions.js:40` | Shared drawing/state or markup |
| `frontend/download-actions.css:13` | Shared drawing/state or markup |
| `frontend/index.html:212` | Shared drawing/state or markup |
| `frontend/app.js:3089` | Shared drawing/state or markup |
| `frontend/fullscreen-orientation.js:112` | Shared drawing/state or markup |
| `frontend/car-mode.js:13` | Shared drawing/state or markup |
| `frontend/remote-client.js:424` | Shared drawing/state or markup |
| `frontend/lyrics-window.js:27` | Shared drawing/state or markup |
| `frontend/views-core.js:225` | Shared drawing/state or markup |
| `frontend/shell-core.js:157` | Shared drawing/state or markup |
| `frontend/share-external.js:189` | Shared drawing/state or markup |
| `frontend/admin.html:388` | Shared drawing/state or markup |
| `frontend/glass-controls.js:10` | Shared drawing/state or markup |
| `frontend/stats-view.js:52` | Shared drawing/state or markup |
| `frontend/watch-party-client.js:350` | Shared drawing/state or markup |
Inventory: main deck previous/play/next/back10/forward10; stage back10/play/forward10;
mini play; service previous/play/next; remote previous/play/next and paired-screen
play; fullscreen HUD; gesture feedback; Car previous/play/back15/next; floating
lyrics play/next; homepage playlist play badges; Queue/sidebar/bottom-navigation
play symbols; Play all/queue/in-full, bookmark jump and Add-to-queue actions;
external-player launch links; admin editor play/pause and Play-here actions;
track-play statistics, party now-playing and empty-state/brand symbols. Speed's
old fast-forward glyph also uses the shared set. Glass's legacy decoder accepts
old input strings but emits shared SVG references; it never restores a transport
Unicode icon from the new markup.
F7 uses round filled shapes and a large circular play button. Classic keeps its
existing surfaces with clean line drawings. Glass uses thin strokes and translucent
surfaces. Bento uses rounded tiles. Labels, IDs and event handlers remain intact;
visible transport targets have at least 44px dimensions. Car retains its readable
high-contrast surface and text labels. Floating lyrics clones symbol definitions
into its separate document and observes playback/layout/theme changes.
Presenter has no transport controls. F7's Up-next sheet uses clickable track rows,
not old transport glyphs. Lock-screen controls are rendered by the OS through
unchanged MediaSession action handlers; website SVG/CSS cannot theme those buttons.
A screenshot/bounding-box check caught a Chromium SVG-reference quirk: ancestor
layout selectors changed styles in referenced groups without switching cloned
geometry (F7 previous remained x=5 instead of x=4). Variant selection now lives on
visible `<use>` elements; the harness checks the rendered bounds, not just styles.
Final verification:
```text
node --test frontend/*.test.js: 246 pass, 0 fail
bun test asset-manifest.test.js static-delivery.test.js: 21 pass, 0 fail
node perf/ui-geometry.mjs: 384 cases; 0 failed checks
node perf/ui-icons.mjs: 24 engine/layout/theme cases passed
```
Both engines passed transport state/geometry checks and the original download-icon
checks. All sources are free of rendered legacy transport glyphs. The only remaining
Unicode transport strings are the legacy decoder input mapping. Evidence is in
`perf/results/transport-icons-2026-10-09.json`; 48 transport preview/remote screenshots
were saved and the four dark-layout previews were visually inspected. Screenshots are local
and ignored in `plans/transport-shots/` and `plans/ui-shots/`. Real iPhone checks:
Car/fullscreen readability and native lock-screen controls; document PiP is browser
feature-dependent and was not verified on a real phone.