feat: consistent headers across all rApps + add mi AI assistant

Header consistency:
- Fix 52px → 56px header height in 7 module CSS files (pubs, funds,
  providers, books, swag, choices, cart)
- Remove custom header background overrides in books.css and pubs.css
- All pages now use the same 3-section header layout: left (app/space
  switchers), center (mi), right (identity)
- Add <rstack-mi> to all 4 standalone HTML pages (index, admin,
  create-space, canvas) and both shell renderers

mi AI assistant:
- New <rstack-mi> web component with search input "Ask mi anything..."
- Dropdown panel with streaming chat UI, typing indicator, markdown
  formatting
- POST /api/mi/ask endpoint: streams from Ollama with full rApp context
  in system prompt (all 22 modules, current space/module)
- Graceful fallback to keyword-based responses when Ollama unavailable
- Configurable via MI_MODEL and OLLAMA_URL env vars

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-25 15:09:41 -08:00
parent 1ff0f69218
commit 4587387dec
16 changed files with 535 additions and 20 deletions

View File

@ -5,13 +5,8 @@ body[data-theme="dark"] {
background: #0f172a;
}
body[data-theme="dark"] .rstack-header {
background: #0f172a;
border-bottom-color: #1e293b;
}
/* Library grid page */
body[data-theme="light"] main {
background: #0f172a;
min-height: calc(100vh - 52px);
min-height: calc(100vh - 56px);
}

View File

@ -1,6 +1,6 @@
/* Cart module theme */
body[data-theme="light"] main {
background: #0f172a;
min-height: calc(100vh - 52px);
min-height: calc(100vh - 56px);
padding: 0;
}

View File

@ -1,6 +1,6 @@
/* Choices module theme */
body[data-theme="light"] main {
background: #0f172a;
min-height: calc(100vh - 52px);
min-height: calc(100vh - 56px);
padding: 0;
}

View File

@ -1,7 +1,7 @@
/* ── Funds module theme ───────────────────────────────── */
body[data-theme="light"] main {
background: #0f172a;
min-height: calc(100vh - 52px);
min-height: calc(100vh - 56px);
padding: 0;
}

View File

@ -1,6 +1,6 @@
/* Providers module theme */
body[data-theme="light"] main {
background: #0f172a;
min-height: calc(100vh - 52px);
min-height: calc(100vh - 56px);
padding: 0;
}

View File

@ -1,11 +1,6 @@
/* Pubs module — editor theme */
body[data-theme="light"] main {
background: #0f172a;
min-height: calc(100vh - 52px);
min-height: calc(100vh - 56px);
padding: 0;
}
body[data-theme="light"] .rstack-header {
background: #0f172a;
border-bottom-color: #1e293b;
}

View File

@ -1,6 +1,6 @@
/* Swag module theme */
body[data-theme="light"] main {
background: #0f172a;
min-height: calc(100vh - 52px);
min-height: calc(100vh - 56px);
padding: 0;
}

View File

