feat(web): reader chrome UX — intent-based reveal, reading actions dismiss (handoff item 7)

The chrome previously woke on ANY activity: pointermove (mouse drift
and, worse, the touch drag of a page turn itself), every keypress, and
every touch tap toggled it. Now reveal requires explicit UI intent and
reading actions suppress:

- Desktop: cursor into the top 10% / bottom 8% band raises the chrome
  (screenY-based so content iframes classify identically); content
  pointer movement never does. Mouse center-clicks no longer toggle.
- Touch: a vertical flick (dominant + fast) reveals; while visible,
  flick down OR up dismisses. Plain taps are fully inert — a stray tap
  can never flash the UI.
- Keyboard: page keys never poke; turning (keys, mouse edge zones, or
  a touch swipe via the snap-relocate) actively dismisses visible
  chrome.
- Highlight saves return to a clean screen: the selection flow owns
  the screen and the post-save pointer/keyboard pokes are gone.
- Chrome starts hidden; always-visible pin and auto-hide timer remain
  (bands use a shorter 1.2s fade).

Pairs with the foliate-js 0.5.4 paginator change (scrolled layouts
scroll manually so the flick gesture is observable in webtoon); the
fork pin bump follows once the gitea mirror shows f622841.
This commit is contained in:
John O'Keefe committed 2026-10-15 13:44:19 -04:00
1 parent 2517baedea
commit f2fd4db81c
1 file changed
+77 -28
+77 -28
View File
@@ -758,9 +758,15 @@ document.addEventListener("alpine:init", () => {
doc.addEventListener("keydown", (ev: KeyboardEvent) => doc.addEventListener("keydown", (ev: KeyboardEvent) =>
this.handleKeydown(ev), this.handleKeydown(ev),
); );
// Pointer activity inside page iframes should keep the chrome awake // Edge bands work inside page documents too — screenY keeps the
// check viewport-relative even when the iframe is content-sized
// (fixed-layout pages and EPUB sections are separate documents). // (fixed-layout pages and EPUB sections are separate documents).
doc.addEventListener("pointermove", () => this.pokeChrome(), { doc.addEventListener("pointermove", (e: PointerEvent) => {
if (e.pointerType !== "mouse") return;
const vy = e.screenY - window.screenY;
if (vy <= window.innerHeight * 0.1 || vy >= window.innerHeight * 0.92)
this.pokeChrome(1200);
}, {
passive: true, passive: true,
}); });
// Tap zones inside the page document (iframe events never bubble // Tap zones inside the page document (iframe events never bubble
@@ -918,6 +924,10 @@ document.addEventListener("alpine:init", () => {
this.renderAllHighlights(); this.renderAllHighlights();
}); });
this.view.addEventListener("relocate", (e: any) => { this.view.addEventListener("relocate", (e: any) => {
// A touch swipe page turn ends in a snap-relocate: reading
// intent — dismiss the chrome (guards keep drawers, selections,
// and pinned mode up).
if (e.detail?.reason === "snap") this.hideChromeNow();
const { fraction, location, pageItem, cfi, tocItem, section } = const { fraction, location, pageItem, cfi, tocItem, section } =
e.detail; e.detail;
// Only dismiss the popover on an actual position change — the // Only dismiss the popover on an actual position change — the
@@ -1036,12 +1046,21 @@ document.addEventListener("alpine:init", () => {
this.setupTapZones(); this.setupTapZones();
}, },
// Chrome visibility: bars overlay the edge-to-edge reading surface. // Chrome visibility: bars overlay the edge-to-edge reading surface.
// 'auto-hide' keeps them up while the pointer is active and fades them // 'auto-hide' reveals them ONLY from explicit UI intent — cursor in
// out after a quiet period; 'always-visible' pins them. // the top/bottom edge bands (desktop) or the flick-down gesture
// (touch) — and every reading action (page turns, selections,
// highlight saves) dismisses or never raises them. 'always-visible'
// pins them.
setupChrome() { setupChrome() {
document.addEventListener("pointermove", () => this.pokeChrome(), { // Band check via screenY so it works identically for the host
passive: true, // document and content iframes (iframe clientY is content-space,
}); // which is unrelated to the viewport in scrolled layouts).
document.addEventListener("pointermove", (e: PointerEvent) => {
if (e.pointerType !== "mouse") return;
const vy = e.screenY - window.screenY;
if (vy <= window.innerHeight * 0.1 || vy >= window.innerHeight * 0.92)
this.pokeChrome(1200);
}, { passive: true });
// Dismiss the selection popover and tools menu on clicks outside them // Dismiss the selection popover and tools menu on clicks outside them
// (iframe clicks are covered by the selection tracker's collapsed // (iframe clicks are covered by the selection tracker's collapsed
// check; the ⋯ toggle is exempt so its own click re-toggles cleanly). // check; the ⋯ toggle is exempt so its own click re-toggles cleanly).
@@ -1060,9 +1079,10 @@ document.addEventListener("alpine:init", () => {
this.chromeVisible = true; this.chromeVisible = true;
return; return;
} }
this.pokeChrome(); // Chrome starts hidden: reveal is an explicit gesture (edge band /
// flick down), never a side effect of loading.
}, },
pokeChrome() { pokeChrome(hideAfterMs = 2500) {
// Never raise the chrome while a touch selection is live — the // Never raise the chrome while a touch selection is live — the
// selection popover owns the screen until the selection is // selection popover owns the screen until the selection is
// consumed or dismissed. // consumed or dismissed.
@@ -1073,7 +1093,18 @@ document.addEventListener("alpine:init", () => {
this.hideTimer = setTimeout(() => { this.hideTimer = setTimeout(() => {
if (this.anyDrawerOpen() || this.chromeHasFocus()) return; if (this.anyDrawerOpen() || this.chromeHasFocus()) return;
this.chromeVisible = false; this.chromeVisible = false;
}, 2500); }, hideAfterMs);
},
// Reading intent: dismiss the chrome immediately. Respects pinned
// mode and anything that owns the screen.
hideChromeNow() {
if (this.chromeBehavior !== "auto-hide") return;
if (this.anyDrawerOpen() || this.anySelection) return;
this.chromeVisible = false;
if (this.hideTimer) {
clearTimeout(this.hideTimer);
this.hideTimer = null;
}
}, },
chromeHasFocus(): boolean { chromeHasFocus(): boolean {
const el = document.activeElement; const el = document.activeElement;
@@ -1182,7 +1213,31 @@ document.addEventListener("alpine:init", () => {
(e: PointerEvent) => { (e: PointerEvent) => {
if (e.pointerId !== downId) return; if (e.pointerId !== downId) return;
downId = -1; downId = -1;
if (moved || longPressed || Date.now() - downT > 500) return; // Flick-down/up chrome gesture (touch only): vertical-dominant
// and fast. Down reveals a hidden chrome; while visible, down
// OR up dismisses (up reads as "push it away"). Reading
// gestures never look like this: side swipes (page turns) are
// horizontal, long-press selection engages slowly, and zoomed
// pages already bail out of tap routing.
const dy = e.clientY - downY;
const dx = e.clientX - downX;
const dt = Date.now() - downT;
if (
e.pointerType !== "mouse" &&
!this.anySelection &&
dt < 400 &&
Math.abs(dy) > 48 &&
Math.abs(dy) > Math.abs(dx) * 1.8
) {
if (this.tapZoneTimer) {
clearTimeout(this.tapZoneTimer);
this.tapZoneTimer = null;
}
if (!this.chromeVisible) this.pokeChrome();
else this.hideChromeNow();
return;
}
if (moved || longPressed || dt > 500) return;
if (!this.tapZonesEnabled) return; if (!this.tapZonesEnabled) return;
const target = e.target as HTMLElement | null; const target = e.target as HTMLElement | null;
if ( if (
@@ -1235,27 +1290,17 @@ document.addEventListener("alpine:init", () => {
} }
this.tapZoneTimer = setTimeout(() => { this.tapZoneTimer = setTimeout(() => {
this.tapZoneTimer = null; this.tapZoneTimer = null;
// Touch pages by swipe only (the paginator's pan + snap); a tap // Touch: taps are fully inert — paging is swipe-only and the
// anywhere just toggles the chrome. The edge zones remain for // chrome is flick-down/up territory (handoff item 7). A stray
// mouse-driven taps. // tap must never flash the UI.
if (window.matchMedia("(pointer: coarse)").matches) { if (window.matchMedia("(pointer: coarse)").matches) return;
this.toggleChromeManually();
return;
}
const zone = this.tapZoneSize / 100; const zone = this.tapZoneSize / 100;
if (relX < zone) this.goLeft(); if (relX < zone) this.goLeft();
else if (relX > 1 - zone) this.goRight(); else if (relX > 1 - zone) this.goRight();
else this.toggleChromeManually(); // Mouse taps in the content are content (links, selection) —
// they never raise the chrome; the edge bands own that.
}, 280); }, 280);
}, },
toggleChromeManually() {
if (this.chromeVisible) {
this.chromeVisible = false;
if (this.hideTimer) clearTimeout(this.hideTimer);
} else {
this.pokeChrome();
}
},
applyTapZoneSettings() { applyTapZoneSettings() {
saveSettings({ saveSettings({
tap_zones_enabled: this.tapZonesEnabled, tap_zones_enabled: this.tapZonesEnabled,
@@ -3194,9 +3239,12 @@ document.addEventListener("alpine:init", () => {
}, },
goLeft() { goLeft() {
this.view?.goLeft?.(); this.view?.goLeft?.();
// A page turn is reading intent: dismiss the chrome.
this.hideChromeNow();
}, },
goRight() { goRight() {
this.view?.goRight?.(); this.view?.goRight?.();
this.hideChromeNow();
}, },
nextPage() { nextPage() {
this.view?.next?.(); this.view?.next?.();
@@ -4076,7 +4124,8 @@ document.addEventListener("alpine:init", () => {
t?.tagName === "SELECT" || t?.tagName === "SELECT" ||
t?.tagName === "TEXTAREA" || t?.tagName === "TEXTAREA" ||
!!t?.isContentEditable; !!t?.isContentEditable;
this.pokeChrome(); // NOTE: no chrome poke here — keyboard page turns are reading
// actions and must leave the chrome alone (handoff item 7).
if (typing && k !== "Escape") return; if (typing && k !== "Escape") return;
if (k === "ArrowLeft" || k === "h") { if (k === "ArrowLeft" || k === "h") {
if (event.altKey) { if (event.altKey) {