From 1b38f9d9e76d0e6561e237990e525e30966d77aa Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 22 Sep 2026 08:30:20 +0000 Subject: [PATCH] Blur the page whenever the window isn't in front Losing focus -- another tab, another window, another app -- now puts the whole page behind a blur, so an app switcher's snapshot or a glance over the shoulder gives nothing away. It comes back on focus, on the tab becoming visible again, and on the first touch or key, which lands on the veil rather than on a page nobody could see. Painted as a backdrop filter on a fixed overlay above everything the app can show: a filter on the page itself would make every fixed element position against the wrong box. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN --- frontend/css/style.css | 31 +++++++++ frontend/index.html | 3 + frontend/js/main.js | 4 ++ frontend/js/privacy.js | 60 ++++++++++++++++++ tests/smoke_privacy.py | 141 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 239 insertions(+) create mode 100644 frontend/js/privacy.js create mode 100644 tests/smoke_privacy.py diff --git a/frontend/css/style.css b/frontend/css/style.css index f3665de..454a5d5 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -3014,3 +3014,34 @@ body.theme-light .video-card img:not(.is-loaded) { pointer-events: none; } + +/* Privacy veil -- the page goes behind a blur whenever this window isn't the + one in front. Above everything else the app can put on screen, and painted + as a backdrop filter rather than a filter on the page itself, which would + make every fixed element inside it position against the wrong box. */ +.privacy-veil { + position: fixed; + inset: 0; + z-index: 9000; + opacity: 0; + pointer-events: none; + background: rgba(10, 8, 6, 0.38); + -webkit-backdrop-filter: blur(26px) saturate(0.55); + backdrop-filter: blur(26px) saturate(0.55); + transition: opacity 200ms ease; +} + +.privacy-veil.is-on { + opacity: 1; + /* Going behind the blur is immediate -- a fade would be a window during + which the page is still readable. Coming back can take its time. */ + transition: none; + /* So the tap that brings the window back lands here and lifts the veil, + rather than pressing something on a page nobody could see. */ + pointer-events: auto; +} + +/* Nothing to blur through without backdrop-filter, so the veil covers instead. */ +@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { + .privacy-veil { background: var(--bg-primary); } +} diff --git a/frontend/index.html b/frontend/index.html index 42410a8..b8d25d7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -226,6 +226,8 @@ + + @@ -240,6 +242,7 @@ + diff --git a/frontend/js/main.js b/frontend/js/main.js index aeee447..5279702 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -3,6 +3,10 @@ window.App = window.App || {}; (function() { // App bootstrap: initialize storage, render UI, and load the first page. async function initApp() { + // Armed before anything is on screen: there is nothing to show behind + // a blur yet, but the window can lose focus during startup too. + App.privacy.start(); + await App.storage.ensureDefaults(); App.ui.applyTheme(); App.ui.applyPreferredQuality(); diff --git a/frontend/js/privacy.js b/frontend/js/privacy.js new file mode 100644 index 0000000..4e40dd8 --- /dev/null +++ b/frontend/js/privacy.js @@ -0,0 +1,60 @@ +window.App = window.App || {}; +App.privacy = App.privacy || {}; + +(function() { + // The moment this window stops being the one in front -- another tab, + // another window, another app -- the page goes behind a blur. What the site + // is showing is nobody else's business: not a glance over the shoulder, not + // the app switcher's snapshot, not a screen share of the tab next door. + const ON = 'is-on'; + let veil = null; + let veiled = false; + + const element = function() { + if (!veil || !veil.isConnected) veil = document.getElementById('privacy-veil'); + return veil; + }; + + const setVeiled = function(next) { + const el = element(); + if (!el || next === veiled) return; + veiled = next; + el.classList.toggle(ON, next); + }; + + const cover = function() { setVeiled(true); }; + const uncover = function() { setVeiled(false); }; + + function start() { + if (!element()) return; + + // Leaving. Any one of these can be the last event before the window is + // out of sight, so each covers on its own, and covers at once: a blur + // that lands after the app switcher took its picture is no blur at all. + window.addEventListener('blur', cover); + window.addEventListener('pagehide', cover); + document.addEventListener('visibilitychange', function() { + if (document.visibilityState === 'hidden') cover(); + else uncover(); + }); + + // Coming back. + window.addEventListener('focus', uncover); + window.addEventListener('pageshow', uncover); + + // A backstop, for whatever the browser did or didn't report: someone + // using the page is someone looking at it. The first touch lands on the + // veil and lifts it instead of reaching the thing underneath, which is + // the right way round for a tap that was meant for a page you could see. + ['pointerdown', 'touchstart', 'keydown', 'wheel'].forEach(function(name) { + window.addEventListener(name, uncover, { capture: true, passive: true }); + }); + } + + App.privacy = { + start: start, + cover: cover, + uncover: uncover, + isVeiled: function() { return veiled; } + }; +})(); diff --git a/tests/smoke_privacy.py b/tests/smoke_privacy.py new file mode 100644 index 0000000..9faf81b --- /dev/null +++ b/tests/smoke_privacy.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""The privacy veil: the page blurs whenever this window is not the one in front. + +Run against a locally running backend: + + backend/main.py & + .venv/bin/python tests/smoke_privacy.py + +Headless Chromium keeps every tab visible and focused -- opening a second tab +and bringing it to the front changes neither, and neither does freezing the +page over CDP -- so there is no way to genuinely send this window to the back. +The transitions are therefore dispatched by hand, which tests what this change +owns -- what the page does when the browser says it has gone away -- and not +what the browser does with a backgrounded tab. +""" +import sys +from playwright.sync_api import sync_playwright + +BASE = "http://127.0.0.1:5000/" + +VEILED = """() => { + const veil = document.getElementById('privacy-veil'); + return !!veil && veil.classList.contains('is-on'); +}""" + +OPACITY = """() => getComputedStyle(document.getElementById('privacy-veil')).opacity""" + +# What a tap in the middle of the window would land on. +UNDER_FINGER = """() => { + const el = document.elementFromPoint(innerWidth / 2, innerHeight / 2); + return el ? el.id || el.className || el.tagName : null; +}""" + +# Neither a second tab nor CDP can put this window in the background, so the +# two halves of a tab switch are announced the way the browser would announce +# them. +HIDE = """() => { + Object.defineProperty(document, 'visibilityState', + { configurable: true, get: () => 'hidden' }); + document.dispatchEvent(new Event('visibilitychange')); +}""" + +SHOW = """() => { + Object.defineProperty(document, 'visibilityState', + { configurable: true, get: () => 'visible' }); + document.dispatchEvent(new Event('visibilitychange')); +}""" + +# The highest z-index anywhere in the app's own stylesheet, veil excluded: the +# veil has to beat the player and the reels feed, not just the page behind them. +HIGHEST_OTHER = """() => { + let top = 0; + for (const sheet of document.styleSheets) { + let rules; + try { rules = sheet.cssRules; } catch (err) { continue; } + for (const rule of rules || []) { + if (!rule.selectorText || rule.selectorText.includes('privacy-veil')) continue; + const z = parseInt(rule.style && rule.style.zIndex, 10); + if (!isNaN(z) && z > top) top = z; + } + } + return top; +}""" + + +class Checks: + def __init__(self): + self.failed = 0 + + def ok(self, label, condition, detail=""): + mark = "PASS" if condition else "FAIL" + if not condition: + self.failed += 1 + print(f" [{mark}] {label}" + (f" -- {detail}" if detail and not condition else "")) + + +def main(): + c = Checks() + with sync_playwright() as p: + browser = p.chromium.launch(args=[ + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + "--renderer-process-limit=1", + "--js-flags=--max-old-space-size=512", + ]) + page = browser.new_page(viewport={"width": 430, "height": 930}) + crashes = [] + page.on("pageerror", lambda e: crashes.append(str(e))) + + page.goto(BASE, wait_until="load") + page.wait_for_function("() => window.App && App.privacy && App.privacy.isVeiled", timeout=30000) + page.wait_for_timeout(300) + + print("\nthe window in front") + c.ok("nothing is veiled", page.evaluate(VEILED) is False) + c.ok("and the veil is out of the way", page.evaluate(OPACITY) == "0") + c.ok("so taps reach the page", page.evaluate(UNDER_FINGER) != "privacy-veil", + str(page.evaluate(UNDER_FINGER))) + + print("\nthe tab going to the back") + page.evaluate(HIDE) + c.ok("the page goes behind the blur", page.evaluate(VEILED) is True) + c.ok("fully, and at once", page.evaluate(OPACITY) == "1") + c.ok("with nothing underneath reachable", page.evaluate(UNDER_FINGER) == "privacy-veil", + str(page.evaluate(UNDER_FINGER))) + c.ok("and it sits above everything the app can show", + page.evaluate("() => parseInt(getComputedStyle(document.getElementById('privacy-veil')).zIndex, 10)") + > page.evaluate(HIGHEST_OTHER), + str(page.evaluate(HIGHEST_OTHER))) + + print("\ncoming back to it") + page.evaluate(SHOW) + page.wait_for_timeout(300) + c.ok("the blur lifts", page.evaluate(VEILED) is False) + + print("\nlosing focus without leaving the tab -- another window, say") + page.evaluate("() => window.dispatchEvent(new Event('blur'))") + c.ok("the page goes behind the blur too", page.evaluate(VEILED) is True) + page.evaluate("() => window.dispatchEvent(new Event('focus'))") + c.ok("and comes back when focus does", page.evaluate(VEILED) is False) + + print("\na touch on the veil") + page.evaluate("() => window.dispatchEvent(new Event('blur'))") + c.ok("veiled to start with", page.evaluate(VEILED) is True) + page.evaluate("""() => document.getElementById('privacy-veil') + .dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))""") + c.ok("lifts it rather than pressing what is underneath", page.evaluate(VEILED) is False) + + print("\nthe tab going away altogether") + page.evaluate("() => window.dispatchEvent(new Event('pagehide'))") + c.ok("leaves it blurred behind us", page.evaluate(VEILED) is True) + + c.ok("nothing threw along the way", not crashes, str(crashes[:2])) + browser.close() + + print(f"\n{'OK' if not c.failed else 'FAILED'}: {c.failed} check(s) failed") + return 1 if c.failed else 0 + + +sys.exit(main())