@ -125,6 +125,98 @@ app.get("/.well-known/webauthn", (c) => {
// ── Space registry API ──
app.route("/api/spaces", spaces);
// ── mi — AI assistant endpoint ──
const MI_MODEL = process.env.MI_MODEL || "llama3.2";
const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434";
app.post("/api/mi/ask", async (c) => {
const { query, messages = [], space, module: currentModule } = await c.req.json();
if (!query) return c.json({ error: "Query required" }, 400);
// Build rApp context for the system prompt
const moduleList = getModuleInfoList()
.map((m) => `- **${m.name}** (${m.id}): ${m.icon} ${m.description}`)
.join("\n");
const systemPrompt = `You are mi, the intelligent assistant for rSpace — a self-hosted, community-run platform.
You help users navigate, understand, and get the most out of the platform's apps (rApps).
## Available rApps
${moduleList}
## Current Context
- Space: ${space || "none selected"}
- Active rApp: ${currentModule || "none"}
## Guidelines
- Be concise and helpful. Keep responses short (2-4 sentences) unless the user asks for detail.
- When suggesting actions, reference specific rApps by name and explain how they connect.
- You can suggest navigating to /:space/:moduleId paths.
- If you don't know something specific about the user's data, say so honestly.
- Use a warm, knowledgeable tone. You're a guide, not a search engine.`;
// Build conversation for Ollama
const ollamaMessages = [
{ role: "system", content: systemPrompt },
...messages.slice(-8).map((m: any) => ({ role: m.role, content: m.content })),
{ role: "user", content: query },
];
try {
const ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: MI_MODEL, messages: ollamaMessages, stream: true }),
});
if (!ollamaRes.ok) {
const errText = await ollamaRes.text().catch(() => "");
console.error("mi: Ollama error:", ollamaRes.status, errText);
return c.json({ error: "AI service unavailable" }, 502);
}
// Stream Ollama's NDJSON response directly to client
return new Response(ollamaRes.body, {
headers: {
"Content-Type": "application/x-ndjson",
"Cache-Control": "no-cache",
"Transfer-Encoding": "chunked",
},
});
} catch (e: any) {
console.error("mi: Failed to reach Ollama:", e.message);
// Fallback: return a static helpful response
const fallback = generateFallbackResponse(query, currentModule, space, getModuleInfoList());
return c.json({ response: fallback });
}
});
function generateFallbackResponse(
query: string,
currentModule: string,
space: string,
modules: ReturnType<typeof getModuleInfoList>,
): string {
const q = query.toLowerCase();
// Simple keyword matching for common questions
for (const m of modules) {
if (q.includes(m.id) || q.includes(m.name.toLowerCase())) {
return `**${m.name}** ${m.icon}${m.description}. You can access it at /${space || "personal"}/${m.id}.`;
}
}
if (q.includes("help") || q.includes("what can")) {
return `rSpace has ${modules.length} apps you can use. Some popular ones: **rSpace** (canvas), **rNotes** (notes), **rChat** (messaging), **rFunds** (community funding), and **rVote** (governance). What would you like to explore?`;
}
if (q.includes("search") || q.includes("find")) {
return `You can browse your content through the app switcher (top-left dropdown), or navigate directly to any rApp. Try **rNotes** for text content, **rFiles** for documents, or **rPhotos** for images.`;
}
return `I'm currently running in offline mode (AI service not connected). I can still help with basic navigation — ask me about any specific rApp or feature! There are ${modules.length} apps available in rSpace.`;
}
// ── Existing /api/communities/* routes (backward compatible) ──
/** Resolve a community slug to SpaceAuthConfig for the SDK guard */

View File

