experiments in a post-browser web
10
fork

Configure Feed

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

feat: scope group mode to window lineage, add screen border indicator

- Remove lastFocusedVisibleWindowId fallback from auto-tagging and window-open
group mode inheritance. Group mode now only propagates through explicit
groupMode option or direct opener lineage, preventing leakage to items from
external apps, peeks, slides, and cmd palette.

- Add full-screen transparent overlay border when group mode is active: 4px solid
border with rounded corners and group name label. Vivid color resolution falls
back to iOS theme palette when group color is too desaturated. Debounced hide
(600ms) prevents flicker during navigation transitions.

- findWindowByUrl now only matches content/child-content windows, skipping
peeks, slides, modals, and other non-page-host window types.

- Add 32 unit tests for color resolution, leakage prevention, lineage scoping,
and border visibility logic.

+612 -36
+388
backend/electron/group-mode.test.ts
··· 1 + /** 2 + * Unit tests for group mode scoping and visual indicator 3 + * 4 + * Tests extracted pure functions from backend/electron/ipc.ts: 5 + * - resolveGroupBorderColor: vivid color selection for screen border 6 + * - shouldAutoTagForGroup: group mode detection (no lastFocusedVisible fallback) 7 + * - shouldInheritGroupMode: window lineage-based group mode inheritance 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 + // resolveGroupBorderColor (from backend/electron/ipc.ts) 17 + // ============================================================================ 18 + 19 + const VIVID_GROUP_COLORS = [ 20 + '#ff3b30', // red 21 + '#ff9500', // orange 22 + '#34c759', // green 23 + '#007aff', // blue 24 + '#af52de', // purple 25 + '#5ac8fa', // cyan 26 + '#ff2d55', // pink 27 + '#ff9f0a', // amber 28 + ]; 29 + 30 + function resolveGroupBorderColor(color: string | undefined, groupId: string | undefined): string { 31 + if (color && color !== '#999' && color !== '#999999') { 32 + const hex = color.replace('#', ''); 33 + if (hex.length >= 6) { 34 + const r = parseInt(hex.substring(0, 2), 16); 35 + const g = parseInt(hex.substring(2, 4), 16); 36 + const b = parseInt(hex.substring(4, 6), 16); 37 + const max = Math.max(r, g, b); 38 + const min = Math.min(r, g, b); 39 + const chroma = max - min; 40 + if (chroma > 40) return color; 41 + } 42 + } 43 + if (groupId) { 44 + let hash = 0; 45 + for (let i = 0; i < groupId.length; i++) { 46 + hash = ((hash << 5) - hash + groupId.charCodeAt(i)) | 0; 47 + } 48 + return VIVID_GROUP_COLORS[Math.abs(hash) % VIVID_GROUP_COLORS.length]; 49 + } 50 + return '#007aff'; 51 + } 52 + 53 + // ============================================================================ 54 + // shouldAutoTagForGroup (extracted from autoTagIfGroupMode in ipc.ts) 55 + // ============================================================================ 56 + // Pure logic: only the calling window's own mode determines tagging. 57 + // No fallback to lastFocusedVisibleWindowId. 58 + 59 + interface ContextEntry { 60 + value: string; 61 + metadata?: Record<string, unknown>; 62 + } 63 + 64 + function shouldAutoTagForGroup( 65 + callerMode: ContextEntry | null, 66 + _lastFocusedVisibleMode: ContextEntry | null, // intentionally ignored 67 + ): { tag: boolean; groupId?: string } { 68 + if (callerMode && callerMode.value === 'group' && callerMode.metadata?.groupId) { 69 + return { tag: true, groupId: callerMode.metadata.groupId as string }; 70 + } 71 + // No fallback — lastFocusedVisibleMode is ignored to prevent group leakage 72 + return { tag: false }; 73 + } 74 + 75 + // ============================================================================ 76 + // shouldInheritGroupMode (extracted from window-open handler in ipc.ts) 77 + // ============================================================================ 78 + // Pure logic: group mode inherits only through explicit option or opener lineage. 79 + // No fallback to lastFocusedVisibleWindowId. 80 + 81 + interface GroupModeOption { 82 + groupId: string; 83 + groupName: string; 84 + color: string; 85 + } 86 + 87 + interface InheritResult { 88 + mode: string; 89 + metadata: Record<string, unknown>; 90 + } 91 + 92 + function shouldInheritGroupMode( 93 + explicitGroupMode: GroupModeOption | undefined, 94 + openerMode: ContextEntry | null, 95 + _lastFocusedVisibleMode: ContextEntry | null, // intentionally ignored 96 + detectedModeFromUrl: string, 97 + url: string, 98 + ): InheritResult { 99 + if (explicitGroupMode) { 100 + return { 101 + mode: 'group', 102 + metadata: { ...explicitGroupMode, url }, 103 + }; 104 + } 105 + if (openerMode && openerMode.value === 'group' && openerMode.metadata) { 106 + return { 107 + mode: 'group', 108 + metadata: { ...openerMode.metadata, url, inheritedFrom: 'opener' }, 109 + }; 110 + } 111 + // No lastFocusedVisibleWindowId fallback 112 + return { 113 + mode: detectedModeFromUrl, 114 + metadata: { url }, 115 + }; 116 + } 117 + 118 + // ============================================================================ 119 + // Tests 120 + // ============================================================================ 121 + 122 + describe('resolveGroupBorderColor', () => { 123 + it('uses vivid group color directly when saturated', () => { 124 + assert.strictEqual(resolveGroupBorderColor('#ff3b30', 'group-1'), '#ff3b30'); 125 + assert.strictEqual(resolveGroupBorderColor('#007aff', 'group-1'), '#007aff'); 126 + assert.strictEqual(resolveGroupBorderColor('#34c759', 'group-1'), '#34c759'); 127 + assert.strictEqual(resolveGroupBorderColor('#af52de', 'group-1'), '#af52de'); 128 + }); 129 + 130 + it('rejects grey #999 and picks vivid fallback', () => { 131 + const result = resolveGroupBorderColor('#999', 'group-1'); 132 + assert.ok(VIVID_GROUP_COLORS.includes(result), `Expected vivid color, got ${result}`); 133 + }); 134 + 135 + it('rejects grey #999999 and picks vivid fallback', () => { 136 + const result = resolveGroupBorderColor('#999999', 'group-1'); 137 + assert.ok(VIVID_GROUP_COLORS.includes(result), `Expected vivid color, got ${result}`); 138 + }); 139 + 140 + it('rejects low-chroma colors (near-white, near-grey)', () => { 141 + // #cccccc has chroma 0 142 + const result1 = resolveGroupBorderColor('#cccccc', 'group-1'); 143 + assert.ok(VIVID_GROUP_COLORS.includes(result1), `#cccccc should be rejected, got ${result1}`); 144 + 145 + // #808080 has chroma 0 146 + const result2 = resolveGroupBorderColor('#808080', 'group-2'); 147 + assert.ok(VIVID_GROUP_COLORS.includes(result2), `#808080 should be rejected, got ${result2}`); 148 + 149 + // #c0b8b0 has chroma 16 (below 40 threshold) 150 + const result3 = resolveGroupBorderColor('#c0b8b0', 'group-3'); 151 + assert.ok(VIVID_GROUP_COLORS.includes(result3), `#c0b8b0 should be rejected, got ${result3}`); 152 + }); 153 + 154 + it('accepts colors with sufficient chroma (> 40)', () => { 155 + // #ff0000 red — chroma 255 156 + assert.strictEqual(resolveGroupBorderColor('#ff0000', 'g'), '#ff0000'); 157 + // #3366cc — chroma = 0xcc - 0x33 = 153 158 + assert.strictEqual(resolveGroupBorderColor('#3366cc', 'g'), '#3366cc'); 159 + // Borderline: chroma exactly 41 (e.g. #50793c → max=0x79=121, min=0x3c=60, chroma=61) 160 + assert.strictEqual(resolveGroupBorderColor('#50793c', 'g'), '#50793c'); 161 + }); 162 + 163 + it('returns deterministic color for same groupId', () => { 164 + const color1 = resolveGroupBorderColor(undefined, 'my-project'); 165 + const color2 = resolveGroupBorderColor(undefined, 'my-project'); 166 + assert.strictEqual(color1, color2); 167 + }); 168 + 169 + it('returns different colors for different groupIds (usually)', () => { 170 + const results = new Set<string>(); 171 + for (const id of ['alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta']) { 172 + results.add(resolveGroupBorderColor(undefined, id)); 173 + } 174 + // With 6 IDs and 8 colors, we should get at least 2 distinct colors 175 + assert.ok(results.size >= 2, `Expected variety, got ${results.size} unique colors`); 176 + }); 177 + 178 + it('all fallback colors are from VIVID_GROUP_COLORS palette', () => { 179 + for (const id of ['a', 'b', 'c', 'test', 'work', 'research', 'project-x', 'foo-bar-baz']) { 180 + const color = resolveGroupBorderColor(undefined, id); 181 + assert.ok(VIVID_GROUP_COLORS.includes(color), `${id} → ${color} not in palette`); 182 + } 183 + }); 184 + 185 + it('returns default blue when no color and no groupId', () => { 186 + assert.strictEqual(resolveGroupBorderColor(undefined, undefined), '#007aff'); 187 + }); 188 + 189 + it('handles short hex strings gracefully', () => { 190 + // 3-char hex can't be parsed as 6-char, falls through to groupId hash 191 + const result = resolveGroupBorderColor('#f00', 'group-1'); 192 + assert.ok(VIVID_GROUP_COLORS.includes(result), `Short hex should fall back, got ${result}`); 193 + }); 194 + }); 195 + 196 + describe('shouldAutoTagForGroup (group leakage prevention)', () => { 197 + const groupMode: ContextEntry = { 198 + value: 'group', 199 + metadata: { groupId: 'tag-123', groupName: 'Research' }, 200 + }; 201 + const defaultMode: ContextEntry = { value: 'default', metadata: {} }; 202 + const pageMode: ContextEntry = { value: 'page', metadata: {} }; 203 + 204 + it('tags when calling window is in group mode', () => { 205 + const result = shouldAutoTagForGroup(groupMode, null); 206 + assert.strictEqual(result.tag, true); 207 + assert.strictEqual(result.groupId, 'tag-123'); 208 + }); 209 + 210 + it('does NOT tag when calling window is in default mode', () => { 211 + const result = shouldAutoTagForGroup(defaultMode, null); 212 + assert.strictEqual(result.tag, false); 213 + }); 214 + 215 + it('does NOT tag when calling window is in page mode', () => { 216 + const result = shouldAutoTagForGroup(pageMode, null); 217 + assert.strictEqual(result.tag, false); 218 + }); 219 + 220 + it('does NOT tag when calling window has no context', () => { 221 + const result = shouldAutoTagForGroup(null, null); 222 + assert.strictEqual(result.tag, false); 223 + }); 224 + 225 + // Critical: group leakage prevention tests 226 + it('does NOT fall back to lastFocusedVisible even if it has group mode', () => { 227 + const result = shouldAutoTagForGroup(null, groupMode); 228 + assert.strictEqual(result.tag, false, 'Should NOT tag via lastFocusedVisible fallback'); 229 + }); 230 + 231 + it('does NOT fall back to lastFocusedVisible when caller is in default mode', () => { 232 + const result = shouldAutoTagForGroup(defaultMode, groupMode); 233 + assert.strictEqual(result.tag, false, 'Should NOT tag via lastFocusedVisible fallback'); 234 + }); 235 + 236 + it('does NOT fall back to lastFocusedVisible when caller is in page mode', () => { 237 + const result = shouldAutoTagForGroup(pageMode, groupMode); 238 + assert.strictEqual(result.tag, false, 'Should NOT tag via lastFocusedVisible fallback'); 239 + }); 240 + 241 + it('does NOT tag when group mode has no groupId', () => { 242 + const broken: ContextEntry = { value: 'group', metadata: {} }; 243 + const result = shouldAutoTagForGroup(broken, null); 244 + assert.strictEqual(result.tag, false); 245 + }); 246 + }); 247 + 248 + describe('shouldInheritGroupMode (window lineage scoping)', () => { 249 + const explicitGroup: GroupModeOption = { 250 + groupId: 'tag-abc', 251 + groupName: 'Work', 252 + color: '#ff3b30', 253 + }; 254 + const openerGroupContext: ContextEntry = { 255 + value: 'group', 256 + metadata: { groupId: 'tag-abc', groupName: 'Work', color: '#ff3b30' }, 257 + }; 258 + const lastFocusedGroupContext: ContextEntry = { 259 + value: 'group', 260 + metadata: { groupId: 'tag-xyz', groupName: 'Side Project', color: '#007aff' }, 261 + }; 262 + const openerDefaultContext: ContextEntry = { value: 'default', metadata: {} }; 263 + 264 + it('inherits group mode from explicit option', () => { 265 + const result = shouldInheritGroupMode(explicitGroup, null, null, 'page', 'https://example.com'); 266 + assert.strictEqual(result.mode, 'group'); 267 + assert.strictEqual(result.metadata.groupId, 'tag-abc'); 268 + assert.strictEqual(result.metadata.groupName, 'Work'); 269 + }); 270 + 271 + it('inherits group mode from opener window lineage', () => { 272 + const result = shouldInheritGroupMode(undefined, openerGroupContext, null, 'page', 'https://example.com'); 273 + assert.strictEqual(result.mode, 'group'); 274 + assert.strictEqual(result.metadata.groupId, 'tag-abc'); 275 + assert.strictEqual(result.metadata.inheritedFrom, 'opener'); 276 + }); 277 + 278 + it('explicit option takes precedence over opener', () => { 279 + const differentGroup: GroupModeOption = { 280 + groupId: 'tag-other', 281 + groupName: 'Other', 282 + color: '#34c759', 283 + }; 284 + const result = shouldInheritGroupMode(differentGroup, openerGroupContext, null, 'page', 'https://example.com'); 285 + assert.strictEqual(result.mode, 'group'); 286 + assert.strictEqual(result.metadata.groupId, 'tag-other'); 287 + }); 288 + 289 + // Critical: no lastFocusedVisible fallback 290 + it('does NOT inherit from lastFocusedVisible when no opener', () => { 291 + const result = shouldInheritGroupMode(undefined, null, lastFocusedGroupContext, 'page', 'https://example.com'); 292 + assert.strictEqual(result.mode, 'page', 'Should NOT inherit group from lastFocusedVisible'); 293 + assert.strictEqual(result.metadata.groupId, undefined); 294 + }); 295 + 296 + it('does NOT inherit from lastFocusedVisible when opener is in default mode', () => { 297 + const result = shouldInheritGroupMode(undefined, openerDefaultContext, lastFocusedGroupContext, 'page', 'https://example.com'); 298 + assert.strictEqual(result.mode, 'page', 'Should NOT inherit group from lastFocusedVisible'); 299 + }); 300 + 301 + it('uses detected mode from URL when no group inheritance', () => { 302 + const result = shouldInheritGroupMode(undefined, null, null, 'page', 'https://example.com'); 303 + assert.strictEqual(result.mode, 'page'); 304 + assert.deepStrictEqual(result.metadata, { url: 'https://example.com' }); 305 + }); 306 + 307 + it('uses detected mode from URL when opener has no context', () => { 308 + const result = shouldInheritGroupMode(undefined, null, null, 'default', 'peek://app/settings.html'); 309 + assert.strictEqual(result.mode, 'default'); 310 + }); 311 + 312 + it('preserves URL in metadata for all inheritance paths', () => { 313 + const url = 'https://example.com/page'; 314 + 315 + const r1 = shouldInheritGroupMode(explicitGroup, null, null, 'page', url); 316 + assert.strictEqual(r1.metadata.url, url); 317 + 318 + const r2 = shouldInheritGroupMode(undefined, openerGroupContext, null, 'page', url); 319 + assert.strictEqual(r2.metadata.url, url); 320 + 321 + const r3 = shouldInheritGroupMode(undefined, null, null, 'page', url); 322 + assert.strictEqual(r3.metadata.url, url); 323 + }); 324 + }); 325 + 326 + describe('group screen border visibility (debounced hide)', () => { 327 + // Tests the logic of the debounced hide behavior. 328 + // The screen border should remain visible during navigation transitions 329 + // where group mode is briefly absent. 330 + 331 + interface WindowState { 332 + id: number; 333 + mode: string; 334 + destroyed: boolean; 335 + } 336 + 337 + function computeBorderAction( 338 + windows: WindowState[], 339 + ): 'show' | 'debounce-hide' { 340 + const liveGroupWindows = windows.filter(w => !w.destroyed && w.mode === 'group'); 341 + return liveGroupWindows.length > 0 ? 'show' : 'debounce-hide'; 342 + } 343 + 344 + it('shows border when any window is in group mode', () => { 345 + const windows: WindowState[] = [ 346 + { id: 1, mode: 'group', destroyed: false }, 347 + { id: 2, mode: 'default', destroyed: false }, 348 + ]; 349 + assert.strictEqual(computeBorderAction(windows), 'show'); 350 + }); 351 + 352 + it('debounce-hides when no windows are in group mode', () => { 353 + const windows: WindowState[] = [ 354 + { id: 1, mode: 'default', destroyed: false }, 355 + { id: 2, mode: 'page', destroyed: false }, 356 + ]; 357 + assert.strictEqual(computeBorderAction(windows), 'debounce-hide'); 358 + }); 359 + 360 + it('ignores destroyed windows when checking group mode', () => { 361 + const windows: WindowState[] = [ 362 + { id: 1, mode: 'group', destroyed: true }, 363 + { id: 2, mode: 'default', destroyed: false }, 364 + ]; 365 + assert.strictEqual(computeBorderAction(windows), 'debounce-hide'); 366 + }); 367 + 368 + it('debounce-hides when no windows exist', () => { 369 + assert.strictEqual(computeBorderAction([]), 'debounce-hide'); 370 + }); 371 + 372 + it('shows when multiple group windows exist', () => { 373 + const windows: WindowState[] = [ 374 + { id: 1, mode: 'group', destroyed: false }, 375 + { id: 2, mode: 'group', destroyed: false }, 376 + ]; 377 + assert.strictEqual(computeBorderAction(windows), 'show'); 378 + }); 379 + 380 + it('shows as long as one live group window remains', () => { 381 + const windows: WindowState[] = [ 382 + { id: 1, mode: 'group', destroyed: true }, 383 + { id: 2, mode: 'group', destroyed: false }, 384 + { id: 3, mode: 'default', destroyed: false }, 385 + ]; 386 + assert.strictEqual(computeBorderAction(windows), 'show'); 387 + }); 388 + });
+216 -34
backend/electron/ipc.ts
··· 329 329 const callingWin = BrowserWindow.fromWebContents(ev.sender); 330 330 const callerWinId = callingWin && !callingWin.isDestroyed() ? callingWin.id : null; 331 331 const callerMode = callerWinId ? getContextEntry('mode', callerWinId) : null; 332 - const lastVisibleMode = lastFocusedVisibleWindowId ? getContextEntry('mode', lastFocusedVisibleWindowId) : null; 333 332 333 + // Only tag if the calling window itself is in group mode. 334 + // Do NOT fall back to lastFocusedVisibleWindowId — that causes group leakage 335 + // where items from external apps, cmd palette, etc. get tagged with whatever 336 + // group happened to be active on the last focused window. 334 337 if (callerMode && callerMode.value === 'group' && callerMode.metadata?.groupId) { 335 338 const groupId = callerMode.metadata.groupId as string; 336 339 tagItemAndPublish(itemId, groupId); 337 340 console.log('[ipc] Auto-tagged item', itemId, 'with group', groupId, '(from caller)'); 338 341 return true; 339 342 } 340 - // Fallback: check last focused visible window (handles cmd palette, modal callers) 341 - if (lastVisibleMode && lastVisibleMode.value === 'group' && lastVisibleMode.metadata?.groupId) { 342 - const groupId = lastVisibleMode.metadata.groupId as string; 343 - tagItemAndPublish(itemId, groupId); 344 - console.log('[ipc] Auto-tagged item', itemId, 'with group', groupId, '(from last visible)'); 345 - return true; 346 - } 347 343 } catch (e) { 348 344 console.log('[ipc] autoTagIfGroupMode error:', e); 349 345 } 350 346 return false; 351 347 } 352 348 349 + // Group mode screen border — transparent overlay window showing a solid colored 350 + // border around the entire screen when any window is in group mode. 351 + let groupScreenBorderWin: BrowserWindow | null = null; 352 + let groupScreenBorderColor: string | null = null; 353 + 354 + // Strong, vivid colors derived from iOS theme palette for group screen border. 355 + // Used when the group's own color is too pale or missing. 356 + const VIVID_GROUP_COLORS = [ 357 + '#ff3b30', // red 358 + '#ff9500', // orange 359 + '#34c759', // green 360 + '#007aff', // blue 361 + '#af52de', // purple 362 + '#5ac8fa', // cyan 363 + '#ff2d55', // pink 364 + '#ff9f0a', // amber 365 + ]; 366 + 367 + /** 368 + * Pick a vivid border color: use the group's color if it has enough saturation, 369 + * otherwise deterministically pick a vivid color from the palette based on the 370 + * group ID hash. 371 + */ 372 + function resolveGroupBorderColor(color: string | undefined, groupId: string | undefined): string { 373 + if (color && color !== '#999' && color !== '#999999') { 374 + // Check if the color has enough saturation/chroma to be visually distinctive. 375 + // Parse hex and check if it's not too grey. 376 + const hex = color.replace('#', ''); 377 + if (hex.length >= 6) { 378 + const r = parseInt(hex.substring(0, 2), 16); 379 + const g = parseInt(hex.substring(2, 4), 16); 380 + const b = parseInt(hex.substring(4, 6), 16); 381 + const max = Math.max(r, g, b); 382 + const min = Math.min(r, g, b); 383 + const chroma = max - min; 384 + // If chroma > 40 the color is vivid enough to use directly 385 + if (chroma > 40) return color; 386 + } 387 + } 388 + // Deterministic pick from vivid palette based on groupId 389 + if (groupId) { 390 + let hash = 0; 391 + for (let i = 0; i < groupId.length; i++) { 392 + hash = ((hash << 5) - hash + groupId.charCodeAt(i)) | 0; 393 + } 394 + return VIVID_GROUP_COLORS[Math.abs(hash) % VIVID_GROUP_COLORS.length]; 395 + } 396 + return '#007aff'; 397 + } 398 + 399 + function showGroupScreenBorder(color: string, name: string): void { 400 + // Sanitize color for CSS injection 401 + const safeColor = /^[#a-zA-Z0-9(),%\s.]+$/.test(color) ? color : '#007aff'; 402 + // Sanitize name for HTML injection 403 + const safeName = name.replace(/[<>"&'\\]/g, ''); 404 + 405 + if (groupScreenBorderWin && !groupScreenBorderWin.isDestroyed()) { 406 + // Update color and name if changed 407 + if (groupScreenBorderColor !== safeColor) { 408 + groupScreenBorderColor = safeColor; 409 + groupScreenBorderWin.webContents.executeJavaScript( 410 + `document.documentElement.style.setProperty('--group-color', '${safeColor}')` 411 + ); 412 + } 413 + groupScreenBorderWin.webContents.executeJavaScript( 414 + `document.getElementById('group-label').textContent = ${JSON.stringify(safeName)}` 415 + ); 416 + if (!groupScreenBorderWin.isVisible()) { 417 + groupScreenBorderWin.showInactive(); 418 + } 419 + return; 420 + } 421 + 422 + groupScreenBorderColor = safeColor; 423 + const primaryDisplay = screen.getPrimaryDisplay(); 424 + const { x, y, width, height } = primaryDisplay.bounds; 425 + 426 + groupScreenBorderWin = new BrowserWindow({ 427 + x, y, width, height, 428 + frame: false, 429 + transparent: true, 430 + hasShadow: false, 431 + alwaysOnTop: true, 432 + focusable: false, 433 + skipTaskbar: true, 434 + roundedCorners: false, 435 + resizable: false, 436 + movable: false, 437 + webPreferences: { 438 + nodeIntegration: false, 439 + contextIsolation: true, 440 + } 441 + }); 442 + 443 + groupScreenBorderWin.setIgnoreMouseEvents(true); 444 + groupScreenBorderWin.setVisibleOnAllWorkspaces(true); 445 + groupScreenBorderWin.setAlwaysOnTop(true, 'screen-saver'); 446 + 447 + const html = `<!DOCTYPE html> 448 + <html style="--group-color: ${safeColor}"> 449 + <head><style> 450 + * { margin: 0; padding: 0; } 451 + html, body { width: 100vw; height: 100vh; background: transparent; overflow: hidden; } 452 + html::after { 453 + content: ''; 454 + position: fixed; 455 + top: 0; left: 0; right: 0; bottom: 0; 456 + border: 4px solid var(--group-color); 457 + border-radius: 10px; 458 + pointer-events: none; 459 + } 460 + #group-label { 461 + position: fixed; 462 + bottom: 8px; 463 + right: 12px; 464 + color: var(--group-color); 465 + font-family: -apple-system, BlinkMacSystemFont, system-ui, sans-serif; 466 + font-size: 11px; 467 + font-weight: 600; 468 + letter-spacing: 0.5px; 469 + text-transform: uppercase; 470 + pointer-events: none; 471 + } 472 + </style></head> 473 + <body> 474 + <div id="group-label">${safeName}</div> 475 + </body> 476 + </html>`; 477 + 478 + groupScreenBorderWin.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`); 479 + groupScreenBorderWin.showInactive(); 480 + 481 + groupScreenBorderWin.on('closed', () => { 482 + groupScreenBorderWin = null; 483 + groupScreenBorderColor = null; 484 + }); 485 + 486 + DEBUG && console.log('[group-screen-border] Created overlay with color:', safeColor, 'name:', safeName); 487 + } 488 + 489 + function hideGroupScreenBorder(): void { 490 + if (groupScreenBorderWin && !groupScreenBorderWin.isDestroyed()) { 491 + groupScreenBorderWin.hide(); 492 + DEBUG && console.log('[group-screen-border] Hidden'); 493 + } 494 + } 495 + 496 + // Debounce timer for hiding the screen border — prevents flicker during 497 + // navigation transitions where group mode is briefly absent (old window's 498 + // context cleared before new window's context is set). 499 + let groupBorderHideTimer: ReturnType<typeof setTimeout> | null = null; 500 + const GROUP_BORDER_HIDE_DELAY_MS = 600; 501 + 502 + /** 503 + * Check if any live window is currently in group mode and show/hide the screen border accordingly. 504 + * Showing is immediate; hiding is debounced to avoid flicker during window transitions. 505 + */ 506 + function updateGroupScreenBorder(): void { 507 + const groupWindowIds = getWindowsWithContextValue('mode', 'group'); 508 + const liveGroupWindows = groupWindowIds.filter(id => { 509 + const win = BrowserWindow.fromId(id); 510 + return win && !win.isDestroyed(); 511 + }); 512 + if (liveGroupWindows.length > 0) { 513 + // Cancel any pending hide — group mode is still active 514 + if (groupBorderHideTimer) { 515 + clearTimeout(groupBorderHideTimer); 516 + groupBorderHideTimer = null; 517 + } 518 + const entry = getContextEntry('mode', liveGroupWindows[0]); 519 + const rawColor = entry?.metadata?.color as string | undefined; 520 + const groupId = entry?.metadata?.groupId as string | undefined; 521 + const color = resolveGroupBorderColor(rawColor, groupId); 522 + const groupName = (entry?.metadata?.groupName as string) || 'group'; 523 + showGroupScreenBorder(color, groupName); 524 + } else { 525 + // Debounce the hide — a new group window may be about to open 526 + if (!groupBorderHideTimer) { 527 + groupBorderHideTimer = setTimeout(() => { 528 + groupBorderHideTimer = null; 529 + // Re-check: if group windows appeared during the delay, don't hide 530 + const recheck = getWindowsWithContextValue('mode', 'group'); 531 + const stillLive = recheck.some(id => { 532 + const win = BrowserWindow.fromId(id); 533 + return win && !win.isDestroyed(); 534 + }); 535 + if (!stillLive) { 536 + hideGroupScreenBorder(); 537 + } 538 + }, GROUP_BORDER_HIDE_DELAY_MS); 539 + } 540 + } 541 + } 542 + 353 543 /** 354 544 * Register datastore IPC handlers 355 545 */ ··· 2534 2724 } 2535 2725 2536 2726 if (openerGroupMode) { 2537 - // Inherit group mode from opener 2727 + // Inherit group mode from opener (window lineage) 2538 2728 addContextEntry('mode', 'group', { 2539 2729 windowId: win.id, 2540 2730 source: msg.source, ··· 2545 2735 } 2546 2736 }); 2547 2737 DEBUG && console.log('Inherited group mode from opener:', openerGroupMode.metadata?.groupName); 2548 - } else if (lastFocusedVisibleWindowId) { 2549 - // Fallback: check lastFocusedVisibleWindowId for group mode 2550 - // This handles the case where a non-group window (e.g. cmd palette) 2551 - // opens a URL while the user was previously focused on a group window 2552 - const lastVisibleContext = getContextEntry('mode', lastFocusedVisibleWindowId); 2553 - if (lastVisibleContext && lastVisibleContext.value === 'group' && lastVisibleContext.metadata) { 2554 - addContextEntry('mode', 'group', { 2555 - windowId: win.id, 2556 - source: msg.source, 2557 - metadata: { 2558 - ...lastVisibleContext.metadata, 2559 - url: modeUrl, 2560 - inheritedFrom: lastFocusedVisibleWindowId 2561 - } 2562 - }); 2563 - console.log('[openWindow] Inherited group mode from lastFocusedVisibleWindow:', lastFocusedVisibleWindowId, lastVisibleContext.metadata?.groupName); 2564 - } else { 2565 - const detectedMode = detectModeFromUrl(modeUrl); 2566 - addContextEntry('mode', detectedMode, { 2567 - windowId: win.id, 2568 - source: msg.source, 2569 - metadata: { url: modeUrl } 2570 - }); 2571 - } 2572 2738 } else { 2573 - // Set mode based on URL detection (original behavior) 2739 + // No group mode inheritance — only explicit groupMode option or direct 2740 + // opener lineage propagates group mode. Do NOT fall back to 2741 + // lastFocusedVisibleWindowId, as that causes group leakage to unrelated 2742 + // windows (external app opens, peeks, slides, etc.) 2574 2743 const detectedMode = detectModeFromUrl(modeUrl); 2575 2744 addContextEntry('mode', detectedMode, { 2576 2745 windowId: win.id, ··· 2840 3009 console.log(`[page-host:${win.id}] Loading: ${loadUrl.substring(0, 120)}`); 2841 3010 await win.loadURL(loadUrl); 2842 3011 console.log(`[page-host:${win.id}] loadURL resolved`); 3012 + 3013 + // Update group screen border if this window entered group mode 3014 + updateGroupScreenBorder(); 2843 3015 2844 3016 // Background detection for non-canvas web pages (slides, modals, quick-views). 2845 3017 // Canvas pages get this via the webview dom-ready handler in page.js. ··· 4735 4907 * New code should use the context API (api.context) instead. 4736 4908 */ 4737 4909 export function registerModesHandlers(): void { 4910 + // Update group screen border when windows close (last group window closing should hide it) 4911 + subscribe(getSystemAddress(), PubSubScopes.GLOBAL, 'window:closed', () => { 4912 + updateGroupScreenBorder(); 4913 + }); 4914 + 4738 4915 // Mode definitions 4739 4916 const MODES = [ 4740 4917 { id: 'default', name: 'Default', description: 'Standard browsing mode' }, ··· 4892 5069 windowId, 4893 5070 source 4894 5071 }); 5072 + 5073 + // Update group screen border when mode changes 5074 + if (data.key === 'mode') { 5075 + updateGroupScreenBorder(); 5076 + } 4895 5077 4896 5078 // Publish context change event for watchers 4897 5079 if (publish && PubSubScopes && getSystemAddress) {
+8 -2
backend/electron/main.ts
··· 1639 1639 } 1640 1640 1641 1641 /** 1642 - * Find an existing window that has the given URL loaded (by address param). 1643 - * Only searches for http/https URLs. Returns the first match. 1642 + * Find an existing page-host window that has the given URL loaded (by address param). 1643 + * Only searches for http/https URLs. Only matches page-host windows (canvas pages 1644 + * with role 'content' or 'child-content'), NOT peeks, slides, modals, or other 1645 + * non-page-host window types. 1644 1646 */ 1645 1647 export function findWindowByUrl(url: string): { id: number; window: BrowserWindow; data: unknown } | null { 1646 1648 if (!url || (!url.startsWith('http://') && !url.startsWith('https://'))) return null; 1647 1649 1648 1650 for (const [id, win] of windowRegistry) { 1649 1651 if (win.params && win.params.address === url) { 1652 + // Only match page-host windows (canvas pages), not peeks, slides, modals, etc. 1653 + const role = win.params.role; 1654 + if (role !== 'content' && role !== 'child-content') continue; 1655 + 1650 1656 const browserWindow = BrowserWindow.fromId(id); 1651 1657 if (browserWindow && !browserWindow.isDestroyed()) { 1652 1658 return { id, window: browserWindow, data: win };