Redesign Lyrics Studio timing editor
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user