Skip to content

fix(reader): restore night-mode coverage of extension overlays - #260

Open
Gnathonic wants to merge 1 commit into
developfrom
fix/night-mode-root-filter
Open

fix(reader): restore night-mode coverage of extension overlays#260
Gnathonic wants to merge 1 commit into
developfrom
fix/night-mode-root-filter

Conversation

@Gnathonic

Copy link
Copy Markdown
Owner

Night mode's 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 on the version boundary:

browser cross-origin frame tinted?
Chromium 145 yes
Chromium 150 yes
Chrome 151.0.7922.71 no

No placement of a url() filter brings it back — blocked on the root and directly on the iframe. Any SVG filter in the ancestor chain also suppresses that frame's own function filters.

The fix

The root filter is replaced by a pair of blend layers on <html>: a saturation pass, then a multiply pass against pure red. Blending, unlike filter, still composites over cross-origin frames. The SVG matrix survives, used only for top-layer content.

Colour is preserved where it matters. Greys are pixel-identical to the old matrix — #0000000,0,0, #ffffff255,0,0, every ramp step exact. Only fully saturated colours differ, by ≤32/255, because the CSS blending spec fixes its luma function at Rec.601 while the SVG matrix is Rec.709. Over five real manga covers that is a mean error of 1.3/255.

Both layers carry the maximum z-index and are kept as the last two children of <html>. This is load-bearing. Extensions routinely inject at z-index: 2147483647 (Yomitan included); anything landing between the two passes is 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. It 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 — reconfirmed on 153.0.1 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. Net −169 lines in that component.

Three more surfaces 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 ~5× 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.

Rejected alternatives

  • CSS filter-function chains cannot express luminance→red-only. Any chain containing grayscale(1) operates on greys afterward, where every remaining function scales all three channels equally. Best fit found had RMS error 0.32 and crushed tonal range to ~43%.
  • filter on <body> or html > * makes it a containing block for position: fixed descendants — the reader HUD unpins and scrolls away. Measured in both engines.
  • backdrop-filter is exact and reaches extension frames in Chrome, but Firefox does not paint it at all (confirmed on system Firefox 153.0.1, not just the Playwright build) and it costs ~2× the frame time.

Cost

~25 ms/frame vs a 16.7 ms baseline on a 1920×1080 pan in Chrome (Firefox: 19 vs 17), and only while night mode is on. Accepted as the price of covering extension popups.

Verification

Chrome 151.0.7922.71 and Firefox 153.0, driven against the running app with the real setting — every surface tints exactly once, and turning night mode off restores the original pixels.

New e2e/night-mode.spec.ts covers the surfaces, the exactly-once requirement, the max-z-index layering, and that position: fixed stays pinned. It fails on the previous code with the reported symptom, and its z-index case fails on a split-z-index layering.

svelte-check 0 errors · 960 unit tests pass · 17/17 e2e pass (zoom suite unaffected) · ESLint clean on changed files.

Note

CHANGELOG is intentionally untouched — happy to add entries in whatever split you prefer, since the drawer, dropdown and light-theme-background fixes are arguably separate user-visible lines.

🤖 Generated with Claude Code

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 <html>. 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.
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
mokuro-reader Ready Ready Preview Aug 13, 2026 4:43am

Request Review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant