6 Commits
Author SHA1 Message Date
christianandClaude Opus 5.5 3891a027f3 Release 1.4.41
Browser tab: no more blue outline flashing around the search box while typing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:31:15 -04:00
christianandClaude Opus 5.5 e1f541d366 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 <noreply@anthropic.com>
2026-10-22 09:28:59 -04:00
christianandClaude Opus 5.5 0f9c468b1e Release 1.4.40
Browser tab icon no longer looks selected when you're on another tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:27:24 -04:00
christianandClaude Opus 5.5 ede79f5c57 Browser tab icon dims like the others when the tab isn't selected
The globe was missing from the rule that dims tab icons at rest, so it
always showed at full brightness and looked like the active tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:25:35 -04:00
christianandClaude Opus 5.5 8ddbee199b Release 1.4.39
Browser tab: the keyboard opens right under the search box, and Enter
searches and puts it away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:20:34 -04:00
christianandClaude Opus 5.5 9d63e00632 Browser tab keyboard: open under the field being typed into; Enter hides it
The stage TV is portrait, and the keyboard opened at the bottom of the screen — a
reach to the floor to type into a search box at the top.

- browser-guest-preload.cjs reports the tapped field's position (and which field it
  is) with the tap, and again when focus moves to another field.
- browser-tab.js opens the keyboard directly under that field, centred on it and
  kept on screen; if there is no room under it, just above it. Focus moving to a
  different field (Tab, the next memorize blank) moves the keyboard with it.
  Re-tapping the same field leaves a dragged keyboard where it was.
- The keyboard's Enter submits to the page and then hides the keyboard, so search
  results are not covered. Tapping a field brings it back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:17:16 -04:00
