feat(app-switcher): emoji badges + recently used section

Replace text abbreviation badges (rN, rPh, etc.) with r+emoji format
(r📝, r📸, etc.), remove duplicate emoji from item rows, and add a
"Recently Used" section at the top of the sidebar persisted via
localStorage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-10 12:10:42 -07:00
parent c8e95ed506
commit fc65bec9dc
1 changed files with 70 additions and 36 deletions

View File

@ -20,42 +20,44 @@ export interface AppSwitcherModule {
// Pastel badge abbreviations & colors for each module
const MODULE_BADGES: Record<string, { badge: string; color: string }> = {
// Creating
rspace: { badge: "rS", color: "#5eead4" }, // teal-300
rnotes: { badge: "rN", color: "#fcd34d" }, // amber-300
rpubs: { badge: "rP", color: "#fda4af" }, // rose-300
rswag: { badge: "rSw", color: "#fda4af" }, // rose-300
rsplat: { badge: "r3", color: "#d8b4fe" }, // purple-300
rspace: { badge: "r🎨", color: "#5eead4" }, // teal-300
rnotes: { badge: "r📝", color: "#fcd34d" }, // amber-300
rpubs: { badge: "r📖", color: "#fda4af" }, // rose-300
rswag: { badge: "r👕", color: "#fda4af" }, // rose-300
rsplat: { badge: "r🔮", color: "#d8b4fe" }, // purple-300
// Planning
rcal: { badge: "rC", color: "#7dd3fc" }, // sky-300
rtrips: { badge: "rT", color: "#6ee7b7" }, // emerald-300
rmaps: { badge: "rM", color: "#86efac" }, // green-300
rcal: { badge: "r📅", color: "#7dd3fc" }, // sky-300
rtrips: { badge: "r✈️", color: "#6ee7b7" }, // emerald-300
rmaps: { badge: "r🗺", color: "#86efac" }, // green-300
// Communicating
rchats: { badge: "rCh", color: "#6ee7b7" }, // emerald-200
rinbox: { badge: "rI", color: "#a5b4fc" }, // indigo-300
rmail: { badge: "rMa", color: "#93c5fd" }, // blue-200
rforum: { badge: "rFo", color: "#fcd34d" }, // amber-200
rchats: { badge: "r🗨", color: "#6ee7b7" }, // emerald-200
rinbox: { badge: "r📨", color: "#a5b4fc" }, // indigo-300
rmail: { badge: "r✉", color: "#93c5fd" }, // blue-200
rforum: { badge: "r💬", color: "#fcd34d" }, // amber-200
rmeets: { badge: "r📹", color: "#67e8f9" }, // cyan-300
// Deciding
rchoices: { badge: "rCo", color: "#f0abfc" }, // fuchsia-300
rvote: { badge: "rV", color: "#c4b5fd" }, // violet-300
rchoices: { badge: "r☑️", color: "#f0abfc" }, // fuchsia-300
rvote: { badge: "r🗳", color: "#c4b5fd" }, // violet-300
// Funding & Commerce
rflows: { badge: "rFl", color: "#bef264" }, // lime-300
rwallet: { badge: "rW", color: "#fde047" }, // yellow-300
rcart: { badge: "rCt", color: "#fdba74" }, // orange-300
rauctions: { badge: "rA", color: "#fca5a5" }, // red-300
rtube: { badge: "rTu", color: "#f9a8d4" }, // pink-300
rflows: { badge: "r🌊", color: "#bef264" }, // lime-300
rwallet: { badge: "r💰", color: "#fde047" }, // yellow-300
rcart: { badge: "r🛒", color: "#fdba74" }, // orange-300
rauctions: { badge: "r🏛", color: "#fca5a5" }, // red-300
rtube: { badge: "r🎬", color: "#f9a8d4" }, // pink-300
// Sharing
rphotos: { badge: "rPh", color: "#f9a8d4" }, // pink-200
rnetwork: { badge: "rNe", color: "#93c5fd" }, // blue-300
rsocials: { badge: "rSo", color: "#7dd3fc" }, // sky-200
rfiles: { badge: "rFi", color: "#67e8f9" }, // cyan-300
rbooks: { badge: "rB", color: "#fda4af" }, // rose-300
rphotos: { badge: "r📸", color: "#f9a8d4" }, // pink-200
rnetwork: { badge: "r🌐", color: "#93c5fd" }, // blue-300
rsocials: { badge: "r📢", color: "#7dd3fc" }, // sky-200
rfiles: { badge: "r📁", color: "#67e8f9" }, // cyan-300
rbooks: { badge: "r📚", color: "#fda4af" }, // rose-300
// Observing
rdata: { badge: "rD", color: "#d8b4fe" }, // purple-300
rdata: { badge: "r📊", color: "#d8b4fe" }, // purple-300
// Work & Productivity
rwork: { badge: "rWo", color: "#cbd5e1" }, // slate-300
rwork: { badge: "r📋", color: "#cbd5e1" }, // slate-300
rschedule: { badge: "r⏱", color: "#a5b4fc" }, // indigo-200
// Identity & Infrastructure
rids: { badge: "rId", color: "#6ee7b7" }, // emerald-300
rstack: { badge: "r*", color: "" }, // gradient (handled separately)
rids: { badge: "r🪪", color: "#6ee7b7" }, // emerald-300
rstack: { badge: "r✨", color: "" }, // gradient (handled separately)
};
// Category definitions for the rApp dropdown (display-only grouping)
@ -73,6 +75,7 @@ const MODULE_CATEGORIES: Record<string, string> = {
rinbox: "Communicating",
rmail: "Communicating",
rforum: "Communicating",
rmeets: "Communicating",
rchoices: "Deciding",
rvote: "Deciding",
rflows: "Funding & Commerce",
@ -86,6 +89,7 @@ const MODULE_CATEGORIES: Record<string, string> = {
rbooks: "Sharing",
rdata: "Observing",
rwork: "Work & Productivity",
rschedule: "Work & Productivity",
rids: "Identity & Infrastructure",
rstack: "Identity & Infrastructure",
};
@ -123,7 +127,25 @@ export class RStackAppSwitcher extends HTMLElement {
return this.getAttribute("current") || "";
}
static #getRecentModules(): string[] {
try {
const raw = localStorage.getItem("rspace-recent-modules");
if (!raw) return [];
const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr : [];
} catch { return []; }
}
static #recordRecentModule(id: string) {
try {
const recent = RStackAppSwitcher.#getRecentModules().filter((x) => x !== id);
recent.unshift(id);
localStorage.setItem("rspace-recent-modules", JSON.stringify(recent.slice(0, 3)));
} catch {}
}
connectedCallback() {
if (this.current) RStackAppSwitcher.#recordRecentModule(this.current);
this.#render();
}
@ -163,7 +185,7 @@ export class RStackAppSwitcher extends HTMLElement {
// rStack header (clickable)
let html = `
<a class="rstack-header" href="https://rstack.online" target="_blank" rel="noopener">
<span class="rstack-badge">r*</span>
<span class="rstack-badge">r</span>
<div class="rstack-info">
<span class="rstack-title">rStack</span>
<span class="rstack-subtitle">Self-hosted community app suite</span>
@ -171,6 +193,16 @@ export class RStackAppSwitcher extends HTMLElement {
</a>
`;
// Recently Used section
const recentIds = RStackAppSwitcher.#getRecentModules().filter((id) => id !== current);
const recentModules = recentIds
.map((id) => this.#modules.find((m) => m.id === id))
.filter((m): m is AppSwitcherModule => !!m);
if (recentModules.length > 0) {
html += `<div class="category-header recent-header">Recent</div>`;
html += recentModules.map((m) => this.#renderItem(m, current)).join("");
}
for (const cat of CATEGORY_ORDER) {
const items = groups.get(cat);
if (!items || items.length === 0) continue;
@ -222,7 +254,6 @@ export class RStackAppSwitcher extends HTMLElement {
<span class="item-name-row">
<span class="item-name">${m.name}</span>
${scopeBadge}
<span class="item-emoji">${m.icon}</span>
</span>
<span class="item-desc">${m.description}</span>
</div>
@ -241,7 +272,7 @@ export class RStackAppSwitcher extends HTMLElement {
? `<span class="trigger-badge" style="background:${badgeInfo.color}">${badgeInfo.badge}</span> ${currentMod!.name}`
: currentMod
? `${currentMod.icon} ${currentMod.name}`
: `<span class="trigger-badge rstack-gradient">r*</span> rSpace`;
: `<span class="trigger-badge rstack-gradient">r</span> rSpace`;
this.#shadow.innerHTML = `
<style>${STYLES}</style>
@ -308,6 +339,7 @@ export class RStackAppSwitcher extends HTMLElement {
el.addEventListener("click", (e) => {
const moduleId = (el as HTMLElement).dataset.id;
if (!moduleId) return;
RStackAppSwitcher.#recordRecentModule(moduleId);
// Skip interception if tab system isn't active (landing pages)
if (!(window as any).__rspaceTabBar) return;
// Only intercept same-origin links
@ -358,8 +390,8 @@ const STYLES = `
.trigger-badge {
display: inline-flex; align-items: center; justify-content: center;
width: 22px; height: 22px; border-radius: 5px;
font-size: 0.6rem; font-weight: 900; color: var(--rs-text-inverse);
line-height: 1; flex-shrink: 0;
font-size: 0.65rem; font-weight: 900; color: var(--rs-text-inverse);
line-height: 1; flex-shrink: 0; white-space: nowrap;
}
.trigger-badge.rstack-gradient {
background: linear-gradient(135deg, #67e8f9, #c4b5fd, #fda4af);
@ -461,14 +493,13 @@ a.rstack-header:hover { background: var(--rs-bg-hover); }
.item-badge {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: 6px;
font-size: 0.6rem; font-weight: 900; color: var(--rs-text-inverse);
line-height: 1; flex-shrink: 0;
font-size: 0.7rem; font-weight: 900; color: var(--rs-text-inverse);
line-height: 1; flex-shrink: 0; white-space: nowrap;
}
.item-icon { font-size: 1.3rem; width: 28px; text-align: center; flex-shrink: 0; }
.item-text { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.item-name-row { display: flex; align-items: center; gap: 6px; }
.item-name { font-size: 0.875rem; font-weight: 600; }
.item-emoji { font-size: 0.875rem; flex-shrink: 0; }
.item-desc { font-size: 0.7rem; opacity: 0.5; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.scope-badge {
@ -486,6 +517,9 @@ a.rstack-header:hover { background: var(--rs-bg-hover); }
border-top: 1px solid var(--rs-border-subtle);
margin-top: 4px; padding-top: 10px;
}
.recent-header {
border-top: none; margin-top: 0; padding-top: 8px;
}
/* Mobile: sidebar overlays instead of pushing */
@media (max-width: 640px) {