experiments in a post-browser web
10
fork

Configure Feed

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

feat(permissions): Phase 5 — favicon + multi-prompt queueing UX

Two distinct polishes finishing the web-permissions feature:

1. **Favicon next to origin** — both in the page-host prompt overlay
AND each row of the Settings → Permissions list. Uses the same
Google s2 favicon service the search-result cards use
(`https://www.google.com/s2/favicons?domain={host}&sz=32`) with
`onerror` falling back to the globe-emoji SVG data-URI for
unreachable origins. Helps the user recognize the requesting site
at a glance instead of parsing the bare URL.

2. **Multi-prompt queueing UX** — replaces the old "stack vertically,
each its own buttons" model. Now: only ONE prompt is visible at a
time, queued by arrival order; resolving the active prompt
automatically advances the queue. A "+N more pending" badge
surfaces queue depth when the head isn't the only request, so the
user knows more are waiting (rather than silently stacking
off-screen if many concurrent permissions fire). The queue lives
entirely in the renderer (`permissionQueue` array +
`activePermissionPrompt` ref) — no backend changes needed.

Renderer (`app/page/page.js`):
* `renderActivePermissionPrompt()` removes any prior DOM and renders
the head of the queue; called on enqueue (when queue was empty)
and on each respond.
* `updatePendingBadge()` keeps the badge live on the active prompt
without re-rendering the whole thing — fires on every enqueue
while a prompt is already shown.
* Dedup: duplicate `requestId` publishes (re-entrant transitions)
are dropped instead of double-rendering.
* `respond()` shifts the current request off the queue and
re-renders, so the "remember" checkbox state is local to the
prompt that owns it (no cross-prompt leak).

CSS (`app/page/index.html`):
* `.permission-prompt-message` is now flex (favicon | text col).
* New `.permission-prompt-favicon` (20px square, object-fit
contain) and `.permission-prompt-pending-badge` (rounded pill,
inline next to origin).

Settings (`app/settings/settings.js`):
* Each row in the Permissions list gains a 20px favicon to the left
of the origin/label/timestamp column. Same Google s2 service +
globe-emoji fallback. New `.permissions-row-favicon` selector
used by the test.

Tests:
* `tests/desktop/permission-prompt.spec.ts` (was 3/3, now 5/5):
- **Queueing test** drives two synthetic `page:permission-request`
publishes back-to-back via `api.pubsub.publish` (skipping the
flaky-in-headless `getUserMedia` path); asserts only ONE prompt
visible, badge shows "+1 more pending", clicking Deny on the
first reveals the second, badge gone.
- **Favicon test** asserts the prompt's `.permission-prompt-favicon`
img has a Google-s2-domain src matching the requesting host.
- Both tests reset stored decisions first since the earlier
"Remember-this-decision" test persists a 127.0.0.1 deny that
would auto-resolve subsequent prompts.
* `tests/desktop/permissions-settings.spec.ts` (was 3/3, now 4/4):
new test asserts the favicon img renders with the origin-derived
src for each row.
* Regression: page-host-fsm 5/5; build clean.

Phase 5 closes out the web-permissions feature. Tasks doc updated.

