eleven / messenger Public

web: the icon system — icons.js, Lucide marks for first-party apps, docs/icons.md (#843)

The consolidation the brand-logos PR set up: every piece of interface
chrome now lives in one module (web/static/icons.js — the ten existing
hand-drawn icons moved verbatim out of shell.js, plus vendored Lucide
marks), and the first-party apps wear line icons instead of emoji —
To-do square-check, Reminders alarm-clock, Bookmarks, Poll, Board,
Vault, Emoji, GIF, Admin wrench, App Studio puzzle, Memory brain — via
the same logo/appGlyphInto rail as the brand marks. The kebab and the
mobile back button swap their text glyphs for SVGs; the Views
dropdown's Memory row wears the brain.

The emoji policy lands in docs/icons.md with the web-Lucide ↔ native-SF
mapping table (Paul's call: emoji are content — a bot's chosen title
glyph, a reaction — never chrome). Native alignment tracked there as
the owed half.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 10eecadd7e401d2a60f62bb29c6a52ed3c1f0cad parent 7ec8238
16 files changed, +258 −66
  • changelog/2026-07-13-real-icons.md +5 −0
  • docs/icons.md +77 −0
  • web/static/apps/admin.js +2 −0
  • web/static/apps/appstudio.js +2 −0
  • web/static/apps/board.js +2 −0
  • web/static/apps/bookmarks.js +2 −0
  • web/static/apps/emoji.js +2 −0
  • web/static/apps/gif.js +2 −0
  • web/static/apps/memory.js +2 −0
  • web/static/apps/poll.js +2 −0
  • web/static/apps/remind.js +2 −0
  • web/static/apps/todo.js +2 −0
  • web/static/apps/vault.js +2 −0
  • web/static/icons.js +145 −0
  • web/static/shell.js +7 −66
  • web/static/style.css +2 −0
diff --git a/changelog/2026-07-13-real-icons.md b/changelog/2026-07-13-real-icons.md
new file mode 100644
index 0000000..4839056
--- /dev/null
+++ b/changelog/2026-07-13-real-icons.md
@@ -0,0 +1,5 @@
+🤖 The built-in apps have proper icons now — To-do, Reminders, Bookmarks,
+Poll, Board, Vault, Admin, App Studio, Memory and friends wear crisp line
+icons instead of emoji, matching the brand logos the integrations got
+earlier. Emoji stay where they belong: in the names and glyphs you and your
+bots choose.
diff --git a/docs/icons.md b/docs/icons.md
new file mode 100644
index 0000000..eb1d083
--- /dev/null
+++ b/docs/icons.md
@@ -0,0 +1,77 @@
+# 🤖 Icons — one system across web and native
+
+Three kinds of mark, three sources, one rule about emoji.
+
+## The three kinds
+
+1. **UI chrome** (carets, the kebab, message actions, app marks for
+ first-party apps): **Lucide** on web (https://lucide.dev, ISC — vendored,
+ never a CDN), **SF Symbols** on iOS/Mac (built into Apple platforms;
+ their license keeps them off the web, which is why one literal library
+ everywhere isn't possible). The table below keeps the two in step.
+2. **Brand marks** (the integration apps): real logos, vendored monochrome in
+ `web/static/logos.js` — Simple Icons (CC0) where available, an official
+ asset otherwise (DNSimple's mask icon — note it needs
+ `fill-rule="evenodd"` or the knockout fills solid). Brands have no SF
+ Symbols; native gets the same SVGs as template images in the asset
+ catalogs, tinted like SF.
+3. **Emoji** — **content, never chrome.** An emoji is right where a person or
+ a model *chose* it: a member bot's title ("RTE News Hourly 📺"), a member
+ app's glyph, a view's glyph, reactions. Interface furniture never uses
+ emoji. Every first-party app still declares an emoji `glyph` — it is the
+ cross-surface fallback (native command palette, string contexts) — but
+ web surfaces render its `logo`.
+
+## How it's wired (web)
+
+- `web/static/icons.js` — every chrome icon as a static in-code SVG string
+ (24×24 viewBox, `currentColor`, stroke idiom). Only ever injected via
+ `innerHTML` with these constants — never user content.
+- `web/static/logos.js` — the brand marks, same shape.
+- Apps carry `logo:` beside `glyph:` in `defineApp`; `appGlyphInto(el, app)`
+ (apps.js) renders logo-else-glyph everywhere app identity draws.
+- The Views button's caret is the one inline SVG in `web/pages/index.html`
+ (static markup; same chevron as `ICON_CHEVRON_DOWN`).
+
+**Adding an icon** = vendor the Lucide SVG into `icons.js` (keep the
+`(Lucide: <slug>)` comment), add a row here with its SF Symbol, use it.
+
+## The mapping
+
+| Meaning | icons.js (Lucide slug) | SF Symbol (native) |
+|---|---|---|
+| Reply | `ICON_REPLY` (hand-drawn, pre-Lucide) | `arrowshape.turn.up.left` |
+| Close / dismiss | `ICON_X` | `xmark` |
+| Edit | `ICON_EDIT` | `pencil` |
+| React | `ICON_REACT` | `face.smiling` |
+| Delete | `ICON_TRASH` | `trash` |
+| Play | `ICON_PLAY` | `play.fill` |
+| Pinned chat | `ICON_PIN` | `pin.fill` |
+| Muted chat | `ICON_MUTE` | `bell.slash` |
+| Report | `ICON_REPORT` | `flag` |
+| Copy | `ICON_COPY` | `doc.on.doc` |
+| Dropdown caret | `ICON_CHEVRON_DOWN` (chevron-down) | `chevron.down` |
+| Back | `ICON_CHEVRON_LEFT` (chevron-left) | `chevron.left` |
+| Kebab | `ICON_MORE_VERTICAL` (ellipsis-vertical) | `ellipsis` |
+| Memory | `ICON_BRAIN` (brain) | `brain` |
+| To-do | `ICON_SQUARE_CHECK` (square-check) | `checkmark.square` |
+| Reminders | `ICON_ALARM_CLOCK` (alarm-clock) | `alarm` |
+| Bookmarks | `ICON_BOOKMARK` (bookmark) | `bookmark` |
+| Poll | `ICON_CHART_COLUMN` (chart-column) | `chart.bar` |
+| Board | `ICON_PRESENTATION` (presentation) | `rectangle.on.rectangle` |
+| Vault | `ICON_LOCK` (lock) | `lock` |
+| Emoji picker | `ICON_SMILE` (smile) | `face.smiling` |
+| GIF | `ICON_FILM` (film) | `film` |
+| Admin | `ICON_WRENCH` (wrench) | `wrench.adjustable` |
+| App Studio | `ICON_PUZZLE` (puzzle) | `puzzlepiece` |
+
+Brand marks (`logos.js`): GitHub, DNSimple, Stripe, HubSpot, Linear
+(awaiting the linear app), plus Lucide's `plug` for the Integrations app
+itself (a capability, not a brand).
+
+## Native status
+
+Chrome on iOS/Mac already leans on SF Symbols ad hoc; the owed work is
+aligning names to this table and carrying the brand marks + first-party app
+marks into the native pickers/rows (template-image assets). Track alongside
+the next native release.
diff --git a/web/static/apps/admin.js b/web/static/apps/admin.js
index 6858618..fe0a87e 100644
--- a/web/static/apps/admin.js
+++ b/web/static/apps/admin.js
@@ -11,6 +11,7 @@
// the update stream: release notes the app posts (admin.note), read in the
// companion Chat view — see announceNew below.
import { defineApp, setupBridge, registerFloorCommand, openAppThread } from "../apps.js";
+import { ICON_WRENCH } from "../icons.js";
import { changelogParagraphs } from "../format.js";
// ── Release notes → the Chat view ─────────────────────────────────────────
@@ -90,6 +91,7 @@ export const app = defineApp({
name: "admin",
title: "Admin",
glyph: "🛠️",
+ logo: ICON_WRENCH,
scope: "system",
snapshot: false,
// A companion app that leads with its Interface: opening Admin shows the
diff --git a/web/static/apps/appstudio.js b/web/static/apps/appstudio.js
index 7109eee..ff9bcae 100644
--- a/web/static/apps/appstudio.js
+++ b/web/static/apps/appstudio.js
@@ -11,6 +11,7 @@
// encrypt/deploy), so the privileged work lives in ../sandbox.js; App Studio
// is just the UI over it. The authoring rules are at GET /api/member-app-guide.
import { defineApp } from "../apps.js";
+import { ICON_PUZZLE } from "../icons.js";
import {
listDeployedApps,
listAppVersions,
@@ -91,6 +92,7 @@ export const app = defineApp({
title: "App Studio",
blurb: "Make and manage your own apps",
glyph: "🧩",
+ logo: ICON_PUZZLE,
scope: "personal",
initialState: () => ({}),
events: {},
diff --git a/web/static/apps/board.js b/web/static/apps/board.js
index 3b932ba..1e7eb05 100644
--- a/web/static/apps/board.js
+++ b/web/static/apps/board.js
@@ -8,6 +8,7 @@
// Relative import (not "/static/apps.js") so the reducer is exercisable in Node
// tests while still resolving to /static/apps.js in the browser.
import { defineApp, addItem } from "../apps.js";
+import { ICON_PRESENTATION } from "../icons.js";
// Points are stored in a fixed logical canvas size, not normalized — every
// device draws the same picture at the same coordinates regardless of its own
@@ -47,6 +48,7 @@ export const app = defineApp({
title: "Whiteboard",
blurb: "Sketch on a whiteboard", // sidebar subtitle before first use (sync w/ native)
glyph: "🎨",
+ logo: ICON_PRESENTATION,
scope: "shared",
initialState: () => ({ open: false, strokes: [] }),
events: {
diff --git a/web/static/apps/bookmarks.js b/web/static/apps/bookmarks.js
index 42e8c90..d505756 100644
--- a/web/static/apps/bookmarks.js
+++ b/web/static/apps/bookmarks.js
@@ -10,6 +10,7 @@
// Relative imports (not "/static/…") so the reducer + link detection are
// exercisable in Node tests while still resolving under /static/ in the browser.
import { defineApp, addItem, removeItem } from "../apps.js";
+import { ICON_BOOKMARK } from "../icons.js";
import { firstLink } from "../format.js";
// A URL is accepted as-is when it already has an http(s) scheme; a bare domain
@@ -47,6 +48,7 @@ export const app = defineApp({
title: "Bookmarks",
blurb: "Save a bookmark", // sidebar subtitle before first use (sync w/ native)
glyph: "🔖",
+ logo: ICON_BOOKMARK,
scope: "personal",
// Also addable to a group chat (⋮ → Add apps): a shared link list for the
// group, folded from the group thread's log; /save there scopes to the group
diff --git a/web/static/apps/emoji.js b/web/static/apps/emoji.js
index fe7deb4..a4e3fcb 100644
--- a/web/static/apps/emoji.js
+++ b/web/static/apps/emoji.js
@@ -5,6 +5,7 @@
// state, no thread of its own — the command and its temporary surface are the
// whole app.
import { defineApp } from "../apps.js";
+import { ICON_SMILE } from "../icons.js";
import { EMOJI } from "../emoji.js";
// searchEmoji filters the shortcode set by a substring of the name and
@@ -27,6 +28,7 @@ export const app = defineApp({
name: "emoji",
title: "Emoji",
glyph: "😊",
+ logo: ICON_SMILE,
// Command-only: no personal thread to route to (the command acts where you
// typed it) and nothing to instantiate from the Apps menu.
personal: false,
diff --git a/web/static/apps/gif.js b/web/static/apps/gif.js
index b2cdd90..b6e635e 100644
--- a/web/static/apps/gif.js
+++ b/web/static/apps/gif.js
@@ -11,6 +11,7 @@
// flow works in the native shells' command-host webview, where there is no web
// composer to stage into.
import { defineApp } from "../apps.js";
+import { ICON_FILM } from "../icons.js";
// The largest GIF we'll post. Giphy's "downsized" rendition targets ~2 MB;
// this cap is the backstop when only bigger renditions exist.
@@ -63,6 +64,7 @@ export const app = defineApp({
name: "gif",
title: "GIFs",
glyph: "🎞️",
+ logo: ICON_FILM,
// Command-only, like /emoji: no personal thread, nothing to instantiate —
// the command acts in the chat you typed it in.
personal: false,
diff --git a/web/static/apps/memory.js b/web/static/apps/memory.js
index 6d5f8cc..5ef1086 100644
--- a/web/static/apps/memory.js
+++ b/web/static/apps/memory.js
@@ -9,6 +9,7 @@
// The store rides a host bridge (setMemoryStore in apps.js, wired by
// shell.js, which holds the thread keys) — this file never touches crypto.
import { defineApp, getMemoryStore, openCommandPanel, motionOK } from "../apps.js";
+import { ICON_BRAIN } from "../icons.js";
export const MAX_FACTS = 24;
export const MAX_FACT_CHARS = 280;
@@ -34,6 +35,7 @@ defineApp({
name: "memory",
title: "Memory",
glyph: "🧠",
+ logo: ICON_BRAIN,
scope: "system", // a scratchpad panel over the current DM — never a listed app
commands: {
"/memory": {
diff --git a/web/static/apps/poll.js b/web/static/apps/poll.js
index 24e0a07..c44667f 100644
--- a/web/static/apps/poll.js
+++ b/web/static/apps/poll.js
@@ -8,6 +8,7 @@
// Relative import (not "/static/apps.js") so the reducer is exercisable in Node
// tests while still resolving to /static/apps.js in the browser.
import { defineApp, addItem, keyedList } from "../apps.js";
+import { ICON_CHART_COLUMN } from "../icons.js";
// pollSummary turns a finished poll into the plain-text results message that
// gets posted back into the chat — options with their counts, then the verdict
@@ -44,6 +45,7 @@ export const app = defineApp({
title: "Poll",
blurb: "Start a poll", // sidebar subtitle before first use (sync w/ native)
glyph: "📊",
+ logo: ICON_CHART_COLUMN,
scope: "shared",
initialState: () => ({ polls: [] }),
events: {
diff --git a/web/static/apps/remind.js b/web/static/apps/remind.js
index 086100b..f8ab5f0 100644
--- a/web/static/apps/remind.js
+++ b/web/static/apps/remind.js
@@ -28,6 +28,7 @@
// Relative import (not "/static/apps.js") so the parser + reducer are exercisable
// in Node tests while still resolving to /static/apps.js in the browser.
import { defineApp } from "../apps.js";
+import { ICON_ALARM_CLOCK } from "../icons.js";
import { schemaEvents, schemaInitialState, schemaRender, schemaStyles } from "./schema-render.js";
import schema from "./schemas/remind.json" with { type: "json" };
@@ -157,6 +158,7 @@ export const app = defineApp({
title: schema.title,
blurb: schema.blurb, // sidebar subtitle before first use (sync w/ native)
glyph: schema.glyph,
+ logo: ICON_ALARM_CLOCK,
scope: "personal",
// Also addable to a group chat (⋮ → Add apps): shared reminders for the whole
// group — the server delivers remind.fire into the GROUP thread, so every
diff --git a/web/static/apps/todo.js b/web/static/apps/todo.js
index a900e72..bab2d4c 100644
--- a/web/static/apps/todo.js
+++ b/web/static/apps/todo.js
@@ -13,6 +13,7 @@
// Relative import (not "/static/apps.js") so the reducer is exercisable in Node
// tests while still resolving to /static/apps.js in the browser.
import { defineApp } from "../apps.js";
+import { ICON_SQUARE_CHECK } from "../icons.js";
import { schemaEvents, schemaInitialState, schemaRender, schemaStyles } from "./schema-render.js";
import schema from "./schemas/todo.json" with { type: "json" };
@@ -23,6 +24,7 @@ export const app = defineApp({
title: schema.title,
blurb: schema.blurb, // sidebar subtitle before first use (sync w/ native)
glyph: schema.glyph,
+ logo: ICON_SQUARE_CHECK,
scope: "personal",
// Also addable to a group chat (⋮ → Add apps): one SHARED list for the whole
// group — the same events and folds, applied to the group thread's log
diff --git a/web/static/apps/vault.js b/web/static/apps/vault.js
index 30ba916..1d33e15 100644
--- a/web/static/apps/vault.js
+++ b/web/static/apps/vault.js
@@ -41,6 +41,7 @@
// Relative import (not "/static/apps.js") so the reducer is exercisable in Node
// tests while still resolving to /static/apps.js in the browser.
import { defineApp, addItem, updateItem, removeItem, currentUserId } from "../apps.js";
+import { ICON_LOCK } from "../icons.js";
// How long a revealed value stays visible before it re-locks itself — short, so a
// vault left open on screen doesn't sit unmasked. Clicking it again re-arms it.
@@ -89,6 +90,7 @@ export const app = defineApp({
title: "Vault",
blurb: "Keep secrets safe", // sidebar subtitle before first use (sync w/ native)
glyph: "🔒",
+ logo: ICON_LOCK,
scope: "personal",
groupCapable: true, // can also be added to a group chat, one shared instance for all
groupChatOnly: true, // chatMessage below projects into a GROUP's chat feed only — never a Chat view of the personal thread (see apps/README.md "Companion projection")
diff --git a/web/static/icons.js b/web/static/icons.js
new file mode 100644
index 0000000..ff3f913
--- /dev/null
+++ b/web/static/icons.js
@@ -0,0 +1,145 @@
+// The UI icon set — every piece of interface chrome, one module. Inline,
+// static, stroke-drawn SVGs in the Feather/Lucide idiom (24x24 viewBox,
+// currentColor, round caps), injected only via innerHTML with these in-code
+// strings — never user content. The mapping to native SF Symbols, the emoji
+// policy (emoji are CONTENT — a member bot's chosen glyph, a reaction —
+// never chrome), and how to add an icon: docs/icons.md.
+//
+// The newer icons are vendored from Lucide (https://lucide.dev, ISC); the
+// originals predate the adoption and are hand-drawn in the same style.
+
+// Curved reply arrow (hover affordance, swipe icon, composer banner).
+export const ICON_REPLY =
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" ' +
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
+ '<polyline points="9 17 4 12 9 7"></polyline>' +
+ '<path d="M20 18v-2a4 4 0 0 0-4-4H4"></path></svg>';
+// Optically-centered close cross (the reply banner's cancel button).
+export const ICON_X =
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" ' +
+ 'stroke-linecap="round" aria-hidden="true">' +
+ '<line x1="6" y1="6" x2="18" y2="18"></line>' +
+ '<line x1="18" y1="6" x2="6" y2="18"></line></svg>';
+// Pencil, for the desktop hover edit affordance on your own messages.
+export const ICON_EDIT =
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" ' +
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
+ '<path d="M12 20h9"></path>' +
+ '<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg>';
+// Smiley, for the desktop hover "react" affordance (opens the emoji picker).
+export const ICON_REACT =
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
+ '<circle cx="12" cy="12" r="9"></circle>' +
+ '<path d="M8 14s1.5 2 4 2 4-2 4-2"></path>' +
+ '<line x1="9" y1="9" x2="9.01" y2="9"></line>' +
+ '<line x1="15" y1="9" x2="15.01" y2="9"></line></svg>';
+// Trash, for the "Delete" action in the message picker.
+export const ICON_TRASH =
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" ' +
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
+ '<polyline points="3 6 5 6 21 6"></polyline>' +
+ '<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>';
+// Filled play triangle, for the click-to-play YouTube card.
+export const ICON_PLAY =
+ '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">' +
+ '<path d="M8 5.5v13a1 1 0 0 0 1.53.85l10.2-6.5a1 1 0 0 0 0-1.7L9.53 4.65A1 1 0 0 0 8 5.5z"></path></svg>';
+// Filled pushpin marking pinned chats in the thread list — solid so it stays
+// crisp at ~12px, tilted via CSS for the classic "tacked on" look.
+export const ICON_PIN =
+ '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">' +
+ '<path d="M16 9V4h1c.55 0 1-.45 1-1s-.45-1-1-1H7c-.55 0-1 .45-1 1s.45 1 1 1h1v5c0 1.66-1.34 ' +
+ '3-3 3v2h5.97v7l1 1 1-1v-7H19v-2c-1.66 0-3-1.34-3-3z"></path></svg>';
+// Slashed bell marking muted chats in the thread list — outline stroke like
+// ICON_COPY so the slash reads clearly at ~12px.
+export const ICON_MUTE =
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
+ '<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>' +
+ '<path d="M18.63 13A17.9 17.9 0 0 1 18 8"></path>' +
+ '<path d="M6.26 6.26A5.86 5.86 0 0 0 6 8c0 7-3 9-3 9h14"></path>' +
+ '<path d="M18 8a6 6 0 0 0-9.33-5"></path>' +
+ '<line x1="1" y1="1" x2="23" y2="23"></line></svg>';
+// A flag, for reporting a message or a person to the space's admins.
+export const ICON_REPORT =
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
+ '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"></path>' +
+ '<line x1="4" y1="22" x2="4" y2="15"></line></svg>';
+// Overlapping pages, for the "Copy" action in the mobile long-press menu.
+export const ICON_COPY =
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" ' +
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
+ '<rect x="9" y="9" width="12" height="12" rx="2"></rect>' +
+ '<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
+
+// Dropdown caret (the Views button). (Lucide: chevron-down)
+export const ICON_CHEVRON_DOWN =
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
+ '<path d="m6 9 6 6 6-6"></path></svg>';
+// Back (mobile thread header). (Lucide: chevron-left)
+export const ICON_CHEVRON_LEFT =
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
+ '<path d="m15 18-6-6 6-6"></path></svg>';
+// The thread-header kebab. (Lucide: ellipsis-vertical)
+export const ICON_MORE_VERTICAL =
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
+ '<circle cx="12" cy="12" r="1"></circle><circle cx="12" cy="5" r="1"></circle><circle cx="12" cy="19" r="1"></circle></svg>';
+// Memory — the AI scratchpad (dropdown row + the memory app's mark). (Lucide: brain)
+export const ICON_BRAIN =
+ '<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 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"></path><path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"></path><path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"></path><path d="M17.599 6.5a3 3 0 0 0 .399-1.375"></path><path d="M6.003 5.125A3 3 0 0 0 6.401 6.5"></path><path d="M3.477 10.896a4 4 0 0 1 .585-.396"></path><path d="M19.938 10.5a4 4 0 0 1 .585.396"></path><path d="M6 18a4 4 0 0 1-1.967-.516"></path><path d="M19.967 17.484A4 4 0 0 1 18 18"></path></svg>';
+// To-do's mark. (Lucide: square-check)
+export const ICON_SQUARE_CHECK =
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
+ '<rect width="18" height="18" x="3" y="3" rx="2"></rect><path d="m9 12 2 2 4-4"></path></svg>';
+// Reminders' mark. (Lucide: alarm-clock)
+export const ICON_ALARM_CLOCK =
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
+ '<circle cx="12" cy="13" r="8"></circle><path d="M12 9v4l2 2"></path><path d="M5 3 2 6"></path><path d="m22 6-3-3"></path><path d="M6.38 18.7 4 21"></path><path d="M17.64 18.67 20 21"></path></svg>';
+// Bookmarks' mark. (Lucide: bookmark)
+export const ICON_BOOKMARK =
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
+ '<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"></path></svg>';
+// Poll's mark. (Lucide: chart-column)
+export const ICON_CHART_COLUMN =
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
+ '<path d="M3 3v16a2 2 0 0 0 2 2h16"></path><path d="M18 17V9"></path><path d="M13 17V5"></path><path d="M8 17v-3"></path></svg>';
+// Board's mark. (Lucide: presentation)
+export const ICON_PRESENTATION =
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
+ '<path d="M2 3h20"></path><path d="M21 3v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3"></path><path d="m7 21 5-5 5 5"></path></svg>';
+// Vault's mark. (Lucide: lock)
+export const ICON_LOCK =
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
+ '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>';
+// Emoji picker app's mark. (Lucide: smile)
+export const ICON_SMILE =
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
+ '<circle cx="12" cy="12" r="10"></circle><path d="M8 14s1.5 2 4 2 4-2 4-2"></path><line x1="9" x2="9.01" y1="9" y2="9"></line><line x1="15" x2="15.01" y1="9" y2="9"></line></svg>';
+// GIF app's mark. (Lucide: film)
+export const ICON_FILM =
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
+ '<rect width="18" height="18" x="3" y="3" rx="2"></rect><path d="M7 3v18"></path><path d="M3 7.5h4"></path><path d="M3 12h18"></path><path d="M3 16.5h4"></path><path d="M17 3v18"></path><path d="M17 7.5h4"></path><path d="M17 16.5h4"></path></svg>';
+// Admin's mark. (Lucide: wrench)
+export const ICON_WRENCH =
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
+ '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path></svg>';
+// App Studio's mark. (Lucide: puzzle)
+export const ICON_PUZZLE =
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
+ '<path d="M15.39 4.39a1 1 0 0 0 1.68-.474 2.5 2.5 0 1 1 3.014 3.015 1 1 0 0 0-.474 1.68l1.683 1.682a2.414 2.414 0 0 1 0 3.414L19.61 15.39a1 1 0 0 1-1.68-.474 2.5 2.5 0 1 0-3.014 3.015 1 1 0 0 1 .474 1.68l-1.683 1.682a2.414 2.414 0 0 1-3.414 0L8.61 19.61a1 1 0 0 0-1.68.474 2.5 2.5 0 1 1-3.014-3.015 1 1 0 0 0 .474-1.68l-1.683-1.682a2.414 2.414 0 0 1 0-3.414L4.39 8.61a1 1 0 0 1 1.68.474 2.5 2.5 0 1 0 3.014-3.015 1 1 0 0 1-.474-1.68l1.683-1.682a2.414 2.414 0 0 1 3.414 0z"></path></svg>';
diff --git a/web/static/shell.js b/web/static/shell.js
index d361aa8..98cf871 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -799,70 +799,8 @@ const TYPING_EXPIRY_S = 15;
// An exact per-message time is still available on hover (desktop).
const TIME_SEPARATOR_GAP_MS = 15 * 60 * 1000;
-// Curved reply arrow (hover affordance, swipe icon, composer banner).
-const ICON_REPLY =
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" ' +
- 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
- '<polyline points="9 17 4 12 9 7"></polyline>' +
- '<path d="M20 18v-2a4 4 0 0 0-4-4H4"></path></svg>';
-// Optically-centered close cross (the reply banner's cancel button).
-const ICON_X =
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" ' +
- 'stroke-linecap="round" aria-hidden="true">' +
- '<line x1="6" y1="6" x2="18" y2="18"></line>' +
- '<line x1="18" y1="6" x2="6" y2="18"></line></svg>';
-// Pencil, for the desktop hover edit affordance on your own messages.
-const ICON_EDIT =
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" ' +
- 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
- '<path d="M12 20h9"></path>' +
- '<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg>';
-// Smiley, for the desktop hover "react" affordance (opens the emoji picker).
-const ICON_REACT =
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
- 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
- '<circle cx="12" cy="12" r="9"></circle>' +
- '<path d="M8 14s1.5 2 4 2 4-2 4-2"></path>' +
- '<line x1="9" y1="9" x2="9.01" y2="9"></line>' +
- '<line x1="15" y1="9" x2="15.01" y2="9"></line></svg>';
-// Trash, for the "Delete" action in the message picker.
-const ICON_TRASH =
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" ' +
- 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
- '<polyline points="3 6 5 6 21 6"></polyline>' +
- '<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>';
-// Filled play triangle, for the click-to-play YouTube card.
-const ICON_PLAY =
- '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">' +
- '<path d="M8 5.5v13a1 1 0 0 0 1.53.85l10.2-6.5a1 1 0 0 0 0-1.7L9.53 4.65A1 1 0 0 0 8 5.5z"></path></svg>';
-// Filled pushpin marking pinned chats in the thread list — solid so it stays
-// crisp at ~12px, tilted via CSS for the classic "tacked on" look.
-const ICON_PIN =
- '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">' +
- '<path d="M16 9V4h1c.55 0 1-.45 1-1s-.45-1-1-1H7c-.55 0-1 .45-1 1s.45 1 1 1h1v5c0 1.66-1.34 ' +
- '3-3 3v2h5.97v7l1 1 1-1v-7H19v-2c-1.66 0-3-1.34-3-3z"></path></svg>';
-// Slashed bell marking muted chats in the thread list — outline stroke like
-// ICON_COPY so the slash reads clearly at ~12px.
-const ICON_MUTE =
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
- 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
- '<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>' +
- '<path d="M18.63 13A17.9 17.9 0 0 1 18 8"></path>' +
- '<path d="M6.26 6.26A5.86 5.86 0 0 0 6 8c0 7-3 9-3 9h14"></path>' +
- '<path d="M18 8a6 6 0 0 0-9.33-5"></path>' +
- '<line x1="1" y1="1" x2="23" y2="23"></line></svg>';
-// A flag, for reporting a message or a person to the space's admins.
-const ICON_REPORT =
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
- 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
- '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"></path>' +
- '<line x1="4" y1="22" x2="4" y2="15"></line></svg>';
-// Overlapping pages, for the "Copy" action in the mobile long-press menu.
-const ICON_COPY =
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" ' +
- 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
- '<rect x="9" y="9" width="12" height="12" rx="2"></rect>' +
- '<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
+// The UI icon set (web/static/icons.js — Lucide idiom, docs/icons.md).
+import { ICON_REPLY, ICON_X, ICON_EDIT, ICON_REACT, ICON_TRASH, ICON_PLAY, ICON_PIN, ICON_MUTE, ICON_REPORT, ICON_COPY, ICON_CHEVRON_LEFT, ICON_MORE_VERTICAL, ICON_BRAIN, ICON_WRENCH } from "/static/icons.js";
// These five always ALIAS the open thread's feed-cache entry (see feedCache /
// bindThreadState). All the render/update code operates on "the active thread"
@@ -3276,6 +3214,9 @@ function renderApp() {
// Per-thread kebab (thread header): rename group / view members.
const tmenu = document.getElementById("thread-menu");
const tmenuBtn = document.getElementById("thread-menu-btn");
+ tmenuBtn.innerHTML = ICON_MORE_VERTICAL; // static in-code SVG (icons.js)
+ const backBtn = document.getElementById("back-btn");
+ if (backBtn) backBtn.innerHTML = ICON_CHEVRON_LEFT;
const closeTMenu = () => tmenu.classList.add("hidden");
tmenuBtn.onclick = (e) => {
e.stopPropagation();
@@ -3800,7 +3741,7 @@ function threadHasAIPeer(id) {
// first (the same order /views shows). Empty = the button hides.
function viewsMenuEntries(id) {
const entries = [];
- if (threadHasAIPeer(id)) entries.push({ glyph: "🧠", label: "Memory", open: () => openMemoryPanel(id) });
+ if (threadHasAIPeer(id)) entries.push({ svg: ICON_BRAIN, label: "Memory", open: () => openMemoryPanel(id) });
const st = appStateFor(id, "views");
const views = st && Array.isArray(st.views) ? st.views : [];
for (const v of views.slice().reverse()) {
@@ -9958,7 +9899,7 @@ function showNewChat(startMode) {
// admins get its row in the Apps section all the same — the discoverable way
// in, beyond the /admin command. Null for everyone else. Deliberately exempt
// from the apps gate: Admin is how an admin switches apps back on.
- const adminApp = isAdmin() ? { name: "admin", title: "Admin", glyph: "🛠️" } : null;
+ const adminApp = isAdmin() ? { name: "admin", title: "Admin", glyph: "🛠️", logo: ICON_WRENCH } : null;
const adminMatches = (q) => !!adminApp && adminApp.title.toLowerCase().includes(q);
const addAppRow = (listEl, app) => {
const b = document.createElement("button");
diff --git a/web/static/style.css b/web/static/style.css
index cd93f7a..ed062f9 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -413,6 +413,8 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
justify-content: center;
}
.icon-btn svg { display: block; }
+#thread-menu-btn svg { width: 19px; height: 19px; }
+#back-btn svg { width: 22px; height: 22px; }
/* The Views dropdown (thread header): a labelled button, and menu rows with a
fixed glyph column so long view titles wrap under their own text, never
under the emoji. */