diff --git a/e2e/night-mode.spec.ts b/e2e/night-mode.spec.ts new file mode 100644 index 00000000..7755f141 --- /dev/null +++ b/e2e/night-mode.spec.ts @@ -0,0 +1,267 @@ +import { test, expect, type Page } from '@playwright/test'; +import http from 'node:http'; + +/** + * E2E coverage for the night-mode red filter. + * + * Night mode has to tint EVERY pixel the user can see, including surfaces that + * a `filter` on the root element never reaches: + * + * - content a browser extension injects into or , + * - a cross-origin iframe (how Yomitan renders its dictionary popup), + * - the top layer: modal dialogs, popovers (every Flowbite dropdown), and + * ::backdrop, + * - the propagated canvas background. + * + * It also has to tint each of them exactly ONCE. The transform is + * luminance -> red channel, which is not idempotent: a second pass multiplies + * the red channel by 0.2126 and the surface renders ~5x too dark. Non-modal + * dialogs (Flowbite's Drawer calls show(), not showModal()) stay in normal flow + * and so are already covered by the page-level overlays — filtering them again + * via a bare `dialog` selector is the specific bug this asserts against. + * + * NOTE on the cross-origin case: Chrome only started refusing to paint SVG + * reference filters onto cross-origin frames in 151, and Playwright's bundled + * Chromium is older, so that assertion only has teeth when E2E_CHROMIUM points + * at a real Chrome >= 151. Every other assertion here reproduces on the bundled + * build. + */ + +const FRAME_PORT = 8673; + +// White and mid-grey patches, so we can check the tint value and not just "is red". +const FRAME_HTML = ` +
+
+`; + +let frameServer: http.Server; + +test.beforeAll(async () => { + frameServer = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(FRAME_HTML); + }); + await new Promise((resolve) => frameServer.listen(FRAME_PORT, '127.0.0.1', resolve)); +}); + +test.afterAll(async () => { + await new Promise((resolve) => frameServer.close(resolve)); +}); + +/** Injects the surfaces an extension (and the app itself) can put on screen. */ +async function injectSurfaces(page: Page, framePort: number) { + await page.evaluate((port) => { + const box = 'position:fixed;top:0;width:60px;height:60px;z-index:2147483647;'; + const mk = (id: string, css: string, parent: Element) => { + const el = document.createElement('div'); + el.id = id; + el.style.cssText = css; + parent.appendChild(el); + return el; + }; + + mk('probe-body', box + 'left:0;background:#ffffff', document.body); + mk('probe-html', box + 'left:60px;background:#ffffff', document.documentElement); + + const frame = document.createElement('iframe'); + frame.id = 'probe-frame'; + frame.style.cssText = box + 'left:120px;width:120px;border:0'; + frame.src = `http://127.0.0.1:${port}/`; + document.documentElement.appendChild(frame); + + const pop = mk( + 'probe-popover', + box + 'left:240px;background:#ffffff;margin:0;border:0', + document.body + ); + pop.setAttribute('popover', 'manual'); + pop.showPopover(); + + const modal = document.createElement('dialog'); + modal.id = 'probe-modal'; + modal.style.cssText = + 'position:fixed;top:80px;left:0;margin:0;padding:0;border:0;width:60px;height:60px;background:#ffffff'; + document.body.appendChild(modal); + modal.showModal(); + + const nonModal = document.createElement('dialog'); + nonModal.id = 'probe-nonmodal'; + nonModal.style.cssText = + 'position:fixed;top:80px;left:60px;margin:0;padding:0;border:0;width:60px;height:60px;background:#ffffff;z-index:2147483647'; + document.body.appendChild(nonModal); + nonModal.show(); + }, framePort); + // let the cross-origin frame paint + await page.waitForTimeout(600); +} + +/** + * Mirrors NightModeFilter.svelte: two max-z-index blend layers kept as the last + * two children of , plus the variable that drives the top-layer rules. + */ +async function setNightMode(page: Page, on: boolean) { + await page.evaluate((active) => { + const root = document.documentElement; + root.querySelectorAll('.night-mode-layer').forEach((el) => el.remove()); + if (active) { + for (const kind of ['desaturate', 'redden']) { + const el = document.createElement('div'); + el.className = `night-mode-layer night-mode-${kind}`; + root.appendChild(el); + } + } + root.classList.toggle('night-mode', active); + root.style.setProperty('--night-mode-filter', active ? 'url(#night-mode-filter)' : 'none'); + }, on); + await page.waitForTimeout(300); +} + +type Rgb = [number, number, number]; + +async function pixel(page: Page, x: number, y: number): Promise { + const shot = await page.screenshot({ clip: { x, y, width: 1, height: 1 } }); + // A 1x1 PNG: decode via the browser rather than shipping a decoder. + const data = await page.evaluate(async (b64) => { + const img = new Image(); + img.src = 'data:image/png;base64,' + b64; + await img.decode(); + const c = document.createElement('canvas'); + c.width = c.height = 1; + const ctx = c.getContext('2d')!; + ctx.drawImage(img, 0, 0); + const d = ctx.getImageData(0, 0, 1, 1).data; + return [d[0], d[1], d[2]]; + }, shot.toString('base64')); + return data as Rgb; +} + +const SURFACES: Array<{ name: string; x: number; y: number }> = [ + { name: 'div injected into ', x: 30, y: 30 }, + { name: 'div injected into ', x: 90, y: 30 }, + { name: 'cross-origin iframe (white)', x: 150, y: 30 }, + { name: 'cross-origin iframe (grey)', x: 210, y: 30 }, + { name: 'popover (top layer)', x: 270, y: 30 }, + { name: 'modal dialog (top layer)', x: 30, y: 110 }, + { name: 'non-modal dialog', x: 90, y: 110 }, + { name: 'page background', x: 700, y: 600 } +]; + +test('night mode tints every visible surface exactly once', async ({ page }) => { + await page.setViewportSize({ width: 900, height: 700 }); + await page.goto('/'); + await page.waitForTimeout(1200); + await injectSurfaces(page, FRAME_PORT); + + await setNightMode(page, false); + const before: Record = {}; + for (const s of SURFACES) before[s.name] = await pixel(page, s.x, s.y); + + await setNightMode(page, true); + + for (const s of SURFACES) { + const [r, g, b] = await pixel(page, s.x, s.y); + const [r0, g0, b0] = before[s.name]; + // Rec.709 luma of the untinted colour is what the red channel must become. + const luma = 0.2126 * r0 + 0.7152 * g0 + 0.0722 * b0; + + expect(g, `${s.name}: green channel must be removed`).toBeLessThan(40); + expect(b, `${s.name}: blue channel must be removed`).toBeLessThan(40); + // Catches BOTH "not tinted at all" and "tinted twice" (~0.21x too dark). + expect( + Math.abs(r - luma), + `${s.name}: red channel should be the luma of ${r0},${g0},${b0} (${luma.toFixed(0)}), got ${r}` + ).toBeLessThan(20); + } +}); + +test('turning night mode off restores the original colours', async ({ page }) => { + await page.setViewportSize({ width: 900, height: 700 }); + await page.goto('/'); + await page.waitForTimeout(1200); + await injectSurfaces(page, FRAME_PORT); + + await setNightMode(page, false); + const before = await pixel(page, 30, 30); + await setNightMode(page, true); + await setNightMode(page, false); + const after = await pixel(page, 30, 30); + + expect(after).toEqual(before); +}); + +/** + * The desaturation and red passes are separate layers, so anything that paints + * BETWEEN them gets reddened without being desaturated — greens and blues + * collapse to black instead of becoming mid-red. Extensions routinely inject at + * z-index 2147483647 (Yomitan included), which is exactly the value that used to + * land in that gap. Saturated colours are the only probe that detects this: + * white and grey render correctly either way. + */ +test('extension content at a maximal z-index is desaturated, not just reddened', async ({ + page +}) => { + await page.setViewportSize({ width: 500, height: 400 }); + await page.goto('/'); + await page.waitForTimeout(1200); + + await page.evaluate((port) => { + for (const [i, z] of [1000, 2147483646, 2147483647].entries()) { + const f = document.createElement('iframe'); + f.className = 'probe-z'; + f.style.cssText = `position:fixed;left:0;top:${i * 50}px;width:240px;height:44px;border:0;z-index:${z}`; + f.src = `http://127.0.0.1:${port}/`; + document.documentElement.appendChild(f); + } + }, FRAME_PORT); + await page.waitForTimeout(700); + await setNightMode(page, true); + + // The served frame is white then mid-grey; add a saturated strip of our own by + // sampling a green patch we inject alongside at the same z-index. + await page.evaluate(() => { + for (const [i, z] of [1000, 2147483646, 2147483647].entries()) { + const d = document.createElement('div'); + d.style.cssText = `position:fixed;left:260px;top:${i * 50}px;width:44px;height:44px;background:#00ff00;z-index:${z}`; + document.documentElement.appendChild(d); + } + }); + // Re-assert the layers the way the component's MutationObserver does. + await setNightMode(page, true); + + for (let i = 0; i < 3; i++) { + const [r, g, b] = await pixel(page, 282, i * 50 + 22); + // Rec.601 desaturation of #00ff00 is 150; only-multiply would give 0. + expect(r, `green patch ${i} must be desaturated before being reddened`).toBeGreaterThan(100); + expect(g).toBeLessThan(40); + expect(b).toBeLessThan(40); + } +}); + +test('night mode does not unpin position:fixed elements', async ({ page }) => { + await page.setViewportSize({ width: 900, height: 700 }); + await page.goto('/'); + await page.waitForTimeout(1200); + + // A filter on (rather than the root) would re-anchor this to the + // document and scroll it away — that is why the page-level tint is a blend + // overlay pair on the root's pseudo-elements, not a filter on a wrapper. + await page.evaluate(() => { + const tall = document.createElement('div'); + tall.style.cssText = 'height:4000px'; + document.body.appendChild(tall); + const hud = document.createElement('div'); + hud.id = 'probe-hud'; + hud.style.cssText = 'position:fixed;bottom:0;left:0;width:100px;height:40px;background:#fff'; + document.body.appendChild(hud); + }); + + await setNightMode(page, true); + await page.evaluate(() => window.scrollTo(0, 1500)); + await page.waitForTimeout(300); + + const top = await page.evaluate( + () => document.getElementById('probe-hud')!.getBoundingClientRect().top + ); + expect(Math.round(top)).toBe(660); // 700px viewport - 40px element +}); diff --git a/src/app.html b/src/app.html index 85136dfb..96ef1153 100644 --- a/src/app.html +++ b/src/app.html @@ -18,18 +18,82 @@ %sveltekit.head% @@ -37,7 +101,7 @@ - +