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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-27 14:11:33 -08:00
parent 15e6a9b9ba
commit 726ef43952
2 changed files with 145 additions and 21 deletions

View File

@ -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);

View File

@ -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<string, { badge: string; color: string }> = {
rspace: { badge: "rS", color: "#5eead4" },
rnotes: { badge: "rN", color: "#fcd34d" },
@ -53,9 +53,37 @@ const MODULE_BADGES: Record<string, { badge: string; color: string }> = {
rwork: { badge: "rWo", color: "#cbd5e1" },
};
// Category definitions for the + menu dropdown grouping
const MODULE_CATEGORIES: Record<string, string> = {
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 `<div class="add-menu" id="add-menu"><div class="add-menu-empty">All modules added</div></div>`;
// 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 `<div class="add-menu" id="add-menu"><div class="add-menu-empty">All rApps added</div></div>`;
}
// Group by category
const groups = new Map<string, typeof availableModules>();
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 += `<div class="add-menu-category">${cat}</div>`;
html += items.map(m => this.#renderAddMenuItem(m)).join("");
}
if (uncategorized.length > 0) {
html += `<div class="add-menu-category">Other</div>`;
html += uncategorized.map(m => this.#renderAddMenuItem(m)).join("");
}
return `<div class="add-menu" id="add-menu">${html}</div>`;
}
#renderAddMenuItem(m: { id: string; name: string; icon: string; description: string }): string {
const badge = MODULE_BADGES[m.id];
const badgeHtml = badge
? `<span class="add-menu-badge" style="background:${badge.color}">${badge.badge}</span>`
: `<span class="add-menu-icon">${m.icon}</span>`;
return `
<div class="add-menu" id="add-menu">
${available.map(m => `
<button class="add-menu-item" data-add-module="${m.id}">
<span class="add-menu-badge" style="background:${m.color}">${m.badge}</span>
<span>${m.id}</span>
</button>
`).join("")}
</div>
<button class="add-menu-item" data-add-module="${m.id}">
${badgeHtml}
<div class="add-menu-text">
<span class="add-menu-name">${m.name} <span class="add-menu-emoji">${m.icon}</span></span>
${m.description ? `<span class="add-menu-desc">${m.description}</span>` : ""}
</div>
</button>
`;
}
@ -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;