[eric] privacy: domain icons render locally (simple-icons + monogram), Google favicon beacon deleted

This commit is contained in:
ciregenz
2026-08-03 14:43:21 -07:00
parent eba1206e47
commit 906ea0fa35
8 changed files with 204 additions and 56 deletions
+20
View File
@@ -42,6 +42,7 @@
"recharts": "^2.15.4",
"remark-gfm": "^4.0.1",
"shiki": "^3.23.0",
"simple-icons": "^16.28.0",
"supercluster": "^8.0.1",
"tailwind-merge": "^3.6.0",
"zod": "^4.4.3"
@@ -12843,6 +12844,25 @@
"dev": true,
"license": "ISC"
},
"node_modules/simple-icons": {
"version": "16.28.0",
"resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-16.28.0.tgz",
"integrity": "sha512-sQPR5AtK/ijRjou7zw7mlLp08oB6FH7i0lOy5XJ2zp9mJs/yejgiOn7KvQoe2q4YJIx6VmgUSW5AOefebPt5kg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/simple-icons"
},
{
"type": "github",
"url": "https://github.com/sponsors/simple-icons"
}
],
"license": "CC0-1.0",
"engines": {
"node": ">=0.12.18"
}
},
"node_modules/sockjs": {
"version": "0.3.24",
"resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz",
+1
View File
@@ -43,6 +43,7 @@
"recharts": "^2.15.4",
"remark-gfm": "^4.0.1",
"shiki": "^3.23.0",
"simple-icons": "^16.28.0",
"supercluster": "^8.0.1",
"tailwind-merge": "^3.6.0",
"zod": "^4.4.3"
@@ -19,7 +19,8 @@ import { ParsedResult } from '../parsing/toolResultParsing';
import { isSettingsWriteTool, settingsWriteSummary } from '../parsing/settingsToolMeta';
import { McpToolInfo, getMcpShortAction, getMcpInputSummary, getWorkflowToolLabel } from '@/shared/mcpToolMeta';
import { McpResultCard } from '../mcp-cards/McpResultCard';
import { domainFromUrl, faviconUrlForDomain } from './SourceFavicons';
import { domainFromUrl } from './SourceFavicons';
import { DomainIcon } from './DomainIcon';
interface CompactMcpBubbleProps {
call: AgentMessage;
@@ -106,16 +107,7 @@ export const CompactMcpBubble: React.FC<CompactMcpBubbleProps> = ({
)}
{visibleSummary && !isError && !stackBelow && (
<>
{webDomain && (
<Box
component="img"
src={faviconUrlForDomain(webDomain)}
alt=""
loading="lazy"
onError={(e: React.SyntheticEvent<HTMLImageElement>) => { e.currentTarget.style.display = 'none'; }}
sx={{ width: 13, height: 13, borderRadius: '3px', flexShrink: 0 }}
/>
)}
{webDomain && <DomainIcon domain={webDomain} size={13} />}
<Typography
sx={{
color: hideVerbLabel ? c.text.primary : c.text.secondary,
@@ -20,7 +20,8 @@ import { useTermColors, colorizeInput, colorizeOutput } from '../parsing/toolCol
import { ParsedResult } from '../parsing/toolResultParsing';
import { McpToolInfo } from '@/shared/mcpToolMeta';
import { McpResultCard } from '../mcp-cards/McpResultCard';
import { domainFromUrl, faviconUrlForDomain } from './SourceFavicons';
import { domainFromUrl } from './SourceFavicons';
import { DomainIcon } from './DomainIcon';
interface DefaultToolBubbleProps {
call: AgentMessage;
@@ -143,16 +144,7 @@ export const DefaultToolBubble: React.FC<DefaultToolBubbleProps> = ({
)}
{inputSummary && !isStreaming && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flex: 1, minWidth: 0 }}>
{webDomain && (
<Box
component="img"
src={faviconUrlForDomain(webDomain)}
alt=""
loading="lazy"
onError={(e: React.SyntheticEvent<HTMLImageElement>) => { e.currentTarget.style.display = 'none'; }}
sx={{ width: 13, height: 13, borderRadius: '3px', flexShrink: 0 }}
/>
)}
{webDomain && <DomainIcon domain={webDomain} size={13} />}
<Typography
noWrap
sx={{
@@ -0,0 +1,47 @@
import React from 'react';
import Box from '@mui/material/Box';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { brandIconForDomain, monogramHue } from './domainBrand';
interface DomainIconProps {
domain: string;
size: number;
shape?: 'circle' | 'rounded';
}
// Brand glyphs render in the neutral text tone: several brand hexes (GitHub's near-black) vanish on the dark theme.
export const DomainIcon: React.FC<DomainIconProps> = ({ domain, size, shape = 'rounded' }) => {
const c = useClaudeTokens();
const icon = brandIconForDomain(domain);
if (icon) {
return (
<Box component="span" sx={{ width: size, height: size, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<svg viewBox="0 0 24 24" width={size} height={size} aria-hidden="true" focusable="false">
<path d={icon.path} fill={c.text.secondary} />
</svg>
</Box>
);
}
return (
<Box
component="span"
sx={{
width: size,
height: size,
borderRadius: shape === 'circle' ? '50%' : `${Math.max(2, Math.round(size * 0.2))}px`,
bgcolor: `hsl(${monogramHue(domain)} 42% 46%)`,
color: '#fff',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: size * 0.62,
fontWeight: 700,
lineHeight: 1,
flexShrink: 0,
userSelect: 'none',
}}
>
{(domain.replace(/^www\./, '')[0] ?? '?').toUpperCase()}
</Box>
);
};
@@ -1,12 +1,7 @@
import React, { useState } from 'react';
import React from 'react';
import Box from '@mui/material/Box';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
// Same favicon service LibreChat ships with; the browser cards already load arbitrary sites, so
// fetching site icons adds no new exposure class.
export function faviconUrlForDomain(domain: string): string {
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=64`;
}
import { DomainIcon } from './DomainIcon';
export function domainFromUrl(url: string): string {
try {
@@ -20,35 +15,26 @@ const MAX_STACK = 3;
const FaviconDot: React.FC<{ domain: string; size: number; overlap: boolean; z: number }> = ({ domain, size, overlap, z }) => {
const c = useClaudeTokens();
const [failed, setFailed] = useState(false);
const ring = {
width: size,
height: size,
borderRadius: '50%',
border: `1.5px solid ${c.bg.elevated}`,
bgcolor: c.bg.secondary,
ml: overlap ? '-6px' : 0,
zIndex: z,
position: 'relative' as const,
flexShrink: 0,
};
if (failed) {
// assistant-ui's fallback: the domain's first letter beats a hole in the stack.
return (
<Box sx={{ ...ring, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', color: c.text.tertiary, fontSize: size * 0.55, fontWeight: 700 }}>
{(domain[0] || '?').toUpperCase()}
</Box>
);
}
return (
<Box
component="img"
src={faviconUrlForDomain(domain)}
alt=""
loading="lazy"
onError={() => setFailed(true)}
sx={ring}
/>
sx={{
width: size,
height: size,
borderRadius: '50%',
border: `1.5px solid ${c.bg.elevated}`,
bgcolor: c.bg.secondary,
ml: overlap ? '-6px' : 0,
zIndex: z,
position: 'relative',
flexShrink: 0,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
}}
>
<DomainIcon domain={domain} size={size - 4} shape="circle" />
</Box>
);
};
@@ -0,0 +1,47 @@
// Run: node --test frontend/src/app/pages/AgentChat/tool-bubbles/domainBrand.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { brandIconForDomain, monogramHue } from './domainBrand.ts';
test('a known brand domain resolves to a bundled icon', () => {
assert.ok(brandIconForDomain('github.com'));
});
test('a subdomain walks up to its registered brand', () => {
assert.equal(brandIconForDomain('docs.github.com'), brandIconForDomain('github.com'));
assert.equal(brandIconForDomain('en.wikipedia.org'), brandIconForDomain('wikipedia.org'));
});
test('www and case are ignored', () => {
assert.equal(brandIconForDomain('www.GitHub.com'), brandIconForDomain('github.com'));
});
test('an unknown domain has no brand icon', () => {
assert.equal(brandIconForDomain('example.org'), null);
});
test('lookalike hosts never match across a label boundary', () => {
assert.equal(brandIconForDomain('evilgithub.com'), null);
});
test('monogram hue is deterministic and a valid hue', () => {
assert.equal(monogramHue('example.org'), monogramHue('example.org'));
for (const d of ['a', 'example.org', 'sub.long-domain-name.co.uk']) {
const h = monogramHue(d);
assert.ok(h >= 0 && h < 360, `${d} hue out of range: ${h}`);
}
});
test('no favicon beacon: nothing in tool-bubbles builds a remote favicon URL', () => {
const dir = dirname(fileURLToPath(import.meta.url));
const sources = readdirSync(dir).filter((n) => (n.endsWith('.ts') || n.endsWith('.tsx')) && !n.includes('.test.'));
for (const f of sources) {
const src = readFileSync(join(dir, f), 'utf8');
assert.ok(!src.includes('s2/favicons'), `${f} references a favicon service`);
assert.ok(!src.includes('favicons?domain='), `${f} builds a favicon beacon URL`);
assert.ok(!src.includes('faviconUrlForDomain'), `${f} still references the deleted beacon helper`);
}
});
@@ -0,0 +1,63 @@
import type { SimpleIcon } from 'simple-icons';
import {
siAnthropic, siArxiv, siBitbucket, siCloudflare, siDiscord, siDuckduckgo, siFacebook, siFigma,
siGithub, siGitlab, siGoogle, siHuggingface, siInstagram, siKaggle, siMedium, siMozilla,
siNetflix, siNotion, siNpm, siPinterest, siQuora, siReddit, siSpotify, siStackoverflow,
siSubstack, siTiktok, siTwitch, siVercel, siWikipedia, siX, siYcombinator, siYoutube,
} from 'simple-icons';
// Bundled CC0 marks instead of a favicon service: fetching icons remotely reports the user's reading list to a third party (ENG-130).
const BRAND_BY_DOMAIN: Record<string, SimpleIcon> = {
'anthropic.com': siAnthropic,
'arxiv.org': siArxiv,
'bitbucket.org': siBitbucket,
'cloudflare.com': siCloudflare,
'discord.com': siDiscord,
'duckduckgo.com': siDuckduckgo,
'facebook.com': siFacebook,
'figma.com': siFigma,
'github.com': siGithub,
'gitlab.com': siGitlab,
'google.com': siGoogle,
'huggingface.co': siHuggingface,
'instagram.com': siInstagram,
'kaggle.com': siKaggle,
'medium.com': siMedium,
'mozilla.org': siMozilla,
'netflix.com': siNetflix,
'notion.so': siNotion,
'npmjs.com': siNpm,
'pinterest.com': siPinterest,
'quora.com': siQuora,
'reddit.com': siReddit,
'spotify.com': siSpotify,
'stackoverflow.com': siStackoverflow,
'substack.com': siSubstack,
'tiktok.com': siTiktok,
'twitch.tv': siTwitch,
'twitter.com': siX,
'vercel.com': siVercel,
'wikipedia.org': siWikipedia,
'x.com': siX,
'ycombinator.com': siYcombinator,
'youtube.com': siYoutube,
};
export function brandIconForDomain(domain: string): SimpleIcon | null {
let host = domain.toLowerCase().replace(/^www\./, '');
while (host.length > 0) {
const hit = BRAND_BY_DOMAIN[host];
if (hit !== undefined) return hit;
const dot = host.indexOf('.');
if (dot === -1) return null;
host = host.slice(dot + 1);
}
return null;
}
// Deterministic hue, so a domain's monogram tile never changes color between renders or sessions.
export function monogramHue(domain: string): number {
let h = 0;
for (let i = 0; i < domain.length; i += 1) h = (h * 31 + domain.charCodeAt(i)) | 0;
return Math.abs(h) % 360;
}