Skip to main content

max / goingson

lock the app behind the system biometric prompt Off by default, mobile only. A pre-paint bootstrap sets .app-locked from a localStorage mirror so the overlay is up before the content it hides; preferences.json stays the authority and corrects the mirror on init. Re-locks on foreground after a 60s grace period, so a glance at a notification does not re-prompt. This is a privacy screen, not encryption: the SQLite file is as readable as it was. It fails open, since an unwritable config dir should not mean an unreadable app. The haptics and biometric plugins ship desktop stubs that error rather than no-op, so both are gated to iOS and Android at the target level instead of being carried into the desktop binary. Also widens the makeover pin to 2.4, which shares a hunk with the plugin additions.
Author: Max Johnson <me@maxj.phd> · 2026-08-06 18:49 UTC
Signed with PGP, not checked
Commit: c9ec052f2e61696e874bfe30f2f340046df286e0
Parent: e5e0424
16 files changed, +624 insertions, -60 deletions
M Cargo.lock +47 -26
@@ -2250,7 +2250,7 @@
2250 2250 "makeover",
2251 2251 "makeover-build",
2252 2252 "makeover-geometry",
2253 - "makeover-layout 0.7.0",
2253 + "makeover-layout",
2254 2254 "makeover-webview",
2255 2255 "notify",
2256 2256 "notify-debouncer-mini",
@@ -2271,7 +2271,9 @@
2271 2271 "synckit-config",
2272 2272 "tauri",
2273 2273 "tauri-build",
2274 + "tauri-plugin-biometric",
2274 2275 "tauri-plugin-dialog",
2276 + "tauri-plugin-haptics",
2275 2277 "tauri-plugin-notification",
2276 2278 "tauri-plugin-opener",
2277 2279 "tauri-plugin-process",
@@ -3376,9 +3378,9 @@
3376 3378
3377 3379 [[package]]
3378 3380 name = "makeover-build"
3379 - version = "0.7.0"
3381 + version = "0.9.0"
3380 3382 source = "registry+https://github.com/rust-lang/crates.io-index"
3381 - checksum = "6e0f93fd97d0ce7e3854c0dd8edddefac444de8197bbaa215dfced38cf7a03b6"
3383 + checksum = "0a6c9d5c42fda6f19adc4dd3e310bade2ef2f0ea1043c20857c913274f904667"
3382 3384 dependencies = [
3383 3385 "makeover",
3384 3386 "makeover-geometry",
@@ -3387,51 +3389,45 @@
3387 3389
3388 3390 [[package]]
3389 3391 name = "makeover-geometry"
3390 - version = "0.6.0"
3392 + version = "0.6.2"
3391 3393 source = "registry+https://github.com/rust-lang/crates.io-index"
3392 - checksum = "b2466b8ca54dba9ef62f3679b33c90a0554e507c23e7b725c178ce9d277f542e"
3394 + checksum = "c4e0cbec6b86c225f5d4a793437fee457b9bf8e93786ee3af0212d668ad8f132"
3393 3395
3394 3396 [[package]]
3395 3397 name = "makeover-layout"
3396 - version = "0.6.0"
3398 + version = "0.8.2"
3397 3399 source = "registry+https://github.com/rust-lang/crates.io-index"
3398 - checksum = "ad745601b5220b2244d73a5da3f3854aefe4cdc955dd45c6d3aa5781ad645f5a"
3399 -
3400 - [[package]]
3401 - name = "makeover-layout"
3402 - version = "0.7.0"
3403 - source = "registry+https://github.com/rust-lang/crates.io-index"
3404 - checksum = "00d6235015ee1a15bc6b3936c5265229de3454524053eb67bf95fc6b741299b0"
3400 + checksum = "9883c75a9d26fce10be2b979c01a74f8c07513800a70ec6bf598de1e7d411f1b"
3405 3401
3406 3402 [[package]]
3407 3403 name = "makeover-touch"
3408 - version = "0.2.0"
3404 + version = "0.4.0"
3409 3405 source = "registry+https://github.com/rust-lang/crates.io-index"
3410 - checksum = "064bb24217e6d49ccafbefbcc29c7c42572c164feb98e5876e9ca49e77d58808"
3406 + checksum = "369083d008157d6d95cd2ac0b8dbfee8689d80eb4c2883b48c2ef627a0116fdb"
3411 3407 dependencies = [
3412 3408 "makeover-geometry",
3413 - "makeover-layout 0.7.0",
3409 + "makeover-layout",
3414 3410 ]
3415 3411
3416 3412 [[package]]
3417 3413 name = "makeover-tui"
3418 - version = "0.7.0"
3414 + version = "0.8.0"
3419 3415 source = "registry+https://github.com/rust-lang/crates.io-index"
3420 - checksum = "e0174c5f2e03f2fc856589d061f5718c2bce77e2d15d1ac6d4bdcb0f53134d63"
3416 + checksum = "fef64d9d88e91745b9d558ec2c12246f776d5a0b3a26092029f27efabe53c64e"
3421 3417 dependencies = [
3422 3418 "makeover",
3423 - "makeover-layout 0.6.0",
3419 + "makeover-layout",
3424 3420 "ratatui",
3425 3421 ]
3426 3422
3427 3423 [[package]]
3428 3424 name = "makeover-webview"
3429 - version = "0.13.0"
3425 + version = "0.17.1"
3430 3426 source = "registry+https://github.com/rust-lang/crates.io-index"
3431 - checksum = "ac7fbadaa7cf1e4a074a0b0c62a9ea3b1616f9de4e40990fab209560b807046f"
3427 + checksum = "c8cfb1a75dcdafc4eab17ca9d1e2ae59f66dea218832c604c1cde5c01a1a799c"
3432 3428 dependencies = [
3433 3429 "makeover-geometry",
3434 - "makeover-layout 0.7.0",
3430 + "makeover-layout",
3435 3431 "makeover-touch",
3436 3432 ]
3437 3433
@@ -6328,6 +6324,21 @@
6328 6324 "walkdir",
6329 6325 ]
6330 6326
6327 + [[package]]
6328 + name = "tauri-plugin-biometric"
6329 + version = "2.3.2"
6330 + source = "registry+https://github.com/rust-lang/crates.io-index"
6331 + checksum = "306175b744890b5e4aeb8add6aae7debec1b1504c5087bd0310ed7c9c6feae38"
6332 + dependencies = [
6333 + "log",
6334 + "serde",
6335 + "serde_json",
6336 + "serde_repr",
6337 + "tauri",
6338 + "tauri-plugin",
6339 + "thiserror 2.0.19",
6340 + ]
6341 +
6331 6342 [[package]]
6332 6343 name = "tauri-plugin-dialog"
6333 6344 version = "2.7.2"
@@ -6370,6 +6381,20 @@
6370 6381 "url",
6371 6382 ]
6372 6383
6384 + [[package]]
6385 + name = "tauri-plugin-haptics"
6386 + version = "2.3.2"
6387 + source = "registry+https://github.com/rust-lang/crates.io-index"
6388 + checksum = "01b167d598ada05c599f4460595108c8d1aa636030a97d19796c1bcda97d881d"
6389 + dependencies = [
6390 + "log",
6391 + "serde",
6392 + "serde_json",
6393 + "tauri",
6394 + "tauri-plugin",
6395 + "thiserror 2.0.19",
6396 + ]
6397 +
6373 6398 [[package]]
6374 6399 name = "tauri-plugin-notification"
6375 6400 version = "2.3.3"
@@ -8482,7 +8507,3 @@
8482 8507 "syn 2.0.119",
8483 8508 "winnow 1.0.4",
8484 8509 ]
8485 -
8486 - [[patch.unused]]
8487 - name = "supernote-push"
8488 - version = "0.1.0"
M Cargo.toml +7 -2
@@ -78,12 +78,17 @@
78 78 tauri-plugin-window-state = "2.4.1"
79 79 tauri-plugin-updater = "2"
80 80 tauri-plugin-process = "2"
81 + # Mobile-only plugins. Both ship a desktop stub whose commands error rather than
82 + # no-op, so they are gated to iOS/Android at the target level in src-tauri rather
83 + # than carried into the desktop binary and guarded at the call site.
84 + tauri-plugin-haptics = "2.3.2"
85 + tauri-plugin-biometric = "2.3.2"
81 86
82 87 # Filesystem watching (db change notifications)
83 88 notify = "8.2"
84 89 notify-debouncer-mini = "0.7"
85 - makeover = "2.4.1"
86 - makeover-tui = "0.7.0"
90 + makeover = "2.4"
91 + makeover-tui = "0.8.0"
87 92 toml = "1.1"
88 93
89 94 # Enums
@@ -131,6 +131,15 @@
131 131 workspace = true
132 132 features = ["image-png", "tray-icon"]
133 133
134 + # === Mobile-only dependencies (no desktop backend) ===
135 +
136 + # Mobile-only: plugins backed by iOS/Android system frameworks. The desktop half
137 + # of each crate exists but errors on every command, so gating the dependency is
138 + # what keeps a call on desktop a compile error instead of a runtime one.
139 + [target.'cfg(any(target_os = "ios", target_os = "android"))'.dependencies]
140 + tauri-plugin-haptics = { workspace = true }
141 + tauri-plugin-biometric = { workspace = true }
142 +
134 143 # Android: bundle SQLite (system SQLite not accessible via NDK)
135 144 [target.'cfg(target_os = "android")'.dependencies]
136 145 libsqlite3-sys = { version = "0.37", features = ["bundled"] }
@@ -29,6 +29,10 @@
29 29 See the TWO AXES section at the top of css/styles.css. -->
30 30 <script src="js/bootstrap-uimode.js"></script>
31 31
32 + <!-- The app lock's pre-paint half, here for the same reason: the overlay
33 + has to be up before the content it hides is. -->
34 + <script src="js/bootstrap-applock.js"></script>
35 +
32 36 <!-- Cascade layer order, declared before any stylesheet so it is a
33 37 statement rather than an accident of link order. A layer's position is
34 38 fixed where the name is FIRST seen, so without this the three generated
@@ -53,6 +57,18 @@
53 57 <link rel="stylesheet" href="css/styles.min.css">
54 58 </head>
55 59 <body>
60 + <!-- Biometric app lock. First in the body so it paints before the header,
61 + and shown by `html.app-locked` rather than by JS creating it. See
62 + js/app-lock.js. -->
63 + <div class="app-lock" id="app-lock" role="dialog" aria-modal="true" aria-labelledby="app-lock-title">
64 + <div class="app-lock-panel">
65 + <h1 class="app-lock-title" id="app-lock-title">GoingsOn is locked</h1>
66 + <p class="app-lock-desc">Unlock with biometrics to continue.</p>
67 + <p class="app-lock-status" id="app-lock-status" role="status" aria-live="polite"></p>
68 + <button class="button button--primary" data-act="appLock.prompt">Unlock</button>
69 + </div>
70 + </div>
71 +
56 72 <a href="#main-content" class="skip-link">Skip to main content</a>
57 73 <header class="app-header">
58 74 <div class="header-content">
@@ -692,6 +708,7 @@
692 708 <script src="js/router.js"></script>
693 709 <script src="js/navigation.js"></script>
694 710 <script src="js/snooze.js"></script>
711 + <script src="js/haptics.js"></script>
695 712 <script src="js/touch.js"></script>
696 713 <!-- Domain render modules (load before their parent domains) -->
697 714 <script src="js/projects-render.js"></script>
@@ -753,6 +770,7 @@
753 770
754 771 <!-- Mobile interaction wiring (no-op on desktop) -->
755 772 <script src="js/mobile.js"></script>
773 + <script src="js/app-lock.js"></script>
756 774
757 775 <!-- OTA update notifications -->
758 776 <script src="js/shared-updater.js"></script>
@@ -321,6 +321,7 @@
321 321 // Preferences
322 322 $crate::commands::get_preferences,
323 323 $crate::commands::set_update_check_on_launch,
324 + $crate::commands::set_require_biometric_unlock,
324 325 ]
325 326 };
326 327 }
@@ -344,6 +345,16 @@
344 345 .plugin(tauri_plugin_window_state::Builder::new().build());
345 346 }
346 347
348 + // Mobile-only plugins. Haptics carries gesture confirmation the desktop
349 + // build answers with hover and focus states instead; biometric backs the
350 + // app lock, which is offered only where the OS has an enrolled biometry.
351 + #[cfg(any(target_os = "ios", target_os = "android"))]
352 + {
353 + builder = builder
354 + .plugin(tauri_plugin_haptics::init())
355 + .plugin(tauri_plugin_biometric::init());
356 + }
357 +
347 358 builder
348 359 .plugin(tauri_plugin_dialog::init())
349 360 .setup(|app| {
@@ -1095,8 +1095,7 @@
1095 1095 border-bottom: none;
1096 1096 }
1097 1097
1098 - .data-table tbody tr.selected,
1099 - .data-table tbody tr.keyboard-selected {
1098 + .data-table tbody tr.selected {
1100 1099 background-color: color-mix(in srgb, var(--action) 25%, var(--surface-raised));
1101 1100 }
1102 1101
@@ -1165,8 +1164,7 @@
1165 1164 border-bottom: none;
1166 1165 }
1167 1166
1168 - .task-row.selected,
1169 - .task-row.keyboard-selected {
1167 + .task-row.selected {
1170 1168 background-color: color-mix(in srgb, var(--action) 25%, var(--surface-raised));
1171 1169 }
1172 1170
@@ -2581,32 +2579,6 @@
2581 2579 margin-bottom: var(--gap-section);
2582 2580 }
2583 2581
2584 - /* Pending-key hint (top-level "go to" prompt) */
2585 - .pending-key-hint {
2586 - position: fixed;
2587 - bottom: var(--gap-pane);
2588 - left: 50%;
2589 - transform: translateX(-50%);
2590 - padding: var(--gap-peer) var(--gap-section);
2591 - background: var(--surface-raised);
2592 - color: var(--content);
2593 - border: var(--border-width) solid var(--border);
2594 - border-radius: var(--radius-md);
2595 - box-shadow: var(--bevel-raised), var(--shadow-brutal-md);
2596 - z-index: 10000;
2597 - font-size: var(--font-size-sm);
2598 - white-space: nowrap;
2599 - }
2600 - .pending-key-hint-label { opacity: 0.7; }
2601 - .pending-key-hint kbd {
2602 - background: var(--surface-overlay);
2603 - border: var(--border-width) solid var(--border);
2604 - border-radius: var(--radius-xs);
2605 - padding: var(--step-hair) var(--step-snug);
2606 - font-family: inherit;
2607 - font-size: var(--font-size-sm);
2608 - }
2609 -
2610 2582 /* Keyboard shortcuts overlay */
2611 2583 .shortcuts-overlay {
2612 2584 position: fixed;
@@ -9688,3 +9660,65 @@
9688 9660 }
9689 9661 }
9690 9662 }
9663 +
9664 + @layer components {
9665 + /* 70. Biometric App Lock */
9666 +
9667 + /* Hidden unless <html> carries .app-locked, which the pre-paint bootstrap sets
9668 + from the localStorage mirror and js/app-lock.js corrects once it has read the
9669 + real preference. `display: none` rather than an opacity fade: this covers
9670 + content the user asked not to be visible, and a transition is a window into
9671 + it. */
9672 + .app-lock {
9673 + display: none;
9674 + }
9675 +
9676 + .app-locked .app-lock {
9677 + position: fixed;
9678 + inset: 0;
9679 + /* Above every other layer in the app, including the action sheet at 10001
9680 + and the command palette at 5000. Nothing is allowed on top of this. */
9681 + z-index: 100000;
9682 + display: flex;
9683 + align-items: center;
9684 + justify-content: center;
9685 + /* Opaque, not the translucent --overlay the modals use. A blurred or dimmed
9686 + task list is still a readable task list at arm's length. */
9687 + background-color: var(--surface);
9688 + padding: var(--gap-section);
9689 + }
9690 +
9691 + /* The content behind is covered, but a screen reader walks the DOM rather than
9692 + the screen. Hiding the rest from the accessibility tree is what makes the
9693 + dialog modal in fact and not only in ARIA. */
9694 + .app-locked body > *:not(.app-lock) {
9695 + visibility: hidden;
9696 + }
9697 +
9698 + .app-lock-panel {
9699 + display: flex;
9700 + flex-direction: column;
9701 + align-items: center;
9702 + gap: var(--gap-bound);
9703 + text-align: center;
9704 + max-width: 32ch;
9705 + }
9706 +
9707 + .app-lock-title {
9708 + font-size: var(--font-size-lg);
9709 + margin: 0;
9710 + }
9711 +
9712 + .app-lock-desc {
9713 + color: var(--content-muted);
9714 + margin: 0;
9715 + }
9716 +
9717 + /* Empty until a prompt fails, and it holds the row's height so the button does
9718 + not jump when the message appears. */
9719 + .app-lock-status {
9720 + color: var(--danger);
9721 + margin: 0;
9722 + min-height: 1.5em;
9723 + }
9724 + }
@@ -376,6 +376,26 @@
376 376 preferences: {
377 377 get: () => invoke('get_preferences'),
378 378 setUpdateCheckOnLaunch: (enabled) => invoke('set_update_check_on_launch', { enabled }),
379 + setRequireBiometricUnlock: (enabled) => invoke('set_require_biometric_unlock', { enabled }),
380 + },
381 +
382 + // Haptics: tauri-plugin-haptics, mobile only. Callers go through
383 + // GoingsOn.haptics rather than here -- these are the raw commands, and the
384 + // module above them owns which feedback a given moment gets and the
385 + // swallow-and-disable policy. Every one of them rejects on desktop, where
386 + // the plugin is not built into the binary at all.
387 + haptics: {
388 + impact: (style) => invoke('plugin:haptics|impact_feedback', { style }), // light | medium | heavy | soft | rigid
389 + notification: (type) => invoke('plugin:haptics|notification_feedback', { type }), // success | warning | error
390 + selection: () => invoke('plugin:haptics|selection_feedback'),
391 + },
392 +
393 + // Biometric: tauri-plugin-biometric, mobile only. `status` is the offer
394 + // test (is there enrolled biometry on this device); `authenticate` rejects
395 + // on failure or cancellation rather than resolving false.
396 + biometric: {
397 + status: () => invoke('plugin:biometric|status'),
398 + authenticate: (reason, options) => invoke('plugin:biometric|authenticate', { reason, ...(options || {}) }),
379 399 },
380 400
381 401 // Import: native CSV/TSV import (entity type auto-detected from headers)
@@ -31,6 +31,7 @@
31 31 let isDragging = false;
32 32 let isHorizontal = null;
33 33 let actions = null;
34 + let pastThreshold = false;
34 35
35 36 // Phase 7 Tier 3 #11, peek-label affordance. As the user drags past
36 37 // the threshold, surface the action that will fire ("Complete" /
@@ -55,9 +56,17 @@
55 56 const ratio = Math.min(1, Math.abs(currentX) / threshold);
56 57 const peek = row.querySelector(currentX >= 0 ? '.swipe-peek--right' : '.swipe-peek--left');
57 58 const otherPeek = row.querySelector(currentX >= 0 ? '.swipe-peek--left' : '.swipe-peek--right');
59 + const nowPast = Math.abs(currentX) >= threshold;
60 + // Paired with the peek label going ready, so the buzz and the word
61 + // arrive together rather than one describing a state the other has
62 + // already left.
63 + if (nowPast !== pastThreshold) {
64 + pastThreshold = nowPast;
65 + GoingsOn.haptics.select();
66 + }
58 67 if (peek) {
59 68 peek.style.opacity = String(ratio);
60 - peek.classList.toggle('swipe-peek--ready', Math.abs(currentX) >= threshold);
69 + peek.classList.toggle('swipe-peek--ready', nowPast);
61 70 }
62 71 if (otherPeek) {
63 72 otherPeek.style.opacity = '0';
@@ -82,6 +91,7 @@
82 91 currentX = 0;
83 92 isDragging = true;
84 93 isHorizontal = null;
94 + pastThreshold = false;
85 95 activeRow.style.transition = 'none';
86 96 attachPeekLabels(activeRow, actions);
87 97 }, { passive: true });
@@ -127,8 +137,10 @@
127 137
128 138 if (Math.abs(currentX) >= threshold) {
129 139 if (currentX < 0 && actions?.left?.action) {
140 + GoingsOn.haptics.commit();
130 141 actions.left.action();
131 142 } else if (currentX > 0 && actions?.right?.action) {
143 + GoingsOn.haptics.commit();
132 144 actions.right.action();
133 145 }
134 146 }
@@ -139,6 +151,7 @@
139 151 actions = null;
140 152 currentX = 0;
141 153 isHorizontal = null;
154 + pastThreshold = false;
142 155 }
143 156
144 157 container.addEventListener('touchend', onEnd, { passive: true });
@@ -177,6 +190,7 @@
177 190 ev.preventDefault();
178 191 ev.stopPropagation();
179 192 }, { once: true, capture: true });
193 + GoingsOn.haptics.commit();
180 194 onLongPress(activeRow);
181 195 }, DURATION);
182 196 }, { passive: true });
@@ -329,6 +329,22 @@
329 329 }
330 330 }
331 331
332 + /**
333 + * Enabling does not lock on the spot. The user is holding the device they
334 + * just unlocked to reach Settings, so prompting them again proves nothing;
335 + * the setting takes effect at the next launch or the next return from the
336 + * background, which is where it matters.
337 + */
338 + async function setRequireBiometricUnlock(enabled) {
339 + try {
340 + await GoingsOn.api.preferences.setRequireBiometricUnlock(enabled);
341 + GoingsOn.appLock.setMirror(enabled);
342 + GoingsOn.ui.showToast(enabled ? 'App lock enabled' : 'App lock disabled');
343 + } catch (err) {
344 + GoingsOn.ui.showToast('Failed to save preference: ' + GoingsOn.utils.getErrorMessage(err), 'error');
345 + }
346 + }
347 +
332 348 async function renderAbout(container) {
333 349 let appVersion = 'unknown';
334 350 try { appVersion = await window.__TAURI__.app.getVersion(); } catch (_) {}
@@ -336,11 +352,30 @@
336 352 const platform = (navigator.userAgentData?.platform || navigator.platform || 'unknown');
337 353
338 354 let updateCheckOnLaunch = true;
355 + let requireBiometricUnlock = false;
339 356 try {
340 357 const prefs = await GoingsOn.api.preferences.get();
341 358 updateCheckOnLaunch = prefs.updateCheckOnLaunch !== false;
359 + requireBiometricUnlock = prefs.requireBiometricUnlock === true;
342 360 } catch (_) {}
343 361
362 + // The row exists only where the OS can answer the prompt: mobile, with
363 + // biometry enrolled. Offering a toggle that cannot work is worse than
364 + // not offering one, because the user turns it on and nothing happens.
365 + const biometricAvailable = await GoingsOn.appLock.available();
366 + const appLockRow = biometricAvailable ? `
367 + <div class="settings-toggle-row">
368 + <div>
369 + <p class="settings-subheading">Require unlock</p>
370 + <p class="settings-desc">Hide the app behind Face ID, Touch ID or fingerprint on launch and when returning from the background. Your data is not encrypted by this.</p>
371 + </div>
372 + <label class="toggle-switch">
373 + <input type="checkbox" ${requireBiometricUnlock ? 'checked' : ''}
374 + data-change="settings.setRequireBiometricUnlock" data-a1="@checked">
375 + <span class="toggle-slider"></span>
376 + </label>
377 + </div>` : '';
378 +
344 379 // No physical keyboard, no reason to offer the shortcuts sheet.
345 380 const shortcutsButton = GoingsOn.touch?.isTouchDevice
346 381 ? ''
@@ -372,6 +407,7 @@
372 407 <span class="toggle-slider"></span>
373 408 </label>
374 409 </div>
410 + ${appLockRow}
375 411
376 412 <div class="settings-actions-row">
377 413 <button class="button button--secondary" data-act="settings.openGettingStarted">Getting Started</button>
@@ -435,6 +471,7 @@
435 471 onEventLeadTimeChange,
436 472 onWorkHoursChange,
437 473 setUpdateCheckOnLaunch,
474 + setRequireBiometricUnlock,
438 475 };
439 476
440 477 })();
@@ -38,6 +38,10 @@
38 38 timer = null;
39 39 // Prevent the subsequent click/tap
40 40 element.addEventListener('click', preventClick, { once: true, capture: true });
41 + // Before the callback, not after: a long press has no visual
42 + // build-up, so the buzz is the only thing telling the user the
43 + // hold was long enough, and it should not wait on the work.
44 + GoingsOn.haptics.commit();
41 45 callback(e);
42 46 }, duration);
43 47 }
@@ -100,6 +104,9 @@
100 104 let currentX = 0;
101 105 let isDragging = false;
102 106 let isHorizontal = null;
107 + // Edge-triggered, so dragging back and forth across the threshold ticks
108 + // on each crossing rather than on every touchmove past it.
109 + let pastThreshold = false;
103 110
104 111 function onTouchStart(e) {
105 112 const touch = e.touches[0];
@@ -108,6 +115,7 @@
108 115 currentX = 0;
109 116 isDragging = true;
110 117 isHorizontal = null;
118 + pastThreshold = false;
111 119 element.style.transition = 'none';
112 120 }
113 121
@@ -143,6 +151,12 @@
143 151 currentX = currentX > 0 ? Math.min(dampened, maxSwipe) : Math.max(-dampened, -maxSwipe);
144 152 }
145 153
154 + const nowPast = Math.abs(currentX) >= threshold;
155 + if (nowPast !== pastThreshold) {
156 + pastThreshold = nowPast;
157 + GoingsOn.haptics.select();
158 + }
159 +
146 160 element.style.transform = `translateX(${currentX}px)`;
147 161 }
148 162
@@ -153,8 +167,10 @@
153 167 if (Math.abs(currentX) >= threshold) {
154 168 // Fire action
155 169 if (currentX < 0 && config.left?.action) {
170 + GoingsOn.haptics.commit();
156 171 config.left.action();
157 172 } else if (currentX > 0 && config.right?.action) {
173 + GoingsOn.haptics.commit();
158 174 config.right.action();
159 175 }
160 176 }
@@ -163,6 +179,7 @@
163 179 element.style.transform = 'translateX(0)';
164 180 currentX = 0;
165 181 isHorizontal = null;
182 + pastThreshold = false;
166 183 }
167 184
168 185 element.addEventListener('touchstart', onTouchStart, { passive: true });
@@ -193,6 +210,9 @@
193 210 let startY = 0;
194 211 let pulling = false;
195 212 let indicator = null;
213 + // Same edge-trigger as the swipe threshold: the tick marks entering the
214 + // release zone, not being in it.
215 + let pastThreshold = false;
196 216
197 217 function getOrCreateIndicator() {
198 218 if (!indicator) {
@@ -208,6 +228,7 @@
208 228 if (container.scrollTop > 0) return;
209 229 startY = e.touches[0].clientY;
210 230 pulling = true;
231 + pastThreshold = false;
211 232 }
212 233
213 234 function onTouchMove(e) {
@@ -228,11 +249,18 @@
228 249 ind.textContent = 'Pull to refresh...';
229 250 ind.classList.add('visible');
230 251 }
252 +
253 + const nowPast = dy > pullThreshold;
254 + if (nowPast !== pastThreshold) {
255 + pastThreshold = nowPast;
256 + GoingsOn.haptics.select();
257 + }
231 258 }
232 259
233 260 async function onTouchEnd(e) {
234 261 if (!pulling) return;
235 262 pulling = false;
263 + pastThreshold = false;
236 264
237 265 const dy = e.changedTouches[0].clientY - startY;
238 266 if (indicator) {
@@ -244,8 +272,16 @@
244 272 indicator.textContent = 'Refreshing...';
245 273 indicator.classList.add('visible');
246 274 }
275 + GoingsOn.haptics.commit();
247 276 try {
248 277 await callback();
278 + // The one place an outcome haptic earns its keep: a refresh
279 + // that finds nothing new leaves the list looking untouched,
280 + // so without this the gesture has no completion at all.
281 + GoingsOn.haptics.success();
282 + } catch (err) {
283 + GoingsOn.haptics.failure();
284 + throw err;
249 285 } finally {
250 286 if (indicator) {
251 287 indicator.classList.remove('visible');
@@ -351,6 +387,7 @@
351 387
352 388 if (currentY >= threshold) {
353 389 element.style.transform = `translateY(100%)`;
390 + GoingsOn.haptics.commit();
354 391 onDismiss();
355 392 } else {
356 393 element.style.transform = 'translateY(0)';
@@ -1,1 +1,1 @@
1 - {"default":{"identifier":"default","description":"Default capabilities for the main window","local":true,"windows":["main","compose-*"],"permissions":["core:default","core:window:allow-create","core:window:allow-close","core:window:allow-center","dialog:default"]},"desktop":{"identifier":"desktop","description":"Desktop-only capabilities (notifications, OTA updater). External URLs are opened via the validated open_external_url command, not a JS opener binding.","local":true,"windows":["main","compose-*"],"permissions":["notification:default","updater:default","process:allow-restart"],"platforms":["linux","macOS","windows"]}}
1 + {"default":{"identifier":"default","description":"Default capabilities for the main window","local":true,"windows":["main","compose-*"],"permissions":["core:default","core:window:allow-create","core:window:allow-close","core:window:allow-center","dialog:default"]},"desktop":{"identifier":"desktop","description":"Desktop-only capabilities (notifications, OTA updater). External URLs are opened via the validated open_external_url command, not a JS opener binding.","local":true,"windows":["main","compose-*"],"permissions":["notification:default","updater:default","process:allow-restart"],"platforms":["linux","macOS","windows"]},"mobile":{"identifier":"mobile","description":"Mobile-only capabilities (haptic feedback, biometric app lock). Granted command by command rather than through the plugins' default sets: vibrate is not granted because nothing calls raw vibration, and a grant nothing uses is a grant nobody notices going wrong.","local":true,"windows":["main"],"permissions":["haptics:allow-impact-feedback","haptics:allow-notification-feedback","haptics:allow-selection-feedback","biometric:allow-status","biometric:allow-authenticate"],"platforms":["iOS","android"]}}