eleven / messenger Public

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>
Paul Campbell 0621fc32e5304498dd6bc8c3880fdf426ba45690 parent b770f85
9 files changed, +99 −11
  • changelog/2026-07-13-brand-logos.md +5 −0
  • web/static/apps.js +24 −7
  • web/static/apps/connect.js +2 −0
  • web/static/apps/dnsimple.js +2 −0
  • web/static/apps/github.js +2 −0
  • web/static/apps/hubspot.js +2 −0
  • web/static/apps/stripe.js +2 −0
  • web/static/logos.js +46 −0
  • web/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);