+ Swipe right to keep unread or left to mark read. Buttons remain available.
Choose an update.
@@ -1089,6 +1090,7 @@
+
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 193cb4a..938be0b 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -79,6 +79,7 @@ const SHELL = [
BASE + 'static/update-triage-session.js',
BASE + 'static/update-read-position.js',
BASE + 'static/update-triage-launcher.js',
+ BASE + 'static/update-triage-gesture.js',
BASE + 'static/agenda-session-launcher.js',
BASE + 'static/mobile-launch.js',
BASE + 'static/mobile-app-shortcuts.js',
diff --git a/frontend/update-triage-gesture.js b/frontend/update-triage-gesture.js
new file mode 100644
index 0000000..48a9f34
--- /dev/null
+++ b/frontend/update-triage-gesture.js
@@ -0,0 +1,92 @@
+(function (root, factory) {
+ if (typeof module === 'object' && module.exports) module.exports = factory;
+ else root.createUpdateTriageGesture = factory;
+})(typeof self !== 'undefined' ? self : this, function createUpdateTriageGesture(options) {
+ const viewportWidth = options.viewportWidth || (() => root.innerWidth);
+ const surface = options.surface;
+ const threshold = options.threshold || 72;
+ const edgeInset = options.edgeInset || 24;
+ const interactive = 'a,button,input,textarea,select,label,summary,[contenteditable="true"],[role="button"]';
+ let active = null;
+ let busy = false;
+
+ function horizontalScroller(target) {
+ for (let node = target; node && node !== surface; node = node.parentElement) {
+ if (Number(node.scrollWidth) > Number(node.clientWidth) + 1) return true;
+ }
+ return false;
+ }
+
+ function ignored(event) {
+ return event.pointerType === 'mouse' || event.clientX <= edgeInset ||
+ event.clientX >= viewportWidth() - edgeInset ||
+ Boolean(event.target?.closest?.(interactive)) || horizontalScroller(event.target);
+ }
+
+ function reset() {
+ active = null;
+ delete surface.dataset.triageGesture;
+ }
+
+ function start(event) {
+ if (busy || !options.enabled() || viewportWidth() > 700 || ignored(event) || active) return;
+ active = {id:event.pointerId, x:event.clientX, y:event.clientY, cancelled:false};
+ surface.setPointerCapture?.(event.pointerId);
+ }
+
+ function move(event) {
+ if (!active || active.id !== event.pointerId) return;
+ const dx = event.clientX - active.x;
+ const dy = event.clientY - active.y;
+ if (Math.abs(dy) > Math.abs(dx) || Math.abs(dy) > 36) {
+ active.cancelled = true;
+ delete surface.dataset.triageGesture;
+ return;
+ }
+ if (Math.abs(dx) < 12) return;
+ event.preventDefault?.();
+ surface.dataset.triageGesture = dx > 0 ? 'keep' : 'read';
+ }
+
+ function decide(callback) {
+ busy = true;
+ let outcome;
+ try {
+ outcome = callback();
+ } catch (error) {
+ busy = false;
+ throw error;
+ }
+ if (outcome && typeof outcome.then === 'function') {
+ Promise.resolve(outcome).finally(() => { busy = false; });
+ } else {
+ busy = false;
+ }
+ }
+
+ function finish(event) {
+ if (!active || active.id !== event.pointerId) return;
+ const gesture = active;
+ const dx = event.clientX - gesture.x;
+ const dy = event.clientY - gesture.y;
+ surface.releasePointerCapture?.(event.pointerId);
+ reset();
+ if (gesture.cancelled || Math.abs(dx) < threshold || Math.abs(dx) <= Math.abs(dy)) return;
+ decide(dx > 0 ? options.keepUnread : options.markRead);
+ }
+
+ surface.addEventListener('pointerdown', start);
+ surface.addEventListener('pointermove', move);
+ surface.addEventListener('pointerup', finish);
+ surface.addEventListener('pointercancel', reset);
+
+ function destroy() {
+ surface.removeEventListener('pointerdown', start);
+ surface.removeEventListener('pointermove', move);
+ surface.removeEventListener('pointerup', finish);
+ surface.removeEventListener('pointercancel', reset);
+ reset();
+ }
+
+ return {destroy};
+});
diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py
index affdaaf..4a28fbf 100644
--- a/src/frontend_bundle.py
+++ b/src/frontend_bundle.py
@@ -28,7 +28,7 @@ FEATURE_SOURCES = {
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
"security-center": ("static/security-center.js",),
"today-timer": (
- "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-triage-launcher.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
+ "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index 045122f..a048794 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -849,6 +849,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/update-triage-session.js",
"/dashboard/static/update-read-position.js",
"/dashboard/static/update-triage-launcher.js",
+ "/dashboard/static/update-triage-gesture.js",
"/dashboard/static/agenda-session-launcher.js",
"/dashboard/static/mobile-launch.js",
"/dashboard/static/mobile-app-shortcuts.js",
diff --git a/tests/test_update_triage_gesture.py b/tests/test_update_triage_gesture.py
new file mode 100644
index 0000000..4a84083
--- /dev/null
+++ b/tests/test_update_triage_gesture.py
@@ -0,0 +1,110 @@
+import json
+import subprocess
+from pathlib import Path
+
+import pytest
+
+from tests.dashboard_bundle import dashboard
+
+
+GESTURE = Path(__file__).resolve().parents[1] / "frontend" / "update-triage-gesture.js"
+
+
+def run_gesture(script):
+ source = f"const createGesture = require({json.dumps(str(GESTURE))});\n" + script
+ result = subprocess.run(["node", "-e", source], capture_output=True, text=True)
+ assert result.returncode == 0, result.stderr
+ return json.loads(result.stdout)
+
+
+def test_committed_horizontal_swipes_decide_once_and_lock_while_pending():
+ result = run_gesture(r"""
+class Surface {
+ constructor() { this.listeners = {}; this.dataset = {}; }
+ addEventListener(name, callback) { this.listeners[name] = callback; }
+ removeEventListener(name) { delete this.listeners[name]; }
+ setPointerCapture() {}
+ releasePointerCapture() {}
+}
+const surface = new Surface();
+const calls = [];
+let enabled = true;
+let resolveRead;
+let lockedCalls;
+const gesture = createGesture({
+ surface,
+ enabled: () => enabled,
+ keepUnread: () => calls.push('keep'),
+ markRead: () => new Promise(resolve => { calls.push('read'); resolveRead = resolve; }),
+ viewportWidth: () => 390,
+});
+const target = {closest: () => null, parentElement: null, scrollWidth: 100, clientWidth: 100};
+const event = (x, y, id=1) => ({pointerId:id, pointerType:'touch', clientX:x, clientY:y, target, preventDefault(){}});
+surface.listeners.pointerdown(event(180, 200));
+surface.listeners.pointermove(event(250, 205));
+surface.listeners.pointerup(event(270, 206));
+surface.listeners.pointerdown(event(280, 200));
+surface.listeners.pointermove(event(205, 204));
+surface.listeners.pointerup(event(190, 205));
+surface.listeners.pointerdown(event(280, 200));
+surface.listeners.pointermove(event(190, 203));
+surface.listeners.pointerup(event(180, 203));
+Promise.resolve().then(() => {
+ lockedCalls = [...calls];
+ resolveRead();
+ return Promise.resolve();
+}).then(() => process.stdout.write(JSON.stringify({calls, lockedCalls, direction:surface.dataset.triageGesture || null})));
+""")
+
+ assert result == {
+ "calls": ["keep", "read"],
+ "lockedCalls": ["keep", "read"],
+ "direction": None,
+ }
+
+
+def test_scrolls_short_drags_edges_and_interactive_targets_never_decide():
+ result = run_gesture(r"""
+class Surface {
+ constructor() { this.listeners = {}; this.dataset = {}; }
+ addEventListener(name, callback) { this.listeners[name] = callback; }
+ removeEventListener(name) { delete this.listeners[name]; }
+ setPointerCapture() {}
+ releasePointerCapture() {}
+}
+const surface = new Surface();
+const calls = [];
+createGesture({surface, enabled:()=>true, keepUnread:()=>calls.push('keep'), markRead:()=>calls.push('read'), viewportWidth:()=>390});
+const plain = {closest: () => null, parentElement: null, scrollWidth:100, clientWidth:100};
+const interactive = {closest: selector => selector.includes('button') ? {} : null, parentElement:null, scrollWidth:100, clientWidth:100};
+const scroller = {closest: () => null, parentElement:null, scrollWidth:400, clientWidth:100};
+const event = (x,y,target=plain,id=1) => ({pointerId:id,pointerType:'touch',clientX:x,clientY:y,target,preventDefault(){}});
+function drag(fromX, fromY, toX, toY, target=plain) {
+ surface.listeners.pointerdown(event(fromX,fromY,target));
+ surface.listeners.pointermove(event(toX,toY,target));
+ surface.listeners.pointerup(event(toX,toY,target));
+}
+drag(180,100,205,104);
+drag(180,100,220,190);
+drag(8,100,120,104);
+drag(180,100,280,104,interactive);
+drag(180,100,280,104,scroller);
+surface.listeners.pointerdown(event(180,100));
+surface.listeners.pointercancel(event(280,104));
+process.stdout.write(JSON.stringify({calls, direction:surface.dataset.triageGesture || null}));
+""")
+
+ assert result == {"calls": [], "direction": None}
+
+
+@pytest.mark.anyio
+async def test_dashboard_wires_accessible_phone_only_update_swipe_triage():
+ html = await dashboard()
+
+ assert '' in html
+ assert 'id="update-gesture-status"' in html
+ assert "createUpdateTriageGesture({" in html
+ assert "qs('#keep-update-unread').click()" in html
+ assert "qs('#mark-update-read-next').click()" in html
+ assert "data-triage-gesture" in html
+ assert "prefers-reduced-motion: reduce" in html