experiments in a post-browser web
10
fork

Configure Feed

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

feat(groups): Phase 1 group mode UX — enhanced HUD, lifecycle fix, groups/tags distinction

Three changes for 'Feel the group' UX:

1. Enhanced HUD for group mode: Shows colored accent stripe and prominent
group name banner when mode is 'group'. Uses the group's color from
metadata for visual theming.

2. Fix group mode lifecycle: Closing the groups panel no longer resets
group mode on all member windows. Group mode persists until the user
explicitly navigates back to the groups list or changes mode.

3. Groups vs Tags distinction via isGroup metadata flag: The groups UI
now filters to show only tags promoted to groups (isGroup: true in
tag metadata). Unpromoted tags are accessible via an 'All Tags'
toggle with promote buttons. Creating a group via the UI or saving
to a group auto-sets isGroup: true.

+297 -34
+24 -8
extensions/groups/background.js
··· 194 194 return { success: false, error: tagResult.error }; 195 195 } 196 196 197 - const tagId = tagResult.data.tag.id; 197 + const tag = tagResult.data.tag; 198 + const tagId = tag.id; 199 + 200 + // Auto-promote: ensure the tag has isGroup: true in metadata 201 + try { 202 + let meta = {}; 203 + if (tag.metadata) { 204 + meta = typeof tag.metadata === 'object' ? tag.metadata : JSON.parse(tag.metadata); 205 + } 206 + if (!meta.isGroup) { 207 + meta.isGroup = true; 208 + await api.datastore.setRow('tags', tagId, { ...tag, metadata: JSON.stringify(meta) }); 209 + debug && console.log('[ext:groups] Auto-promoted tag to group:', groupName); 210 + } 211 + } catch (err) { 212 + console.error('[ext:groups] Failed to auto-promote tag:', err); 213 + } 198 214 199 215 const listResult = await api.window.list({ includeInternal: false }); 200 216 if (!listResult.success || listResult.windows.length === 0) { ··· 380 396 // Register commands (cmd loads first with its subscribers ready via 100ms head start) 381 397 initCommands(); 382 398 383 - // Listen for window close events to cleanup group mode 399 + // Listen for window close events to clean up groups window tracking 400 + // NOTE: We do NOT exit group mode when the groups panel closes. 401 + // Group mode persists on member windows. It is only cleared when: 402 + // - The user navigates back to the groups list (handled in home.js showGroups) 403 + // - The user explicitly changes mode 384 404 api.subscribe('window:closed', async (msg) => { 385 405 const closedWindowId = msg?.id; 386 406 387 - // If the groups window closed, exit group mode for all windows in the active group 388 - if (closedWindowId === groupsWindowId && activeGroupId) { 389 - debug && console.log('[ext:groups] Groups window closed, exiting group mode for active group'); 390 - await exitGroupMode(activeGroupId); 391 - activeGroupId = null; 392 - activeGroupName = null; 407 + if (closedWindowId === groupsWindowId) { 408 + debug && console.log('[ext:groups] Groups window closed, keeping group mode on member windows'); 393 409 groupsWindowId = null; 394 410 } 395 411 }, api.scopes.GLOBAL);
+59
extensions/groups/home.css
··· 180 180 color: var(--base03); 181 181 font-size: 15px; 182 182 } 183 + 184 + /* All Tags toggle section */ 185 + .all-tags-toggle { 186 + grid-column: 1 / -1; 187 + padding: 8px 0; 188 + border-top: 1px solid var(--base02); 189 + margin-top: 8px; 190 + } 191 + 192 + .all-tags-btn { 193 + background: none; 194 + border: 1px solid var(--base02); 195 + border-radius: 6px; 196 + color: var(--base04); 197 + font-size: 12px; 198 + cursor: pointer; 199 + padding: 6px 12px; 200 + transition: all 0.15s; 201 + } 202 + 203 + .all-tags-btn:hover { 204 + color: var(--base05); 205 + border-color: var(--base03); 206 + background: var(--base01); 207 + } 208 + 209 + /* Unpromoted tag cards — slightly dimmed */ 210 + peek-card.unpromoted-tag { 211 + opacity: 0.7; 212 + } 213 + 214 + peek-card.unpromoted-tag:hover { 215 + opacity: 1; 216 + } 217 + 218 + /* Promote button on unpromoted tag cards */ 219 + .promote-btn { 220 + display: flex; 221 + align-items: center; 222 + justify-content: center; 223 + width: 22px; 224 + height: 22px; 225 + background: var(--base02); 226 + border: 1px solid var(--base03); 227 + border-radius: 4px; 228 + color: var(--base04); 229 + font-size: 14px; 230 + font-weight: 600; 231 + cursor: pointer; 232 + flex-shrink: 0; 233 + transition: all 0.15s; 234 + line-height: 1; 235 + } 236 + 237 + .promote-btn:hover { 238 + background: var(--base0D); 239 + color: var(--base00); 240 + border-color: var(--base0D); 241 + }
+117 -18
extensions/groups/home.js
··· 70 70 addresses: [], 71 71 untaggedCount: 0, 72 72 selectedIndex: 0, 73 - searchQuery: '' 73 + searchQuery: '', 74 + showAllTags: false 74 75 }; 75 76 76 77 // Expose state for debugging in tests 77 78 window._groupsState = state; 78 79 79 80 /** 81 + * Parse tag metadata JSON safely 82 + */ 83 + const parseTagMetadata = (tag) => { 84 + if (!tag.metadata) return {}; 85 + if (typeof tag.metadata === 'object') return tag.metadata; 86 + try { 87 + return JSON.parse(tag.metadata); 88 + } catch { 89 + return {}; 90 + } 91 + }; 92 + 93 + /** 94 + * Check if a tag is promoted to a group (has isGroup: true in metadata) 95 + */ 96 + const isGroupTag = (tag) => { 97 + const meta = parseTagMetadata(tag); 98 + return meta.isGroup === true; 99 + }; 100 + 101 + /** 102 + * Promote a tag to a group by setting isGroup: true in its metadata 103 + */ 104 + const promoteTagToGroup = async (tag) => { 105 + const meta = parseTagMetadata(tag); 106 + meta.isGroup = true; 107 + const metadataStr = JSON.stringify(meta); 108 + try { 109 + await api.datastore.setRow('tags', tag.id, { 110 + ...tag, 111 + metadata: metadataStr 112 + }); 113 + tag.metadata = metadataStr; 114 + debug && console.log('[groups] Promoted tag to group:', tag.name); 115 + return true; 116 + } catch (err) { 117 + console.error('[groups] Failed to promote tag to group:', err); 118 + return false; 119 + } 120 + }; 121 + 122 + /** 80 123 * Internal ESC handler for groups navigation 81 124 * Returns { handled: true } if we navigated internally 82 125 * Returns { handled: false } if at root (groups list) and window should close ··· 385 428 const result = await api.datastore.getOrCreateTag(name); 386 429 if (result.success) { 387 430 debug && console.log('[groups] Created new group (tag):', name, result.data); 431 + // Mark the tag as a group 432 + const tag = result.data.tag; 433 + await promoteTagToGroup(tag); 388 434 hideForm(); 389 435 // Refresh groups list 390 436 await loadTags(); ··· 667 713 container.innerHTML = ''; 668 714 updateToolbarSortOptions(); 669 715 670 - // Build list of all groups (untagged first if it has items) 671 - let allGroups = []; 716 + // Separate promoted groups from regular tags 717 + const promotedGroups = state.tags.filter(tag => isGroupTag(tag)); 718 + const unpromotedTags = state.tags.filter(tag => !isGroupTag(tag)); 719 + 720 + // Build list of groups to show 721 + let groupsToShow = []; 672 722 if (state.untaggedCount > 0) { 673 - allGroups.push({ ...UNTAGGED_GROUP, frequency: state.untaggedCount }); 723 + groupsToShow.push({ ...UNTAGGED_GROUP, frequency: state.untaggedCount }); 674 724 } 725 + groupsToShow = groupsToShow.concat(promotedGroups); 675 726 676 - // Add all tags (including empty ones so newly created groups appear) 677 - allGroups = allGroups.concat(state.tags); 727 + // Apply search filter 728 + let filteredGroups = filterGroups(groupsToShow); 678 729 679 - // Apply search filter 680 - let filteredGroups = filterGroups(allGroups); 730 + // Apply sorting 731 + filteredGroups = sortGroups(filteredGroups); 681 732 682 - if (filteredGroups.length === 0) { 733 + if (filteredGroups.length === 0 && !state.showAllTags) { 683 734 const message = state.searchQuery 684 735 ? 'No groups match your search.' 685 - : 'No groups yet. Tag some pages to create groups.'; 736 + : 'No groups yet. Create a group or promote tags below.'; 686 737 container.innerHTML = `<div class="empty-state">${message}</div>`; 687 - return; 738 + } else { 739 + filteredGroups.forEach(tag => { 740 + const card = createGroupCard(tag); 741 + container.appendChild(card); 742 + }); 688 743 } 689 744 690 - // Apply sorting 691 - filteredGroups = sortGroups(filteredGroups); 745 + // "All Tags" toggle section — show unpromoted tags so users can promote them 746 + const hasUnpromotedTags = unpromotedTags.length > 0; 747 + if (hasUnpromotedTags) { 748 + const toggleContainer = document.createElement('div'); 749 + toggleContainer.className = 'all-tags-toggle'; 750 + 751 + const toggleBtn = document.createElement('button'); 752 + toggleBtn.className = 'all-tags-btn'; 753 + toggleBtn.textContent = state.showAllTags 754 + ? `Hide tags (${unpromotedTags.length})` 755 + : `Show all tags (${unpromotedTags.length})`; 756 + toggleBtn.addEventListener('click', () => { 757 + state.showAllTags = !state.showAllTags; 758 + renderGroups(); 759 + }); 760 + toggleContainer.appendChild(toggleBtn); 761 + container.appendChild(toggleContainer); 692 762 693 - filteredGroups.forEach(tag => { 694 - const card = createGroupCard(tag); 695 - container.appendChild(card); 696 - }); 763 + if (state.showAllTags) { 764 + let filteredTags = filterGroups(unpromotedTags); 765 + filteredTags = sortGroups(filteredTags); 766 + filteredTags.forEach(tag => { 767 + const card = createGroupCard(tag, { showPromote: true }); 768 + container.appendChild(card); 769 + }); 770 + } 771 + } 697 772 698 773 // Reset selection 699 774 state.selectedIndex = 0; ··· 787 862 788 863 /** 789 864 * Create a card element for a group (tag) 865 + * @param {object} tag - Tag object 866 + * @param {object} [options] - Options 867 + * @param {boolean} [options.showPromote] - Show "Add to Groups" button for unpromoted tags 790 868 */ 791 - const createGroupCard = (tag) => { 869 + const createGroupCard = (tag, options = {}) => { 792 870 const card = document.createElement('peek-card'); 793 871 card.className = 'group-card'; 794 872 card.interactive = true; ··· 796 874 if (tag.isSpecial) { 797 875 card.classList.add('special-group'); 798 876 } 877 + if (options.showPromote) { 878 + card.classList.add('unpromoted-tag'); 879 + } 799 880 card.dataset.tagId = tag.id; 800 881 801 882 // Header slot: color dot + name ··· 813 894 title.className = 'card-title'; 814 895 title.textContent = tag.name; 815 896 title.style.margin = '0'; 897 + title.style.flex = '1'; 898 + title.style.minWidth = '0'; 816 899 817 900 header.appendChild(colorDot); 818 901 header.appendChild(title); 902 + 903 + // Add "promote to group" button for unpromoted tags 904 + if (options.showPromote) { 905 + const promoteBtn = document.createElement('button'); 906 + promoteBtn.className = 'promote-btn'; 907 + promoteBtn.title = 'Add to Groups'; 908 + promoteBtn.textContent = '+'; 909 + promoteBtn.addEventListener('click', async (e) => { 910 + e.stopPropagation(); 911 + await promoteTagToGroup(tag); 912 + await loadTags(); 913 + renderGroups(); 914 + }); 915 + header.appendChild(promoteBtn); 916 + } 917 + 819 918 card.appendChild(header); 820 919 821 920 // Footer slot: count
+5
extensions/hud/hud.html
··· 9 9 </head> 10 10 <body> 11 11 <div id="hud-container"> 12 + <div id="group-accent" class="group-accent" style="display: none;"></div> 13 + <div id="group-banner" class="group-banner" style="display: none;"> 14 + <div id="group-name" class="group-name"></div> 15 + </div> 16 + 12 17 <div class="hud-section"> 13 18 <div class="hud-label">Mode</div> 14 19 <div id="mode-value" class="hud-value">-</div>
+21
extensions/hud/hud.js
··· 14 14 const izuiValue = document.getElementById('izui-value'); 15 15 const windowTitle = document.getElementById('window-title'); 16 16 const statsValue = document.getElementById('stats-value'); 17 + const groupAccent = document.getElementById('group-accent'); 18 + const groupBanner = document.getElementById('group-banner'); 19 + const groupName = document.getElementById('group-name'); 20 + const hudContainer = document.getElementById('hud-container'); 17 21 18 22 // Current state 19 23 let currentMode = 'default'; ··· 56 60 modeValue.textContent = displayText; 57 61 modeValue.classList.add(`mode-${currentMode}`); 58 62 63 + // Update group visual treatment 64 + if (currentMode === 'group' && currentModeMetadata.groupName) { 65 + const color = currentModeMetadata.color || '#999'; 66 + groupAccent.style.background = color; 67 + groupAccent.style.display = ''; 68 + groupName.textContent = currentModeMetadata.groupName; 69 + groupBanner.style.display = ''; 70 + hudContainer.classList.add('hud-group-active'); 71 + hudContainer.style.setProperty('--group-color', color + '40'); 72 + } else { 73 + groupAccent.style.display = 'none'; 74 + groupBanner.style.display = 'none'; 75 + hudContainer.classList.remove('hud-group-active'); 76 + hudContainer.style.removeProperty('--group-color'); 77 + } 78 + 79 + autoResizeWindow(); 59 80 debug && console.log('[hud] Updated mode display:', displayText); 60 81 }; 61 82
+37 -1
extensions/hud/styles.css
··· 76 76 } 77 77 78 78 /* Mode and IZUI state classes (no color overrides — all muted) */ 79 - .mode-default, .mode-page, .mode-group, .mode-settings, 79 + .mode-default, .mode-page, .mode-settings, 80 80 .izui-idle, .izui-transient, .izui-active, .izui-overlay { 81 81 color: inherit; 82 82 } 83 + 84 + /* Group mode — elevated visual treatment */ 85 + .mode-group { 86 + color: rgba(255, 255, 255, 0.7); 87 + font-weight: 500; 88 + } 89 + 90 + /* Group accent stripe — colored bar at top of HUD */ 91 + .group-accent { 92 + height: 3px; 93 + border-radius: 3px 3px 0 0; 94 + margin: -10px -10px 8px -10px; 95 + background: #999; 96 + } 97 + 98 + /* Group banner — prominent group name */ 99 + .group-banner { 100 + margin-bottom: 8px; 101 + padding-bottom: 6px; 102 + border-bottom: 1px solid rgba(255, 255, 255, 0.06); 103 + } 104 + 105 + .group-name { 106 + font-size: 13px; 107 + font-weight: 600; 108 + color: rgba(255, 255, 255, 0.75); 109 + letter-spacing: 0.2px; 110 + white-space: nowrap; 111 + overflow: hidden; 112 + text-overflow: ellipsis; 113 + } 114 + 115 + /* When in group mode, tint the container border */ 116 + #hud-container.hud-group-active { 117 + border-color: var(--group-color, rgba(255, 255, 255, 0.04)); 118 + }
+10 -1
tests/desktop/groups-context.spec.ts
··· 36 36 urls: string[] 37 37 ): Promise<{ tagId: string; itemIds: string[] }> { 38 38 const tagResult = await bgWindow.evaluate(async (name: string) => { 39 - return await (window as any).app.datastore.getOrCreateTag(name); 39 + const result = await (window as any).app.datastore.getOrCreateTag(name); 40 + if (result.success) { 41 + // Promote tag to group so it appears in the groups list 42 + const tag = result.data.tag; 43 + let meta = {}; 44 + try { meta = tag.metadata ? JSON.parse(tag.metadata) : {}; } catch {} 45 + meta.isGroup = true; 46 + await (window as any).app.datastore.setRow('tags', tag.id, { ...tag, metadata: JSON.stringify(meta) }); 47 + } 48 + return result; 40 49 }, groupName); 41 50 expect(tagResult.success).toBe(true); 42 51 const tagId = tagResult.data?.tag?.id || tagResult.data?.data?.id || tagResult.data?.id;
+24 -6
tests/desktop/smoke.spec.ts
··· 207 207 test.describe('Groups Navigation @desktop', () => { 208 208 test('groups to group to url and back navigation', async () => { 209 209 const bgWindow = sharedBgWindow; 210 - // Create a tag/group with some items 210 + // Create a tag/group with some items and promote it to a group 211 211 const tagResult = await bgWindow.evaluate(async () => { 212 - return await (window as any).app.datastore.getOrCreateTag('test-group'); 212 + const result = await (window as any).app.datastore.getOrCreateTag('test-group'); 213 + if (result.success) { 214 + const tag = result.data.tag; 215 + let meta = {}; 216 + try { meta = tag.metadata ? JSON.parse(tag.metadata) : {}; } catch {} 217 + meta.isGroup = true; 218 + await (window as any).app.datastore.setRow('tags', tag.id, { ...tag, metadata: JSON.stringify(meta) }); 219 + } 220 + return result; 213 221 }); 214 222 expect(tagResult.success).toBe(true); 215 223 const tagId = tagResult.data?.tag?.id || tagResult.data?.data?.id || tagResult.data?.id; ··· 2230 2238 }); 2231 2239 2232 2240 test('empty groups are not shown in groups list', async () => { 2233 - // Create an empty tag (group with no items) 2241 + // Create an empty tag (group with no items) and promote it 2234 2242 const emptyTag = await bgWindow.evaluate(async () => { 2235 - return await (window as any).app.datastore.getOrCreateTag('empty-group-test'); 2243 + const result = await (window as any).app.datastore.getOrCreateTag('empty-group-test'); 2244 + if (result.success) { 2245 + const tag = result.data.tag; 2246 + await (window as any).app.datastore.setRow('tags', tag.id, { ...tag, metadata: JSON.stringify({ isGroup: true }) }); 2247 + } 2248 + return result; 2236 2249 }); 2237 2250 expect(emptyTag.success).toBe(true); 2238 2251 2239 - // Create a tag with an item 2252 + // Create a tag with an item and promote it 2240 2253 const nonEmptyTag = await bgWindow.evaluate(async () => { 2241 - return await (window as any).app.datastore.getOrCreateTag('non-empty-group-test'); 2254 + const result = await (window as any).app.datastore.getOrCreateTag('non-empty-group-test'); 2255 + if (result.success) { 2256 + const tag = result.data.tag; 2257 + await (window as any).app.datastore.setRow('tags', tag.id, { ...tag, metadata: JSON.stringify({ isGroup: true }) }); 2258 + } 2259 + return result; 2242 2260 }); 2243 2261 expect(nonEmptyTag.success).toBe(true); 2244 2262