5 changed files with 138 additions and 21 deletions
+5 -3
View File
@@ -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.
@@ -10,7 +10,7 @@ showing, so it is ready the moment it is opened. Built for It Is Written
| Do this | What happens |
|---|---|
| Drag a finger | The page scrolls. |
| Tap a text box on the page | The app's own keyboard (the Prayer tab's) comes up and types into it. Enter submits; the keyboard puts itself away when the page moves on (e.g. after a search). × closes it; tap the box again to bring it back. |
| Tap a text box on the page | The app's own keyboard (the Prayer tab's) comes up right under the box (above it if the box is near the bottom of the screen — the stage TV is portrait) and types into it. Enter submits and puts the keyboard away; it also goes away when the page moves on. × closes it; tap the box again to bring it back. |
| Tap a scripture reference (e.g. "Isaiah 41:10") | It opens in a new Bible tab, at that verse, in the translation printed beside it (NIV, ESV…) when that translation is switched on. |
| Tap anything else | Works on the page as normal. |
| Leave the tab and come back | The page is exactly as it was: same search, same place, same typing. |
@@ -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`)
+83 -2
View File
@@ -127,6 +127,19 @@ function restoreOsKeyboard(el) {
if (v === null) el.removeAttribute('inputmode'); else el.setAttribute('inputmode', v);
}
/*
* Where the field is, so the app can open its keyboard right under it: the stage TV is portrait, and a
* keyboard parked at the bottom of a 75" screen is a reach to the floor. `field` tells one field from
* another, so focus moving to a different one (Tab, the next memorize blank) can move the keyboard too.
*/
let fieldSeq = 0;
const fieldIds = new WeakMap();
function fieldInfo(el) {
if (!fieldIds.has(el)) fieldIds.set(el, ++fieldSeq);
const r = el.getBoundingClientRect();
return { field: fieldIds.get(el), rect: { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) } };
}
/*
* Only a TAP raises the keyboard. A page that focuses its own search box as it loads must not throw a
* keyboard over the stage while nobody is touching it; focus moving between fields (Tab, the next
@@ -137,10 +150,13 @@ window.addEventListener('pointerdown', (e) => {
const el = editableFrom(e.target);
if (!el) return;
quietOsKeyboard(el);
send('bibleapp:editable', { on: true, tap: true });
send('bibleapp:editable', Object.assign({ on: true, tap: true }, fieldInfo(el)));
}, true);
document.addEventListener('focusin', (e) => {
send('bibleapp:editable', { on: isEditable(e.target), tap: false });
const el = e.target;
if (!isEditable(el)) { send('bibleapp:editable', { on: false, tap: false }); return; }
// Measured a frame later: focusing can scroll the field into view first.
requestAnimationFrame(() => send('bibleapp:editable', Object.assign({ on: true, tap: false }, fieldInfo(el))));
}, true);
document.addEventListener('focusout', (e) => {
restoreOsKeyboard(e.target);
@@ -151,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() {
+46 -14
View File
@@ -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 <style>, so both screens look the same.
* The page itself is the only thing on screen: no scrollbars, no blue tap flash, no focus ring, 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 <style>, so both screens look the same. (The ring matters
* twice: every key on the app's keyboard takes focus from the page for a moment, so a ring would flash
* with each letter. The site's other focus styles are taken out by browser-guest-preload.cjs.)
*/
var CLEAN_CSS =
'html,body{scrollbar-width:none!important;overscroll-behavior:none!important;}' +
'::-webkit-scrollbar{display:none!important;width:0!important;height:0!important;}' +
'*{-webkit-tap-highlight-color:transparent!important;}' +
':focus,:focus-visible{outline:none!important;}' +
'body *{-webkit-user-select:none!important;user-select:none!important;-webkit-touch-callout:none!important;}' +
'input,textarea,[contenteditable],[contenteditable] *{-webkit-user-select:text!important;user-select:text!important;}';
@@ -360,7 +363,9 @@
// Only the page on screen may raise or drop the keyboard; a background page focusing its own
// search box as it loads must not.
if (!d || !isShown(v)) return;
if (d.on && d.tap) showKeyboard();
if (d.on && d.tap) showKeyboard(v, d);
// Focus moved on to a DIFFERENT field while typing (Tab, the next memorize blank): follow it.
else if (d.on && kb && kb.el.style.display === 'block' && d.field && d.field !== kb.field) placeKeyboard(v, d);
else if (!d.on) hideKeyboard();
return;
case 'bibleapp:activity':
@@ -600,6 +605,9 @@
if (name === 'Enter') v.el.sendInputEvent({ type: 'char', keyCode: '\r' });
v.el.sendInputEvent({ type: 'keyUp', keyCode: code });
} catch (_) {}
// Enter is "done": the search runs and the keyboard gets out of the way of the results. A tap on a
// field brings it back.
if (name === 'Enter') hideKeyboard();
},
focus: function () {
var v = activeView();
@@ -616,20 +624,44 @@
el.style.display = 'none';
document.body.appendChild(el);
a.createPrayerKeyboard(el, null, 'browser', sink);
kb = { el: el, placed: false };
kb = { el: el, field: null };
return kb;
}
function showKeyboard() {
/*
* The keyboard opens directly under the field being typed into. The stage TV is portrait: anything at
* the bottom of the screen is a reach to the floor, and the page's search box is near the top. If the
* field is too low for the keyboard to fit under it, it goes just above instead. Re-tapping the same
* field while the keyboard is up leaves it wherever it was dragged.
*/
function placeKeyboard(v, d) {
var k = kb;
if (!k || !d || !d.rect || !v || !v.el) return;
var W = v.el.getBoundingClientRect(), z = v.zoom || 1;
var fieldTop = W.top + d.rect.y * z;
var fieldBottom = W.top + (d.rect.y + d.rect.h) * z;
var fieldMid = W.left + (d.rect.x + d.rect.w / 2) * z;
var w = k.el.offsetWidth || 760, h = k.el.offsetHeight || 340, gap = 14;
var y = fieldBottom + gap;
if (y + h > window.innerHeight - 8) {
var above = fieldTop - gap - h;
y = above >= 0 ? above : Math.max(0, window.innerHeight - h - 8);
}
var x = Math.max(0, Math.min(window.innerWidth - w, Math.round(fieldMid - w / 2)));
k.el.style.left = x + 'px';
k.el.style.top = Math.round(y) + 'px';
k.field = d.field || null;
}
function showKeyboard(v, d) {
if (ROLE !== 'stage') return;
var k = keyboard();
if (!k) return;
k.el.style.display = 'block';
// First time: bottom centre, clear of the page's search box at the top. After that, wherever it was dragged.
if (!k.placed) {
k.placed = true;
var w = k.el.offsetWidth || 760, hgt = k.el.offsetHeight || 340;
k.el.style.left = Math.max(0, Math.round((window.innerWidth - w) / 2)) + 'px';
k.el.style.top = Math.max(0, Math.round(window.innerHeight - hgt - Math.max(24, window.innerHeight * 0.04))) + 'px';
var wasHidden = k.el.style.display !== 'block';
k.el.style.display = 'block'; // shown before measuring: a hidden element has no size
if (d && d.rect && (wasHidden || d.field !== k.field)) placeKeyboard(v, d);
else if (wasHidden && !k.field) {
// No position from the page (should not happen): centre it rather than leave it anywhere odd.
k.el.style.left = Math.max(0, Math.round((window.innerWidth - (k.el.offsetWidth || 760)) / 2)) + 'px';
k.el.style.top = Math.max(0, Math.round((window.innerHeight - (k.el.offsetHeight || 340)) / 3)) + 'px';
}
}
function hideKeyboard() { if (kb) kb.el.style.display = 'none'; }
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bible-app",
"version": "1.4.38",
"version": "1.4.41",
"description": "Minimalist Bible App for Portrait-Oriented Touchscreen TV",
"type": "module",
"main": "main.cjs",
+3 -1
View File
@@ -1547,7 +1547,7 @@ body.drawing * {
}
/* Removed explicit close button; using hold-to-close gesture on tab */
.bible-icon, .media-icon, .whiteboard-icon, .media-tab-icon, .graphics-tab-icon, .maps-tab-icon, .prayer-tab-icon, .alpha-icon, .notes-icon, .ask-tab-icon {
.bible-icon, .media-icon, .whiteboard-icon, .media-tab-icon, .graphics-tab-icon, .maps-tab-icon, .browser-tab-icon, .prayer-tab-icon, .alpha-icon, .notes-icon, .ask-tab-icon {
opacity: 0.7;
transition: opacity 0.2s;
}
@@ -1564,6 +1564,8 @@ body.drawing * {
.tab.active .graphics-tab-icon,
.tab:hover .maps-tab-icon,
.tab.active .maps-tab-icon,
.tab:hover .browser-tab-icon,
.tab.active .browser-tab-icon,
.tab:hover .prayer-tab-icon,
.tab.active .prayer-tab-icon,
.tab:hover .alpha-icon,