@ -66,10 +66,16 @@ export function renderShell(opts: ShellOptions): string {
<rstack-app-switcher current="${escapeAttr(moduleId)}"></rstack-app-switcher>
<rstack-space-switcher current="${escapeAttr(spaceSlug)}" name="${escapeAttr(spaceName || spaceSlug)}"></rstack-space-switcher>
</div>
<div class="rstack-header__center">
<rstack-mi></rstack-mi>
</div>
<div class="rstack-header__right">
<rstack-identity></rstack-identity>
</div>
</header>
<div class="rstack-tab-row" data-theme="${theme}">
<rstack-tab-bar space="${escapeAttr(spaceSlug)}" active="" view-mode="flat"></rstack-tab-bar>
</div>
<main id="app">
${body}
</main>
@ -77,6 +83,89 @@ export function renderShell(opts: ShellOptions): string {
import '/shell.js';
// Provide module list to app switcher
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
// ── Tab bar / Layer system initialization ──
const tabBar = document.querySelector('rstack-tab-bar');
const spaceSlug = '${escapeAttr(spaceSlug)}';
const currentModuleId = '${escapeAttr(moduleId)}';
if (tabBar) {
// Default layer: current module (bootstrap if no layers saved yet)
const defaultLayer = {
id: 'layer-' + currentModuleId,
moduleId: currentModuleId,
label: ${JSON.stringify(modules.find((m: any) => m.id === moduleId)?.name || moduleId)},
order: 0,
color: '',
visible: true,
createdAt: Date.now(),
};
// Set the current module as the active layer
tabBar.setLayers([defaultLayer]);
tabBar.setAttribute('active', defaultLayer.id);
// Listen for tab events
tabBar.addEventListener('layer-switch', (e) => {
const { moduleId } = e.detail;
window.location.href = '/' + spaceSlug + '/' + moduleId;
});
tabBar.addEventListener('layer-add', (e) => {
const { moduleId } = e.detail;
// Navigate to the new module (layer will be persisted when sync connects)
window.location.href = '/' + spaceSlug + '/' + moduleId;
});
tabBar.addEventListener('layer-close', (e) => {
const { layerId } = e.detail;
tabBar.removeLayer(layerId);
// If we closed the active layer, switch to first remaining
const remaining = tabBar.querySelectorAll?.('[data-layer-id]');
// The tab bar handles this internally
});
tabBar.addEventListener('view-toggle', (e) => {
const { mode } = e.detail;
// When switching to stack view, emit event for canvas to connect
document.dispatchEvent(new CustomEvent('layer-view-mode', { detail: { mode } }));
});
// Expose tabBar for CommunitySync integration
window.__rspaceTabBar = tabBar;
// If CommunitySync is available, wire up layer persistence
document.addEventListener('community-sync-ready', (e) => {
const sync = e.detail?.sync;
if (!sync) return;
// Load persisted layers
const layers = sync.getLayers();
if (layers.length > 0) {
tabBar.setLayers(layers);
const activeId = sync.doc.activeLayerId;
if (activeId) tabBar.setAttribute('active', activeId);
tabBar.setFlows(sync.getFlows());
} else {
// First visit: save the default layer
sync.addLayer(defaultLayer);
sync.setActiveLayer(defaultLayer.id);
}
// Sync layer changes back to Automerge
tabBar.addEventListener('layer-switch', (e) => {
sync.setActiveLayer(e.detail.layerId);
});
// Listen for remote layer changes
sync.addEventListener('change', () => {
tabBar.setLayers(sync.getLayers());
tabBar.setFlows(sync.getFlows());
const activeId = sync.doc.activeLayerId;
if (activeId) tabBar.setAttribute('active', activeId);
});
});
}
</script>
${scripts}
</body>
@ -111,6 +200,9 @@ export function renderStandaloneShell(opts: {
<span class="rstack-header__brand-gradient">rSpace</span>
</a>
</div>
<div class="rstack-header__center">
<rstack-mi></rstack-mi>
</div>
<div class="rstack-header__right">
<rstack-identity></rstack-identity>
</div>

View File

@ -0,0 +1,296 @@
/**
* <rstack-mi> AI-powered assistant embedded in the rSpace header.
*
* Renders a search input ("Ask mi anything..."). On submit, queries
* /api/mi/ask and streams the response into a dropdown panel.
* Supports multi-turn conversation with context.
*/
import { getAccessToken } from "./rstack-identity";
interface MiMessage {
role: "user" | "assistant";
content: string;
}
export class RStackMi extends HTMLElement {
#shadow: ShadowRoot;
#messages: MiMessage[] = [];
#abortController: AbortController | null = null;
constructor() {
super();
this.#shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.#render();
}
#render() {
this.#shadow.innerHTML = `
<style>${STYLES}</style>
<div class="mi">
<div class="mi-bar" id="mi-bar">
<span class="mi-icon">&#10023;</span>
<input class="mi-input" id="mi-input" type="text"
placeholder="Ask mi anything..." autocomplete="off" />
</div>
<div class="mi-panel" id="mi-panel">
<div class="mi-messages" id="mi-messages">
<div class="mi-welcome">
<span class="mi-welcome-icon">&#10023;</span>
<p>Hi, I'm <strong>mi</strong> your guide to rSpace.</p>
<p class="mi-welcome-sub">Ask me about any rApp, how to find things, or what you can do here.</p>
</div>
</div>
</div>
</div>
`;
const input = this.#shadow.getElementById("mi-input") as HTMLInputElement;
const panel = this.#shadow.getElementById("mi-panel")!;
const bar = this.#shadow.getElementById("mi-bar")!;
input.addEventListener("focus", () => {
panel.classList.add("open");
bar.classList.add("focused");
});
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && input.value.trim()) {
this.#ask(input.value.trim());
input.value = "";
}
if (e.key === "Escape") {
panel.classList.remove("open");
bar.classList.remove("focused");
input.blur();
}
});
document.addEventListener("click", (e) => {
if (!this.contains(e.target as Node)) {
panel.classList.remove("open");
bar.classList.remove("focused");
}
});
// Stop clicks inside the panel from closing it
panel.addEventListener("click", (e) => e.stopPropagation());
bar.addEventListener("click", (e) => e.stopPropagation());
}
async #ask(query: string) {
const panel = this.#shadow.getElementById("mi-panel")!;
const messagesEl = this.#shadow.getElementById("mi-messages")!;
panel.classList.add("open");
this.#shadow.getElementById("mi-bar")!.classList.add("focused");
// Add user message
this.#messages.push({ role: "user", content: query });
this.#renderMessages(messagesEl);
// Add placeholder for assistant
this.#messages.push({ role: "assistant", content: "" });
const assistantIdx = this.#messages.length - 1;
this.#renderMessages(messagesEl);
// Abort previous
this.#abortController?.abort();
this.#abortController = new AbortController();
try {
const token = getAccessToken();
const res = await fetch("/api/mi/ask", {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
query,
messages: this.#messages.slice(0, -1).slice(-10),
space: document.querySelector("rstack-space-switcher")?.getAttribute("current") || "",
module: document.querySelector("rstack-app-switcher")?.getAttribute("current") || "",
}),
signal: this.#abortController.signal,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Request failed" }));
throw new Error(err.error || "Request failed");
}
if (!res.body) throw new Error("No response stream");
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
for (const line of chunk.split("\n").filter(Boolean)) {
try {
const data = JSON.parse(line);
if (data.message?.content) {
this.#messages[assistantIdx].content += data.message.content;
}
// Non-streaming fallback
if (data.response) {
this.#messages[assistantIdx].content = data.response;
}
} catch { /* skip malformed lines */ }
}
this.#renderMessages(messagesEl);
}
// If still empty after stream, show fallback
if (!this.#messages[assistantIdx].content) {
this.#messages[assistantIdx].content = "I couldn't generate a response. Please try again.";
this.#renderMessages(messagesEl);
}
} catch (e: any) {
if (e.name !== "AbortError") {
this.#messages[this.#messages.length - 1].content =
"Sorry, I'm not available right now. Please try again later.";
this.#renderMessages(messagesEl);
}
}
}
#renderMessages(container: HTMLElement) {
container.innerHTML = this.#messages
.map(
(m) => `
<div class="mi-msg mi-msg--${m.role}">
<span class="mi-msg-who">${m.role === "user" ? "You" : "&#10023; mi"}</span>
<div class="mi-msg-body">${m.content ? this.#formatContent(m.content) : '<span class="mi-typing"><span></span><span></span><span></span></span>'}</div>
</div>
`,
)
.join("");
container.scrollTop = container.scrollHeight;
}
#formatContent(s: string): string {
// Escape HTML then convert markdown-like formatting
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/`(.+?)`/g, '<code class="mi-code">$1</code>')
.replace(/\n/g, "<br>");
}
static define(tag = "rstack-mi") {
if (!customElements.get(tag)) customElements.define(tag, RStackMi);
}
}
const STYLES = `
:host { display: contents; }
.mi { position: relative; flex: 1; max-width: 480px; min-width: 0; }
.mi-bar {
display: flex; align-items: center; gap: 8px;
padding: 6px 14px; border-radius: 10px;
transition: all 0.2s;
}
:host-context([data-theme="dark"]) .mi-bar { background: rgba(255,255,255,0.06); }
:host-context([data-theme="light"]) .mi-bar { background: rgba(0,0,0,0.04); }
:host-context([data-theme="dark"]) .mi-bar.focused { background: rgba(255,255,255,0.1); box-shadow: 0 0 0 1px rgba(6,182,212,0.3); }
:host-context([data-theme="light"]) .mi-bar.focused { background: rgba(0,0,0,0.06); box-shadow: 0 0 0 1px rgba(6,182,212,0.3); }
.mi-icon {
font-size: 0.9rem; flex-shrink: 0;
background: linear-gradient(135deg, #06b6d4, #7c3aed);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.mi-input {
flex: 1; border: none; outline: none; background: none;
font-size: 0.85rem; min-width: 0;
font-family: inherit;
}
:host-context([data-theme="dark"]) .mi-input { color: #e2e8f0; }
:host-context([data-theme="light"]) .mi-input { color: #0f172a; }
:host-context([data-theme="dark"]) .mi-input::placeholder { color: #64748b; }
:host-context([data-theme="light"]) .mi-input::placeholder { color: #94a3b8; }
.mi-panel {
position: absolute; top: calc(100% + 8px); left: 0; right: 0;
min-width: 360px; max-height: 420px;
border-radius: 14px; overflow: hidden;
box-shadow: 0 12px 40px rgba(0,0,0,0.3);
display: none; z-index: 300;
}
.mi-panel.open { display: flex; flex-direction: column; }
:host-context([data-theme="dark"]) .mi-panel { background: #1e293b; border: 1px solid rgba(255,255,255,0.1); }
:host-context([data-theme="light"]) .mi-panel { background: white; border: 1px solid rgba(0,0,0,0.1); }
.mi-messages {
flex: 1; overflow-y: auto; padding: 16px;
display: flex; flex-direction: column; gap: 12px;
max-height: 400px;
}
.mi-welcome { text-align: center; padding: 24px 16px; }
.mi-welcome-icon {
font-size: 2rem; display: block; margin-bottom: 8px;
background: linear-gradient(135deg, #06b6d4, #7c3aed);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
:host-context([data-theme="dark"]) .mi-welcome p { color: #e2e8f0; }
:host-context([data-theme="light"]) .mi-welcome p { color: #374151; }
.mi-welcome p { font-size: 0.9rem; line-height: 1.5; margin: 0; }
.mi-welcome-sub { font-size: 0.8rem; opacity: 0.6; margin-top: 6px !important; }
.mi-msg { display: flex; flex-direction: column; gap: 4px; }
.mi-msg-who { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
:host-context([data-theme="dark"]) .mi-msg--user .mi-msg-who { color: #06b6d4; }
:host-context([data-theme="light"]) .mi-msg--user .mi-msg-who { color: #0891b2; }
.mi-msg--assistant .mi-msg-who {
background: linear-gradient(135deg, #06b6d4, #7c3aed);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.mi-msg-body {
font-size: 0.85rem; line-height: 1.6; word-break: break-word;
}
:host-context([data-theme="dark"]) .mi-msg-body { color: #cbd5e1; }
:host-context([data-theme="light"]) .mi-msg-body { color: #374151; }
:host-context([data-theme="dark"]) .mi-msg--user .mi-msg-body { color: #e2e8f0; }
:host-context([data-theme="light"]) .mi-msg--user .mi-msg-body { color: #0f172a; }
.mi-code {
padding: 1px 5px; border-radius: 4px; font-size: 0.8rem;
font-family: 'SF Mono', Monaco, Consolas, monospace;
}
:host-context([data-theme="dark"]) .mi-code { background: rgba(255,255,255,0.08); color: #7dd3fc; }
:host-context([data-theme="light"]) .mi-code { background: rgba(0,0,0,0.06); color: #0284c7; }
.mi-typing { display: inline-flex; gap: 4px; padding: 4px 0; }
.mi-typing span {
width: 6px; height: 6px; border-radius: 50%;
animation: miBounce 1.2s ease-in-out infinite;
}
:host-context([data-theme="dark"]) .mi-typing span { background: #64748b; }
:host-context([data-theme="light"]) .mi-typing span { background: #94a3b8; }
.mi-typing span:nth-child(2) { animation-delay: 0.15s; }
.mi-typing span:nth-child(3) { animation-delay: 0.3s; }
@keyframes miBounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-4px); }
}
@media (max-width: 640px) {
.mi { max-width: 200px; }
.mi-panel { min-width: 300px; left: -60px; }
}
`;

View File

@ -366,6 +366,9 @@
<rstack-app-switcher current=""></rstack-app-switcher>
<rstack-space-switcher current="" name="Spaces"></rstack-space-switcher>
</div>
<div class="rstack-header__center">
<rstack-mi></rstack-mi>
</div>
<div class="rstack-header__right">
<rstack-identity></rstack-identity>
</div>

View File

@ -22,7 +22,7 @@
#toolbar {
position: fixed;
top: 72px;
top: 108px; /* header(56) + tab-row(36) + gap(16) */
left: 50%;
transform: translateX(-50%);
display: flex;
@ -570,6 +570,9 @@
<rstack-app-switcher current="canvas"></rstack-app-switcher>
<rstack-space-switcher current="" name=""></rstack-space-switcher>
</div>
<div class="rstack-header__center">
<rstack-mi></rstack-mi>
</div>
<div class="rstack-header__right">
<rstack-identity></rstack-identity>
</div>

View File

@ -170,6 +170,9 @@
<rstack-app-switcher current=""></rstack-app-switcher>
<rstack-space-switcher current="" name="Spaces"></rstack-space-switcher>
</div>
<div class="rstack-header__center">
<rstack-mi></rstack-mi>
</div>
<div class="rstack-header__right">
<rstack-identity></rstack-identity>
</div>

View File

@ -365,6 +365,9 @@
<rstack-app-switcher current=""></rstack-app-switcher>
<rstack-space-switcher current="" name="Spaces"></rstack-space-switcher>
</div>
<div class="rstack-header__center">
<rstack-mi></rstack-mi>
</div>
<div class="rstack-header__right">
<rstack-identity></rstack-identity>
</div>

View File

@ -41,6 +41,14 @@ body {
gap: 4px;
}
.rstack-header__center {
flex: 1;
display: flex;
justify-content: center;
padding: 0 16px;
min-width: 0;
}
.rstack-header__right {
display: flex;
align-items: center;
@ -63,16 +71,37 @@ body {
-webkit-text-fill-color: transparent;
}
/* ── Tab row (below header) ── */
.rstack-tab-row {
position: fixed;
top: 56px;
left: 0;
right: 0;
z-index: 9998;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(128,128,128,0.1);
}
.rstack-tab-row[data-theme="dark"] {
background: rgba(15, 23, 42, 0.8);
}
.rstack-tab-row[data-theme="light"] {
background: rgba(255, 255, 255, 0.85);
}
/* ── Main content area ── */
#app {
padding-top: 56px; /* Below fixed header */
padding-top: 92px; /* Below fixed header (56px) + tab row (36px) */
min-height: 100vh;
}
/* When canvas module is active, make it fill the viewport */
#app.canvas-layout {
padding-top: 56px;
padding-top: 92px;
height: 100vh;
overflow: hidden;
}

View File

@ -10,11 +10,15 @@
import { RStackIdentity } from "../shared/components/rstack-identity";
import { RStackAppSwitcher } from "../shared/components/rstack-app-switcher";
import { RStackSpaceSwitcher } from "../shared/components/rstack-space-switcher";
import { RStackTabBar } from "../shared/components/rstack-tab-bar";
import { RStackMi } from "../shared/components/rstack-mi";
// Register all header components
RStackIdentity.define();
RStackAppSwitcher.define();
RStackSpaceSwitcher.define();
RStackTabBar.define();
RStackMi.define();
// Reload space list when user signs in/out (to show/hide private spaces)
document.addEventListener("auth-change", () => {