From e1f541d3668aae10f3a8d4f6450ce316fbf5eef3 Mon Sep 17 00:00:00 2001 From: christian Date: Thu, 22 Oct 2026 09:28:59 -0400 Subject: [PATCH] Browser tab: no focus highlight on the page Every key on the app's keyboard takes focus from the page for a moment and gives it back, so the site's focus ring (a 2px blue outline on the search box, and a brighter border) flashed with each letter. Outline rings are turned off with the page's clean-up CSS, and the guest preload strips outline, border and box-shadow from the site's own :focus / :focus-within / :focus-visible rules, keeping anything else those rules do. Co-Authored-By: Claude Opus 5.5 --- BROWSER_TAB.md | 6 ++-- browser-guest-preload.cjs | 65 +++++++++++++++++++++++++++++++++++++++ browser-tab.js | 11 ++++--- 3 files changed, 76 insertions(+), 6 deletions(-) diff --git a/BROWSER_TAB.md b/BROWSER_TAB.md index ebb9b6e..6e7bb48 100644 --- a/BROWSER_TAB.md +++ b/BROWSER_TAB.md @@ -1,6 +1,6 @@ # Browser tab -One web page, full screen in the tab: no address bar, bookmarks or scrollbars — just the page, in the +One web page, full screen in the tab: no address bar, bookmarks, scrollbars or focus highlights — just the page, in the same area the Graphics tab uses. It loads when the app starts and keeps running while other tabs are showing, so it is ready the moment it is opened. Built for It Is Written (`https://it-is-written-waters-church.vercel.app/`), but any web page works. @@ -65,7 +65,9 @@ the search and could put different results on the program feed. - **`browser-guest-preload.cjs`** runs inside the page (invisible to the site) and reports what the app cannot see from outside: a tapped text field, a tapped reference (found with the app's own `scripture-scan.js` — the same rules ProPresenter Sync uses), scroll, activity, five fingers, and — only - while a follower is linked — a script-free copy of the page's DOM. + while a follower is linked — a script-free copy of the page's DOM. It also takes the look (outline, + border, glow) out of the site's focus styles: every key on the app's keyboard takes focus from the page + for a moment, so a focus highlight would flash with each letter typed. - **`browser-tab.js`** owns the tab: the pages, the keyboard (via `createPrayerKeyboard` with a "sink"), scripture taps, scrolling for the phone / OSC / admin, and the follower's copy. On the follower it shows that copy in sandboxed frames (no scripts); in a plain browser (admin offstage copy, `npm start`) diff --git a/browser-guest-preload.cjs b/browser-guest-preload.cjs index a997153..e588b0e 100644 --- a/browser-guest-preload.cjs +++ b/browser-guest-preload.cjs @@ -167,6 +167,71 @@ document.addEventListener('focusout', (e) => { }, 0); }, true); +// ------------------------------------------------------------------ no focus highlight +/* + * Nothing on the page shows that it has focus. The app's keyboard is outside the page, so every key press + * takes focus from the page for a moment and gives it straight back: a site's focus style would flash + * with each letter — and on stage it is clutter anyway. Outline rings (the browser's own and any site's) + * go with one rule in browser-tab.js's CLEAN_CSS; this takes the rest — a border that brightens, a glow — + * out of the site's own :focus / :focus-within / :focus-visible rules, and leaves everything else those + * rules do: a menu that opens on focus still opens. Sites add rules as they go (CSS-in-JS, late + * stylesheets), so this runs again just before anything can take focus. Another origin's stylesheets + * cannot be read and keep theirs; the outline rule still covers their rings. + */ +const FOCUS_LOOK = /^(?:outline|box-shadow|border)(?:-|$)/; +const seenRules = new WeakSet(); + +// Split at commas that are not inside brackets or quotes: "a:hover, b:is(c, d)" -> 2; "x:1; y:f(a;b)" -> 2. +function splitTop(text, sep) { + const out = []; + let depth = 0, quote = '', start = 0; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (quote) { if (c === '\\') i++; else if (c === quote) quote = ''; continue; } + if (c === '"' || c === "'") quote = c; + else if (c === '(' || c === '[') depth++; + else if (c === ')' || c === ']') depth--; + else if (c === sep && depth === 0) { out.push(text.slice(start, i).trim()); start = i + 1; } + } + out.push(text.slice(start).trim()); + return out.filter(Boolean); +} +// Styled BECAUSE it has focus. ":not(:focus)" styles a field at rest, so it does not count. +function focusDriven(sel) { + return /:focus(?:-within|-visible)?(?![\w-])/.test(sel.replace(/:not\((?:[^()]|\([^()]*\))*\)/g, '')); +} +function isLook(decl) { return FOCUS_LOOK.test(decl.slice(0, decl.indexOf(':')).trim().toLowerCase()); } + +function stripFocusLook(list, owner) { + for (let i = 0; i < list.length; i++) { + const r = list[i]; + if (r.cssRules && r.cssRules.length) stripFocusLook(r.cssRules, r); // @media, @supports, @layer, nesting + if (!r.selectorText || !r.style || seenRules.has(r)) continue; + seenRules.add(r); + const sels = splitTop(r.selectorText, ','); + const focus = sels.filter(focusDriven); + if (!focus.length) continue; + // Declarations as written (cssText), so a shorthand built on var() survives intact. + const decls = splitTop(r.style.cssText, ';'); + const keep = decls.filter((d) => !isLook(d)); + if (keep.length === decls.length) continue; + if (focus.length === sels.length) { r.style.cssText = keep.join('; '); continue; } + // Shared with selectors that are not about focus ("a:hover, a:focus"): those keep the whole rule, and + // the focus ones move to a copy without the look. + r.selectorText = sels.filter((s) => !focusDriven(s)).join(', '); + if (!keep.length) continue; + try { owner.insertRule(focus.join(', ') + ' { ' + keep.join('; ') + ' }', i + 1); i++; seenRules.add(list[i]); } catch (_) {} + } +} +function hideFocusLook() { + const sheets = [...document.styleSheets, ...(document.adoptedStyleSheets || [])]; + for (const sh of sheets) { try { stripFocusLook(sh.cssRules, sh); } catch (_) {} } // another origin's: unreadable +} +window.addEventListener('DOMContentLoaded', hideFocusLook); +window.addEventListener('load', hideFocusLook); +window.addEventListener('pointerdown', hideFocusLook, true); +document.addEventListener('focusin', hideFocusLook, true); + // ------------------------------------------------------------------ activity + five fingers let lastActivity = 0; function activity() { diff --git a/browser-tab.js b/browser-tab.js index 9c2a4e8..1bef15f 100644 --- a/browser-tab.js +++ b/browser-tab.js @@ -41,15 +41,18 @@ var RETRY_MS = 10000; /* - * The page itself is the only thing on screen: no scrollbars, no blue tap flash, no text selection from - * a resting finger, and no rubber-band pull that would drag the whole page (or, sideways, swipe back a - * page). Inputs keep their selection so typing still behaves. Applied to the live page with insertCSS - * and to the booth's copy as a