+278 -29
+28
app/page/index.html
··· 1373 1373 .permission-prompt-message { 1374 1374 margin-bottom: 10px; 1375 1375 line-height: 1.4; 1376 + display: flex; 1377 + align-items: flex-start; 1378 + gap: 10px; 1379 + } 1380 + 1381 + .permission-prompt-favicon { 1382 + width: 20px; 1383 + height: 20px; 1384 + flex-shrink: 0; 1385 + margin-top: 1px; 1386 + object-fit: contain; 1387 + } 1388 + 1389 + .permission-prompt-text { 1390 + flex: 1; 1391 + min-width: 0; 1376 1392 } 1377 1393 1378 1394 .permission-prompt-origin { 1379 1395 font-family: var(--theme-font-mono, ui-monospace, SFMono-Regular, monospace); 1380 1396 font-weight: 600; 1381 1397 color: var(--theme-text, #e0e0e0); 1398 + } 1399 + 1400 + .permission-prompt-pending-badge { 1401 + display: inline-block; 1402 + margin-left: 8px; 1403 + padding: 2px 8px; 1404 + border-radius: 10px; 1405 + background: color-mix(in srgb, var(--theme-bg-tertiary, #2a2a2a) 80%, transparent); 1406 + color: var(--theme-text-secondary, #aaa); 1407 + font-size: 11px; 1408 + font-weight: 500; 1409 + vertical-align: middle; 1382 1410 } 1383 1411 1384 1412 .permission-prompt-remember {
+93 -25
app/page/page.js
··· 2308 2308 // --- Web permission prompt --- 2309 2309 // Backend's permission-handler.ts publishes `page:permission-request` when a 2310 2310 // guest webview requests a risky permission (geolocation, camera/mic, etc.). 2311 - // We render an approve/reject overlay near the top of the page; on click the 2312 - // user's decision is published back as `page:permission-response` and the 2313 - // backend resolves the deferred Chromium callback. 2314 - // 2315 - // Multiple concurrent prompts stack vertically — each is a separate DOM node 2316 - // with its own requestId. No queueing logic; whichever the user clicks first 2317 - // resolves first. See backend/electron/permission-handler.ts for the wire 2318 - // protocol (requestId / windowId / permission / origin / label). 2311 + // Phase 5 UX: only ONE prompt is shown at a time. Subsequent requests queue 2312 + // behind it; when the user resolves the current one, the next dequeues 2313 + // automatically. A "+N more pending" badge surfaces queue depth so the user 2314 + // knows more requests are waiting (rather than silently stacking off-screen). 2315 + // On click the user's decision is published back as `page:permission-response` 2316 + // and the backend resolves the deferred Chromium callback. See 2317 + // backend/electron/permission-handler.ts for the wire protocol. 2318 + 2319 + const permissionQueue = []; 2320 + let activePermissionPrompt = null; // { req, wrap, badgeEl, resolved } 2321 + 2322 + function originForFavicon(origin) { 2323 + // Strip scheme + path so we can hand bare hostname to the favicon service. 2324 + if (!origin) return null; 2325 + try { 2326 + const u = new URL(origin); 2327 + return u.hostname || null; 2328 + } catch { 2329 + return null; 2330 + } 2331 + } 2332 + 2333 + function renderActivePermissionPrompt() { 2334 + // Removes any prior DOM and renders the head of the queue (if any). 2335 + if (activePermissionPrompt) { 2336 + activePermissionPrompt.wrap.remove(); 2337 + activePermissionPrompt = null; 2338 + } 2339 + if (permissionQueue.length === 0) return; 2340 + 2341 + const req = permissionQueue[0]; 2319 2342 2320 - function renderPermissionPrompt(req) { 2321 2343 const wrap = document.createElement('div'); 2322 2344 wrap.className = 'permission-prompt'; 2323 2345 wrap.dataset.requestId = req.requestId; 2324 2346 2325 2347 const message = document.createElement('div'); 2326 2348 message.className = 'permission-prompt-message'; 2349 + 2350 + // Favicon — uses the same Google s2 favicon service the search-result 2351 + // cards use (see app/lib/card-helpers.js). onerror falls back to the 2352 + // globe-emoji SVG so unreachable origins don't show a broken-image icon. 2353 + const favicon = document.createElement('img'); 2354 + favicon.className = 'permission-prompt-favicon'; 2355 + favicon.alt = ''; 2356 + const host = originForFavicon(req.origin); 2357 + const fallback = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">\u{1F310}</text></svg>'; 2358 + favicon.src = host ? `https://www.google.com/s2/favicons?domain=${host}&sz=32` : fallback; 2359 + favicon.onerror = () => { favicon.src = fallback; favicon.onerror = null; }; 2360 + message.appendChild(favicon); 2361 + 2362 + const text = document.createElement('div'); 2363 + text.className = 'permission-prompt-text'; 2327 2364 const originSpan = document.createElement('span'); 2328 2365 originSpan.className = 'permission-prompt-origin'; 2329 2366 originSpan.textContent = req.origin || '(unknown origin)'; 2330 - message.appendChild(originSpan); 2331 - message.appendChild(document.createTextNode(` wants to ${req.label || req.permission}.`)); 2367 + text.appendChild(originSpan); 2368 + 2369 + // "+N more pending" badge — visible only when more than one request is 2370 + // queued. Updated in place via badgeEl.textContent on enqueue/dequeue. 2371 + const badge = document.createElement('span'); 2372 + badge.className = 'permission-prompt-pending-badge'; 2373 + const queuedExtras = permissionQueue.length - 1; 2374 + if (queuedExtras > 0) { 2375 + badge.textContent = `+${queuedExtras} more pending`; 2376 + } else { 2377 + badge.style.display = 'none'; 2378 + } 2379 + text.appendChild(badge); 2380 + 2381 + text.appendChild(document.createElement('br')); 2382 + text.appendChild(document.createTextNode(`wants to ${req.label || req.permission}.`)); 2383 + message.appendChild(text); 2332 2384 2333 2385 // "Remember this decision" checkbox — when checked, the response includes 2334 2386 // remember:true and permission-handler.ts persists the {origin, permission, 2335 - // allowed} tuple to feature_settings. Future requests with the same pair 2336 - // skip the prompt entirely. 2387 + // allowed} tuple to feature_settings. 2337 2388 const rememberLabel = document.createElement('label'); 2338 2389 rememberLabel.className = 'permission-prompt-remember'; 2339 2390 const rememberCheckbox = document.createElement('input'); ··· 2355 2406 allowBtn.className = 'permission-prompt-allow'; 2356 2407 allowBtn.textContent = 'Allow'; 2357 2408 2358 - let resolved = false; 2359 2409 function respond(allowed) { 2360 - if (resolved) return; 2361 - resolved = true; 2410 + if (!activePermissionPrompt || activePermissionPrompt.resolved) return; 2411 + activePermissionPrompt.resolved = true; 2362 2412 api.publish('page:permission-response', { 2363 2413 requestId: req.requestId, 2364 2414 allowed, 2365 2415 remember: rememberCheckbox.checked, 2366 2416 }); 2367 - wrap.remove(); 2417 + permissionQueue.shift(); 2418 + renderActivePermissionPrompt(); 2368 2419 } 2369 2420 denyBtn.addEventListener('click', () => respond(false)); 2370 2421 allowBtn.addEventListener('click', () => respond(true)); ··· 2375 2426 wrap.appendChild(rememberLabel); 2376 2427 wrap.appendChild(actions); 2377 2428 2378 - // Stack subsequent prompts beneath any existing ones. 2379 - const existing = document.querySelectorAll('.permission-prompt'); 2380 - if (existing.length > 0) { 2381 - const lastBottom = existing[existing.length - 1].getBoundingClientRect().bottom; 2382 - wrap.style.top = `${lastBottom + 8}px`; 2429 + document.body.appendChild(wrap); 2430 + activePermissionPrompt = { req, wrap, badgeEl: badge, resolved: false }; 2431 + } 2432 + 2433 + function updatePendingBadge() { 2434 + // Refreshes the badge on the active prompt without re-rendering it. 2435 + if (!activePermissionPrompt) return; 2436 + const queuedExtras = permissionQueue.length - 1; 2437 + const badge = activePermissionPrompt.badgeEl; 2438 + if (queuedExtras > 0) { 2439 + badge.textContent = `+${queuedExtras} more pending`; 2440 + badge.style.display = ''; 2441 + } else { 2442 + badge.style.display = 'none'; 2383 2443 } 2384 - 2385 - document.body.appendChild(wrap); 2386 2444 } 2387 2445 2388 2446 api.subscribe('page:permission-request', (msg) => { 2389 2447 if (msg.windowId != null && msg.windowId !== myWindowId) return; 2390 2448 if (!msg.requestId) return; 2391 - renderPermissionPrompt(msg); 2449 + // Dedup: if the same requestId is already queued (duplicate publish from 2450 + // a re-entrant transition), ignore it. 2451 + if (permissionQueue.some(r => r.requestId === msg.requestId)) return; 2452 + permissionQueue.push(msg); 2453 + if (permissionQueue.length === 1) { 2454 + // Nothing currently shown — render this one. 2455 + renderActivePermissionPrompt(); 2456 + } else { 2457 + // Active prompt exists; just bump the pending badge. 2458 + updatePendingBadge(); 2459 + } 2392 2460 }); 2393 2461 2394 2462 // Navbar double-click to toggle maximize
+28 -3
app/settings/settings.js
··· 1108 1108 openExternal: 'Open external apps', 1109 1109 }; 1110 1110 1111 + // Globe-emoji SVG fallback used when the favicon service is unreachable 1112 + // for an origin (offline, blocked, weird URL). Same data-URI used in the 1113 + // page-host prompt + cards. 1114 + const FALLBACK_FAVICON = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">\u{1F310}</text></svg>'; 1115 + 1116 + const hostFromOrigin = (origin) => { 1117 + if (!origin) return null; 1118 + try { return new URL(origin).hostname || null; } catch { return null; } 1119 + }; 1120 + 1111 1121 const renderRow = (decision) => { 1112 1122 const row = document.createElement('div'); 1113 1123 row.className = 'item-card no-collapse'; ··· 1118 1128 header.style.cssText = 'display: flex; align-items: center; justify-content: space-between; gap: 12px;'; 1119 1129 1120 1130 const left = document.createElement('div'); 1121 - left.style.cssText = 'display: flex; flex-direction: column; gap: 2px; min-width: 0;'; 1131 + left.style.cssText = 'display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1;'; 1132 + 1133 + // Favicon — Google s2 favicon service with globe-emoji fallback. Same 1134 + // pattern used in the page-host permission prompt + search-result cards. 1135 + const favicon = document.createElement('img'); 1136 + favicon.className = 'permissions-row-favicon'; 1137 + favicon.alt = ''; 1138 + favicon.style.cssText = 'width: 20px; height: 20px; flex-shrink: 0; object-fit: contain;'; 1139 + const host = hostFromOrigin(decision.origin); 1140 + favicon.src = host ? `https://www.google.com/s2/favicons?domain=${host}&sz=32` : FALLBACK_FAVICON; 1141 + favicon.onerror = () => { favicon.src = FALLBACK_FAVICON; favicon.onerror = null; }; 1142 + left.appendChild(favicon); 1143 + 1144 + const textCol = document.createElement('div'); 1145 + textCol.style.cssText = 'display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1;'; 1122 1146 1123 1147 const originEl = document.createElement('div'); 1124 1148 originEl.style.cssText = 'font-family: var(--font-mono, monospace); font-size: 13px; word-break: break-all;'; 1125 1149 originEl.textContent = decision.origin; 1126 - left.appendChild(originEl); 1150 + textCol.appendChild(originEl); 1127 1151 1128 1152 const meta = document.createElement('div'); 1129 1153 meta.className = 'help-text'; ··· 1132 1156 const verdict = decision.allowed ? 'Allowed' : 'Denied'; 1133 1157 const when = decision.timestamp ? new Date(decision.timestamp).toLocaleString() : ''; 1134 1158 meta.textContent = `${label} · ${verdict}${when ? ' · ' + when : ''}`; 1135 - left.appendChild(meta); 1159 + textCol.appendChild(meta); 1160 + left.appendChild(textCol); 1136 1161 1137 1162 const revokeBtn = document.createElement('button'); 1138 1163 revokeBtn.className = 'btn btn-secondary';
+1 -1
docs/tasks.md
··· 23 23 24 24 ## Bugs 25 25 26 - - [ ] **Web permission requests — Phase 5 (polish): favicon + multi-prompt queueing UX.** Phases 1-4 shipped 2026-04-27. Phase 4: new "Permissions" section in `app/settings/settings.js` (between Extensions and Dark Mode) lists every stored decision via `tile:permissions:list`, with per-row Revoke + page-level Clear-all. IPC handlers in `tile-ipc.ts` (`list/forget/forgetAll`) gate on `trustedBuiltin` so only peek-core renderers can read or mutate. `api.permissions = { list, forget, forgetAll }` exposed in `tile-preload.cts` with matching type defs in `tile-api.d.ts`. Section auto-refreshes on every nav-click (and via `window._refreshPermissionsList` for external triggers) so freshly persisted decisions show up without reopening the window. Coverage: `tests/desktop/permissions-settings.spec.ts` (3/3) — render with origin/label/verdict/Revoke, single revoke clears the row + backend, Clear-all wipes everything. Phase 5 polish remaining: favicon next to origin in both prompt overlay and settings list; multi-prompt queueing UX (today they stack visually but each fires in arrival order — could surface a single prompt with "X more pending" badge). 26 + - [x] **Web permission requests — feature complete (Phases 1-5).** All shipped 2026-04-27. **Phase 5**: favicon next to origin in both the page-host prompt overlay and each Settings → Permissions row (Google s2 favicon service with globe-emoji SVG fallback, same pattern as search-result cards). Multi-prompt queueing UX replaces the old vertical-stack model — only ONE prompt is visible at a time, queued by arrival order; resolving the active prompt advances the queue; "+N more pending" badge surfaces queue depth on the active prompt. Renderer-only change (`permissionQueue` + `activePermissionPrompt` in `app/page/page.js`); no backend wire-protocol change. Coverage: `tests/desktop/permission-prompt.spec.ts` 5/5 (incl. queueing via synthetic publishes + favicon src assertion), `tests/desktop/permissions-settings.spec.ts` 4/4 (incl. favicon row check). Future work: per-permission default policy override in settings (e.g. "always deny notifications by default") if user demand surfaces. 27 27 28 28 - [x] **Server-not-found / page-load failure shows blank white page forever.** User-reported 2026-04-27 with `http://www.metikmusic.com/`. DNS-failure / connection-refused paths were covered earlier via `did-fail-load` + `.load-error-overlay`. The remaining gap (and the actual repro) was hung loads — server accepts the connection, never responds. The 30s loading-timeout safety net silently flipped the spinner off and left the user staring at a blank page. Fixed 2026-04-27: timeout now surfaces the same `.load-error-overlay` ("The page took too long to load.") with the in-flight URL (read from `latestNavigationUrl`, not `webview.getURL()` which returns the previously-displayed URL during a pending navigation). Loading timeout is now overridable via `window.__loadingTimeoutMs` so the Playwright spec can fire the path in 1.5s instead of 30s. Test: `tests/desktop/page-load-failure.spec.ts` "Hung load: …" (6/6, was 5/5). Future polish (HTTP 4xx/5xx, TLS errors, certificate-error on session) tracked separately if/when reported. 29 29
+110
tests/desktop/permission-prompt.spec.ts
··· 52 52 ); 53 53 }); 54 54 }; 55 + window.__requestMedia = function() { 56 + return navigator.mediaDevices.getUserMedia({ audio: true, video: false }) 57 + .then(() => 'allowed') 58 + .catch(err => 'denied:' + (err && err.name ? err.name : 'unknown')); 59 + }; 55 60 </script> 56 61 </body> 57 62 </html>`); ··· 178 183 // No prompt should have appeared in the second window. 179 184 const promptCount = await second.locator('.permission-prompt').count(); 180 185 expect(promptCount).toBe(0); 186 + }); 187 + 188 + test('Phase 5 — queueing: synthetic concurrent requests show one prompt + pending badge; resolving advances queue', async () => { 189 + // Earlier "Remember-this-decision" test persisted a deny for 190 + // 127.0.0.1 geolocation. Clear it so this test gets a fresh prompt. 191 + await bgWindow.evaluate(async () => { 192 + await (window as any).app.permissions.forgetAll(); 193 + }); 194 + 195 + const pageWindow = await openPageHost('perm-queue'); 196 + const myWindowId = await pageWindow.evaluate(async () => { 197 + const r = await (window as any).app.window.getWindowId(); 198 + return (r && typeof r === 'object' && 'id' in r) ? r.id : r; 199 + }); 200 + 201 + // Drive the renderer queue directly via two synthetic 202 + // `page:permission-request` publishes. The queueing UX lives entirely 203 + // in page.js — independent of which Chromium permission triggered it 204 + // — so this test isolates the renderer logic from the (flaky in 205 + // headless) media-device path. 206 + await bgWindow.evaluate(async (windowId) => { 207 + const api = (window as any).app; 208 + api.pubsub.publish('page:permission-request', { 209 + requestId: 'q1', windowId, 210 + permission: 'geolocation', origin: 'https://example.com', 211 + label: 'know your location', 212 + }); 213 + api.pubsub.publish('page:permission-request', { 214 + requestId: 'q2', windowId, 215 + permission: 'media', origin: 'https://meet.example.com', 216 + label: 'use your camera and microphone', 217 + }); 218 + }, myWindowId); 219 + 220 + // Only ONE prompt visible (queue head); badge shows "+1 more pending". 221 + await pageWindow.waitForFunction(() => { 222 + const prompts = document.querySelectorAll('.permission-prompt'); 223 + const badge = document.querySelector('.permission-prompt-pending-badge') as HTMLElement | null; 224 + return prompts.length === 1 225 + && badge 226 + && badge.style.display !== 'none' 227 + && /\+1 more pending/.test(badge.textContent || ''); 228 + }, undefined, { timeout: 5000 }); 229 + 230 + // The visible prompt is for the FIRST request (geolocation). 231 + const firstText = await pageWindow.locator('.permission-prompt').innerText(); 232 + expect(firstText).toContain('know your location'); 233 + expect(firstText).toContain('https://example.com'); 234 + 235 + // Click Deny on the first prompt — second prompt should take its place. 236 + await pageWindow.locator('.permission-prompt .permission-prompt-deny').click(); 237 + 238 + await pageWindow.waitForFunction(() => { 239 + const p = document.querySelector('.permission-prompt'); 240 + return p && /camera and microphone/i.test(p.textContent || ''); 241 + }, undefined, { timeout: 5000 }); 242 + 243 + // Still only ONE prompt; badge gone. 244 + const after = await pageWindow.evaluate(() => { 245 + const prompts = document.querySelectorAll('.permission-prompt'); 246 + const badge = document.querySelector('.permission-prompt-pending-badge') as HTMLElement | null; 247 + return { 248 + promptCount: prompts.length, 249 + badgeVisible: !!(badge && badge.style.display !== 'none'), 250 + }; 251 + }); 252 + expect(after.promptCount).toBe(1); 253 + expect(after.badgeVisible).toBe(false); 254 + 255 + // Resolve the second one — queue empty, no prompts left. 256 + await pageWindow.locator('.permission-prompt .permission-prompt-deny').click(); 257 + await pageWindow.waitForFunction( 258 + () => document.querySelectorAll('.permission-prompt').length === 0, 259 + undefined, 260 + { timeout: 5000 }, 261 + ); 262 + }); 263 + 264 + test('Phase 5 — favicon img renders with origin-derived src in the prompt', async () => { 265 + // Same reason as above — clear any stored decisions so the prompt fires. 266 + await bgWindow.evaluate(async () => { 267 + await (window as any).app.permissions.forgetAll(); 268 + }); 269 + 270 + const pageWindow = await openPageHost('perm-favicon'); 271 + const webview = await pageWindow.evaluateHandle(() => document.getElementById('content')); 272 + await pageWindow.evaluate((wv: any) => { 273 + (window as any).__geoPromise = wv.executeJavaScript('window.__requestGeo()'); 274 + }, webview); 275 + 276 + await pageWindow.waitForSelector('.permission-prompt-favicon', { timeout: 5000 }); 277 + 278 + const faviconSrc = await pageWindow.locator('.permission-prompt-favicon').getAttribute('src'); 279 + expect(faviconSrc).toBeTruthy(); 280 + // Either Google s2 favicons URL with the host, or the globe-emoji SVG fallback. 281 + // 127.0.0.1 routes through the Google service path. 282 + expect(faviconSrc).toMatch(/google\.com\/s2\/favicons\?domain=127\.0\.0\.1/); 283 + 284 + // Cleanup — deny so the deferred Chromium callback resolves. 285 + await pageWindow.locator('.permission-prompt .permission-prompt-deny').click(); 286 + await pageWindow.waitForFunction( 287 + () => document.querySelectorAll('.permission-prompt').length === 0, 288 + undefined, 289 + { timeout: 5000 }, 290 + ); 181 291 }); 182 292 });
+18
tests/desktop/permissions-settings.spec.ts
··· 136 136 expect(stillThere).toBe(0); 137 137 }); 138 138 139 + test('Phase 5 — favicon img renders next to each decision row', async () => { 140 + await seedDecision('https://example.com', 'geolocation', false); 141 + await seedDecision('https://meet.example.com', 'media', true); 142 + 143 + const settings = await openSettings(); 144 + await settings.locator('a.nav-item[data-section="permissions"]').click(); 145 + 146 + await settings.waitForSelector('#section-permissions .item-card', { timeout: 5000 }); 147 + 148 + const favicons = settings.locator('#section-permissions .permissions-row-favicon'); 149 + await expect(favicons).toHaveCount(2); 150 + 151 + // Each src should be the Google s2 favicon URL with the right host. 152 + const srcs = await favicons.evaluateAll(els => els.map(el => (el as HTMLImageElement).src)); 153 + expect(srcs.some(s => /google\.com\/s2\/favicons\?domain=example\.com/.test(s))).toBe(true); 154 + expect(srcs.some(s => /google\.com\/s2\/favicons\?domain=meet\.example\.com/.test(s))).toBe(true); 155 + }); 156 + 139 157 test('Clear all removes every decision and shows empty message', async () => { 140 158 await seedDecision('https://a.com', 'geolocation', false); 141 159 await seedDecision('https://b.com', 'media', true);