From 726ef43952838d0df0d91c251a9ab1eb969ae939 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 27 Feb 2026 14:11:33 -0800 Subject: [PATCH] feat: categorized rApp dropdown in tab bar + button - Tab bar + button now shows full rApp dropdown with names, icons, descriptions - Grouped by category (Creating, Planning, Communicating, etc.) - Only shows modules not already open as tabs - Shell passes module list to tab bar via setModules() Co-Authored-By: Claude Opus 4.6 --- server/shell.ts | 3 + shared/components/rstack-tab-bar.ts | 163 ++++++++++++++++++++++++---- 2 files changed, 145 insertions(+), 21 deletions(-) diff --git a/server/shell.ts b/server/shell.ts index 42b0ddd..80ec06a 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -158,6 +158,9 @@ export function renderShell(opts: ShellOptions): string { const moduleList = ${moduleListJSON}; if (tabBar) { + // Provide module list for the + add menu dropdown + tabBar.setModules(moduleList); + // Helper: look up a module's display name function getModuleLabel(id) { const m = moduleList.find(mod => mod.id === id); diff --git a/shared/components/rstack-tab-bar.ts b/shared/components/rstack-tab-bar.ts index 1756e84..7d38251 100644 --- a/shared/components/rstack-tab-bar.ts +++ b/shared/components/rstack-tab-bar.ts @@ -23,7 +23,7 @@ import type { Layer, LayerFlow, FlowKind } from "../../lib/layer-types"; import { FLOW_COLORS, FLOW_LABELS } from "../../lib/layer-types"; -// Re-export badge info so the tab bar can show module colors +// Badge info for tab display const MODULE_BADGES: Record = { rspace: { badge: "rS", color: "#5eead4" }, rnotes: { badge: "rN", color: "#fcd34d" }, @@ -53,9 +53,37 @@ const MODULE_BADGES: Record = { rwork: { badge: "rWo", color: "#cbd5e1" }, }; +// Category definitions for the + menu dropdown grouping +const MODULE_CATEGORIES: Record = { + rspace: "Creating", rnotes: "Creating", rpubs: "Creating", rtube: "Creating", + rswag: "Creating", rsplat: "Creating", + rcal: "Planning", rtrips: "Planning", rmaps: "Planning", + rchats: "Communicating", rinbox: "Communicating", rmail: "Communicating", rforum: "Communicating", + rchoices: "Deciding", rvote: "Deciding", + rfunds: "Funding & Commerce", rwallet: "Funding & Commerce", rcart: "Funding & Commerce", rauctions: "Funding & Commerce", + rphotos: "Sharing", rnetwork: "Sharing", rsocials: "Sharing", rfiles: "Sharing", rbooks: "Sharing", + rdata: "Observing", + rwork: "Work & Productivity", + rids: "Identity & Infrastructure", rstack: "Identity & Infrastructure", +}; + +const CATEGORY_ORDER = [ + "Creating", "Planning", "Communicating", "Deciding", + "Funding & Commerce", "Sharing", "Observing", + "Work & Productivity", "Identity & Infrastructure", +]; + +export interface TabBarModule { + id: string; + name: string; + icon: string; + description: string; +} + export class RStackTabBar extends HTMLElement { #shadow: ShadowRoot; #layers: Layer[] = []; + #modules: TabBarModule[] = []; #flows: LayerFlow[] = []; #viewMode: "flat" | "stack" = "flat"; #draggedTabId: string | null = null; @@ -110,6 +138,11 @@ export class RStackTabBar extends HTMLElement { this.#render(); } + /** Provide the available module list (for the + add menu) */ + setModules(modules: TabBarModule[]) { + this.#modules = modules; + } + /** Set the inter-layer flows (for stack view) */ setFlows(flows: LayerFlow[]) { this.#flows = flows; @@ -184,25 +217,62 @@ export class RStackTabBar extends HTMLElement { } #renderAddMenu(): string { - // Group available modules (show ones not yet added as layers) const existingModuleIds = new Set(this.#layers.map(l => l.moduleId)); - const available = Object.entries(MODULE_BADGES) - .filter(([id]) => !existingModuleIds.has(id)) - .map(([id, info]) => ({ id, ...info })); - if (available.length === 0) { - return `
All modules added
`; + // Use server module list if available, fall back to MODULE_BADGES keys + const availableModules: Array<{ id: string; name: string; icon: string; description: string }> = + this.#modules.length > 0 + ? this.#modules.filter(m => !existingModuleIds.has(m.id)) + : Object.keys(MODULE_BADGES) + .filter(id => !existingModuleIds.has(id)) + .map(id => ({ id, name: id, icon: "", description: "" })); + + if (availableModules.length === 0) { + return `
All rApps added
`; } + // Group by category + const groups = new Map(); + const uncategorized: typeof availableModules = []; + for (const m of availableModules) { + const cat = MODULE_CATEGORIES[m.id]; + if (cat) { + if (!groups.has(cat)) groups.set(cat, []); + groups.get(cat)!.push(m); + } else { + uncategorized.push(m); + } + } + + let html = ""; + for (const cat of CATEGORY_ORDER) { + const items = groups.get(cat); + if (!items || items.length === 0) continue; + html += `
${cat}
`; + html += items.map(m => this.#renderAddMenuItem(m)).join(""); + } + if (uncategorized.length > 0) { + html += `
Other
`; + html += uncategorized.map(m => this.#renderAddMenuItem(m)).join(""); + } + + return `
${html}
`; + } + + #renderAddMenuItem(m: { id: string; name: string; icon: string; description: string }): string { + const badge = MODULE_BADGES[m.id]; + const badgeHtml = badge + ? `${badge.badge}` + : `${m.icon}`; + return ` -
- ${available.map(m => ` - - `).join("")} -
+ `; } @@ -800,13 +870,14 @@ const STYLES = ` left: auto; right: 0; margin-top: 4px; - min-width: 180px; - max-height: 300px; + min-width: 260px; + max-height: 400px; overflow-y: auto; - border-radius: 8px; + border-radius: 10px; padding: 4px; z-index: 100; box-shadow: 0 8px 30px rgba(0,0,0,0.25); + scrollbar-width: thin; } :host-context([data-theme="dark"]) .add-menu { background: #1e293b; @@ -817,6 +888,21 @@ const STYLES = ` border: 1px solid rgba(0,0,0,0.1); } +.add-menu-category { + padding: 6px 10px 3px; + font-size: 0.6rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + opacity: 0.45; + user-select: none; +} +.add-menu-category:not(:first-child) { + border-top: 1px solid rgba(128,128,128,0.12); + margin-top: 2px; + padding-top: 8px; +} + .add-menu-item { display: flex; align-items: center; @@ -824,7 +910,7 @@ const STYLES = ` width: 100%; padding: 6px 10px; border: none; - border-radius: 5px; + border-radius: 6px; background: transparent; color: inherit; font-size: 0.8rem; @@ -839,8 +925,8 @@ const STYLES = ` display: inline-flex; align-items: center; justify-content: center; - width: 22px; - height: 22px; + width: 24px; + height: 24px; border-radius: 5px; font-size: 0.55rem; font-weight: 900; @@ -848,6 +934,41 @@ const STYLES = ` flex-shrink: 0; } +.add-menu-icon { + font-size: 1.1rem; + width: 24px; + text-align: center; + flex-shrink: 0; +} + +.add-menu-text { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; +} + +.add-menu-name { + font-size: 0.8rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 4px; +} + +.add-menu-emoji { + font-size: 0.8rem; + flex-shrink: 0; +} + +.add-menu-desc { + font-size: 0.65rem; + opacity: 0.5; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .add-menu-empty { padding: 12px; text-align: center;