From 11171df403b8333c54476df22fffb84fe89e8198 Mon Sep 17 00:00:00 2001 From: Gnathonic Date: Wed, 12 Aug 2026 13:10:53 -0700 Subject: [PATCH] fix(reader): restore night-mode coverage of extension overlays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The night-mode red filter stopped tinting browser-extension popups. The regression is in Chrome, not in this repo: from Chrome 151, an SVG *reference* filter is no longer painted onto cross-origin or restricted frames (csswg-drafts#13846). Yomitan renders its dictionary popup as a chrome-extension:// iframe, so `html { filter: url(#night-mode-filter) }` skips it. Measured: Chromium 145 and 150 tint the frame, Chrome 151 does not, and no placement of a url() filter — on the root or directly on the iframe — brings it back. Replace the root filter with a pair of blend layers: a `saturation` pass then a `multiply` pass against pure red. Blending, unlike `filter`, still composites over cross-origin frames. On greys the result is identical to the old matrix (#000000 -> 0,0,0, #ffffff -> 255,0,0, every ramp step exact); it differs only on fully saturated colour, where the CSS blending spec's luma is Rec.601 rather than Rec.709 — a mean error of ~1/255 over real manga covers. Both layers carry the maximum z-index and are kept as the last two children of . Extensions routinely inject at z-index 2147483647; anything landing between the passes would be reddened but never desaturated, collapsing its greens and blues to black. Ties resolve by tree order, so a MutationObserver on the root re-asserts the pair when an extension injects. This is also why the layers cannot be pseudo-elements: a ::before box is the first child and loses every tie. Firefox now takes the same path. Its overlay hack existed because Gecko silently drops url() filters on the root element (Bugzilla 1769223, still NEW in 153.0.1 — confirmed against inline, external and data: references, and every stacking-context workaround). Since the blend layers are what both engines use, the isFirefox branch and its dialog MutationObserver are deleted rather than ported. Also fixes three surfaces that the root filter never reached: - `[popover]` top-layer content — every Flowbite dropdown, tooltip and popover — was untinted. Now covered via `:popover-open`, along with `::backdrop` and non-root `:fullscreen`. - The bare `dialog` rule double-filtered non-modal dialogs. Flowbite's Drawer calls show(), not showModal(), so it stays in normal flow and was already tinted; the matrix is not idempotent, so the Settings drawer rendered ~5x too dark. Narrowed to `dialog:modal`. - On light themes the page background escaped entirely, because a root filter does not apply to the propagated canvas background. The full-viewport blend layers cover it. Verified in Chrome 151.0.7922.71 and Firefox 153.0 against the running app: every surface tints exactly once, and turning night mode off restores the original pixels. e2e/night-mode.spec.ts fails on the previous code with the reported symptom. --- e2e/night-mode.spec.ts | 267 ++++++++++++++++++++++ src/app.html | 78 ++++++- src/lib/components/NightModeFilter.svelte | 217 +++++------------- 3 files changed, 393 insertions(+), 169 deletions(-) create mode 100644 e2e/night-mode.spec.ts 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 @@ - +