feat: add mobile update swipe triage (Closes #769)
All checks were successful
CI / lint (pull_request) Successful in 1m41s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-13 20:30:15 +00:00
parent 18f56583ba
commit 931f656f78
8 changed files with 219 additions and 2 deletions

View File

@ -669,6 +669,9 @@ textarea { resize: vertical; min-height: 120px; }
.review-sheet-panel { width:100%; border-left:0; padding:14px; } .review-sheet-panel { width:100%; border-left:0; padding:14px; }
.update-sheet-panel { width:100%; border-left:0; padding:14px; overflow-x:hidden; } .update-sheet-panel { width:100%; border-left:0; padding:14px; overflow-x:hidden; }
.update-sheet-panel { padding-bottom:calc(82px + env(safe-area-inset-bottom)); } .update-sheet-panel { padding-bottom:calc(82px + env(safe-area-inset-bottom)); }
.update-gesture-status { display:block; padding:6px 10px; border-radius:999px; text-align:center; background:#102641; }
.update-sheet-panel[data-triage-gesture="keep"] { box-shadow:inset 8px 0 #60a5fa; }
.update-sheet-panel[data-triage-gesture="read"] { box-shadow:inset -8px 0 #34d399; }
.update-decision-bar { position:fixed; inset:auto 0 0; z-index:57; margin:0; padding-bottom:calc(8px + env(safe-area-inset-bottom)); } .update-decision-bar { position:fixed; inset:auto 0 0; z-index:57; margin:0; padding-bottom:calc(8px + env(safe-area-inset-bottom)); }
.update-more-actions > summary { display:none; } .update-more-actions > summary { display:none; }
.issue-sheet-panel { width:100%; border-left:0; padding:14px; } .issue-sheet-panel { width:100%; border-left:0; padding:14px; }
@ -719,6 +722,10 @@ textarea { resize: vertical; min-height: 120px; }
.mobile-today-hud button { min-height:44px; max-width:100%; } .mobile-today-hud button { min-height:44px; max-width:100%; }
.mobile-today-hud [data-work-session-adjust-plan] { grid-column:1 / -1; } .mobile-today-hud [data-work-session-adjust-plan] { grid-column:1 / -1; }
} }
@media (min-width:701px) { .update-gesture-status { display:none; } }
@media (prefers-reduced-motion: reduce) {
.update-sheet-panel[data-triage-gesture] { transition:none; }
}
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; } .obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; } .footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } } @keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }

View File

@ -5558,6 +5558,10 @@
if (updateTriage.active()) updateTriage.keepUnreadAndNext(); if (updateTriage.active()) updateTriage.keepUnreadAndNext();
else closeUpdateSheet(true); else closeUpdateSheet(true);
}); });
createUpdateTriageGesture({
surface: qs('#update-sheet .update-sheet-panel'), enabled: () => selectedUpdateDetail,
keepUnread: () => qs('#keep-update-unread').click(), markRead: () => qs('#mark-update-read-next').click(),
});
qs('#update-ownership-action').addEventListener('click', () => updateOwnership.act()); qs('#update-ownership-action').addEventListener('click', () => updateOwnership.act());
qs('#update-ownership-start').addEventListener('click', () => updateOwnership.start()); qs('#update-ownership-start').addEventListener('click', () => updateOwnership.start());
qs('#retry-update-load').addEventListener('click', () => { qs('#retry-update-load').addEventListener('click', () => {

View File

@ -743,7 +743,7 @@
</div> </div>
<div class="update-sheet" id="update-sheet" role="dialog" aria-modal="true" aria-labelledby="update-sheet-title"> <div class="update-sheet" id="update-sheet" role="dialog" aria-modal="true" aria-labelledby="update-sheet-title">
<section class="update-sheet-panel"> <section class="update-sheet-panel" data-triage-gesture="">
<div class="update-sheet-header"> <div class="update-sheet-header">
<div> <div>
<div class="small" id="update-sheet-key"></div> <div class="small" id="update-sheet-key"></div>
@ -751,6 +751,7 @@
</div> </div>
</div> </div>
<div id="update-triage-progress" class="update-triage-progress small" aria-live="polite" hidden></div> <div id="update-triage-progress" class="update-triage-progress small" aria-live="polite" hidden></div>
<div id="update-gesture-status" class="update-gesture-status small" aria-live="polite">Swipe right to keep unread or left to mark read. Buttons remain available.</div>
<div id="update-sheet-status" class="small" aria-live="polite">Choose an update.</div> <div id="update-sheet-status" class="small" aria-live="polite">Choose an update.</div>
<button class="update-retry" id="retry-update-load" type="button" hidden>Retry loading update</button> <button class="update-retry" id="retry-update-load" type="button" hidden>Retry loading update</button>
<div class="row"> <div class="row">
@ -1089,6 +1090,7 @@
<script src="static/update-triage-session.js"></script> <script src="static/update-triage-session.js"></script>
<script src="static/update-read-position.js"></script> <script src="static/update-read-position.js"></script>
<script src="static/update-triage-launcher.js"></script> <script src="static/update-triage-launcher.js"></script>
<script src="static/update-triage-gesture.js"></script>
<script src="static/agenda-session-launcher.js"></script> <script src="static/agenda-session-launcher.js"></script>
<script src="static/mobile-launch.js"></script> <script src="static/mobile-launch.js"></script>
<script src="static/mobile-app-shortcuts.js"></script> <script src="static/mobile-app-shortcuts.js"></script>

View File

@ -79,6 +79,7 @@ const SHELL = [
BASE + 'static/update-triage-session.js', BASE + 'static/update-triage-session.js',
BASE + 'static/update-read-position.js', BASE + 'static/update-read-position.js',
BASE + 'static/update-triage-launcher.js', BASE + 'static/update-triage-launcher.js',
BASE + 'static/update-triage-gesture.js',
BASE + 'static/agenda-session-launcher.js', BASE + 'static/agenda-session-launcher.js',
BASE + 'static/mobile-launch.js', BASE + 'static/mobile-launch.js',
BASE + 'static/mobile-app-shortcuts.js', BASE + 'static/mobile-app-shortcuts.js',

View File

@ -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};
});

View File

@ -28,7 +28,7 @@ FEATURE_SOURCES = {
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"), "device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
"security-center": ("static/security-center.js",), "security-center": ("static/security-center.js",),
"today-timer": ( "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/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/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", "static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",

View File

@ -849,6 +849,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/update-triage-session.js", "/dashboard/static/update-triage-session.js",
"/dashboard/static/update-read-position.js", "/dashboard/static/update-read-position.js",
"/dashboard/static/update-triage-launcher.js", "/dashboard/static/update-triage-launcher.js",
"/dashboard/static/update-triage-gesture.js",
"/dashboard/static/agenda-session-launcher.js", "/dashboard/static/agenda-session-launcher.js",
"/dashboard/static/mobile-launch.js", "/dashboard/static/mobile-launch.js",
"/dashboard/static/mobile-app-shortcuts.js", "/dashboard/static/mobile-app-shortcuts.js",

View File

@ -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 '<script src="static/update-triage-gesture.js"></script>' 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