experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: shutdown hang, pagestream key interception, group border cleanup

- Destroy group screen border overlay on app quit (was preventing
Electron from exiting since the hidden BrowserWindow stayed alive)
- Fix j/k/g/G keys being swallowed in tags input and notes panel —
pagestream nav handler now checks for focused input elements
- Add cleanupGroupScreenBorder() called in before-quit handler
- Add 70 unit tests for pagestream key navigation guards
- Fix README typo

+285 -1
+1 -1
README.md
··· 115 115 What makes a home / kitchen / workshop 116 116 - Everything is right where you need it, b/c you control what is where 117 117 - When you know what is where, you can make things with less frustration 118 - - You generally know what’s happening - who’s around/coming/going, etc, not too much surpri 118 + - You generally know what’s happening - who’s around/coming/going, etc, not too much surprise 119 119 120 120 What makes magical mind-readingness 121 121 - Frecency everywhere all of the time
+6
app/page/page.js
··· 1226 1226 // Don't intercept if typing in navbar 1227 1227 if (document.activeElement === navbar || navbar.contains(document.activeElement)) return; 1228 1228 1229 + // Don't intercept if typing in any input, textarea, or contenteditable element 1230 + // (e.g., tags input in the widget container) 1231 + const active = document.activeElement; 1232 + if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) return; 1233 + if (active && widgetContainer && widgetContainer.contains(active)) return; 1234 + 1229 1235 const NAV_MAP = { 1230 1236 'j': 'down', 'ArrowDown': 'down', 1231 1237 'k': 'up', 'ArrowUp': 'up',
+2
backend/electron/entry.ts
··· 54 54 registerAllHandlers, 55 55 restoreSavedTheme, 56 56 reopenLastClosedWindow, 57 + cleanupGroupScreenBorder, 57 58 // Database 58 59 getDb, 59 60 // Config ··· 321 322 _beforeQuitCleanupDone = true; 322 323 DEBUG && console.log('[lifecycle] before-quit: running cleanup'); 323 324 try { stopAutosaveTimer(); } catch (e) { DEBUG && console.error('[lifecycle] stopAutosaveTimer error:', e); } 325 + try { cleanupGroupScreenBorder(); } catch (e) { DEBUG && console.error('[lifecycle] cleanupGroupScreenBorder error:', e); } 324 326 try { cleanupDisplayWatcher(); } catch (e) { DEBUG && console.error('[lifecycle] cleanupDisplayWatcher error:', e); } 325 327 try { saveSessionSnapshot('before-quit'); } catch (e) { DEBUG && console.error('[lifecycle] saveSessionSnapshot error:', e); } 326 328 try { cleanupDevExtensions(); } catch (e) { DEBUG && console.error('[lifecycle] cleanupDevExtensions error:', e); }
+1
backend/electron/index.ts
··· 200 200 getDarkModeSetting, 201 201 applyDarkModeSetting, 202 202 reopenLastClosedWindow, 203 + cleanupGroupScreenBorder, 203 204 } from './ipc.js'; 204 205 205 206 // Window helpers
+17
backend/electron/ipc.ts
··· 495 495 } 496 496 } 497 497 498 + /** 499 + * Destroy the group screen border overlay and clear any pending timers. 500 + * Called during app shutdown to ensure Electron can quit cleanly. 501 + */ 502 + export function cleanupGroupScreenBorder(): void { 503 + if (groupBorderHideTimer) { 504 + clearTimeout(groupBorderHideTimer); 505 + groupBorderHideTimer = null; 506 + } 507 + if (groupScreenBorderWin && !groupScreenBorderWin.isDestroyed()) { 508 + groupScreenBorderWin.destroy(); 509 + DEBUG && console.log('[group-screen-border] Destroyed during shutdown'); 510 + } 511 + groupScreenBorderWin = null; 512 + groupScreenBorderColor = null; 513 + } 514 + 498 515 // Debounce timer for hiding the screen border — prevents flicker during 499 516 // navigation transitions where group mode is briefly absent (old window's 500 517 // context cleared before new window's context is set).
+258
backend/electron/page-navigation.test.ts
··· 1 + /** 2 + * Unit tests for pagestream keyboard navigation guard logic 3 + * 4 + * Tests extracted pure function from app/page/page.js: 5 + * - shouldHandleNavKey: determines whether j/k/g/G/Arrow/Home/End keys 6 + * should be intercepted for pagestream navigation, or passed through 7 + * when the user is typing in an input field, navbar, or widget container. 8 + * 9 + * These are logic-only tests -- no DOM, no Electron, no IPC runtime. 10 + */ 11 + 12 + import { describe, it } from 'node:test'; 13 + import * as assert from 'node:assert'; 14 + 15 + // ============================================================================ 16 + // shouldHandleNavKey (extracted from app/page/page.js keydown listener) 17 + // ============================================================================ 18 + 19 + const NAV_MAP: Record<string, string> = { 20 + 'j': 'down', 'ArrowDown': 'down', 21 + 'k': 'up', 'ArrowUp': 'up', 22 + 'g': 'first', 'Home': 'first', 23 + 'G': 'last', 'End': 'last', 24 + }; 25 + 26 + interface NavKeyContext { 27 + activeElement: { tagName: string; isContentEditable: boolean } | null; 28 + isInNavbar: boolean; 29 + isInWidgetContainer: boolean; 30 + } 31 + 32 + function shouldHandleNavKey(key: string, context: NavKeyContext): { handled: boolean; action?: string } { 33 + // Don't intercept if typing in navbar 34 + if (context.isInNavbar) return { handled: false }; 35 + 36 + // Don't intercept if typing in any input, textarea, or contenteditable element 37 + const active = context.activeElement; 38 + if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) { 39 + return { handled: false }; 40 + } 41 + if (active && context.isInWidgetContainer) return { handled: false }; 42 + 43 + const action = NAV_MAP[key]; 44 + if (action) { 45 + return { handled: true, action }; 46 + } 47 + 48 + return { handled: false }; 49 + } 50 + 51 + // ============================================================================ 52 + // Tests 53 + // ============================================================================ 54 + 55 + const defaultContext: NavKeyContext = { 56 + activeElement: { tagName: 'DIV', isContentEditable: false }, 57 + isInNavbar: false, 58 + isInWidgetContainer: false, 59 + }; 60 + 61 + const nullActiveContext: NavKeyContext = { 62 + activeElement: null, 63 + isInNavbar: false, 64 + isInWidgetContainer: false, 65 + }; 66 + 67 + describe('shouldHandleNavKey — basic key mapping', () => { 68 + it('k maps to up when no input is focused', () => { 69 + const result = shouldHandleNavKey('k', defaultContext); 70 + assert.strictEqual(result.handled, true); 71 + assert.strictEqual(result.action, 'up'); 72 + }); 73 + 74 + it('j maps to down when no input is focused', () => { 75 + const result = shouldHandleNavKey('j', defaultContext); 76 + assert.strictEqual(result.handled, true); 77 + assert.strictEqual(result.action, 'down'); 78 + }); 79 + 80 + it('g maps to first', () => { 81 + const result = shouldHandleNavKey('g', defaultContext); 82 + assert.strictEqual(result.handled, true); 83 + assert.strictEqual(result.action, 'first'); 84 + }); 85 + 86 + it('G maps to last', () => { 87 + const result = shouldHandleNavKey('G', defaultContext); 88 + assert.strictEqual(result.handled, true); 89 + assert.strictEqual(result.action, 'last'); 90 + }); 91 + 92 + it('ArrowDown maps to down', () => { 93 + const result = shouldHandleNavKey('ArrowDown', defaultContext); 94 + assert.strictEqual(result.handled, true); 95 + assert.strictEqual(result.action, 'down'); 96 + }); 97 + 98 + it('ArrowUp maps to up', () => { 99 + const result = shouldHandleNavKey('ArrowUp', defaultContext); 100 + assert.strictEqual(result.handled, true); 101 + assert.strictEqual(result.action, 'up'); 102 + }); 103 + 104 + it('Home maps to first', () => { 105 + const result = shouldHandleNavKey('Home', defaultContext); 106 + assert.strictEqual(result.handled, true); 107 + assert.strictEqual(result.action, 'first'); 108 + }); 109 + 110 + it('End maps to last', () => { 111 + const result = shouldHandleNavKey('End', defaultContext); 112 + assert.strictEqual(result.handled, true); 113 + assert.strictEqual(result.action, 'last'); 114 + }); 115 + 116 + it('works when activeElement is null', () => { 117 + const result = shouldHandleNavKey('j', nullActiveContext); 118 + assert.strictEqual(result.handled, true); 119 + assert.strictEqual(result.action, 'down'); 120 + }); 121 + }); 122 + 123 + describe('shouldHandleNavKey — non-nav keys are never handled', () => { 124 + for (const key of ['a', 'b', 'Enter', 'Escape', ' ', 'Tab', '1', 'Shift']) { 125 + it(`"${key}" is not handled`, () => { 126 + const result = shouldHandleNavKey(key, defaultContext); 127 + assert.strictEqual(result.handled, false); 128 + assert.strictEqual(result.action, undefined); 129 + }); 130 + } 131 + }); 132 + 133 + describe('shouldHandleNavKey — INPUT guard', () => { 134 + const inputContext: NavKeyContext = { 135 + activeElement: { tagName: 'INPUT', isContentEditable: false }, 136 + isInNavbar: false, 137 + isInWidgetContainer: false, 138 + }; 139 + 140 + it('k is NOT handled when activeElement is INPUT', () => { 141 + assert.strictEqual(shouldHandleNavKey('k', inputContext).handled, false); 142 + }); 143 + 144 + it('j is NOT handled when activeElement is INPUT', () => { 145 + assert.strictEqual(shouldHandleNavKey('j', inputContext).handled, false); 146 + }); 147 + 148 + it('ArrowDown is NOT handled when activeElement is INPUT', () => { 149 + assert.strictEqual(shouldHandleNavKey('ArrowDown', inputContext).handled, false); 150 + }); 151 + }); 152 + 153 + describe('shouldHandleNavKey — TEXTAREA guard', () => { 154 + const textareaContext: NavKeyContext = { 155 + activeElement: { tagName: 'TEXTAREA', isContentEditable: false }, 156 + isInNavbar: false, 157 + isInWidgetContainer: false, 158 + }; 159 + 160 + it('j is NOT handled when activeElement is TEXTAREA', () => { 161 + assert.strictEqual(shouldHandleNavKey('j', textareaContext).handled, false); 162 + }); 163 + 164 + it('G is NOT handled when activeElement is TEXTAREA', () => { 165 + assert.strictEqual(shouldHandleNavKey('G', textareaContext).handled, false); 166 + }); 167 + }); 168 + 169 + describe('shouldHandleNavKey — contentEditable guard', () => { 170 + const editableContext: NavKeyContext = { 171 + activeElement: { tagName: 'DIV', isContentEditable: true }, 172 + isInNavbar: false, 173 + isInWidgetContainer: false, 174 + }; 175 + 176 + it('g is NOT handled when activeElement is contentEditable', () => { 177 + assert.strictEqual(shouldHandleNavKey('g', editableContext).handled, false); 178 + }); 179 + 180 + it('k is NOT handled when activeElement is contentEditable', () => { 181 + assert.strictEqual(shouldHandleNavKey('k', editableContext).handled, false); 182 + }); 183 + }); 184 + 185 + describe('shouldHandleNavKey — navbar guard', () => { 186 + const navbarContext: NavKeyContext = { 187 + activeElement: { tagName: 'DIV', isContentEditable: false }, 188 + isInNavbar: true, 189 + isInWidgetContainer: false, 190 + }; 191 + 192 + it('j is NOT handled when focus is in navbar', () => { 193 + assert.strictEqual(shouldHandleNavKey('j', navbarContext).handled, false); 194 + }); 195 + 196 + it('ArrowUp is NOT handled when focus is in navbar', () => { 197 + assert.strictEqual(shouldHandleNavKey('ArrowUp', navbarContext).handled, false); 198 + }); 199 + 200 + it('G is NOT handled when focus is in navbar', () => { 201 + assert.strictEqual(shouldHandleNavKey('G', navbarContext).handled, false); 202 + }); 203 + }); 204 + 205 + describe('shouldHandleNavKey — widget container guard', () => { 206 + const widgetContext: NavKeyContext = { 207 + activeElement: { tagName: 'SPAN', isContentEditable: false }, 208 + isInNavbar: false, 209 + isInWidgetContainer: true, 210 + }; 211 + 212 + it('j is NOT handled when focus is in widget container', () => { 213 + assert.strictEqual(shouldHandleNavKey('j', widgetContext).handled, false); 214 + }); 215 + 216 + it('k is NOT handled when focus is in widget container', () => { 217 + assert.strictEqual(shouldHandleNavKey('k', widgetContext).handled, false); 218 + }); 219 + 220 + it('Home is NOT handled when focus is in widget container', () => { 221 + assert.strictEqual(shouldHandleNavKey('Home', widgetContext).handled, false); 222 + }); 223 + }); 224 + 225 + describe('shouldHandleNavKey — all nav keys pass through in input contexts', () => { 226 + const navKeys = ['j', 'k', 'g', 'G', 'ArrowDown', 'ArrowUp', 'Home', 'End']; 227 + 228 + const inputContexts: { name: string; ctx: NavKeyContext }[] = [ 229 + { 230 + name: 'INPUT element', 231 + ctx: { activeElement: { tagName: 'INPUT', isContentEditable: false }, isInNavbar: false, isInWidgetContainer: false }, 232 + }, 233 + { 234 + name: 'TEXTAREA element', 235 + ctx: { activeElement: { tagName: 'TEXTAREA', isContentEditable: false }, isInNavbar: false, isInWidgetContainer: false }, 236 + }, 237 + { 238 + name: 'contentEditable element', 239 + ctx: { activeElement: { tagName: 'DIV', isContentEditable: true }, isInNavbar: false, isInWidgetContainer: false }, 240 + }, 241 + { 242 + name: 'navbar', 243 + ctx: { activeElement: { tagName: 'DIV', isContentEditable: false }, isInNavbar: true, isInWidgetContainer: false }, 244 + }, 245 + { 246 + name: 'widget container', 247 + ctx: { activeElement: { tagName: 'DIV', isContentEditable: false }, isInNavbar: false, isInWidgetContainer: true }, 248 + }, 249 + ]; 250 + 251 + for (const { name, ctx } of inputContexts) { 252 + for (const key of navKeys) { 253 + it(`"${key}" passes through when in ${name}`, () => { 254 + assert.strictEqual(shouldHandleNavKey(key, ctx).handled, false); 255 + }); 256 + } 257 + } 258 + });