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>
16 files changed,
+258
−66
changelog/2026-07-13-real-icons.md+5 −0docs/icons.md+77 −0web/static/apps/admin.js+2 −0web/static/apps/appstudio.js+2 −0web/static/apps/board.js+2 −0web/static/apps/bookmarks.js+2 −0web/static/apps/emoji.js+2 −0web/static/apps/gif.js+2 −0web/static/apps/memory.js+2 −0web/static/apps/poll.js+2 −0web/static/apps/remind.js+2 −0web/static/apps/todo.js+2 −0web/static/apps/vault.js+2 −0web/static/icons.js+145 −0web/static/shell.js+7 −66web/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. */ |