experiments in a post-browser web
10
fork

Configure Feed

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

feat: focus-based group screen border, modal exemption, spec

- Rewrite screen border to track focused window's mode, not all windows.
Border shows only when the focused window is a group member.
- Modal windows (cmd palette) are transparent to border state — opening
cmd while in a group keeps the border visible, preventing flicker.
- Remove groups URL auto-detection from detectModeFromUrl() — groups
home manages its own mode explicitly via setMode() calls.
- Add shutdown flag to prevent border recreation during quit.
- Add group-screen-border-spec.md with full visibility rules.
- Update tests: 19 focus-based border tests including modal exemption.

+415 -147
+4 -4
backend/electron/datastore.ts
··· 2429 2429 export function detectModeFromUrl(url: string): MajorModeId { 2430 2430 if (!url) return 'default'; 2431 2431 2432 - // Groups extension 2433 - if (url.includes('/groups/') || url.includes('groups.html')) { 2434 - return 'group'; 2435 - } 2432 + // Groups extension UI manages its own mode explicitly via setMode(): 2433 + // 'group' when viewing a specific group's addresses, 'default' on the 2434 + // groups list. Auto-detecting from URL caused the screen border to 2435 + // appear just from opening the groups browser. 2436 2436 2437 2437 // Web pages 2438 2438 if (url.startsWith('http://') || url.startsWith('https://')) {
+281 -112
backend/electron/group-mode.test.ts
··· 5 5 * - resolveGroupBorderColor: vivid color selection for screen border 6 6 * - shouldAutoTagForGroup: group mode detection (no lastFocusedVisible fallback) 7 7 * - shouldInheritGroupMode: window lineage-based group mode inheritance 8 - * - colorPersistence: vivid colors assigned at group creation/promotion time 9 8 * 10 9 * These are logic-only tests — no DOM, no Electron, no IPC runtime. 11 10 */ ··· 324 323 }); 325 324 }); 326 325 327 - describe('group screen border visibility (debounced hide)', () => { 328 - // Tests the logic of the debounced hide behavior. 329 - // The screen border should remain visible during navigation transitions 330 - // where group mode is briefly absent. 326 + describe('group screen border visibility (focus-based)', () => { 327 + // Tests the focus-based border visibility logic. 328 + // The border is a per-focused-window indicator: 329 + // visible IFF the focused visible window has context.mode = 'group' 330 + // See features/groups/group-screen-border-spec.md 331 331 332 - interface WindowState { 333 - id: number; 334 - mode: string; 335 - destroyed: boolean; 336 - } 337 - 338 - function computeBorderAction( 339 - windows: WindowState[], 340 - ): 'show' | 'debounce-hide' { 341 - const liveGroupWindows = windows.filter(w => !w.destroyed && w.mode === 'group'); 342 - return liveGroupWindows.length > 0 ? 'show' : 'debounce-hide'; 332 + /** 333 + * Pure decision function extracted from updateGroupScreenBorder() in ipc.ts. 334 + * Determines whether the group screen border should be shown based on 335 + * the currently focused window's state. 336 + */ 337 + function shouldShowGroupBorder(context: { 338 + focusedWindowId: number | null; 339 + focusedWindowDestroyed: boolean; 340 + focusedWindowMode: string | null; 341 + focusedWindowMetadata: Record<string, unknown> | null; 342 + isModal?: boolean; 343 + }): { show: boolean; unchanged?: boolean; color?: string; name?: string } { 344 + // No focused window → hide 345 + if (!context.focusedWindowId) { 346 + return { show: false }; 347 + } 348 + // Focused window destroyed → hide 349 + if (context.focusedWindowDestroyed) { 350 + return { show: false }; 351 + } 352 + // Modal/transient windows (cmd palette, etc.) are "transparent" to border state — 353 + // focusing a modal does not change the border, preventing constant flicker during group work 354 + if (context.isModal) { 355 + return { show: false, unchanged: true }; 356 + } 357 + // Focused window in group mode with valid groupId → show 358 + if ( 359 + context.focusedWindowMode === 'group' && 360 + context.focusedWindowMetadata?.groupId 361 + ) { 362 + const rawColor = context.focusedWindowMetadata.color as string | undefined; 363 + const groupId = context.focusedWindowMetadata.groupId as string | undefined; 364 + const color = resolveGroupBorderColor(rawColor, groupId); 365 + const name = (context.focusedWindowMetadata.groupName as string) || 'group'; 366 + return { show: true, color, name }; 367 + } 368 + // Otherwise → hide 369 + return { show: false }; 343 370 } 344 371 345 - it('shows border when any window is in group mode', () => { 346 - const windows: WindowState[] = [ 347 - { id: 1, mode: 'group', destroyed: false }, 348 - { id: 2, mode: 'default', destroyed: false }, 349 - ]; 350 - assert.strictEqual(computeBorderAction(windows), 'show'); 372 + it('shows border when focused window is in group mode with valid groupId', () => { 373 + const result = shouldShowGroupBorder({ 374 + focusedWindowId: 1, 375 + focusedWindowDestroyed: false, 376 + focusedWindowMode: 'group', 377 + focusedWindowMetadata: { groupId: 'tag-123', groupName: 'Research', color: '#ff3b30' }, 378 + }); 379 + assert.strictEqual(result.show, true); 380 + assert.strictEqual(result.color, '#ff3b30'); 381 + assert.strictEqual(result.name, 'Research'); 351 382 }); 352 383 353 - it('debounce-hides when no windows are in group mode', () => { 354 - const windows: WindowState[] = [ 355 - { id: 1, mode: 'default', destroyed: false }, 356 - { id: 2, mode: 'page', destroyed: false }, 357 - ]; 358 - assert.strictEqual(computeBorderAction(windows), 'debounce-hide'); 384 + it('hides border when focused window is in default mode', () => { 385 + const result = shouldShowGroupBorder({ 386 + focusedWindowId: 1, 387 + focusedWindowDestroyed: false, 388 + focusedWindowMode: 'default', 389 + focusedWindowMetadata: {}, 390 + }); 391 + assert.strictEqual(result.show, false); 359 392 }); 360 393 361 - it('ignores destroyed windows when checking group mode', () => { 362 - const windows: WindowState[] = [ 363 - { id: 1, mode: 'group', destroyed: true }, 364 - { id: 2, mode: 'default', destroyed: false }, 365 - ]; 366 - assert.strictEqual(computeBorderAction(windows), 'debounce-hide'); 394 + it('hides border when focused window is in page mode', () => { 395 + const result = shouldShowGroupBorder({ 396 + focusedWindowId: 1, 397 + focusedWindowDestroyed: false, 398 + focusedWindowMode: 'page', 399 + focusedWindowMetadata: { url: 'https://example.com' }, 400 + }); 401 + assert.strictEqual(result.show, false); 367 402 }); 368 403 369 - it('debounce-hides when no windows exist', () => { 370 - assert.strictEqual(computeBorderAction([]), 'debounce-hide'); 404 + it('hides border when no focused window exists', () => { 405 + const result = shouldShowGroupBorder({ 406 + focusedWindowId: null, 407 + focusedWindowDestroyed: false, 408 + focusedWindowMode: null, 409 + focusedWindowMetadata: null, 410 + }); 411 + assert.strictEqual(result.show, false); 371 412 }); 372 413 373 - it('shows when multiple group windows exist', () => { 374 - const windows: WindowState[] = [ 375 - { id: 1, mode: 'group', destroyed: false }, 376 - { id: 2, mode: 'group', destroyed: false }, 377 - ]; 378 - assert.strictEqual(computeBorderAction(windows), 'show'); 414 + it('hides border when focused window is destroyed', () => { 415 + const result = shouldShowGroupBorder({ 416 + focusedWindowId: 1, 417 + focusedWindowDestroyed: true, 418 + focusedWindowMode: 'group', 419 + focusedWindowMetadata: { groupId: 'tag-123' }, 420 + }); 421 + assert.strictEqual(result.show, false); 379 422 }); 380 423 381 - it('shows as long as one live group window remains', () => { 382 - const windows: WindowState[] = [ 383 - { id: 1, mode: 'group', destroyed: true }, 384 - { id: 2, mode: 'group', destroyed: false }, 385 - { id: 3, mode: 'default', destroyed: false }, 386 - ]; 387 - assert.strictEqual(computeBorderAction(windows), 'show'); 424 + it('hides border when focused window is group mode but no groupId in metadata', () => { 425 + const result = shouldShowGroupBorder({ 426 + focusedWindowId: 1, 427 + focusedWindowDestroyed: false, 428 + focusedWindowMode: 'group', 429 + focusedWindowMetadata: {}, 430 + }); 431 + assert.strictEqual(result.show, false); 388 432 }); 389 - }); 390 433 391 - describe('color persistence (vivid color assigned at group creation/promotion)', () => { 392 - // Vivid colors are now assigned eagerly in the datastore-set-row IPC handler 393 - // when a tag is promoted to a group (isGroup: true in metadata). 394 - // The screen border no longer lazily persists resolved colors. 434 + it('hides border when focused window is group mode with null metadata', () => { 435 + const result = shouldShowGroupBorder({ 436 + focusedWindowId: 1, 437 + focusedWindowDestroyed: false, 438 + focusedWindowMode: 'group', 439 + focusedWindowMetadata: null, 440 + }); 441 + assert.strictEqual(result.show, false); 442 + }); 395 443 396 - interface ColorPersistResult { 397 - shouldPersist: boolean; 398 - resolvedColor: string; 399 - } 444 + it('uses group name from metadata', () => { 445 + const result = shouldShowGroupBorder({ 446 + focusedWindowId: 1, 447 + focusedWindowDestroyed: false, 448 + focusedWindowMode: 'group', 449 + focusedWindowMetadata: { groupId: 'tag-abc', groupName: 'Work', color: '#007aff' }, 450 + }); 451 + assert.strictEqual(result.show, true); 452 + assert.strictEqual(result.name, 'Work'); 453 + }); 400 454 401 - function shouldPersistColor( 402 - rawColor: string | undefined, 403 - groupId: string | undefined, 404 - ): ColorPersistResult { 405 - const resolvedColor = resolveGroupBorderColor(rawColor, groupId); 406 - return { 407 - shouldPersist: resolvedColor !== rawColor && !!groupId, 408 - resolvedColor, 409 - }; 410 - } 455 + it('defaults group name to "group" when not in metadata', () => { 456 + const result = shouldShowGroupBorder({ 457 + focusedWindowId: 1, 458 + focusedWindowDestroyed: false, 459 + focusedWindowMode: 'group', 460 + focusedWindowMetadata: { groupId: 'tag-abc' }, 461 + }); 462 + assert.strictEqual(result.show, true); 463 + assert.strictEqual(result.name, 'group'); 464 + }); 411 465 412 - it('persists when raw color is undefined (default grey group)', () => { 413 - const result = shouldPersistColor(undefined, 'group-1'); 414 - assert.strictEqual(result.shouldPersist, true); 415 - assert.ok(VIVID_GROUP_COLORS.includes(result.resolvedColor)); 466 + it('resolves vivid color when group color is too pale', () => { 467 + const result = shouldShowGroupBorder({ 468 + focusedWindowId: 1, 469 + focusedWindowDestroyed: false, 470 + focusedWindowMode: 'group', 471 + focusedWindowMetadata: { groupId: 'tag-abc', groupName: 'Grey Group', color: '#999999' }, 472 + }); 473 + assert.strictEqual(result.show, true); 474 + assert.ok(VIVID_GROUP_COLORS.includes(result.color!), `Expected vivid color, got ${result.color}`); 416 475 }); 417 476 418 - it('persists when raw color is #999 (default tag color)', () => { 419 - const result = shouldPersistColor('#999', 'group-1'); 420 - assert.strictEqual(result.shouldPersist, true); 421 - assert.ok(VIVID_GROUP_COLORS.includes(result.resolvedColor)); 477 + // Scenario tests simulating focus transitions 478 + it('focus changes from group window to non-group window → hide', () => { 479 + // First: group window focused → show 480 + const r1 = shouldShowGroupBorder({ 481 + focusedWindowId: 1, 482 + focusedWindowDestroyed: false, 483 + focusedWindowMode: 'group', 484 + focusedWindowMetadata: { groupId: 'tag-123', groupName: 'Research' }, 485 + }); 486 + assert.strictEqual(r1.show, true); 487 + 488 + // Then: non-group window focused → hide 489 + const r2 = shouldShowGroupBorder({ 490 + focusedWindowId: 2, 491 + focusedWindowDestroyed: false, 492 + focusedWindowMode: 'default', 493 + focusedWindowMetadata: {}, 494 + }); 495 + assert.strictEqual(r2.show, false); 422 496 }); 423 497 424 - it('persists when raw color is #999999 (default tag color long form)', () => { 425 - const result = shouldPersistColor('#999999', 'group-1'); 426 - assert.strictEqual(result.shouldPersist, true); 427 - assert.ok(VIVID_GROUP_COLORS.includes(result.resolvedColor)); 498 + it('focus changes from non-group window to group window → show', () => { 499 + // First: non-group window focused → hide 500 + const r1 = shouldShowGroupBorder({ 501 + focusedWindowId: 1, 502 + focusedWindowDestroyed: false, 503 + focusedWindowMode: 'page', 504 + focusedWindowMetadata: { url: 'https://example.com' }, 505 + }); 506 + assert.strictEqual(r1.show, false); 507 + 508 + // Then: group window focused → show 509 + const r2 = shouldShowGroupBorder({ 510 + focusedWindowId: 2, 511 + focusedWindowDestroyed: false, 512 + focusedWindowMode: 'group', 513 + focusedWindowMetadata: { groupId: 'tag-456', groupName: 'Work', color: '#34c759' }, 514 + }); 515 + assert.strictEqual(r2.show, true); 516 + assert.strictEqual(r2.color, '#34c759'); 428 517 }); 429 518 430 - it('persists when raw color is desaturated (low chroma)', () => { 431 - const result = shouldPersistColor('#cccccc', 'group-1'); 432 - assert.strictEqual(result.shouldPersist, true); 433 - assert.ok(VIVID_GROUP_COLORS.includes(result.resolvedColor)); 519 + it('mode changes on focused window from group → default → hide', () => { 520 + // Initially in group mode 521 + const r1 = shouldShowGroupBorder({ 522 + focusedWindowId: 1, 523 + focusedWindowDestroyed: false, 524 + focusedWindowMode: 'group', 525 + focusedWindowMetadata: { groupId: 'tag-123', groupName: 'Research' }, 526 + }); 527 + assert.strictEqual(r1.show, true); 528 + 529 + // Mode changed to default (e.g., navigated back to groups list) 530 + const r2 = shouldShowGroupBorder({ 531 + focusedWindowId: 1, 532 + focusedWindowDestroyed: false, 533 + focusedWindowMode: 'default', 534 + focusedWindowMetadata: {}, 535 + }); 536 + assert.strictEqual(r2.show, false); 434 537 }); 435 538 436 - it('does NOT persist when raw color is already vivid', () => { 437 - const result = shouldPersistColor('#ff3b30', 'group-1'); 438 - assert.strictEqual(result.shouldPersist, false); 439 - assert.strictEqual(result.resolvedColor, '#ff3b30'); 539 + it('mode changes on focused window from default → group → show', () => { 540 + // Initially in default mode 541 + const r1 = shouldShowGroupBorder({ 542 + focusedWindowId: 1, 543 + focusedWindowDestroyed: false, 544 + focusedWindowMode: 'default', 545 + focusedWindowMetadata: {}, 546 + }); 547 + assert.strictEqual(r1.show, false); 548 + 549 + // Mode changed to group (e.g., opened a group address) 550 + const r2 = shouldShowGroupBorder({ 551 + focusedWindowId: 1, 552 + focusedWindowDestroyed: false, 553 + focusedWindowMode: 'group', 554 + focusedWindowMetadata: { groupId: 'tag-789', groupName: 'Project', color: '#af52de' }, 555 + }); 556 + assert.strictEqual(r2.show, true); 557 + assert.strictEqual(r2.name, 'Project'); 440 558 }); 441 559 442 - it('does NOT persist when raw color is a different vivid color', () => { 443 - const result = shouldPersistColor('#007aff', 'group-1'); 444 - assert.strictEqual(result.shouldPersist, false); 445 - assert.strictEqual(result.resolvedColor, '#007aff'); 560 + // Modal window exemption — cmd palette and other modals are "transparent" to border state 561 + it('modal window (cmd palette) does not change border state', () => { 562 + const result = shouldShowGroupBorder({ 563 + focusedWindowId: 99, 564 + focusedWindowDestroyed: false, 565 + focusedWindowMode: 'default', 566 + focusedWindowMetadata: {}, 567 + isModal: true, 568 + }); 569 + assert.strictEqual(result.unchanged, true); 446 570 }); 447 571 448 - it('does NOT persist when groupId is undefined', () => { 449 - const result = shouldPersistColor('#999', undefined); 450 - assert.strictEqual(result.shouldPersist, false); 572 + it('modal window in group mode still treated as transparent', () => { 573 + const result = shouldShowGroupBorder({ 574 + focusedWindowId: 99, 575 + focusedWindowDestroyed: false, 576 + focusedWindowMode: 'group', 577 + focusedWindowMetadata: { groupId: 'tag-123' }, 578 + isModal: true, 579 + }); 580 + assert.strictEqual(result.unchanged, true); 451 581 }); 452 582 453 - it('persisted color is deterministic for same groupId', () => { 454 - const result1 = shouldPersistColor('#999', 'my-project'); 455 - const result2 = shouldPersistColor('#999', 'my-project'); 456 - assert.strictEqual(result1.resolvedColor, result2.resolvedColor); 583 + it('border stays visible through group → modal → group focus sequence', () => { 584 + // Group window focused → show 585 + const r1 = shouldShowGroupBorder({ 586 + focusedWindowId: 1, 587 + focusedWindowDestroyed: false, 588 + focusedWindowMode: 'group', 589 + focusedWindowMetadata: { groupId: 'tag-123', groupName: 'Work', color: '#ff3b30' }, 590 + }); 591 + assert.strictEqual(r1.show, true); 592 + 593 + // Cmd palette opens → unchanged (border stays visible) 594 + const r2 = shouldShowGroupBorder({ 595 + focusedWindowId: 99, 596 + focusedWindowDestroyed: false, 597 + focusedWindowMode: 'default', 598 + focusedWindowMetadata: {}, 599 + isModal: true, 600 + }); 601 + assert.strictEqual(r2.unchanged, true); 602 + 603 + // Cmd palette closes, group window regains focus → show 604 + const r3 = shouldShowGroupBorder({ 605 + focusedWindowId: 1, 606 + focusedWindowDestroyed: false, 607 + focusedWindowMode: 'group', 608 + focusedWindowMetadata: { groupId: 'tag-123', groupName: 'Work', color: '#ff3b30' }, 609 + }); 610 + assert.strictEqual(r3.show, true); 457 611 }); 458 612 459 - it('after persistence, subsequent call with resolved color does NOT re-persist', () => { 460 - // Simulate: first call resolves and persists 461 - const first = shouldPersistColor('#999', 'group-1'); 462 - assert.strictEqual(first.shouldPersist, true); 613 + it('switching between two different groups updates color and name', () => { 614 + const r1 = shouldShowGroupBorder({ 615 + focusedWindowId: 1, 616 + focusedWindowDestroyed: false, 617 + focusedWindowMode: 'group', 618 + focusedWindowMetadata: { groupId: 'tag-a', groupName: 'Alpha', color: '#ff3b30' }, 619 + }); 620 + assert.strictEqual(r1.show, true); 621 + assert.strictEqual(r1.color, '#ff3b30'); 622 + assert.strictEqual(r1.name, 'Alpha'); 463 623 464 - // Second call with the now-persisted vivid color 465 - const second = shouldPersistColor(first.resolvedColor, 'group-1'); 466 - assert.strictEqual(second.shouldPersist, false); 467 - assert.strictEqual(second.resolvedColor, first.resolvedColor); 624 + const r2 = shouldShowGroupBorder({ 625 + focusedWindowId: 2, 626 + focusedWindowDestroyed: false, 627 + focusedWindowMode: 'group', 628 + focusedWindowMetadata: { groupId: 'tag-b', groupName: 'Beta', color: '#007aff' }, 629 + }); 630 + assert.strictEqual(r2.show, true); 631 + assert.strictEqual(r2.color, '#007aff'); 632 + assert.strictEqual(r2.name, 'Beta'); 468 633 }); 469 634 470 - it('user-chosen vivid color is not overwritten', () => { 471 - // User picks a custom vivid color via the color picker 472 - const result = shouldPersistColor('#e91e63', 'group-1'); 473 - assert.strictEqual(result.shouldPersist, false); 474 - assert.strictEqual(result.resolvedColor, '#e91e63'); 635 + it('non-group windows do NOT cause border to show even if other group windows exist', () => { 636 + // The decision is purely about the FOCUSED window, not about what other windows exist 637 + const result = shouldShowGroupBorder({ 638 + focusedWindowId: 3, 639 + focusedWindowDestroyed: false, 640 + focusedWindowMode: 'default', 641 + focusedWindowMetadata: {}, 642 + }); 643 + assert.strictEqual(result.show, false); 475 644 }); 476 645 });
+65 -31
backend/electron/ipc.ts
··· 352 352 // border around the entire screen when any window is in group mode. 353 353 let groupScreenBorderWin: BrowserWindow | null = null; 354 354 let groupScreenBorderColor: string | null = null; 355 + // Shutdown flag — prevents updateGroupScreenBorder from recreating the overlay 356 + // after cleanupGroupScreenBorder destroys it during quit (window:closed events 357 + // fire during shutdown and would otherwise trigger recreation). 358 + let groupScreenBorderShuttingDown = false; 355 359 356 360 // Strong, vivid colors derived from iOS theme palette for group screen border. 357 361 // Used when the group's own color is too pale or missing. ··· 500 504 * Called during app shutdown to ensure Electron can quit cleanly. 501 505 */ 502 506 export function cleanupGroupScreenBorder(): void { 507 + groupScreenBorderShuttingDown = true; 503 508 if (groupBorderHideTimer) { 504 509 clearTimeout(groupBorderHideTimer); 505 510 groupBorderHideTimer = null; ··· 513 518 } 514 519 515 520 // Debounce timer for hiding the screen border — prevents flicker during 516 - // navigation transitions where group mode is briefly absent (old window's 517 - // context cleared before new window's context is set). 521 + // navigation transitions where group mode is briefly absent. 518 522 let groupBorderHideTimer: ReturnType<typeof setTimeout> | null = null; 519 523 const GROUP_BORDER_HIDE_DELAY_MS = 600; 520 524 521 525 /** 522 - * Check if any live window is currently in group mode and show/hide the screen border accordingly. 526 + * Debounce the hide — a new group window may be about to gain focus. 527 + * Prevents flicker during window transitions. 528 + */ 529 + function scheduleHideGroupScreenBorder(): void { 530 + if (!groupBorderHideTimer) { 531 + groupBorderHideTimer = setTimeout(() => { 532 + groupBorderHideTimer = null; 533 + hideGroupScreenBorder(); 534 + }, GROUP_BORDER_HIDE_DELAY_MS); 535 + } 536 + } 537 + 538 + /** 539 + * Check if the currently focused visible window is in group mode and show/hide the screen border. 540 + * The border is a per-focused-window indicator, NOT a global "any window has group mode" indicator. 523 541 * Showing is immediate; hiding is debounced to avoid flicker during window transitions. 542 + * 543 + * Special case: modal windows (cmd palette, etc.) are "transparent" to the border — focusing 544 + * a modal does not hide the border, since it would flicker constantly during normal group work. 524 545 */ 525 - function updateGroupScreenBorder(): void { 526 - const groupWindowIds = getWindowsWithContextValue('mode', 'group'); 527 - const liveGroupWindows = groupWindowIds.filter(id => { 528 - const win = BrowserWindow.fromId(id); 529 - return win && !win.isDestroyed(); 530 - }); 531 - if (liveGroupWindows.length > 0) { 532 - // Cancel any pending hide — group mode is still active 546 + export function updateGroupScreenBorder(): void { 547 + if (groupScreenBorderShuttingDown) return; 548 + 549 + if (!lastFocusedVisibleWindowId) { 550 + scheduleHideGroupScreenBorder(); 551 + return; 552 + } 553 + const win = BrowserWindow.fromId(lastFocusedVisibleWindowId); 554 + if (!win || win.isDestroyed()) { 555 + scheduleHideGroupScreenBorder(); 556 + return; 557 + } 558 + 559 + // Skip modal/transient windows (cmd palette, etc.) — they're "transparent" to border state. 560 + // When a modal is focused, keep the border in its current state (don't show or hide). 561 + const winInfo = getWindowInfo(lastFocusedVisibleWindowId); 562 + if (winInfo?.params?.modal) return; 563 + 564 + const entry = getContextEntry('mode', lastFocusedVisibleWindowId); 565 + if (entry && entry.value === 'group' && entry.metadata?.groupId) { 566 + // Cancel any pending hide — focused window is in group mode 533 567 if (groupBorderHideTimer) { 534 568 clearTimeout(groupBorderHideTimer); 535 569 groupBorderHideTimer = null; 536 570 } 537 - const entry = getContextEntry('mode', liveGroupWindows[0]); 538 - const rawColor = entry?.metadata?.color as string | undefined; 539 - const groupId = entry?.metadata?.groupId as string | undefined; 571 + const rawColor = entry.metadata.color as string | undefined; 572 + const groupId = entry.metadata.groupId as string | undefined; 540 573 const color = resolveGroupBorderColor(rawColor, groupId); 541 - const groupName = (entry?.metadata?.groupName as string) || 'group'; 574 + const groupName = (entry.metadata.groupName as string) || 'group'; 542 575 showGroupScreenBorder(color, groupName); 543 576 } else { 544 - // Debounce the hide — a new group window may be about to open 545 - if (!groupBorderHideTimer) { 546 - groupBorderHideTimer = setTimeout(() => { 547 - groupBorderHideTimer = null; 548 - // Re-check: if group windows appeared during the delay, don't hide 549 - const recheck = getWindowsWithContextValue('mode', 'group'); 550 - const stillLive = recheck.some(id => { 551 - const win = BrowserWindow.fromId(id); 552 - return win && !win.isDestroyed(); 553 - }); 554 - if (!stillLive) { 555 - hideGroupScreenBorder(); 556 - } 557 - }, GROUP_BORDER_HIDE_DELAY_MS); 558 - } 577 + scheduleHideGroupScreenBorder(); 559 578 } 560 579 } 561 580 ··· 5012 5031 * New code should use the context API (api.context) instead. 5013 5032 */ 5014 5033 export function registerModesHandlers(): void { 5015 - // Update group screen border when windows close (last group window closing should hide it) 5034 + // Update group screen border when focus changes — border tracks the focused window's mode 5035 + subscribe(getSystemAddress(), PubSubScopes.GLOBAL, 'window:focused', () => { 5036 + updateGroupScreenBorder(); 5037 + }); 5038 + 5039 + // Update group screen border when windows close (focused window may have closed) 5016 5040 subscribe(getSystemAddress(), PubSubScopes.GLOBAL, 'window:closed', () => { 5017 5041 updateGroupScreenBorder(); 5042 + }); 5043 + 5044 + // Hide group screen border when app loses focus (no window is active) 5045 + subscribe(getSystemAddress(), PubSubScopes.GLOBAL, 'app:focus-changed', (msg: unknown) => { 5046 + const data = msg as { focused: boolean }; 5047 + if (!data.focused) { 5048 + scheduleHideGroupScreenBorder(); 5049 + } else { 5050 + updateGroupScreenBorder(); 5051 + } 5018 5052 }); 5019 5053 5020 5054 // Mode definitions
+65
features/groups/group-screen-border-spec.md
··· 1 + # Group Screen Border Specification 2 + 3 + ## Overview 4 + The group screen border is a colored overlay around the screen edges that indicates the currently focused window belongs to a group. It includes a 4px solid border in the group's color with rounded corners, and a label showing the group name in the bottom-right corner. 5 + 6 + ## Visibility Rule 7 + The border is visible **if and only if** the currently focused visible window has `context.mode = 'group'`. 8 + 9 + ### Border is VISIBLE when focused window is: 10 + - The groups home window in address view (viewing a specific group's contents) 11 + - A content page opened from within a group (inherited group mode from opener) 12 + - A content page opened from another group content page (lineage inheritance) 13 + - A popup window from a group content page (inherited from parent) 14 + 15 + ### Border is HIDDEN when focused window is: 16 + - The groups list view (groups home navigated back, mode reset to 'default') 17 + - Any non-group content window (opened from cmd palette, external app, etc.) 18 + - A slide, peek, or non-modal window without group mode 19 + - No window is focused (app in background) 20 + 21 + ### Border is UNCHANGED (transparent) when focused window is: 22 + - The cmd palette or any other modal/transient window 23 + - These windows don't affect border state — opening cmd while in a group keeps the border visible, opening cmd while not in a group keeps it hidden 24 + 25 + ## Color and Label 26 + - Color: the group's assigned color (vivid colors assigned at group creation) 27 + - Label: the group name, shown in bottom-right corner 28 + - Both update immediately when switching between groups 29 + 30 + ## Transitions 31 + - **Show**: immediate (no delay) 32 + - **Hide**: debounced by 600ms to prevent flicker during window transitions 33 + - **Focus change**: re-evaluates on every `window:focused` event 34 + - **Mode change**: re-evaluates when any window's mode changes via context API 35 + - **Window close**: re-evaluates (focus shifts to next window) 36 + - **App focus lost**: debounced hide (via `app:focus-changed` event) 37 + - **App focus regained**: re-evaluates based on which window is focused 38 + 39 + ## Implementation 40 + 41 + ### Decision function 42 + `updateGroupScreenBorder()` in `backend/electron/ipc.ts` checks `lastFocusedVisibleWindowId`: 43 + 1. If no focused visible window exists, schedule hide 44 + 2. If focused window is destroyed, schedule hide 45 + 3. If focused window's `context.mode === 'group'` with a valid `groupId`, show border with group's color and name 46 + 4. Otherwise, schedule hide 47 + 48 + ### Trigger points 49 + - `window:focused` pubsub event (focus changes between windows) 50 + - `window:closed` pubsub event (focused window may have closed) 51 + - `context-set` IPC when `key === 'mode'` (mode changed on any window) 52 + - `app:focus-changed` pubsub event (app gains/loses OS-level focus) 53 + - After `loadURL` in window open flow (new window may be in group mode) 54 + 55 + ### `scheduleHideGroupScreenBorder()` 56 + Debounced hide helper. Starts a 600ms timer. If `updateGroupScreenBorder()` finds a group-mode focused window before the timer fires, the timer is cancelled. Prevents flicker during rapid focus transitions. 57 + 58 + ## Non-goals 59 + - The border does NOT indicate that group windows exist somewhere in the background 60 + - The border does NOT persist when switching to non-group work 61 + - The border is NOT a global ambient state indicator 62 + 63 + ## Shutdown 64 + - `cleanupGroupScreenBorder()` destroys the overlay window during app quit 65 + - A `groupScreenBorderShuttingDown` flag prevents recreation during shutdown