···26692669 return getDb().prepare('SELECT * FROM tags WHERE id = ?').get(id) as Tag;
26702670}
2671267126722672+export function updateTagColor(id: string, color: string): Tag | null {
26732673+ const timestamp = now();
26742674+ const result = getDb().prepare(
26752675+ 'UPDATE tags SET color = ?, updatedAt = ? WHERE id = ?'
26762676+ ).run(color, timestamp, id);
26772677+ if (result.changes === 0) return null;
26782678+ return getDb().prepare('SELECT * FROM tags WHERE id = ?').get(id) as Tag;
26792679+}
26802680+26722681export function deleteTag(id: string): boolean {
26732682 const db = getDb();
26742683 db.prepare('DELETE FROM item_tags WHERE tagId = ?').run(id);
···30063015 if (!existing.title || existing.title === 'Loading...') {
30073016 d.prepare('UPDATE items SET title = ?, updatedAt = ? WHERE id = ?')
30083017 .run(title, Date.now(), existing.id);
30183018+ return true;
30193019+ }
30203020+ return false;
30213021+}
30223022+30233023+/**
30243024+ * Update the favicon of a URL item if it's currently empty.
30253025+ * Used by page-favicon-updated handlers and entity enrichment.
30263026+ */
30273027+export function updateItemFavicon(url: string, faviconUrl: string): boolean {
30283028+ const normalizedUri = normalizeUrl(url);
30293029+ const d = getDb();
30303030+30313031+ // Phase 1: Try exact normalized URL match
30323032+ let existing = d.prepare(
30333033+ 'SELECT id, favicon FROM items WHERE type = ? AND content = ? AND deletedAt = 0'
30343034+ ).get('url', normalizedUri) as { id: string; favicon: string | null } | undefined;
30353035+30363036+ // Phase 2: If no match, try with raw URL
30373037+ if (!existing && url !== normalizedUri) {
30383038+ existing = d.prepare(
30393039+ 'SELECT id, favicon FROM items WHERE type = ? AND content = ? AND deletedAt = 0'
30403040+ ).get('url', url) as { id: string; favicon: string | null } | undefined;
30413041+ }
30423042+30433043+ // Phase 3: Domain-based lookup for the most recent item missing a favicon
30443044+ if (!existing) {
30453045+ try {
30463046+ const parsed = new URL(url);
30473047+ const domain = parsed.hostname;
30483048+ existing = d.prepare(
30493049+ `SELECT id, favicon FROM items WHERE type = ? AND domain = ? AND deletedAt = 0
30503050+ AND (favicon = '' OR favicon IS NULL)
30513051+ ORDER BY updatedAt DESC LIMIT 1`
30523052+ ).get('url', domain) as { id: string; favicon: string | null } | undefined;
30533053+ } catch { /* invalid URL, skip phase 3 */ }
30543054+ }
30553055+30563056+ if (!existing) return false;
30573057+30583058+ if (!existing.favicon) {
30593059+ d.prepare('UPDATE items SET favicon = ?, updatedAt = ? WHERE id = ?')
30603060+ .run(faviconUrl, Date.now(), existing.id);
30093061 return true;
30103062 }
30113063 return false;
+88
backend/electron/group-mode.test.ts
···55 * - resolveGroupBorderColor: vivid color selection for screen border
66 * - shouldAutoTagForGroup: group mode detection (no lastFocusedVisible fallback)
77 * - shouldInheritGroupMode: window lineage-based group mode inheritance
88+ * - colorPersistence: vivid colors assigned at group creation/promotion time
89 *
910 * These are logic-only tests — no DOM, no Electron, no IPC runtime.
1011 */
···386387 assert.strictEqual(computeBorderAction(windows), 'show');
387388 });
388389});
390390+391391+describe('color persistence (vivid color assigned at group creation/promotion)', () => {
392392+ // Vivid colors are now assigned eagerly in the datastore-set-row IPC handler
393393+ // when a tag is promoted to a group (isGroup: true in metadata).
394394+ // The screen border no longer lazily persists resolved colors.
395395+396396+ interface ColorPersistResult {
397397+ shouldPersist: boolean;
398398+ resolvedColor: string;
399399+ }
400400+401401+ function shouldPersistColor(
402402+ rawColor: string | undefined,
403403+ groupId: string | undefined,
404404+ ): ColorPersistResult {
405405+ const resolvedColor = resolveGroupBorderColor(rawColor, groupId);
406406+ return {
407407+ shouldPersist: resolvedColor !== rawColor && !!groupId,
408408+ resolvedColor,
409409+ };
410410+ }
411411+412412+ it('persists when raw color is undefined (default grey group)', () => {
413413+ const result = shouldPersistColor(undefined, 'group-1');
414414+ assert.strictEqual(result.shouldPersist, true);
415415+ assert.ok(VIVID_GROUP_COLORS.includes(result.resolvedColor));
416416+ });
417417+418418+ it('persists when raw color is #999 (default tag color)', () => {
419419+ const result = shouldPersistColor('#999', 'group-1');
420420+ assert.strictEqual(result.shouldPersist, true);
421421+ assert.ok(VIVID_GROUP_COLORS.includes(result.resolvedColor));
422422+ });
423423+424424+ it('persists when raw color is #999999 (default tag color long form)', () => {
425425+ const result = shouldPersistColor('#999999', 'group-1');
426426+ assert.strictEqual(result.shouldPersist, true);
427427+ assert.ok(VIVID_GROUP_COLORS.includes(result.resolvedColor));
428428+ });
429429+430430+ it('persists when raw color is desaturated (low chroma)', () => {
431431+ const result = shouldPersistColor('#cccccc', 'group-1');
432432+ assert.strictEqual(result.shouldPersist, true);
433433+ assert.ok(VIVID_GROUP_COLORS.includes(result.resolvedColor));
434434+ });
435435+436436+ it('does NOT persist when raw color is already vivid', () => {
437437+ const result = shouldPersistColor('#ff3b30', 'group-1');
438438+ assert.strictEqual(result.shouldPersist, false);
439439+ assert.strictEqual(result.resolvedColor, '#ff3b30');
440440+ });
441441+442442+ it('does NOT persist when raw color is a different vivid color', () => {
443443+ const result = shouldPersistColor('#007aff', 'group-1');
444444+ assert.strictEqual(result.shouldPersist, false);
445445+ assert.strictEqual(result.resolvedColor, '#007aff');
446446+ });
447447+448448+ it('does NOT persist when groupId is undefined', () => {
449449+ const result = shouldPersistColor('#999', undefined);
450450+ assert.strictEqual(result.shouldPersist, false);
451451+ });
452452+453453+ it('persisted color is deterministic for same groupId', () => {
454454+ const result1 = shouldPersistColor('#999', 'my-project');
455455+ const result2 = shouldPersistColor('#999', 'my-project');
456456+ assert.strictEqual(result1.resolvedColor, result2.resolvedColor);
457457+ });
458458+459459+ it('after persistence, subsequent call with resolved color does NOT re-persist', () => {
460460+ // Simulate: first call resolves and persists
461461+ const first = shouldPersistColor('#999', 'group-1');
462462+ assert.strictEqual(first.shouldPersist, true);
463463+464464+ // Second call with the now-persisted vivid color
465465+ const second = shouldPersistColor(first.resolvedColor, 'group-1');
466466+ assert.strictEqual(second.shouldPersist, false);
467467+ assert.strictEqual(second.resolvedColor, first.resolvedColor);
468468+ });
469469+470470+ it('user-chosen vivid color is not overwritten', () => {
471471+ // User picks a custom vivid color via the color picker
472472+ const result = shouldPersistColor('#e91e63', 'group-1');
473473+ assert.strictEqual(result.shouldPersist, false);
474474+ assert.strictEqual(result.resolvedColor, '#e91e63');
475475+ });
476476+});