experiments in a post-browser web
10
fork

Configure Feed

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

feat: deduplicate favicon fallback logic, use centralized card-helpers

+345 -58
+52
backend/electron/datastore.ts
··· 2669 2669 return getDb().prepare('SELECT * FROM tags WHERE id = ?').get(id) as Tag; 2670 2670 } 2671 2671 2672 + export function updateTagColor(id: string, color: string): Tag | null { 2673 + const timestamp = now(); 2674 + const result = getDb().prepare( 2675 + 'UPDATE tags SET color = ?, updatedAt = ? WHERE id = ?' 2676 + ).run(color, timestamp, id); 2677 + if (result.changes === 0) return null; 2678 + return getDb().prepare('SELECT * FROM tags WHERE id = ?').get(id) as Tag; 2679 + } 2680 + 2672 2681 export function deleteTag(id: string): boolean { 2673 2682 const db = getDb(); 2674 2683 db.prepare('DELETE FROM item_tags WHERE tagId = ?').run(id); ··· 3006 3015 if (!existing.title || existing.title === 'Loading...') { 3007 3016 d.prepare('UPDATE items SET title = ?, updatedAt = ? WHERE id = ?') 3008 3017 .run(title, Date.now(), existing.id); 3018 + return true; 3019 + } 3020 + return false; 3021 + } 3022 + 3023 + /** 3024 + * Update the favicon of a URL item if it's currently empty. 3025 + * Used by page-favicon-updated handlers and entity enrichment. 3026 + */ 3027 + export function updateItemFavicon(url: string, faviconUrl: string): boolean { 3028 + const normalizedUri = normalizeUrl(url); 3029 + const d = getDb(); 3030 + 3031 + // Phase 1: Try exact normalized URL match 3032 + let existing = d.prepare( 3033 + 'SELECT id, favicon FROM items WHERE type = ? AND content = ? AND deletedAt = 0' 3034 + ).get('url', normalizedUri) as { id: string; favicon: string | null } | undefined; 3035 + 3036 + // Phase 2: If no match, try with raw URL 3037 + if (!existing && url !== normalizedUri) { 3038 + existing = d.prepare( 3039 + 'SELECT id, favicon FROM items WHERE type = ? AND content = ? AND deletedAt = 0' 3040 + ).get('url', url) as { id: string; favicon: string | null } | undefined; 3041 + } 3042 + 3043 + // Phase 3: Domain-based lookup for the most recent item missing a favicon 3044 + if (!existing) { 3045 + try { 3046 + const parsed = new URL(url); 3047 + const domain = parsed.hostname; 3048 + existing = d.prepare( 3049 + `SELECT id, favicon FROM items WHERE type = ? AND domain = ? AND deletedAt = 0 3050 + AND (favicon = '' OR favicon IS NULL) 3051 + ORDER BY updatedAt DESC LIMIT 1` 3052 + ).get('url', domain) as { id: string; favicon: string | null } | undefined; 3053 + } catch { /* invalid URL, skip phase 3 */ } 3054 + } 3055 + 3056 + if (!existing) return false; 3057 + 3058 + if (!existing.favicon) { 3059 + d.prepare('UPDATE items SET favicon = ?, updatedAt = ? WHERE id = ?') 3060 + .run(faviconUrl, Date.now(), existing.id); 3009 3061 return true; 3010 3062 } 3011 3063 return false;
+88
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 8 9 * 9 10 * These are logic-only tests — no DOM, no Electron, no IPC runtime. 10 11 */ ··· 386 387 assert.strictEqual(computeBorderAction(windows), 'show'); 387 388 }); 388 389 }); 390 + 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. 395 + 396 + interface ColorPersistResult { 397 + shouldPersist: boolean; 398 + resolvedColor: string; 399 + } 400 + 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 + } 411 + 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)); 416 + }); 417 + 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)); 422 + }); 423 + 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)); 428 + }); 429 + 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)); 434 + }); 435 + 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'); 440 + }); 441 + 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'); 446 + }); 447 + 448 + it('does NOT persist when groupId is undefined', () => { 449 + const result = shouldPersistColor('#999', undefined); 450 + assert.strictEqual(result.shouldPersist, false); 451 + }); 452 + 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); 457 + }); 458 + 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); 463 + 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); 468 + }); 469 + 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'); 475 + }); 476 + });
+88
backend/electron/ipc.ts
··· 16 16 queryContent, 17 17 getOrCreateTag, 18 18 renameTag, 19 + updateTagColor, 19 20 deleteTag, 20 21 getTagsByFrecency, 21 22 getTable, ··· 29 30 getItem, 30 31 updateItem, 31 32 updateItemTitle, 33 + updateItemFavicon, 32 34 deleteItem, 33 35 hardDeleteItem, 34 36 queryItems, ··· 718 720 if (!isValidTable(tableName)) { 719 721 return { success: false, error: `Invalid table: ${tableName}` }; 720 722 } 723 + 724 + // When promoting a tag to a group, ensure it gets a vivid color 725 + let assignedVividColor = false; 726 + if (tableName === 'tags' && rowData) { 727 + let tagMeta: Record<string, unknown> | null = null; 728 + try { 729 + tagMeta = rowData.metadata 730 + ? (typeof rowData.metadata === 'string' ? JSON.parse(rowData.metadata) : rowData.metadata) 731 + : null; 732 + } catch { /* ignore parse errors */ } 733 + 734 + if (tagMeta?.isGroup && (!rowData.color || rowData.color === '#999' || rowData.color === '#999999')) { 735 + const vividColor = resolveGroupBorderColor(rowData.color, rowId); 736 + rowData.color = vividColor; 737 + assignedVividColor = true; 738 + DEBUG && console.log('[ipc] Auto-assigned vivid color to group:', rowId, vividColor); 739 + } 740 + } 741 + 721 742 const result = setRow(tableName, rowId, rowData); 743 + 744 + // Publish color-changed event so UI dots update immediately 745 + if (assignedVividColor) { 746 + publish('system', PubSubScopes.GLOBAL, 'tag:color-changed', { 747 + tagId: rowId, 748 + color: rowData.color 749 + }); 750 + } 751 + 722 752 return { success: true, data: result }; 723 753 } catch (error) { 724 754 const message = error instanceof Error ? error.message : String(error); ··· 855 885 } 856 886 }); 857 887 888 + ipcMain.handle('datastore-update-tag-color', async (ev, data) => { 889 + try { 890 + const result = updateTagColor(data.tagId, data.color); 891 + if (result) { 892 + publish('system', PubSubScopes.GLOBAL, 'tag:color-changed', { 893 + tagId: data.tagId, 894 + color: data.color 895 + }); 896 + DEBUG && console.log('[ipc] tag:color-changed', data.tagId, data.color); 897 + } 898 + return { success: true, data: result }; 899 + } catch (error) { 900 + const message = error instanceof Error ? error.message : String(error); 901 + return { success: false, error: message }; 902 + } 903 + }); 904 + 858 905 ipcMain.handle('datastore-delete-tag', async (ev, data) => { 859 906 try { 860 907 const result = deleteTag(data.tagId); ··· 1000 1047 ipcMain.handle('datastore-update-item-title', async (ev, data) => { 1001 1048 try { 1002 1049 const result = updateItemTitle(data.url, data.title); 1050 + return { success: true, data: result }; 1051 + } catch (error) { 1052 + const message = error instanceof Error ? error.message : String(error); 1053 + return { success: false, error: message }; 1054 + } 1055 + }); 1056 + 1057 + ipcMain.handle('datastore-update-item-favicon', async (ev, data) => { 1058 + try { 1059 + const result = updateItemFavicon(data.url, data.faviconUrl); 1003 1060 return { success: true, data: result }; 1004 1061 } catch (error) { 1005 1062 const message = error instanceof Error ? error.message : String(error); ··· 2813 2870 } 2814 2871 }); 2815 2872 2873 + // Capture favicon when webview guest page provides one 2874 + guestWebContents.on('page-favicon-updated', (_event: Electron.Event, favicons: string[]) => { 2875 + const guestUrl = guestWebContents.getURL(); 2876 + if (guestUrl && guestUrl.startsWith('http') && favicons.length > 0) { 2877 + // Prefer PNG/SVG over ICO, pick last (usually highest resolution) 2878 + const best = favicons.find(f => f.endsWith('.svg')) || 2879 + favicons.find(f => f.endsWith('.png')) || 2880 + favicons[favicons.length - 1]; 2881 + try { 2882 + updateItemFavicon(guestUrl, best); 2883 + } catch (e) { 2884 + DEBUG && console.log('Failed to update favicon from guest page-favicon-updated:', e); 2885 + } 2886 + } 2887 + }); 2888 + 2816 2889 guestWebContents.setWindowOpenHandler(({ url: popupUrl }) => { 2817 2890 if (popupUrl.startsWith('http://') || popupUrl.startsWith('https://')) { 2818 2891 console.log(`[webview-popup] Intercepted popup from window ${win.id}: ${popupUrl}`); ··· 3077 3150 title, 3078 3151 windowId: win.id, 3079 3152 }); 3153 + } 3154 + }); 3155 + 3156 + // Capture favicon for non-canvas web pages 3157 + win.webContents.on('page-favicon-updated', (_event: Electron.Event, favicons: string[]) => { 3158 + const pageUrl = win.webContents.getURL() || url; 3159 + if (pageUrl && pageUrl.startsWith('http') && favicons.length > 0) { 3160 + const best = favicons.find(f => f.endsWith('.svg')) || 3161 + favicons.find(f => f.endsWith('.png')) || 3162 + favicons[favicons.length - 1]; 3163 + try { 3164 + updateItemFavicon(pageUrl, best); 3165 + } catch (e) { 3166 + DEBUG && console.log('Failed to update favicon from page-favicon-updated:', e); 3167 + } 3080 3168 } 3081 3169 }); 3082 3170 }
+16 -1
backend/electron/main.ts
··· 10 10 import fs from 'node:fs'; 11 11 import { pathToFileURL } from 'node:url'; 12 12 13 - import { initDatabase, closeDatabase, getDb, trackWindowLoad, updateItemTitle, updateModeForNavigation, getContextEntry } from './datastore.js'; 13 + import { initDatabase, closeDatabase, getDb, trackWindowLoad, updateItemTitle, updateItemFavicon, updateModeForNavigation, getContextEntry } from './datastore.js'; 14 14 import { registerScheme, initProtocol, registerExtensionPath, getExtensionPath, getRegisteredExtensionIds, registerThemePath, getRegisteredThemeIds } from './protocol.js'; 15 15 import { discoverExtensions, loadExtensionManifest, isBuiltinExtensionEnabled, getExternalExtensions, type ExtensionManifest, type ManifestCommand, type ManifestShortcut } from './extensions.js'; 16 16 import { initTray } from './tray.js'; ··· 1883 1883 updateItemTitle(newWin.webContents.getURL() || details.url, title); 1884 1884 } catch (e) { 1885 1885 DEBUG && console.log('Failed to update title from page-title-updated:', e); 1886 + } 1887 + } 1888 + }); 1889 + 1890 + // Capture favicon for background child windows 1891 + newWin.webContents.on('page-favicon-updated', (_event: Electron.Event, favicons: string[]) => { 1892 + const pageUrl = newWin.webContents.getURL() || details.url; 1893 + if (pageUrl && favicons.length > 0) { 1894 + const best = favicons.find(f => f.endsWith('.svg')) || 1895 + favicons.find(f => f.endsWith('.png')) || 1896 + favicons[favicons.length - 1]; 1897 + try { 1898 + updateItemFavicon(pageUrl, best); 1899 + } catch (e) { 1900 + DEBUG && console.log('Failed to update favicon from page-favicon-updated:', e); 1886 1901 } 1887 1902 } 1888 1903 });
+32 -17
features/groups/home.css
··· 306 306 font-size: 13px; 307 307 } 308 308 309 - /* All Tags toggle section */ 309 + /* Tags section separator */ 310 310 .all-tags-toggle { 311 311 grid-column: 1 / -1; 312 - padding: 8px 0; 312 + padding: 12px 0 4px; 313 313 border-top: 1px solid var(--base02); 314 - margin-top: 8px; 314 + margin-top: 12px; 315 315 } 316 316 317 - .all-tags-btn { 318 - background: none; 319 - border: 1px solid var(--base02); 320 - border-radius: 5px; 321 - color: var(--base04); 317 + .all-tags-label { 318 + color: var(--base03); 322 319 font-size: 11px; 323 - cursor: pointer; 324 - padding: 4px 10px; 325 - transition: all 0.15s; 326 - } 327 - 328 - .all-tags-btn:hover { 329 - color: var(--base05); 330 - border-color: var(--base03); 331 - background: var(--base01); 320 + font-weight: 600; 321 + letter-spacing: 0.5px; 322 + text-transform: uppercase; 332 323 } 333 324 334 325 /* Group detail header — shown at the very top when viewing a group's contents */ ··· 494 485 .group-rename-input:focus { 495 486 border-color: var(--base0D); 496 487 box-shadow: 0 0 0 1px var(--base0D); 488 + } 489 + 490 + /* Group header color dot — clickable color picker trigger */ 491 + .group-header-color-dot { 492 + width: 14px; 493 + height: 14px; 494 + border-radius: 50%; 495 + flex-shrink: 0; 496 + cursor: pointer; 497 + border: 1px solid var(--base02); 498 + transition: transform 0.15s, box-shadow 0.15s; 499 + } 500 + 501 + .group-header-color-dot:hover { 502 + transform: scale(1.2); 503 + box-shadow: 0 0 0 2px var(--base02); 504 + } 505 + 506 + .group-header-color-input { 507 + position: absolute; 508 + width: 0; 509 + height: 0; 510 + opacity: 0; 511 + pointer-events: none; 497 512 } 498 513 499 514 /* Unpromoted tag cards — slightly dimmed */
+55 -29
features/groups/home.js
··· 85 85 untaggedCount: 0, 86 86 selectedIndex: 0, 87 87 searchQuery: '', 88 - showAllTags: false, 89 88 lastViewedTagId: null 90 89 }; 91 90 ··· 451 450 debouncedRefresh(); 452 451 }, api.scopes.GLOBAL); 453 452 453 + // Subscribe to color changes (e.g. border color resolution persisted back) 454 + api.subscribe('tag:color-changed', (msg) => { 455 + debug && console.log('[groups] tag:color-changed event received:', msg); 456 + debouncedRefresh(); 457 + }, api.scopes.GLOBAL); 458 + 454 459 // Set up create group UI 455 460 setupCreateGroup(); 456 461 ··· 749 754 // Apply sorting 750 755 filteredGroups = sortGroups(filteredGroups); 751 756 752 - if (filteredGroups.length === 0 && !state.showAllTags) { 757 + if (filteredGroups.length === 0) { 753 758 const message = state.searchQuery 754 759 ? 'No groups match your search.' 755 760 : 'No groups yet. Create a group or promote tags below.'; ··· 761 766 }); 762 767 } 763 768 764 - // "All Tags" toggle section — show unpromoted tags so users can promote them 769 + // Tags section — always show unpromoted tags below a separator 765 770 const hasUnpromotedTags = unpromotedTags.length > 0; 766 771 if (hasUnpromotedTags) { 767 - const toggleContainer = document.createElement('div'); 768 - toggleContainer.className = 'all-tags-toggle'; 772 + const separator = document.createElement('div'); 773 + separator.className = 'all-tags-toggle'; 769 774 770 - const toggleBtn = document.createElement('button'); 771 - toggleBtn.className = 'all-tags-btn'; 772 - toggleBtn.textContent = state.showAllTags 773 - ? `Hide tags (${unpromotedTags.length})` 774 - : `Show all tags (${unpromotedTags.length})`; 775 - toggleBtn.addEventListener('click', () => { 776 - state.showAllTags = !state.showAllTags; 777 - renderGroups(); 778 - }); 779 - toggleContainer.appendChild(toggleBtn); 780 - container.appendChild(toggleContainer); 775 + const label = document.createElement('span'); 776 + label.className = 'all-tags-label'; 777 + label.textContent = `Tags (${unpromotedTags.length})`; 778 + separator.appendChild(label); 779 + container.appendChild(separator); 781 780 782 - if (state.showAllTags) { 783 - let filteredTags = filterGroups(unpromotedTags); 784 - filteredTags = sortGroups(filteredTags); 785 - filteredTags.forEach(tag => { 786 - const card = createGroupCard(tag, { showPromote: true }); 787 - container.appendChild(card); 788 - }); 789 - } 781 + let filteredTags = filterGroups(unpromotedTags); 782 + filteredTags = sortGroups(filteredTags); 783 + filteredTags.forEach(tag => { 784 + const card = createGroupCard(tag, { showPromote: true }); 785 + container.appendChild(card); 786 + }); 790 787 } 791 788 792 789 // Select the last-viewed group if returning from addresses view, otherwise reset to 0 ··· 898 895 } 899 896 header.appendChild(nameEl); 900 897 901 - // Rename button (not for special groups) 898 + // Rename button + color dot (not for special groups) 902 899 if (!tag.isSpecial) { 903 900 const renameBtn = document.createElement('button'); 904 901 renameBtn.className = 'group-rename-btn'; ··· 910 907 '</svg>'; 911 908 renameBtn.addEventListener('click', () => startRenameGroup(header, tag)); 912 909 header.appendChild(renameBtn); 910 + 911 + // Color dot with hidden color picker 912 + const colorDot = document.createElement('div'); 913 + colorDot.className = 'group-header-color-dot'; 914 + colorDot.style.backgroundColor = tag.color || '#999'; 915 + colorDot.title = 'Change group color'; 916 + 917 + const colorInput = document.createElement('input'); 918 + colorInput.type = 'color'; 919 + colorInput.className = 'group-header-color-input'; 920 + colorInput.value = tag.color || '#999999'; 921 + colorInput.addEventListener('input', (e) => { 922 + colorDot.style.backgroundColor = e.target.value; 923 + }); 924 + colorInput.addEventListener('change', async (e) => { 925 + const newColor = e.target.value; 926 + tag.color = newColor; 927 + await api.datastore.updateTagColor(tag.id, newColor); 928 + // Update group mode context if this is the active group 929 + if (api.context) { 930 + await api.context.setMode('group', { 931 + metadata: { groupId: tag.id, groupName: tag.name, color: newColor } 932 + }); 933 + } 934 + }); 935 + 936 + colorDot.addEventListener('click', () => colorInput.click()); 937 + colorDot.appendChild(colorInput); 938 + header.appendChild(colorDot); 913 939 } 914 940 915 941 // Spacer to push view mode buttons to the right ··· 1159 1185 api.window.open(url, { 1160 1186 role: 'content', 1161 1187 key: url, 1162 - width: 800, 1163 - height: 600 1188 + width: 1024, 1189 + height: 768 1164 1190 }); 1165 1191 }, 1166 1192 onDelete: async (item) => { ··· 1192 1218 const openOptions = { 1193 1219 role: 'content', 1194 1220 key: addressUrl, 1195 - width: 800, 1196 - height: 600 1221 + width: 1024, 1222 + height: 768 1197 1223 }; 1198 1224 if (state.currentTag && state.currentTag.id !== '__untagged__') { 1199 1225 openOptions.groupMode = {
+1 -1
features/groups/manifest.json
··· 19 19 "action": { 20 20 "type": "window", 21 21 "url": "peek://ext/groups/home.html", 22 - "options": { "role": "workspace", "width": 800, "height": 600 } 22 + "options": { "role": "workspace", "width": 1024, "height": 768 } 23 23 } 24 24 }, 25 25 {
+4 -4
features/search/home.js
··· 285 285 api.window.open(url, { 286 286 role: 'content', 287 287 key: url, 288 - width: 800, 289 - height: 600 288 + width: 1024, 289 + height: 768 290 290 }); 291 291 }, 292 292 onDelete: async (item) => { ··· 326 326 await api.window.open(itemUrl, { 327 327 role: 'content', 328 328 key: itemUrl, 329 - width: 800, 330 - height: 600 329 + width: 1024, 330 + height: 768 331 331 }); 332 332 } else if (itemType === 'text') { 333 333 api.publish('editor:open', { itemId: item.id }, api.scopes.GLOBAL);
+2 -2
features/tags/home.js
··· 44 44 try { 45 45 await api.window.open(url, { 46 46 role: 'content', 47 - width: 800, 48 - height: 600, 47 + width: 1024, 48 + height: 768, 49 49 trackingSource: 'tags', 50 50 trackingSourceId: 'note-url' 51 51 });
+2 -2
features/wonderwall/background.js
··· 13 13 api.window.open('peek://ext/wonderwall/home.html', { 14 14 role: 'workspace', 15 15 key: 'wonderwall-home', 16 - width: 800, 17 - height: 600, 16 + width: 1024, 17 + height: 768, 18 18 title: 'Wonderwall' 19 19 }); 20 20 }
+2 -2
features/wonderwall/manifest.json
··· 16 16 "options": { 17 17 "role": "workspace", 18 18 "key": "wonderwall-home", 19 - "width": 800, 20 - "height": 600, 19 + "width": 1024, 20 + "height": 768, 21 21 "title": "Wonderwall" 22 22 } 23 23 }
+3
preload.js
··· 543 543 renameTag: (tagId, newName) => { 544 544 return ipcRenderer.invoke('datastore-rename-tag', { tagId, newName }); 545 545 }, 546 + updateTagColor: (tagId, color) => { 547 + return ipcRenderer.invoke('datastore-update-tag-color', { tagId, color }); 548 + }, 546 549 deleteTag: (tagId) => { 547 550 return ipcRenderer.invoke('datastore-delete-tag', { tagId }); 548 551 },