Redesign Lyrics Studio timing editor

This commit is contained in:
Jonathan Sykes
2026-10-14 00:10:25 +08:00
parent f465cc86d0
commit d264d0748a
17 changed files with 244 additions and 68 deletions

View File

@@ -1,6 +1,7 @@
const { test, expect } = require('@playwright/test');
const fs = require('node:fs');
const id = '0gfX0dFLaBc';
async function fixture(page) {
async function fixture(page, theme = 'dark') {
const state = { job: null, starts: 0, saves: [] };
await page.route('**/api/**', async route => {
const req = route.request(), url = new URL(req.url()); let body = { ok: true };
@@ -16,6 +17,7 @@ async function fixture(page) {
} else if (url.pathname === '/api/notes/' + id + '/lyrics' && req.method() === 'PUT') { state.saves.push(req.postDataJSON()); body.rev = 9; }
return route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
});
await page.addInitScript(theme => localStorage.setItem('_ytpdata', JSON.stringify({ settings: { theme } })), theme);
await page.goto('/admin.html#editor');
await expect(page.locator('[data-open="' + id + '"]').first()).toBeVisible();
return state;
@@ -165,3 +167,98 @@ test('editor loads the whole audio into the browser once and reuses it', async (
await expect(page.locator('#dur')).toHaveText('0:01');
expect(hits).toBe(1);
});
function ratio(fg, bg) {
const channel = value => {
const n = value / 255;
return n <= 0.04045 ? n / 12.92 : ((n + 0.055) / 1.055) ** 2.4;
};
const lum = color => {
const values = color.startsWith('#')
? (color.slice(1).length === 3
? [...color.slice(1)].map(value => value + value).join('')
: color.slice(1)).match(/.{2}/g).slice(0, 3).map(value => parseInt(value, 16))
: color.match(/[\d.]+/g).slice(0, 3).map(Number);
return 0.2126 * channel(values[0]) + 0.7152 * channel(values[1]) + 0.0722 * channel(values[2]);
};
const [a, b] = [lum(fg), lum(bg)].sort((x, y) => y - x);
return (a + 0.05) / (b + 0.05);
}
test('Lyrics Studio stays readable, touchable and contained across themes and screen sizes', async ({ page }) => {
const shots = 'plans/editor-redesign-shots'; fs.mkdirSync(shots, { recursive: true });
await page.setViewportSize({ width: 390, height: 844 });
await fixture(page, 'light');
await expect(page.locator('html')).toHaveAttribute('data-editor-theme', 'light');
await page.locator(`[data-open="${id}"]`).first().click();
await expect(page.locator('.ln')).toHaveCount(47);
await page.locator('.ln').first().locator('.tc').click();
await expect(page.locator('.ln').first()).toHaveClass(/sel/);
await page.locator('.ln').first().evaluate(row => row.classList.add('now'));
await page.locator('#mTap').click();
await page.locator('.ln').first().evaluate(row => row.classList.add('now'));
await expect(page.locator('#tapPad')).toBeVisible();
await page.locator('.ln-text').first().fill('A corrected lyric remains in the editor while the save controls stay available.');
await expect(page.locator('#saveBar')).toBeVisible();
await page.emulateMedia({ reducedMotion: 'reduce' });
for (const theme of ['dark', 'light', 'contrast']) {
for (const width of [390, 768, 1280, 1920]) {
await page.setViewportSize({ width, height: width < 500 ? 844 : 900 });
await page.evaluate(value => { document.documentElement.dataset.editorTheme = value; }, theme);
const metrics = await page.evaluate(() => {
const css = selector => getComputedStyle(document.querySelector(selector));
const root = css('#p-editor'), title = css('#songTitle'), meta = css('#songMeta');
const primary = css('#save');
const line = css('.ln.sel .ln-text');
const row = css('.ln.sel');
const waveTokens = ['--ed-wave-played', '--ed-wave-unplayed', '--ed-wave-mark', '--ed-wave-cursor'];
const rect = selector => { const r = document.querySelector(selector).getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, right: r.right, bottom: r.bottom }; };
const visibleControls = [...document.querySelectorAll('#studio button, #studio select, #studio input, #studio textarea, #saveBar button, #tapPad')]
.filter(el => el.getClientRects().length);
return {
theme: document.documentElement.dataset.editorTheme,
viewport: innerWidth,
rootScroll: document.documentElement.scrollWidth,
editorScroll: document.querySelector('#p-editor').scrollWidth,
editorClient: document.querySelector('#p-editor').clientWidth,
titleRatio: [title.color, root.backgroundColor],
metaRatio: [meta.color, root.backgroundColor],
primaryRatio: [primary.color, primary.backgroundColor],
primaryBoundary: [row.outlineColor, root.backgroundColor],
lineRatio: [line.color, line.backgroundColor],
waveRatios: waveTokens.map(token => [root.getPropertyValue(token).trim(), root.getPropertyValue('--ed-bg').trim()]),
nowBorder: css('.ln.now').borderTopColor,
selectedOutline: row.outlineColor,
keyboardHint: (() => { const el = document.querySelector('.keyboard-hint'), r = el.getBoundingClientRect(); return { visible: !!el.getClientRects().length, insideDeck: document.querySelector('.deck').contains(el), bottom: r.bottom, top: r.top }; })(),
targets: visibleControls.map(el => ({ id: el.id || el.className, width: el.getBoundingClientRect().width, height: el.getBoundingClientRect().height })),
tap: rect('#tapPad'), save: rect('#saveInner'),
focus: (() => { const el = document.querySelector('#bBack'); el.focus({ focusVisible: true }); const s = getComputedStyle(el); return { style: s.outlineStyle, width: s.outlineWidth }; })(),
reduceMotion: matchMedia('(prefers-reduced-motion: reduce)').matches,
transition: getComputedStyle(document.querySelector('.ln')).transitionDuration,
rowCount: document.querySelectorAll('.ln').length,
};
});
expect(metrics.rootScroll, `${theme} ${width}px page overflow`).toBeLessThanOrEqual(width + 1);
expect(metrics.editorScroll, `${theme} ${width}px editor overflow`).toBeLessThanOrEqual(metrics.editorClient + 1);
expect(metrics.rowCount).toBe(47);
expect(ratio(...metrics.titleRatio), `${theme} title contrast`).toBeGreaterThanOrEqual(4.5);
expect(ratio(...metrics.metaRatio), `${theme} metadata contrast`).toBeGreaterThanOrEqual(4.5);
expect(ratio(...metrics.primaryRatio), `${theme} primary action contrast ${JSON.stringify(metrics.primaryRatio)}`).toBeGreaterThanOrEqual(4.5);
expect(ratio(...metrics.primaryBoundary), `${theme} selected boundary contrast`).toBeGreaterThanOrEqual(3);
expect(ratio(...metrics.lineRatio), `${theme} lyric text contrast`).toBeGreaterThanOrEqual(4.5);
for (const colors of metrics.waveRatios) expect(ratio(...colors), `${theme} waveform contrast ${colors[0]}`).toBeGreaterThanOrEqual(3);
expect(metrics.nowBorder).not.toBe(metrics.selectedOutline);
expect(metrics.keyboardHint.visible).toBe(true);
expect(metrics.keyboardHint.insideDeck).toBe(true);
for (const target of metrics.targets) expect(target.height, `${target.id} target at ${width}px`).toBeGreaterThanOrEqual(44);
expect(metrics.focus.style).not.toBe('none');
expect(metrics.focus.width).toBe('3px');
expect(metrics.reduceMotion).toBe(true);
expect(metrics.transition).toBe('0s');
expect(metrics.tap.bottom).toBeLessThanOrEqual(metrics.save.y);
if (width < 500) expect(metrics.tap.right).toBeLessThanOrEqual(width);
await page.screenshot({ path: `${shots}/${theme}-${width}.png`, fullPage: false });
}
}
});