experiments in a post-browser web
10
fork

Configure Feed

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

refactor: rename extension_settings to feature_settings (Phase 1a)

+457
+85
backend/electron/page-loading.test.ts
··· 8 8 // When compiled, __dirname is dist/backend/electron/ — go up 3 levels to project root 9 9 const pageHtml = readFileSync(join(__dirname, '..', '..', '..', 'app', 'page', 'index.html'), 'utf-8'); 10 10 11 + // --- Extracted decision logic from page.js did-fail-load handler --- 12 + 13 + interface FailLoadContext { 14 + isMainFrame: boolean; 15 + errorCode: number; 16 + hasEverLoaded: boolean; 17 + } 18 + 19 + /** 20 + * Determines whether the loading indicator should be cleared on a did-fail-load event. 21 + * 22 + * Key behaviors: 23 + * - Sub-frame failures are always ignored (isMainFrame guard) 24 + * - Error -3 (ABORTED) always clears loading regardless of hasEverLoaded 25 + * (previously the bug: error -3 with hasEverLoaded=true left indicator stuck) 26 + * - All other main-frame errors clear loading 27 + */ 28 + export function shouldClearLoadingOnFail(context: FailLoadContext): { clearLoading: boolean; reason?: string } { 29 + if (!context.isMainFrame) { 30 + return { clearLoading: false, reason: 'sub-frame failure ignored' }; 31 + } 32 + 33 + if (context.errorCode === -3) { 34 + return { clearLoading: true, reason: 'aborted navigation — always clear' }; 35 + } 36 + 37 + return { clearLoading: true, reason: 'main frame load error' }; 38 + } 39 + 11 40 describe('Page loading CSS', () => { 12 41 it('webview.loading should have a dark mode background', () => { 13 42 // The loading state must not hardcode a bright white background. ··· 86 115 ); 87 116 }); 88 117 }); 118 + 119 + describe('shouldClearLoadingOnFail — aborted navigation fix', () => { 120 + it('main frame error -3 (ABORTED) should clear loading regardless of hasEverLoaded', () => { 121 + const result = shouldClearLoadingOnFail({ isMainFrame: true, errorCode: -3, hasEverLoaded: false }); 122 + assert.equal(result.clearLoading, true); 123 + }); 124 + 125 + it('main frame error -3 with hasEverLoaded=true should clear (this was the bug)', () => { 126 + // Previously, error -3 with hasEverLoaded=true skipped stopLoading(), 127 + // leaving the loading indicator permanently stuck. 128 + const result = shouldClearLoadingOnFail({ isMainFrame: true, errorCode: -3, hasEverLoaded: true }); 129 + assert.equal(result.clearLoading, true, 'error -3 must clear loading even when hasEverLoaded is true'); 130 + }); 131 + 132 + it('main frame error -3 with hasEverLoaded=false should clear', () => { 133 + const result = shouldClearLoadingOnFail({ isMainFrame: true, errorCode: -3, hasEverLoaded: false }); 134 + assert.equal(result.clearLoading, true); 135 + }); 136 + 137 + it('sub-frame error -3 should NOT clear loading (isMainFrame guard)', () => { 138 + const result = shouldClearLoadingOnFail({ isMainFrame: false, errorCode: -3, hasEverLoaded: false }); 139 + assert.equal(result.clearLoading, false, 'sub-frame failures must be ignored'); 140 + assert.ok(result.reason?.includes('sub-frame')); 141 + }); 142 + 143 + it('sub-frame any error should NOT clear loading', () => { 144 + const result = shouldClearLoadingOnFail({ isMainFrame: false, errorCode: -6, hasEverLoaded: false }); 145 + assert.equal(result.clearLoading, false); 146 + 147 + const result2 = shouldClearLoadingOnFail({ isMainFrame: false, errorCode: -102, hasEverLoaded: true }); 148 + assert.equal(result2.clearLoading, false); 149 + }); 150 + 151 + it('main frame other error (e.g., -6 CONNECTION_REFUSED) should clear loading', () => { 152 + const result = shouldClearLoadingOnFail({ isMainFrame: true, errorCode: -6, hasEverLoaded: false }); 153 + assert.equal(result.clearLoading, true); 154 + }); 155 + 156 + it('main frame error -3 then did-start-navigation: loading restarts (not stuck)', () => { 157 + // Simulate: error -3 clears loading, then a new navigation starts. 158 + // This verifies the design: stopLoading() on -3 is safe because 159 + // did-start-navigation will call startLoading() for the new nav. 160 + let isLoading = true; 161 + 162 + // did-fail-load with -3 163 + const failResult = shouldClearLoadingOnFail({ isMainFrame: true, errorCode: -3, hasEverLoaded: true }); 164 + if (failResult.clearLoading) { 165 + isLoading = false; // stopLoading() 166 + } 167 + assert.equal(isLoading, false, 'loading should be cleared after error -3'); 168 + 169 + // did-start-navigation fires for the new navigation 170 + isLoading = true; // startLoading() 171 + assert.equal(isLoading, true, 'loading should restart on new navigation'); 172 + }); 173 + });
+372
features/spaces/DESIGN.md
··· 1 + # Spaces Feature Design 2 + 3 + ## Overview 4 + 5 + Split the current groups feature into two separate features: 6 + - **Groups**: simplified tag browser (tags == groups, no promote/demote) 7 + - **Spaces**: full workspace experience (mode context, border, auto-tagging, session management) 8 + 9 + ## Status: DRAFT — iterating on design 10 + 11 + --- 12 + 13 + ## Core Directive: Lean Backend 14 + 15 + The backend must contain NO space-specific logic. All space behavior lives in the feature layer (`features/spaces/`). The backend provides only generic primitives: context API, pubsub, datastore, window management. 16 + 17 + --- 18 + 19 + ## Data Model: Machine Tags 20 + 21 + ### Space identity 22 + 23 + A space is identified by a tag with a `space:` prefix (machine tag): 24 + - `space:research`, `space:work`, `space:side-project` 25 + 26 + No new tables. No separate registry. Tags are the building block. The `isGroup` metadata flag is removed entirely. 27 + 28 + ### Space membership 29 + 30 + All contents of a space are tagged with the space's machine tag. A URL tagged with `space:research` belongs to the research space. An item can belong to multiple spaces. 31 + 32 + Users can manually add/remove the tag on any platform (e.g., tag on mobile, see in space on desktop). 33 + 34 + ### Space metadata 35 + 36 + Stored in the tag's `metadata` JSON field: 37 + ```json 38 + { 39 + "color": "#ff6b6b", 40 + "displayName": "Research", 41 + "description": "Papers and references" 42 + } 43 + ``` 44 + 45 + `displayName` is the human-friendly name. If absent, strip the `space:` prefix for display. 46 + 47 + ### Content types 48 + 49 + Spaces can contain any addressable content: 50 + - URLs (web pages) 51 + - Notes 52 + - `peek://` addresses (search views for tag combos, editor, etc.) 53 + - Images/videos (future) 54 + 55 + ### Groups vs Spaces 56 + 57 + | Aspect | Regular tag (group) | Space tag (`space:*`) | 58 + |--------|--------------------|-----------------------| 59 + | Prefix | None | `space:` | 60 + | Browsing | Groups UI shows items | Groups UI shows items (same) | 61 + | Workspace mode | No | Yes — mode indicator, auto-tagging, screen border | 62 + | Auto-tagging | No | New items created while in space get tagged | 63 + | Load behavior | N/A | Load-on-access — items open when user clicks them | 64 + | Session management | No | Close/restore hides/shows open windows | 65 + | Content types | URLs | URLs, notes, peek:// addresses, media (future) | 66 + 67 + --- 68 + 69 + ## Groups Feature (simplified: tags == groups) 70 + 71 + Every tag is a group. Every group is a tag. 72 + 73 + ### Changes 74 + - Remove `isGroupTag()`, `promoteTagToGroup()`, promoted/unpromoted split 75 + - `renderGroups()` shows ALL tags (plus untagged pseudo-group) 76 + - Remove promote buttons, "All Tags" toggle section 77 + - Remove all workspace commands and mode context calls 78 + - `open group <name>` navigates groups UI to detail view (no workspace activation) 79 + - Groups is purely a data browser — no mode context 80 + 81 + ### Files: 4 modified 82 + - `features/groups/home.js` — remove promote/demote, merge all tags, strip mode calls 83 + - `features/groups/background.js` — remove workspace logic, keep tag CRUD and noun 84 + - `features/groups/manifest.json` — remove workspace commands 85 + - `features/groups/home.css` — remove promote/unpromoted styles 86 + 87 + --- 88 + 89 + ## Spaces Feature (workspace) 90 + 91 + ### Mental model 92 + "I enter a space to focus on a project. Everything I do is part of that space until I leave. Items load when I access them." 93 + 94 + ### Load-on-access pattern 95 + `open space` does NOT open all tagged items as windows. It: 96 + 1. Activates space mode (border, auto-tagging context) 97 + 2. Opens the spaces home UI (`peek://ext/spaces/home.html`) showing the space's items 98 + 3. Items open as windows when the user clicks them, in space mode 99 + 100 + This means a space with 50 items doesn't spawn 50 windows. The user browses and opens what they need. 101 + 102 + ### Commands 103 + | Command | What it does | 104 + |---------|-------------| 105 + | `open space <name>` | Restores the space as you left it (windows, positions). If never opened before, shows spaces home UI. Activates space mode. | 106 + | `close space` | Saves current window state (which windows, positions), deactivates space mode | 107 + | `switch space <name>` | Closes current space, opens target | 108 + 109 + ### Spaces home UI 110 + Spaces has its own home UI (`features/spaces/home.html`), initially forked from groups home. This will diverge quickly — spaces UI will gain workspace-specific affordances (space settings, member management, layout controls) that don't belong in the simple groups tag browser. 111 + 112 + ### Page host space widget 113 + The group-mode widget in `app/page/page.js` becomes the **space widget**. Reacts to `currentMode === 'space'`. Shows space name, color dot, ESC-to-close behavior. 114 + 115 + --- 116 + 117 + ## What Lives Where 118 + 119 + ### Feature layer (`features/spaces/background.js`) — ALL space logic 120 + 121 + **Auto-tagging**: Subscribe to `item:created` (enriched with `windowId`). Check originating window's mode via `api.context.get('mode', windowId)`. If mode is `'space'`, tag item with space tag. 122 + 123 + **Screen border**: Feature-owned overlay window via `api.window.open()` with special options. Subscribes to `window:focused`, `window:closed`, `context:changed`, `app:focus-changed`. Owns debounce logic, color resolution, label rendering. 124 + 125 + **Tag removal reset**: Subscribe to `tag:item-removed`. If removed tag matches active space, reset window mode to `'page'`. 126 + 127 + **Session management**: `close space` hides windows + saves positions. `restore space` re-shows them. No "open everything" pattern. 128 + 129 + ### Backend — ONLY generic primitives 130 + 131 + - **Context API**: `setMode`, `getContextEntry`, `getWindowsMatchingContext`. No space awareness. 132 + - **Mode inheritance**: Generic — inherits opener's mode if `metadata.inherit !== false`. Not space-specific. 133 + - **`item:created` with `windowId`**: Generic enrichment, 6 call sites. 134 + - **Window primitives**: `setIgnoreMouseEvents()`, `setVisibleOnAllWorkspaces()`, `getPrimaryDisplay()`. 135 + - **`detectModeFromUrl`**: Just `http(s) → 'page'`, else `'default'`. 136 + 137 + ### Removed from backend (~250 lines from ipc.ts) 138 + - `autoTagIfGroupMode()` and all 6 inline auto-tag call sites 139 + - All screen border functions and variables 140 + - `VIVID_GROUP_COLORS`, `resolveGroupBorderColor` 141 + - Border-related pubsub subscriptions in `registerModesHandlers()` 142 + - `tag:item-removed` mode reset subscription 143 + - `cleanupGroupScreenBorder()` from quit handler 144 + 145 + --- 146 + 147 + ## Naming Renames 148 + 149 + ### 1. `extension_settings` → `feature_settings` 150 + 151 + SQL migration: `ALTER TABLE extension_settings RENAME TO feature_settings`, rename `extensionId` → `featureId`. 152 + 153 + ~15 files. The `api.settings.*` methods (`get`, `set`, `getKey`) don't contain "extension" so 30+ feature files need no changes — only IPC transport layer and SQL. 154 + 155 + ### 2. Backend mode: `group` → `space` 156 + 157 + ~10 files, ~200 mechanical changes. Mode value, metadata fields (`spaceId`, `spaceName`), IPC names, preload methods, page host widget CSS, test file rename. 158 + 159 + ### 3. Storage keys 160 + - `group-workspaces` → `spaces` 161 + 162 + --- 163 + 164 + ## Implementation Phases 165 + 166 + ### Phase 1: Naming renames (foundation) 167 + - 1a: `extension_settings` → `feature_settings` (~15 files + SQL migration) 168 + - 1b: Backend `group` → `space` (~10 files, ~200 changes) 169 + - 1c: Storage key renames (~4 files) 170 + 171 + ### Phase 2: Backend generic enrichments 172 + - Add `windowId` to `item:created` payloads (6 sites) 173 + - Expose `setIgnoreMouseEvents`, `setVisibleOnAllWorkspaces`, `getPrimaryDisplay` in preload 174 + - Generalize mode inheritance to use `inherit` metadata flag 175 + 176 + ### Phase 3a: Create spaces feature (parallel with 3b) 177 + - `features/spaces/` — manifest, config, background, home.html, home.js, home.css 178 + - Home UI forked from groups (will diverge) 179 + - Machine tag resolution (`space:` prefix) 180 + - Auto-tagging via `item:created` subscription 181 + - Screen border as feature-owned window 182 + - Session management (close/restore window positions) 183 + 184 + ### Phase 3b: Strip backend space logic (parallel with 3a) 185 + - Remove ~250 lines from `ipc.ts` 186 + - Remove quit handler cleanup 187 + - Simplify `detectModeFromUrl` 188 + 189 + ### Phase 4: Simplify groups (parallel with 3) 190 + - Tags == groups, remove promote/demote 191 + - Strip mode context from home.js 192 + - ~4 files 193 + 194 + ### Phase 5: Integration 195 + - "Open as space" action on group cards (creates `space:` tag, opens spaces home) 196 + - Page host widget rename (CSS classes) 197 + - Test updates 198 + 199 + --- 200 + 201 + ## Open Questions 202 + 203 + - Mode inheritance opt-in (`inherit: true`) vs opt-out? Recommend opt-in. 204 + - ESC behavior when space tag removed from page? 205 + - Multi-monitor border? (future) 206 + - Space extensibility/theming/personalization APIs? (future) 207 + - How should tag combos auto-import into spaces? (future) 208 + - Search views as space members (e.g., "all items tagged X+Y") — what's the item representation? 209 + 210 + --- 211 + 212 + ## Specification 213 + 214 + Testable requirements for the Spaces feature. Each item follows the form WHEN [trigger], THEN [expected result]. 215 + 216 + The mode value is `'space'` (replacing `'group'`). Metadata fields use `spaceId` and `spaceName`. 217 + 218 + ### 1. Space Identity and Data Model 219 + 220 + **1.1** WHEN a tag is created with name `space:research`, THEN it is discoverable by querying tags with the `space:` prefix. 221 + 222 + **1.2** WHEN a space tag has metadata `{"displayName": "Work"}`, THEN display name resolves to `"Work"`. 223 + 224 + **1.3** WHEN a space tag has no `displayName` in metadata, THEN display name is derived by stripping the `space:` prefix. 225 + 226 + **1.4** WHEN an item is tagged with `space:research`, THEN it is a member of the research space. 227 + 228 + **1.5** WHEN an item is tagged with both `space:research` and `space:work`, THEN it belongs to both spaces. 229 + 230 + **1.6** WHEN querying for spaces, THEN only tags starting with exactly `space:` match (not `spaceship` or `myspace:foo`). 231 + 232 + ### 2. `open space` Command 233 + 234 + **2.1** WHEN `open space <name>` is executed with no saved state, THEN the spaces home UI opens showing the space's tagged items. 235 + 236 + **2.2** WHEN `open space <name>` is executed with saved state (URLs + positions), THEN the saved windows are restored at their saved positions. 237 + 238 + **2.3** WHEN windows are restored by `open space`, THEN each has `context.mode = 'space'` with `spaceId` and `spaceName`. 239 + 240 + **2.4** WHEN `open space` executes, THEN the screen border becomes visible with the space's color and name. 241 + 242 + **2.5** WHEN `open space` executes, THEN auto-tagging activates for space-mode windows. 243 + 244 + **2.6** WHEN `open space` executes for a space with 50 tagged items and no saved state, THEN exactly one window opens (spaces home UI), not 50. 245 + 246 + ### 3. `close space` Command 247 + 248 + **3.1** WHEN `close space` is executed, THEN current window state (URLs, positions, sizes) is saved. 249 + 250 + **3.2** WHEN `close space` is executed, THEN all space-mode windows for the active space are closed. 251 + 252 + **3.3** WHEN `close space` is executed, THEN space mode is deactivated on all affected windows. 253 + 254 + **3.4** WHEN `close space` is executed, THEN the screen border is hidden. 255 + 256 + **3.5** WHEN `close space` is executed, THEN the spaces home window is also closed. 257 + 258 + ### 4. `switch space` Command 259 + 260 + **4.1** WHEN `switch space <target>` is executed, THEN the current space is closed (equivalent to `close space`). 261 + 262 + **4.2** WHEN `switch space <target>` is executed, THEN the target space is opened (equivalent to `open space <target>`). 263 + 264 + **4.3** WHEN `switch space` completes, THEN the border color and label reflect the target space. 265 + 266 + ### 5. Auto-tagging 267 + 268 + **5.1** WHEN a new item is created from a space-mode window, THEN it is tagged with the space's tag. 269 + 270 + **5.2** WHEN a new item is created from a non-space window, THEN it is NOT tagged, even if space windows exist elsewhere. 271 + 272 + **5.3** WHEN a new item is created from the cmd palette, THEN it is NOT auto-tagged (cmd palette has no space mode context). 273 + 274 + **5.4** WHEN a new item is created from an external app, THEN it is NOT auto-tagged. 275 + 276 + **5.5** WHEN auto-tagging fires, THEN a `tag:item-added` event is published for reactive UI updates. 277 + 278 + ### 6. Screen Border 279 + 280 + **6.1** WHEN focused window has `context.mode = 'space'`, THEN border is visible with space color and name. 281 + 282 + **6.2** WHEN focused window has mode `'page'` or `'default'`, THEN border is hidden (debounced). 283 + 284 + **6.3** WHEN no window is focused, THEN border is hidden. 285 + 286 + **6.4** WHEN focused window is destroyed, THEN border is hidden. 287 + 288 + **6.5** WHEN app loses OS focus, THEN border is hidden. 289 + 290 + **6.6** WHEN app regains OS focus, THEN border state is re-evaluated from focused window's mode. 291 + 292 + **6.7** WHEN a modal window (cmd palette) gains focus while border was visible, THEN border remains visible (modals are transparent). 293 + 294 + **6.8** WHEN a modal window gains focus while border was hidden, THEN border remains hidden. 295 + 296 + **6.9** WHEN border would hide, THEN a 600ms debounce applies; cancelled if a space-mode window regains focus. 297 + 298 + **6.10** WHEN border would show, THEN it appears immediately (no delay). 299 + 300 + **6.11** WHEN focus switches from space A to space B, THEN border color and label update immediately. 301 + 302 + **6.12** WHEN space color has chroma ≤ 40, THEN a vivid fallback is selected deterministically from palette. 303 + 304 + **6.13** WHEN space color has chroma > 40, THEN the actual color is used. 305 + 306 + ### 7. Mode Inheritance 307 + 308 + **7.1** WHEN a window is opened from a space-mode window, THEN it inherits space mode with the same metadata. 309 + 310 + **7.2** WHEN a window is opened with explicit `spaceMode` option, THEN it receives that mode regardless of opener. 311 + 312 + **7.3** WHEN a window is opened from a non-space window with no `spaceMode` option, THEN it does NOT get space mode. 313 + 314 + **7.4** WHEN a window is opened with no opener and no `spaceMode`, THEN it does NOT get space mode (no lastFocusedVisible fallback). 315 + 316 + **7.5** WHEN explicit `spaceMode` option AND opener space mode conflict, THEN explicit option wins. 317 + 318 + **7.6** WHEN a popup is spawned from a space-mode page, THEN the popup inherits space mode. 319 + 320 + ### 8. Page Host Space Widget 321 + 322 + **8.1** WHEN page has `context.mode = 'space'`, THEN space widget is visible with name and color dot. 323 + 324 + **8.2** WHEN page has mode `'page'` or `'default'`, THEN space widget is hidden. 325 + 326 + **8.3** WHEN ESC is pressed on a space-mode page, THEN the window closes. 327 + 328 + ### 9. Tag Removal and Mode Reset 329 + 330 + **9.1** WHEN a space tag is removed from an item AND a matching space-mode window displays that URL, THEN that window's mode resets to `'page'`. 331 + 332 + **9.2** WHEN a removed tag doesn't match any window's `spaceId`, THEN no windows are affected. 333 + 334 + **9.3** WHEN a removed tag matches `spaceId` but the item URL doesn't match any window URL (after normalization), THEN no windows are affected. 335 + 336 + **9.4** WHEN URLs are compared for tag-removal matching, THEN trailing slashes, default ports, and query param order are normalized. 337 + 338 + **9.5** WHEN a window resets from space to page mode, THEN the border re-evaluates and hides if needed. 339 + 340 + ### 10. Groups Feature (Simplified) 341 + 342 + **10.1** WHEN groups home opens, THEN ALL tags are displayed (no promoted/unpromoted distinction). 343 + 344 + **10.2** WHEN a group card is clicked, THEN groups UI shows the tag's items. No mode context is set. 345 + 346 + **10.3** WHEN viewing a group detail, THEN window mode stays `'default'`. 347 + 348 + **10.4** WHEN viewing a group detail, THEN no screen border appears. 349 + 350 + **10.5** WHEN "Open as space" is triggered on a group, THEN a `space:` tag is created and `open space` executes. 351 + 352 + ### 11. Spaces Home UI 353 + 354 + **11.1** WHEN spaces home opens, THEN it loads at `peek://ext/spaces/home.html`. 355 + 356 + **11.2** WHEN spaces home is active, THEN its window has `context.mode = 'space'`. 357 + 358 + **11.3** WHEN items are added/removed from the space tag, THEN spaces home updates reactively. 359 + 360 + **11.4** WHEN a user clicks an item in spaces home, THEN a window opens with space mode. 361 + 362 + **11.5** WHEN a user clicks an item already open, THEN the existing window is focused (not duplicated). 363 + 364 + ### 12. Backend Boundary 365 + 366 + **12.1** WHEN backend `ipc.ts` is inspected, THEN it contains NO space-specific functions or variables. 367 + 368 + **12.2** WHEN backend emits `item:created`, THEN payload includes `windowId`. 369 + 370 + **12.3** WHEN `detectModeFromUrl` is called, THEN it returns only `'page'` (http/s) or `'default'`. Never `'space'`. 371 + 372 + **12.4** WHEN mode inheritance runs in the window-open handler, THEN it uses a generic `inherit` metadata flag, not space-specific logic.