web: integration apps wear their brand marks, not emoji (#831)
* web: integration apps wear their brand marks, not emoji GitHub as an octopus reads as a toy. The integration apps now carry their real marks — vendored, static in-code monochrome SVGs (web/static/logos.js: Simple Icons CC0 for GitHub/Stripe/HubSpot/ Linear, DNSimple's official mask icon, Lucide's plug for the Integrations app itself) — rendered via one painter (appGlyphInto) wherever app avatars draw: sidebar rows, the thread header, the new-chat and add-apps pickers, and app-sender notices. currentColor throughout, so they theme like every other icon. The emoji glyph remains the app's cross-surface fallback: native palettes and plain-string contexts can't render an SVG, and member-made apps and bots keep their chosen emoji — those are content, not chrome. LOGO_LINEAR ships for the linear app to adopt when it lands (#828). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: DNSimple mark needs fill-rule evenodd — the knockout rendered as a solid disc without it ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
9 files changed,
+99
−11
changelog/2026-07-13-brand-logos.md+5 −0web/static/apps.js+24 −7web/static/apps/connect.js+2 −0web/static/apps/dnsimple.js+2 −0web/static/apps/github.js+2 −0web/static/apps/hubspot.js+2 −0web/static/apps/stripe.js+2 −0web/static/logos.js+46 −0web/static/shell.js+14 −4
diff --git a/changelog/2026-07-13-brand-logos.md b/changelog/2026-07-13-brand-logos.md| new file mode 100644 |
| index 0000000..deb7a22 |
| --- /dev/null |
| +++ b/changelog/2026-07-13-brand-logos.md |
| @@ -0,0 +1,5 @@ |
| +🤖 Integration apps wear their real logos now — GitHub is the GitHub mark, |
| +DNSimple, Stripe, and HubSpot likewise, and the Integrations app gets a |
| +proper plug icon — in the sidebar, the chat header, the pickers, and their |
| +"only visible to you" replies. Emoji stay for the apps and bots you make |
| +yourself. |
diff --git a/web/static/apps.js b/web/static/apps.js| index 69b0370..49c6c66 100644 |
| --- a/web/static/apps.js |
| +++ b/web/static/apps.js |
| @@ -113,6 +113,10 @@ injectStyles( |
| } |
| /* An app's glyph avatar (sidebar + header) and the "app" badge on its row. */ |
| .app-avatar { display: flex; align-items: center; justify-content: center; background: var(--bubble-them); font-size: 18px; } |
| +/* A vendored brand logo inside an avatar or notice glyph slot (appGlyphInto): |
| + sized to sit like the emoji it replaces, inked like text. */ |
| +.app-logo svg { width: 55%; height: 55%; display: block; color: var(--ink); } |
| +.local-notice-glyph.app-logo svg { width: 14px; height: 14px; } |
| .app-badge { |
| font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; |
| color: var(--muted); background: var(--bubble-them); border-radius: 6px; |
| @@ -171,6 +175,19 @@ injectStyles( |
| // never stored, never folded — presence traffic like typing |
| // The owned event types (app.types) and the reducer (app.reduce) are derived from |
| // `events` — one source of truth, so a type can never drift out of sync. |
| +// appGlyphInto paints an app's identity mark into an avatar-ish element: its |
| +// vendored brand logo (static in-code SVG — web/static/logos.js) when it has |
| +// one, else its emoji glyph. The glyph stays the app's cross-surface fallback |
| +// (native palettes and plain-string contexts can't render an SVG). |
| +export function appGlyphInto(el, app) { |
| + if (app && app.logo) { |
| + el.classList.add("app-logo"); |
| + el.innerHTML = app.logo; // static in-code SVG, never user content |
| + } else { |
| + el.textContent = (app && app.glyph) || "▦"; |
| + } |
| +} |
| + |
| export function defineApp(spec) { |
| const app = { commands: {}, ...spec }; |
| // Reconcile the legacy `scope` enum with the capability flags it's dissolving |
| @@ -1132,7 +1149,7 @@ export function runAppMention(text, threadId, respond) { |
| const spec = m.app.commands[m.app.defaultCommand]; |
| if (!spec || !spec.run) return false; |
| const label = m.app.title || m.app.name; |
| - const sender = { name: label, glyph: m.app.glyph, kind: "app" }; |
| + const sender = { name: label, glyph: m.app.glyph, logo: m.app.logo, kind: "app" }; |
| const reply = (r) => { |
| try { |
| respond && respond({ sender, ...r }); |
| @@ -1279,7 +1296,7 @@ export function runCommand(text, activeThreadId, respond) { |
| if (BOT_COMMANDS[cmd.name]) return false; |
| // In an app's own thread the app answers for itself; elsewhere Eleven does. |
| const owner = threadOwnerApp(activeThreadId); |
| - const sender = owner ? { name: owner.title || owner.name, glyph: owner.glyph, kind: "app" } : defaultBotFn ? defaultBotFn() : null; |
| + const sender = owner ? { name: owner.title || owner.name, glyph: owner.glyph, logo: owner.logo, kind: "app" } : defaultBotFn ? defaultBotFn() : null; |
| reply({ sender, text: `${cmd.name} isn't a command. Type / to see what's available.` }); |
| return true; // swallow — a failed command is answered, not broadcast |
| } |
| @@ -1287,7 +1304,7 @@ export function runCommand(text, activeThreadId, respond) { |
| (async () => { |
| let target = activeThreadId; |
| let args = cmd.args; |
| - const sender = { name: app.title || app.name, glyph: app.glyph, kind: "app" }; |
| + const sender = { name: app.title || app.name, glyph: app.glyph, logo: app.logo, kind: "app" }; |
| // "/todo @here buy milk" — the @here flag addresses THIS chat's shared |
| // instance, INSTALLING the app into the group first if needed (the same |
| // groupapps.add the Add-apps sheet emits, so everyone gets the widget bar |
| @@ -1502,10 +1519,10 @@ function fillFullNotice(row, spec = {}, opts = {}) { |
| const head = document.createElement("div"); |
| head.className = "local-notice-head"; |
| if (!opts.cont && sender && sender.name) { |
| - if (sender.glyph) { |
| + if (sender.logo || sender.glyph) { |
| const g = document.createElement("span"); |
| g.className = "local-notice-glyph"; |
| - g.textContent = sender.glyph; |
| + appGlyphInto(g, sender); |
| head.appendChild(g); |
| } |
| const nm = document.createElement("span"); |
| @@ -1613,10 +1630,10 @@ function fillCompactNotice(row, spec = {}) { |
| row.classList.add("local-notice--compact"); |
| const { sender, actions } = spec; |
| const { from, what } = noticeParts(spec); |
| - if (sender && sender.glyph) { |
| + if (sender && (sender.logo || sender.glyph)) { |
| const g = document.createElement("span"); |
| g.className = "local-notice-glyph"; |
| - g.textContent = sender.glyph; |
| + appGlyphInto(g, sender); |
| row.appendChild(g); |
| } |
| if (from) { |
diff --git a/web/static/apps/connect.js b/web/static/apps/connect.js| index 4116b69..4296fb0 100644 |
| --- a/web/static/apps/connect.js |
| +++ b/web/static/apps/connect.js |
| @@ -13,6 +13,7 @@ |
| // verb owned here — it belongs to the service you connected: the DNSimple app |
| // owns a context-local `/list` that only works inside its own thread.) |
| import { defineApp, appByName, openAppThread } from "../apps.js"; |
| +import { LOGO_PLUG } from "../logos.js"; |
| const unavailable = (ctx) => ctx.reply({ text: "Connecting services isn't available here yet." }); |
| @@ -58,6 +59,7 @@ export const app = defineApp({ |
| // activity (mirrored natively in ThreadStore.appDescriptions — keep in sync). |
| blurb: "Connect third-party services", |
| glyph: "🔌", |
| + logo: LOGO_PLUG, |
| scope: "personal", |
| initialState: () => ({}), |
| events: {}, |
diff --git a/web/static/apps/dnsimple.js b/web/static/apps/dnsimple.js| index 1fd15bd..43e90ae 100644 |
| --- a/web/static/apps/dnsimple.js |
| +++ b/web/static/apps/dnsimple.js |
| @@ -14,6 +14,7 @@ |
| // split (see apps/README.md). Your domain list is folded state, so it persists |
| // across devices and survives reopen via the snapshot, just like any other app. |
| import { defineApp } from "../apps.js"; |
| +import { LOGO_DNSIMPLE } from "../logos.js"; |
| // A tiny helper so both the Chat projection and the reducer agree on wording. |
| const summary = (domains) => |
| @@ -43,6 +44,7 @@ export const app = defineApp({ |
| name: "dnsimple", |
| title: "DNSimple", |
| glyph: "🌐", |
| + logo: LOGO_DNSIMPLE, |
| scope: "personal", |
| // Only listed in New chat once DNSimple is linked (shell.js gates on this). |
| requiresService: "dnsimple", |
diff --git a/web/static/apps/github.js b/web/static/apps/github.js| index a8875ec..ab4f60c 100644 |
| --- a/web/static/apps/github.js |
| +++ b/web/static/apps/github.js |
| @@ -15,6 +15,7 @@ |
| // It's a TAKEOVER app (no chatMessage): the tables ARE the thread, so repo |
| // and issue data never echoes into chat bubbles, previews, or notifications. |
| import { defineApp } from "../apps.js"; |
| +import { LOGO_GITHUB } from "../logos.js"; |
| // Results arrive as pick-projected rows with flattened dot-path keys |
| // ("user.login") — normalise to plain fields once, at fold time, and tolerate |
| @@ -83,6 +84,7 @@ export const app = defineApp({ |
| name: "github", |
| title: "GitHub", |
| glyph: "🐙", |
| + logo: LOGO_GITHUB, |
| scope: "personal", |
| // Only listed in New chat once GitHub is linked (shell.js gates on this). |
| requiresService: "github", |
diff --git a/web/static/apps/hubspot.js b/web/static/apps/hubspot.js| index 2ede387..18f3004 100644 |
| --- a/web/static/apps/hubspot.js |
| +++ b/web/static/apps/hubspot.js |
| @@ -10,6 +10,7 @@ |
| // devices via snapshots. TAKEOVER app (no chatMessage): CRM data never echoes |
| // into chat bubbles or notifications. |
| import { defineApp } from "../apps.js"; |
| +import { LOGO_HUBSPOT } from "../logos.js"; |
| // Results arrive pick-projected with flattened dot-path keys |
| // ("properties.email") — normalise once at fold time, drop the malformed. |
| @@ -59,6 +60,7 @@ export const app = defineApp({ |
| title: "HubSpot", |
| blurb: "Contacts & deals", |
| glyph: "🧲", |
| + logo: LOGO_HUBSPOT, |
| scope: "personal", |
| // Only listed in New chat once HubSpot is linked (shell.js gates on this). |
| requiresService: "hubspot", |
diff --git a/web/static/apps/stripe.js b/web/static/apps/stripe.js| index d5faca4..6283c23 100644 |
| --- a/web/static/apps/stripe.js |
| +++ b/web/static/apps/stripe.js |
| @@ -11,6 +11,7 @@ |
| // throughout. It's a TAKEOVER app (no chatMessage): the dashboard IS the |
| // thread, so money data never echoes into chat bubbles or notifications. |
| import { defineApp } from "../apps.js"; |
| +import { LOGO_STRIPE } from "../logos.js"; |
| // Stripe amounts are integer minor units except in zero-decimal currencies. |
| const ZERO_DECIMAL = new Set(["bif", "clp", "djf", "gnf", "jpy", "kmf", "krw", "mga", "pyg", "rwf", "ugx", "vnd", "vuv", "xaf", "xof", "xpf"]); |
| @@ -78,6 +79,7 @@ export const app = defineApp({ |
| title: "Stripe", |
| blurb: "Balance, charges & payouts", |
| glyph: "💳", |
| + logo: LOGO_STRIPE, |
| scope: "personal", |
| // Only listed in New chat once Stripe is linked (shell.js gates on this). |
| requiresService: "stripe", |
diff --git a/web/static/logos.js b/web/static/logos.js| new file mode 100644 |
| index 0000000..5228bf3 |
| --- /dev/null |
| +++ b/web/static/logos.js |
| @@ -0,0 +1,46 @@ |
| +// Brand marks for the integration apps — the app's identity where an emoji |
| +// glyph used to stand in (a plug called 🔌 reads as a toy; the GitHub mark |
| +// reads as GitHub). Static, in-code SVG only, monochrome via currentColor so |
| +// they theme like every other icon. `logo` renders wherever app avatars and |
| +// app-sender notices draw (appGlyphInto, apps.js); the emoji `glyph` remains |
| +// the fallback everywhere a string is required (native palettes, typeahead |
| +// insert text) and for apps with no mark of their own. |
| +// |
| +// Sources: GitHub, Stripe, HubSpot, Linear from Simple Icons |
| +// (https://simpleicons.org, CC0). DNSimple from its official pinned-tab mask |
| +// icon (dnsimple.com, monochrome by design). The "plug" mark (the |
| +// Integrations app itself — not a brand) is Lucide's plug (ISC). All |
| +// trademarks identify their services and remain their owners' property. |
| + |
| +// The GitHub mark (Invertocat). |
| +export const LOGO_GITHUB = |
| + '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">' + |
| + '<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"></path></svg>'; |
| + |
| +// DNSimple's mask-icon mark. |
| +export const LOGO_DNSIMPLE = |
| + '<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">' + |
| + '<path fill-rule="evenodd" d="M8 16c4.418 0 8-3.582 8-8s-3.582-8-8-8-8 3.582-8 8 3.582 8 8 8zm2.146-13c-.285 0-.57.015-.84.075v2.788c-.39-.15-.84-.225-1.44-.225C5.74 5.638 4 7.092 4 9.43c0 2.4 1.59 3.718 3.927 3.718 1.154 0 2.34-.315 3.073-.705V3.075c-.285-.06-.585-.075-.854-.075zm-2.25 8.664c-1.288 0-2.172-.87-2.172-2.264 0-1.38.944-2.263 2.203-2.263.51 0 .974.06 1.38.315v3.987c-.45.164-.915.24-1.41.224z"></path></svg>'; |
| + |
| +// The Stripe S. |
| +export const LOGO_STRIPE = |
| + '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">' + |
| + '<path d="M13.976 9.15c-2.172-.806-3.356-1.426-3.356-2.409 0-.831.683-1.305 1.901-1.305 2.227 0 4.515.858 6.09 1.631l.89-5.494C18.252.975 15.697 0 12.165 0 9.667 0 7.589.654 6.104 1.872 4.56 3.147 3.757 4.992 3.757 7.218c0 4.039 2.467 5.76 6.476 7.219 2.585.92 3.445 1.574 3.445 2.583 0 .98-.84 1.545-2.354 1.545-1.875 0-4.965-.921-6.99-2.109l-.9 5.555C5.175 22.99 8.385 24 11.714 24c2.641 0 4.843-.624 6.328-1.813 1.664-1.305 2.525-3.236 2.525-5.732 0-4.128-2.524-5.851-6.594-7.305h.003z"></path></svg>'; |
| + |
| +// The HubSpot sprocket. |
| +export const LOGO_HUBSPOT = |
| + '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">' + |
| + '<path d="M18.164 7.93V5.084a2.198 2.198 0 001.267-1.978v-.067A2.2 2.2 0 0017.238.845h-.067a2.2 2.2 0 00-2.193 2.193v.067a2.196 2.196 0 001.252 1.973l.013.006v2.852a6.22 6.22 0 00-2.969 1.31l.012-.01-7.828-6.095A2.497 2.497 0 104.3 4.656l-.012.006 7.697 5.991a6.176 6.176 0 00-1.038 3.446c0 1.343.425 2.588 1.147 3.607l-.013-.02-2.342 2.343a1.968 1.968 0 00-.58-.095h-.002a2.033 2.033 0 102.033 2.033 1.978 1.978 0 00-.1-.595l.005.014 2.317-2.317a6.247 6.247 0 104.782-11.134l-.036-.005zm-.964 9.378a3.206 3.206 0 113.215-3.207v.002a3.206 3.206 0 01-3.207 3.207z"></path></svg>'; |
| + |
| +// The Linear mark (for the linear app when it lands). |
| +export const LOGO_LINEAR = |
| + '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">' + |
| + '<path d="M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z"></path></svg>'; |
| + |
| +// Lucide plug — the Integrations app's own mark (a capability, not a brand), |
| +// stroke-drawn like the rest of the UI icon set. |
| +export const LOGO_PLUG = |
| + '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' + |
| + 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + |
| + '<path d="M12 22v-5"></path><path d="M9 8V2"></path><path d="M15 8V2"></path>' + |
| + '<path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"></path></svg>'; |
diff --git a/web/static/shell.js b/web/static/shell.js| index 48bb89b..d27e28c 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -110,6 +110,7 @@ import { |
| setCommandPanelObserver, |
| closeCommandPanel, |
| setMemoryStore, |
| + appGlyphInto, |
| } from "/static/apps.js"; |
| // Register the mini-apps (side-effect imports). Each is a single self-contained |
| // file (JS + its own CSS). Personal apps get their own sidebar thread; shared |
| @@ -3361,7 +3362,7 @@ function renderThreadList() { |
| av.setAttribute("role", "img"); |
| av.setAttribute("aria-label", (app && app.title) || appName); |
| } else { |
| - av.textContent = (app && app.glyph) || "▦"; |
| + appGlyphInto(av, app); |
| } |
| row.appendChild(av); |
| } else { |
| @@ -3584,6 +3585,15 @@ function paintHeaderAvatar(id) { |
| if (!el) return; |
| const t = threads.find((x) => x.id === id); |
| const ownerApp = threadApp[id] && appByName(threadApp[id]); |
| + // A logo-bearing app heads its thread with the mark itself — no photo |
| + // loader, no initials fallback (paintAvatar can't draw an inline SVG). |
| + if (ownerApp && !ownerApp.avatar && ownerApp.logo) { |
| + el.classList.add("avatar", "app-avatar"); |
| + el.classList.remove("zoomable"); |
| + el.onclick = null; |
| + appGlyphInto(el, ownerApp); |
| + return; |
| + } |
| let loader; |
| if (ownerApp && ownerApp.avatar) loader = () => Promise.resolve(ownerApp.avatar); // e.g. Eleven's app icon |
| else if (t && t.kind === "group") loader = () => threadAvatarFor(id); |
| @@ -9656,7 +9666,7 @@ function manageGroupApps() { |
| row.className = "list-row static"; |
| const av = document.createElement("span"); |
| av.className = "avatar app-avatar"; |
| - av.textContent = app.glyph || "▦"; |
| + appGlyphInto(av, app); |
| const body = document.createElement("span"); |
| body.style.cssText = "flex:1;min-width:0;"; |
| const nm = document.createElement("span"); |
| @@ -9829,7 +9839,7 @@ function showNewChat(startMode) { |
| b.className = "list-row"; |
| const g = document.createElement("span"); |
| g.className = "avatar app-avatar"; |
| - g.textContent = app.glyph || "▦"; |
| + appGlyphInto(g, app); |
| const nm = document.createElement("span"); |
| nm.textContent = app.title || app.name; |
| b.append(g, nm); |
| @@ -10626,7 +10636,7 @@ function showApps() { |
| b.className = "list-row"; |
| const g = document.createElement("span"); |
| g.className = "avatar app-avatar"; |
| - g.textContent = app.glyph || "▦"; |
| + appGlyphInto(g, app); |
| const nm = document.createElement("span"); |
| nm.textContent = app.title || app.name; |
| b.append(g, nm); |