diff mrjunejune/src/jrpg/jrpg.js @ 264:04fee26ecce0

add authenticated JRPG conversation platform Add reusable auth/session storage, owned conversation recovery, guest quotas, admin workflows, URL-routed conversation UI, mobile frame support, and parallel browser acceptance. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Fri, 07 Aug 2026 07:34:12 -0700
parents ee04e4e69fed
children 056790c4fb0d
line wrap: on
line diff
--- a/mrjunejune/src/jrpg/jrpg.js	Thu Aug 06 11:31:30 2026 -0700
+++ b/mrjunejune/src/jrpg/jrpg.js	Fri Aug 07 07:34:12 2026 -0700
@@ -15,8 +15,7 @@
 
 const PREVIEWS = Object.freeze({
   resume: {
-    copy: "Experience, projects, and the systems I have helped build.",
-    kicker: "CHARACTER RECORD",
+    copy: "Member of Technical Staff and engineering leader with 10+ years building AI agent platforms and production systems across Microsoft, Meta, Google, and growth-stage companies.",
     title: "Resume",
     url: "/resume",
     works: [
@@ -27,27 +26,22 @@
         url: "/resume",
       },
       {
-        detail: "Agentic execution",
+        detail: "Led engineering",
         label: "Copilot Tasks",
         url: "https://www.microsoft.com/en-us/microsoft-copilot/blog/2026/02/26/copilot-tasks-from-answers-to-actions/",
       },
       {
-        detail: "AI platform",
-        label: "Copilot SuperApp",
+        detail: "Foundational platform",
+        label: "AIX Harness / Copilot",
         url: "https://www.cio.com/article/3977098/microsoft-doubles-down-on-multi-model-ai-as-it-builds-a-copilot-super-app.html",
       },
       {
-        detail: "Build 2026",
-        label: "Code",
+        detail: "Build 2026 products",
+        label: "Code & Autopilot",
         url: "https://news.microsoft.com/build-2026/",
       },
       {
-        detail: "Personal agent",
-        label: "Autopilot",
-        url: "https://www.microsoft.com/en-us/microsoft-365/blog/2026/06/02/introducing-microsoft-scout-your-always-on-personal-agent/",
-      },
-      {
-        detail: "Ads systems",
+        detail: "Full-stack ads systems",
         label: "Meta",
         url: "https://www.meta.com/",
       },
@@ -56,11 +50,20 @@
         label: "Google",
         url: "https://www.google.com/",
       },
+      {
+        detail: "Technical lead",
+        label: "Warner Music Group",
+        url: "https://www.wmg.com/",
+      },
+      {
+        detail: "Personal agent launch",
+        label: "Microsoft Scout",
+        url: "https://www.microsoft.com/en-us/microsoft-365/blog/2026/06/02/introducing-microsoft-scout-your-always-on-personal-agent/",
+      },
     ],
   },
   tools: {
-    copy: "Small, focused utilities for writing, media, and experimentation.",
-    kicker: "ITEM INVENTORY",
+    copy: "Useful browser tools backed by first-party C, WASM, media, and document-processing systems.",
     title: "Tools",
     url: "/tools",
     works: [
@@ -70,8 +73,7 @@
     ],
   },
   blog: {
-    copy: "Notes from building systems, games, web tools, and curious prototypes.",
-    kicker: "QUEST ARCHIVE",
+    copy: "Technical writing about networking, rendering, performance, developer tooling, and experiments.",
     title: "Blogs",
     url: "/blog",
     works: [
@@ -81,8 +83,12 @@
 });
 
 const CONVERSATION_STORAGE_KEY = "mjj-jrpg-conversation-id";
+const CONVERSATION_LEGACY_KEY = "mjj-jrpg-legacy-claim";
+const ARCHIVE_PAGE_SIZE = 20;
 const TYPING_INTERVAL_MS = 18;
 const SCRIPT_LOADS = new Map();
+const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+const VALID_PANELS = Object.freeze(["resume", "tools", "blog", "conversations"]);
 const TOOL_ICON_NAMES = Object.freeze({
   "/notes": "repository",
   "/tools/file_converter": "retry",
@@ -598,7 +604,6 @@
     this.cleanupTool();
     const preview = PREVIEWS[selection] || PREVIEWS.resume;
     this.dataset.selection = selection;
-    this.querySelector("[data-preview-kicker]").textContent = preview.kicker;
     this.querySelector("[data-preview-title]").textContent = preview.title;
     this.querySelector("[data-preview-copy]").textContent = preview.copy;
     this.querySelector("[data-dialog-title]").textContent = preview.title;
@@ -1401,9 +1406,384 @@
   }
 }
 
-class MjjJrpgShell extends HTMLElement {}
+/* =====================================================================
+   MjjConversationArchive — archive panel within the scene
+   ===================================================================== */
+
+class MjjConversationArchive extends HTMLElement {
+  connectedCallback() {
+    this._list = this.querySelector("[data-archive-list]");
+    this._status = this.querySelector("[data-archive-status]");
+    this._loadMoreOwner = this.querySelector("[data-archive-load-more-owner]");
+    this._loadMoreButton = this.querySelector("[data-archive-load-more]");
+    this._newButton = this.querySelector("[data-archive-new]");
+    this._closeButton = this.querySelector("[data-archive-close]");
+    this._deleteDialog = this.querySelector("[data-archive-delete-dialog]");
+    this._deleteNameEl = this.querySelector("[data-archive-delete-name]");
+    this._deleteCancelButton = this.querySelector("[data-archive-delete-cancel]");
+    this._deleteConfirmButton = this.querySelector("[data-archive-delete-confirm]");
+    this._renameDialog = this.querySelector("[data-archive-rename-dialog]");
+    this._renameInput = this.querySelector("[data-archive-rename-input]");
+    this._renameForm = this.querySelector("[data-archive-rename-form]");
+    this._renameCancelButton = this.querySelector("[data-archive-rename-cancel]");
+    this._pendingDeleteId = null;
+    this._pendingDeleteTrigger = null;
+    this._pendingRenameId = null;
+    this._pendingRenameTrigger = null;
+
+    this._closeButton?.addEventListener("click", () => {
+      this.dispatchEvent(new CustomEvent("mjj-archive-toggle", { bubbles: true }));
+    });
+
+    this._newButton?.addEventListener("click", () => {
+      this.dispatchEvent(new CustomEvent("mjj-archive-new", { bubbles: true }));
+    });
+
+    this._loadMoreButton?.addEventListener("click", () => {
+      this.dispatchEvent(new CustomEvent("mjj-archive-load-more", { bubbles: true }));
+    });
+
+    this._deleteCancelButton?.addEventListener("click", () => {
+      this._deleteDialog?.close();
+      const t = this._pendingDeleteTrigger;
+      this._pendingDeleteId = null;
+      this._pendingDeleteTrigger = null;
+      t?.focus();
+    });
+
+    this._deleteConfirmButton?.addEventListener("click", () => {
+      const id = this._pendingDeleteId;
+      const trigger = this._pendingDeleteTrigger;
+      this._deleteDialog?.close();
+      this._pendingDeleteId = null;
+      this._pendingDeleteTrigger = null;
+      if (id) {
+        this.dispatchEvent(new CustomEvent("mjj-archive-delete", {
+          bubbles: true,
+          detail: { id, trigger },
+        }));
+      }
+    });
+
+    this._deleteDialog?.addEventListener("close", () => {
+      const t = this._pendingDeleteTrigger;
+      this._pendingDeleteId = null;
+      this._pendingDeleteTrigger = null;
+      t?.focus();
+    });
+
+    this._renameCancelButton?.addEventListener("click", () => {
+      this._renameDialog?.close();
+      const t = this._pendingRenameTrigger;
+      this._pendingRenameId = null;
+      this._pendingRenameTrigger = null;
+      t?.focus();
+    });
+
+    this._renameForm?.addEventListener("submit", event => {
+      event.preventDefault();
+      const id = this._pendingRenameId;
+      const trigger = this._pendingRenameTrigger;
+      const newTitle = this._renameInput?.value.trim();
+      if (!id || !newTitle) return;
+      this._renameDialog?.close();
+      this._pendingRenameId = null;
+      this._pendingRenameTrigger = null;
+      this.dispatchEvent(new CustomEvent("mjj-archive-rename", {
+        bubbles: true,
+        detail: { id, newTitle, trigger },
+      }));
+    });
+
+    this._renameDialog?.addEventListener("close", () => {
+      const t = this._pendingRenameTrigger;
+      this._pendingRenameId = null;
+      this._pendingRenameTrigger = null;
+      t?.focus();
+    });
+
+    this._onListClick = event => {
+      const openButton = event.target.closest("[data-conv-open]");
+      if (openButton) {
+        const id = openButton.closest("[data-conv-item]")?._convId;
+        if (id) {
+          this.dispatchEvent(new CustomEvent("mjj-archive-open", {
+            bubbles: true,
+            detail: { id },
+          }));
+        }
+        return;
+      }
+      const renameButton = event.target.closest("[data-conv-rename]");
+      if (renameButton) {
+        const item = renameButton.closest("[data-conv-item]");
+        if (item?._convId) {
+          this._pendingRenameId = item._convId;
+          this._pendingRenameTrigger = renameButton;
+          if (this._renameInput) this._renameInput.value = item._convTitle || "";
+          this._renameDialog?.showModal();
+          requestAnimationFrame(() => { this._renameInput?.select(); });
+        }
+        return;
+      }
+      const deleteButton = event.target.closest("[data-conv-delete]");
+      if (deleteButton) {
+        const item = deleteButton.closest("[data-conv-item]");
+        if (item?._convId) {
+          this._pendingDeleteId = item._convId;
+          this._pendingDeleteTrigger = deleteButton;
+          if (this._deleteNameEl) this._deleteNameEl.textContent = item._convTitle || "";
+          this._deleteDialog?.showModal();
+          requestAnimationFrame(() => { this._deleteConfirmButton?.focus(); });
+        }
+        return;
+      }
+    };
+    this._list?.addEventListener("click", this._onListClick);
+
+    this._onListKeydown = event => {
+      if (!["ArrowUp", "ArrowDown"].includes(event.key)) return;
+      const items = [...(this._list?.querySelectorAll("[data-conv-open]") || [])];
+      const index = items.indexOf(document.activeElement);
+      if (index < 0) return;
+      event.preventDefault();
+      const next = event.key === "ArrowDown"
+        ? items[Math.min(index + 1, items.length - 1)]
+        : items[Math.max(index - 1, 0)];
+      next?.focus();
+    };
+    this._list?.addEventListener("keydown", this._onListKeydown);
+  }
+
+  disconnectedCallback() {
+    this._list?.removeEventListener("click", this._onListClick);
+    this._list?.removeEventListener("keydown", this._onListKeydown);
+  }
+
+  setConversations(conversations, cursor, append = false) {
+    if (!this._list) return;
+    if (!append) this._list.replaceChildren();
+    for (const conv of conversations) this._list.append(this._createItem(conv));
+    if (this._loadMoreOwner) this._loadMoreOwner.hidden = !cursor;
+    if (!conversations.length && !append) {
+      this._showStatus("No conversations yet. Start a new one!");
+    } else {
+      this._hideStatus();
+    }
+  }
+
+  setCurrentId(id) {
+    for (const button of (this._list?.querySelectorAll("[data-conv-open]") || [])) {
+      const isCurrent = id && button.closest("[data-conv-item]")?._convId === id;
+      button.setAttribute("aria-current", String(Boolean(isCurrent)));
+    }
+  }
+
+  addConversation(conv) {
+    if (!this._list) return;
+    this._list.prepend(this._createItem(conv));
+    this._hideStatus();
+  }
+
+  updateConversation(conv) {
+    if (!this._list) return;
+    for (const item of this._list.querySelectorAll("[data-conv-item]")) {
+      if (item._convId === conv.id) {
+        const titleEl = item.querySelector("[data-conv-title]");
+        const metaEl = item.querySelector("[data-conv-meta]");
+        if (titleEl) titleEl.textContent = conv.title;
+        if (metaEl) metaEl.textContent = this._formatMeta(conv);
+        item._convTitle = conv.title;
+        return;
+      }
+    }
+    this.addConversation(conv);
+  }
+
+  removeConversation(id) {
+    for (const item of (this._list?.querySelectorAll("[data-conv-item]") || [])) {
+      if (item._convId === id) {
+        item.remove();
+        break;
+      }
+    }
+    if (!this._list?.querySelector("[data-conv-item]")) {
+      this._showStatus("No conversations yet. Start a new one!");
+    }
+  }
+
+  setLoading(loading) {
+    if (loading) {
+      this._showStatus("Loading conversations...");
+    } else if (!this._list?.querySelector("[data-conv-item]")) {
+      this._showStatus("No conversations yet. Start a new one!");
+    } else {
+      this._hideStatus();
+    }
+  }
+
+  setError(message) {
+    this._showStatus(message);
+    if (this._status) this._status.dataset.state = "error";
+  }
+
+  setLoadMoreDisabled(disabled) {
+    if (this._loadMoreButton) this._loadMoreButton.disabled = disabled;
+  }
+
+  /* Disable/enable destructive archive controls while a stream is active. */
+  setStreamActive(active) {
+    const controls = this._list?.querySelectorAll(
+      "[data-conv-delete], [data-conv-rename]",
+    ) || [];
+    for (const el of controls) el.disabled = active;
+    if (this._newButton) this._newButton.disabled = active;
+    if (this._deleteConfirmButton) this._deleteConfirmButton.disabled = active;
+    this.dataset.streamActive = active ? "true" : "";
+    if (!active) delete this.dataset.streamActive;
+  }
+
+  /* Return the open-button of the adjacent surviving item, or New button. */
+  adjacentItemOrNewButton(id) {
+    const items = [...(this._list?.querySelectorAll("[data-conv-item]") || [])];
+    const idx = items.findIndex(el => el._convId === id);
+    if (idx < 0) return this._newButton;
+    const sibling = items[idx + 1] || items[idx - 1];
+    return sibling?.querySelector("[data-conv-open]") || this._newButton;
+  }
+
+  showLegacyClaim(statusMessage = null, showButton = true) {
+    if (!this._status) return;
+    this._status.hidden = false;
+    delete this._status.dataset.state;
+    this._status.textContent = "";
+    const msg = document.createElement("span");
+    msg.textContent = statusMessage ?? "You have a prior conversation. ";
+    this._status.append(msg);
+    if (showButton) {
+      const btn = document.createElement("button");
+      btn.type = "button";
+      btn.textContent = "Claim it";
+      btn.dataset.archiveClaim = "";
+      btn.addEventListener("click", () => {
+        this.dispatchEvent(new CustomEvent("mjj-archive-claim", { bubbles: true }));
+      });
+      this._status.append(btn);
+    }
+  }
+
+  _showStatus(text) {
+    if (this._status) {
+      this._status.textContent = text;
+      this._status.hidden = false;
+      delete this._status.dataset.state;
+    }
+  }
+
+  _hideStatus() {
+    if (this._status) this._status.hidden = true;
+  }
+
+  _createItem(conv) {
+    const item = document.createElement("li");
+    item.dataset.convItem = "";
+    item._convId = conv.id;
+    item._convTitle = conv.title;
+
+    const openButton = document.createElement("button");
+    openButton.type = "button";
+    openButton.dataset.convOpen = "";
+    openButton.setAttribute("aria-current", "false");
+
+    const titleEl = document.createElement("span");
+    titleEl.dataset.convTitle = "";
+    titleEl.textContent = conv.title;
+
+    const metaEl = document.createElement("small");
+    metaEl.dataset.convMeta = "";
+    metaEl.textContent = this._formatMeta(conv);
+
+    openButton.append(titleEl, metaEl);
+
+    const actions = document.createElement("div");
+    actions.className = "jrpg-archive-item-actions";
+
+    const renameButton = document.createElement("button");
+    renameButton.type = "button";
+    renameButton.dataset.convRename = "";
+    renameButton.setAttribute("aria-label", `Rename "${conv.title}"`);
+    renameButton.textContent = "Rename";
+
+    const deleteButton = document.createElement("button");
+    deleteButton.type = "button";
+    deleteButton.dataset.convDelete = "";
+    deleteButton.setAttribute("aria-label", `Delete "${conv.title}"`);
+    deleteButton.textContent = "Delete";
+
+    actions.append(renameButton, deleteButton);
+    item.append(openButton, actions);
+    return item;
+  }
+
+  _formatMeta(conv) {
+    const count = conv.turn_count ?? 0;
+    const turns = count === 1 ? "1 turn" : `${count} turns`;
+    if (conv.last_message_preview) {
+      return `${turns} · ${conv.last_message_preview.slice(0, 28)}`;
+    }
+    return turns;
+  }
+}
+
+class MjjJrpgShell extends HTMLElement {
+  connectedCallback() {
+    this._mobileMenuQuery = window.matchMedia(
+      "(max-width: 52rem) and (orientation: portrait)",
+    );
+    this._syncMobileMenu = expanded => {
+      const button = this.querySelector("[data-mobile-menu-toggle]");
+      const menu = this.querySelector("mjj-jrpg-menu");
+      if (!button || !menu) return;
+      if (!this._mobileMenuQuery.matches) {
+        button.setAttribute("aria-expanded", "true");
+        menu.removeAttribute("data-mobile-menu-collapsed");
+        return;
+      }
+      button.setAttribute("aria-expanded", String(expanded));
+      menu.toggleAttribute("data-mobile-menu-collapsed", !expanded);
+    };
+    this._onMenuToggle = event => {
+      const button = event.target.closest("[data-mobile-menu-toggle]");
+      if (!button || !this.contains(button)) return;
+      const expanded = button.getAttribute("aria-expanded") === "true";
+      this._syncMobileMenu(!expanded);
+      if (!expanded) {
+        const firstItem = this.querySelector(
+          "mjj-jrpg-menu button[data-preview]",
+        );
+        if (firstItem) firstItem.focus();
+      }
+    };
+    this._onMobileMenuMediaChange = () => this._syncMobileMenu(true);
+    this.addEventListener("click", this._onMenuToggle);
+    this._mobileMenuQuery.addEventListener(
+      "change",
+      this._onMobileMenuMediaChange,
+    );
+    this._syncMobileMenu(true);
+  }
+
+  disconnectedCallback() {
+    this.removeEventListener("click", this._onMenuToggle);
+    this._mobileMenuQuery?.removeEventListener(
+      "change",
+      this._onMobileMenuMediaChange,
+    );
+  }
+}
 
 for (const [name, constructor] of Object.entries({
+  "mjj-conversation-archive": MjjConversationArchive,
   "mjj-jrpg-character": MjjJrpgCharacter,
   "mjj-jrpg-chat": MjjJrpgChat,
   "mjj-jrpg-composer": MjjJrpgComposer,
@@ -1417,6 +1797,7 @@
 async function requestJson(url, options = {}) {
   const response = await fetch(url, {
     ...options,
+    credentials: "same-origin",
     headers: {
       "Content-Type": "application/json",
       ...options.headers,
@@ -1435,6 +1816,102 @@
   return response.status === 204 ? null : response.json();
 }
 
+/* Session state for CSRF: bootstrapped once, refreshed at most once on 401/403 */
+let _csrfToken = null;
+let _sessionData = null;
+let _sessionRefreshInFlight = null; /* single-flight guard */
+
+function _principalFingerprint(data) {
+  if (!data) return null;
+  if (data.kind === "user") return `user:${data.username || ""}:${data.role || ""}`;
+  if (data.kind === "guest") return `guest:${data.csrfToken || ""}`;
+  return null;
+}
+
+async function _fetchSession() {
+  try {
+    const response = await fetch("/api/auth/session", {
+      credentials: "same-origin",
+    });
+    if (!response.ok) return null;
+    const data = await response.json().catch(() => null);
+    if (!data) return null;
+    _sessionData = data;
+    return data.csrfToken || null;
+  } catch {
+    return null;
+  }
+}
+
+/* Single-flight session refresh: dedupe concurrent callers. */
+async function _refreshSessionOnce() {
+  if (_sessionRefreshInFlight) return _sessionRefreshInFlight;
+  _sessionRefreshInFlight = (async () => {
+    const prev = _principalFingerprint(_sessionData);
+    const token = await _fetchSession();
+    const next = _principalFingerprint(_sessionData);
+    return { token, prev, next };
+  })();
+  try {
+    return await _sessionRefreshInFlight;
+  } finally {
+    _sessionRefreshInFlight = null;
+  }
+}
+
+/*
+ * Perform a state-changing (mutating) fetch with CSRF token and same-origin
+ * credentials.  On 401 or CSRF-specific 403 (csrf_invalid), refreshes the
+ * session once and retries — but only if the refreshed principal matches the
+ * original.  If the user becomes a guest or a different account, rejects and
+ * triggers the session-changed callback.
+ */
+let _sessionRefreshCallback = null;
+
+async function mutatingFetch(url, options = {}) {
+  const buildHeaders = () => ({
+    ...(options.headers || {}),
+    Origin: window.location.origin,
+    ...(_csrfToken ? { "X-CSRF-Token": _csrfToken } : {}),
+  });
+
+  const response = await fetch(url, {
+    ...options,
+    credentials: "same-origin",
+    headers: buildHeaders(),
+  });
+
+  const shouldRefresh = response.status === 401 ||
+    (response.status === 403 && await (async () => {
+      try {
+        const clone = response.clone();
+        const body = await clone.json();
+        return body?.error?.code === "csrf_invalid";
+      } catch { return false; }
+    })());
+
+  if (shouldRefresh) {
+    const prevFingerprint = _principalFingerprint(_sessionData);
+    const { token, next } = await _refreshSessionOnce();
+    _csrfToken = token;
+    if (next !== prevFingerprint) {
+      if (_sessionRefreshCallback) _sessionRefreshCallback();
+      const err = Object.assign(new Error("Session changed"), { status: 401 });
+      throw err;
+    }
+    const retried = await fetch(url, {
+      ...options,
+      credentials: "same-origin",
+      headers: buildHeaders(),
+    });
+    if (retried.status === 401 && _sessionRefreshCallback) {
+      _sessionRefreshCallback();
+    }
+    return retried;
+  }
+  return response;
+}
+
 async function consumeSse(response, onEvent) {
   if (!response.body) throw new Error("Streaming response body is unavailable");
   const reader = response.body.getReader();
@@ -1467,6 +1944,132 @@
   if (buffer.trim()) dispatch(buffer);
 }
 
+/* =====================================================================
+   Account slot rendering
+   ===================================================================== */
+
+function renderAccountSlot(slotEl, sessionData) {
+  if (!slotEl) return;
+  slotEl.textContent = "";
+  if (!sessionData || sessionData.kind === "guest") {
+    const owner = document.createElement("zen-button");
+    const btn = document.createElement("button");
+    owner.setAttribute("appearance", "plain");
+    owner.setAttribute("size", "xs");
+    btn.type = "button";
+    btn.dataset.loginOpen = "";
+    btn.textContent = "LOGIN";
+    owner.append(btn);
+    slotEl.setAttribute("aria-label", "Not signed in");
+    slotEl.append(owner);
+    return;
+  }
+  const username = (sessionData.username || "").slice(0, 8).toUpperCase();
+  if (sessionData.mustChangePassword) {
+    const link = document.createElement("a");
+    link.href = "/account/password";
+    link.textContent = username ? `${username}\u26A0` : "PASSWD";
+    link.title = "Password change required";
+    slotEl.setAttribute("aria-label", `${sessionData.username}: password change required`);
+    slotEl.append(link);
+    return;
+  }
+  const nameSpan = document.createElement("span");
+  nameSpan.dataset.accountUser = "";
+  nameSpan.textContent = username;
+  slotEl.append(nameSpan);
+  if (sessionData.role === "admin") {
+    const adminLink = document.createElement("a");
+    adminLink.href = "/admin/users";
+    adminLink.textContent = "\u00A0ADM";
+    slotEl.append(adminLink);
+  }
+  const logoutBtn = document.createElement("button");
+  logoutBtn.type = "button";
+  logoutBtn.textContent = "\u00A0LGOUT";
+  logoutBtn.addEventListener("click", () => {
+    logoutBtn.disabled = true;
+    mutatingFetch("/api/auth/logout", { method: "POST" }).then(response => {
+      if (!response.ok) throw new Error(`Logout failed (${response.status})`);
+      window.dispatchEvent(new CustomEvent("mjj-logout"));
+    }).catch(() => {
+      logoutBtn.disabled = false;
+    });
+  });
+  slotEl.append(logoutBtn);
+  slotEl.setAttribute("aria-label", `Signed in as ${sessionData.username}`);
+}
+
+/* =====================================================================
+   Quota display helpers
+   ===================================================================== */
+
+function formatQuotaText(quota) {
+  if (!quota) return null;
+  const turns = quota.turnsRemaining ?? 0;
+  const tokens = quota.outputTokensRemaining ?? 0;
+  if (turns <= 0) {
+    const resetAt = quota.resetsAt ? new Date(quota.resetsAt * 1000) : null;
+    const resetStr = resetAt
+      ? ` Resets ${resetAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`
+      : "";
+    return { text: `Quota exhausted.${resetStr}`, state: "exhausted" };
+  }
+  if (turns <= 2) {
+    return {
+      text: `${turns} turn${turns === 1 ? "" : "s"} remaining (${tokens.toLocaleString()} tokens)`,
+      state: "warning",
+    };
+  }
+  return {
+    text: `${turns} turns remaining (${tokens.toLocaleString()} tokens)`,
+    state: "ok",
+  };
+}
+
+function updateQuotaDisplay(quotaEl, quota) {
+  if (!quotaEl) return;
+  if (!quota) {
+    quotaEl.hidden = true;
+    return;
+  }
+  const result = formatQuotaText(quota);
+  /* Only show the bar for low or exhausted quota; hide when turns are plentiful */
+  if (!result || result.state === "ok") {
+    quotaEl.hidden = true;
+    return;
+  }
+  quotaEl.textContent = result.text;
+  if (result.state === "exhausted") {
+    quotaEl.dataset.state = "exhausted";
+  } else if (result.state === "warning") {
+    quotaEl.dataset.state = "warning";
+  } else {
+    delete quotaEl.dataset.state;
+  }
+  quotaEl.hidden = false;
+}
+
+/* =====================================================================
+   Conversation archive helpers
+   ===================================================================== */
+
+async function fetchConversationList(cursor) {
+  const url = cursor
+    ? `/api/conversations?limit=${ARCHIVE_PAGE_SIZE}&cursor=${encodeURIComponent(cursor)}`
+    : `/api/conversations?limit=${ARCHIVE_PAGE_SIZE}`;
+  const response = await fetch(url, { credentials: "same-origin" });
+  if (!response.ok) {
+    let message = `Failed to load conversations (${response.status})`;
+    try {
+      const body = await response.json();
+      message = body.error?.message || message;
+    } catch { /* keep status message */ }
+    throw new Error(message);
+  }
+  return response.json();
+}
+
 async function initializeJrpgPage() {
   const colorProbe = document.createElement("span");
   colorProbe.style.background = "var(--zenbu-sys-color-surface-page)";
@@ -1477,19 +2080,46 @@
     meta.setAttribute("content", themeColor);
   }
 
+  /* Bootstrap session to obtain CSRF token and session data */
+  _csrfToken = await _fetchSession();
+
   const shell = document.querySelector("mjj-jrpg-shell");
   const character = shell?.querySelector("mjj-jrpg-character");
   const chat = shell?.querySelector("mjj-jrpg-chat");
   const composer = shell?.querySelector("mjj-jrpg-composer");
   const preview = shell?.querySelector("mjj-jrpg-preview");
+  const archive = shell?.querySelector("mjj-conversation-archive");
+  const accountSlot = shell?.querySelector("[data-frame-account]");
+  const quotaEl = shell?.querySelector("[data-quota]");
   const frameNetwork = shell?.querySelector("[data-frame-network]");
   const frameUptime = shell?.querySelector("[data-frame-uptime]");
   const minimizeButton = shell?.querySelector("[data-frame-minimize]");
   const fullscreenButton = shell?.querySelector("[data-frame-fullscreen]");
+
   let characterTimer = 0;
-  let conversationId = sessionStorage.getItem(CONVERSATION_STORAGE_KEY);
+  let currentConversationId = null;
+  let currentPanel = "resume";
+  let archiveCursor = null;
   let activeController = null;
+  let archiveLoadInFlight = false;  /* serialize pagination requests */
+  let openSeq = 0;                  /* sequence guard for conversation open */
+  let popSeq = 0;                   /* sequence guard for popstate */
+  let _pendingPopState = null;      /* queued popstate received during active stream */
+  let _claimInFlight = false;       /* guard against duplicate concurrent claims */
+  let legacyClaimId = sessionStorage.getItem(CONVERSATION_LEGACY_KEY);
+  let principalEpoch = 0;           /* rejects responses from a prior owner */
+  /* Dedupe conversation IDs: set of IDs currently rendered in archive */
+  const _renderedIds = new Set();
+  const invalidatePrincipalRequests = () => {
+    principalEpoch += 1;
+    openSeq += 1;
+    popSeq += 1;
+    archiveCursor = null;
+  };
+
+  /* Disable composer until fully initialized */
   composer?.setDisabled(true);
+
   const setFrameStatus = (label, state, title = "") => {
     if (frameNetwork) {
       frameNetwork.textContent = label;
@@ -1498,7 +2128,16 @@
       frameNetwork.title = title;
     }
   };
+
+  /* Bootstrap failure: keep controls disabled and expose retry */
+  if (!_csrfToken) {
+    setFrameStatus("OFFLINE", "offline", "Bootstrap failed");
+    archive?.setError("Connection failed. Reload to retry.");
+    return;
+  }
+
   setFrameStatus("ONLINE", "online");
+
   const startedAt = Date.now();
   const updateUptime = () => {
     const elapsed = Math.floor((Date.now() - startedAt) / 1000);
@@ -1536,21 +2175,705 @@
     );
   });
 
-  if (conversationId) {
+  /* Render account slot */
+  renderAccountSlot(accountSlot, _sessionData);
+
+  /* ---- Login modal ---- */
+  const loginDialogOwner = shell?.querySelector("[data-login-dialog-owner]");
+  const loginDialog = loginDialogOwner?.querySelector("[data-login-dialog]");
+  const loginForm = loginDialog?.querySelector("[data-login-form]");
+  const loginSubmitBtn = loginDialog?.querySelector("[data-login-submit]");
+  const loginCancelBtn = loginDialog?.querySelector("[data-login-cancel]");
+  const loginErrorEl = loginDialog?.querySelector("[data-login-error]");
+  const loginUsernameInput = loginDialog?.querySelector("#jrpg-login-username");
+  const loginPasswordInput = loginDialog?.querySelector("#jrpg-login-password");
+  let _loginTrigger = null;
+  const setAccountActionsDisabled = disabled => {
+    for (const control of accountSlot?.querySelectorAll("button") || []) {
+      control.disabled = disabled;
+    }
+  };
+
+  const _clearLoginError = () => {
+    if (loginErrorEl) {
+      loginErrorEl.textContent = "";
+      loginErrorEl.hidden = true;
+    }
+  };
+
+  const _showLoginError = (message) => {
+    if (!loginErrorEl) return;
+    loginErrorEl.textContent = message;
+    loginErrorEl.hidden = false;
+  };
+
+  const openLoginModal = (triggerEl) => {
+    if (!loginDialogOwner || !loginDialog) return;
+    _loginTrigger = triggerEl || null;
+    _clearLoginError();
+    loginForm?.reset();
+    loginDialogOwner.open();
+    requestAnimationFrame(() => { loginUsernameInput?.focus(); });
+  };
+
+  /* Delegate clicks on data-login-open in the account slot */
+  accountSlot?.addEventListener("click", event => {
+    const btn = event.target.closest("[data-login-open]");
+    if (!btn || activeController) return;
+    openLoginModal(btn);
+  });
+
+  /* Cancel button */
+  loginCancelBtn?.addEventListener("click", () => { loginDialog?.close(); });
+
+  /* Cleanup on any close (Escape, X button, cancel) */
+  loginDialog?.addEventListener("close", () => {
+    if (loginPasswordInput) loginPasswordInput.value = "";
+    _clearLoginError();
+    const trigger = _loginTrigger;
+    _loginTrigger = null;
+    requestAnimationFrame(() => { trigger?.focus(); });
+  });
+
+  /* Login form submission */
+  loginForm?.addEventListener("submit", async event => {
+    event.preventDefault();
+    if (loginSubmitBtn?.disabled) return;
+    const username = loginUsernameInput?.value.trim() || "";
+    const password = loginPasswordInput?.value || "";
+    if (!username || !password) {
+      _showLoginError("Please enter your username and password.");
+      return;
+    }
+    if (loginSubmitBtn) loginSubmitBtn.disabled = true;
+    _clearLoginError();
     try {
-      const conversation = await requestJson(
-        `/api/conversations/${encodeURIComponent(conversationId)}`,
-        { headers: {} },
+      const response = await fetch("/api/auth/login", {
+        method: "POST",
+        headers: {
+          "Content-Type": "application/json",
+          "Origin": window.location.origin,
+        },
+        credentials: "same-origin",
+        body: JSON.stringify({ username, password, csrfToken: _csrfToken }),
+      });
+      if (loginPasswordInput) loginPasswordInput.value = "";
+      if (response.status === 429) {
+        _showLoginError("Too many attempts. Please try again later.");
+        return;
+      }
+      if (!response.ok) {
+        _showLoginError("Invalid username or password.");
+        return;
+      }
+      let data = await response.json().catch(() => null);
+      if (!data) {
+        _csrfToken = await _fetchSession();
+        if (_sessionData?.kind !== "user") {
+          loginDialog?.close();
+          window.location.reload();
+          return;
+        }
+        data = _sessionData;
+      }
+      invalidatePrincipalRequests();
+      loginDialog?.close();
+      _sessionData = {
+        kind: "user",
+        username: data.username || username,
+        role: data.role || "member",
+        mustChangePassword: Boolean(data.mustChangePassword),
+        csrfToken: data.csrfToken,
+        quota: null,
+      };
+      _csrfToken = data.csrfToken || null;
+      if (data.mustChangePassword) {
+        window.location.href = "/account/password";
+        return;
+      }
+      /* Commit the returned principal before optional UI refresh work. */
+      renderAccountSlot(accountSlot, _sessionData);
+      updateQuotaDisplay(quotaEl, _sessionData?.quota || null);
+      composer?.setDisabled(false);
+      /* Clear conversation state — guest convs may transfer to user */
+      currentConversationId = null;
+      sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
+      sessionStorage.removeItem(CONVERSATION_LEGACY_KEY);
+      legacyClaimId = null;
+      _replaceUrlState(currentPanel, null);
+      chat?.replaceMessages([]);
+      try {
+        await refreshArchiveFromScratch();
+      } catch (error) {
+        archive?.setError(`Signed in, but archive refresh failed: ${error.message}`);
+      }
+      requestAnimationFrame(() => {
+        accountSlot?.querySelector("button, a")?.focus();
+      });
+    } catch {
+      if (loginPasswordInput) loginPasswordInput.value = "";
+      _showLoginError("Sign-in failed. Please try again.");
+    } finally {
+      if (loginSubmitBtn) loginSubmitBtn.disabled = false;
+    }
+  });
+
+  /* Handle logout */
+  _sessionRefreshCallback = () => {
+    /* Account changed or session lost — clear active state */
+    invalidatePrincipalRequests();
+    if (activeController) {
+      activeController.abort();
+      activeController = null;
+    }
+    currentConversationId = null;
+    sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
+    sessionStorage.removeItem(CONVERSATION_LEGACY_KEY);
+    legacyClaimId = null;
+    _replaceUrlState(currentPanel, null);
+    chat?.replaceMessages([]);
+    _renderedIds.clear();
+    archiveCursor = null;
+    archive?.setConversations([], null);
+    renderAccountSlot(accountSlot, _sessionData);
+    archive?.setError("Session changed. Loading conversations...");
+    void refreshArchiveFromScratch();
+  };
+  window.addEventListener("mjj-logout", () => {
+    invalidatePrincipalRequests();
+    if (activeController) {
+      activeController.abort();
+      activeController = null;
+    }
+    archive?.setStreamActive(false);
+    currentConversationId = null;
+    sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
+    sessionStorage.removeItem(CONVERSATION_LEGACY_KEY);
+    legacyClaimId = null;
+    _replaceUrlState(currentPanel, null);
+    chat?.replaceMessages([]);
+    _renderedIds.clear();
+    archiveCursor = null;
+    archive?.setConversations([], null);
+    void refreshArchiveFromScratch();
+    void (async () => {
+      _csrfToken = await _fetchSession();
+      renderAccountSlot(accountSlot, _sessionData);
+      updateQuotaDisplay(quotaEl, _sessionData?.quota || null);
+      if (_sessionData?.kind === "user" && _sessionData?.mustChangePassword) {
+        composer?.setDisabled(true);
+      } else {
+        composer?.setDisabled(false);
+      }
+    })();
+  });
+
+  /* Session quota display */
+  if (_sessionData?.kind === "guest") {
+    updateQuotaDisplay(quotaEl, _sessionData.quota || null);
+  }
+
+  /* Disable composer for forced-password-change */
+  const isForced = _sessionData?.kind === "user" && _sessionData?.mustChangePassword;
+
+  /* ---- URL state helpers ---- */
+  const _buildStateUrl = (panel, convId) => {
+    const url = new URL(window.location.href);
+    if (panel && VALID_PANELS.includes(panel)) {
+      url.searchParams.set("panel", panel);
+    } else {
+      url.searchParams.delete("panel");
+    }
+    if (convId && UUID_REGEX.test(convId)) {
+      url.searchParams.set("conversation", convId);
+    } else {
+      url.searchParams.delete("conversation");
+    }
+    return url;
+  };
+
+  const _pushUrlState = (panel, convId) => {
+    history.pushState({ panel, conversation: convId || null }, "", _buildStateUrl(panel, convId));
+  };
+
+  const _replaceUrlState = (panel, convId) => {
+    history.replaceState({ panel, conversation: convId || null }, "", _buildStateUrl(panel, convId));
+  };
+
+  /* ---- Panel selection: shows archive or preview in utility ---- */
+  const utilityEl = shell?.querySelector(".jrpg-utility");
+
+  const selectPanel = (panel, pushHistory = true) => {
+    const validPanel = VALID_PANELS.includes(panel) ? panel : "resume";
+    currentPanel = validPanel;
+    for (const btn of (shell?.querySelectorAll("button[data-preview]") || [])) {
+      btn.setAttribute("aria-pressed", String(btn.dataset.preview === validPanel));
+    }
+    if (validPanel === "conversations") {
+      if (archive) archive.hidden = false;
+      if (preview) preview.hidden = true;
+      if (utilityEl) utilityEl.setAttribute("aria-label", "Conversations");
+    } else {
+      if (archive) archive.hidden = true;
+      if (preview) preview.hidden = false;
+      preview?.show(validPanel);
+      if (utilityEl) utilityEl.setAttribute("aria-label", "Selected destination");
+    }
+    if (pushHistory) _pushUrlState(validPanel, currentConversationId);
+  };
+
+  /* ---- Archive close button: navigate back to resume panel ---- */
+  shell?.addEventListener("mjj-archive-toggle", () => {
+    selectPanel("resume");
+  });
+
+  shell?.addEventListener("mjj-archive-new", () => {
+    if (activeController) return;
+    currentConversationId = null;
+    sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
+    _pushUrlState(currentPanel, null);
+    chat?.replaceMessages([]);
+    archive?.setCurrentId(null);
+    composer?.querySelector("textarea")?.focus();
+  });
+
+  /* ---- Popstate: re-apply panel and conversation on back/forward ---- */
+  /* _applyPopState always increments openSeq so any pending archive-open
+     fetch (including a transition to no conversation) cannot overwrite the
+     state the user navigated to (Fix 2). */
+  const _applyPopState = async (seq, popPanel, popConvId) => {
+    const localSeq = ++openSeq;
+    const epoch = principalEpoch;
+
+    selectPanel(popPanel, false);
+
+    if (popConvId && popConvId !== currentConversationId) {
+      try {
+        const conversation = await requestJson(`/api/conversations/${encodeURIComponent(popConvId)}`);
+        if (seq !== popSeq || localSeq !== openSeq ||
+            epoch !== principalEpoch) return;
+        currentConversationId = popConvId;
+        sessionStorage.setItem(CONVERSATION_STORAGE_KEY, popConvId);
+        chat?.replaceMessages(conversation.turns);
+        archive?.setCurrentId(popConvId);
+      } catch {
+        if (seq !== popSeq || localSeq !== openSeq ||
+            epoch !== principalEpoch) return;
+        currentConversationId = null;
+        sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
+        chat?.replaceMessages([]);
+        archive?.setCurrentId(null);
+        history.replaceState(
+          { panel: popPanel, conversation: null },
+          "",
+          _buildStateUrl(popPanel, null),
+        );
+      }
+    } else if (!popConvId && currentConversationId) {
+      currentConversationId = null;
+      sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
+      chat?.replaceMessages([]);
+      archive?.setCurrentId(null);
+    }
+  };
+
+  /* When popstate fires while a stream is active the URL has already changed.
+     Queue the parsed destination, abort the stream, and apply exactly once in
+     the stream's finally block so URL and UI always converge (Fix 1). */
+  window.addEventListener("popstate", async event => {
+    const seq = ++popSeq;
+    ++openSeq;
+    const state = event.state || {};
+    const popPanel = VALID_PANELS.includes(state.panel) ? state.panel : "resume";
+    const popConvId = (state.conversation && UUID_REGEX.test(state.conversation))
+      ? state.conversation : null;
+
+    if (activeController) {
+      _pendingPopState = { seq, popPanel, popConvId };
+      activeController.abort();
+      return;
+    }
+
+    await _applyPopState(seq, popPanel, popConvId);
+  });
+
+  shell?.addEventListener("mjj-archive-rename", async event => {
+    const { id, newTitle, trigger } = event.detail;
+    if (activeController) { trigger?.focus(); return; }
+    try {
+      const response = await mutatingFetch(
+        `/api/conversations/${encodeURIComponent(id)}`,
+        {
+          method: "PATCH",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify({ title: newTitle }),
+        },
       );
+      if (!response.ok) {
+        const body = await response.json().catch(() => ({}));
+        throw new Error(body.error?.message || `Rename failed (${response.status})`);
+      }
+      archive?.updateConversation({ id, title: newTitle, turn_count: null });
+    } catch (error) {
+      archive?.setError(`Rename failed: ${error.message}`);
+    } finally {
+      trigger?.focus();
+    }
+  });
+
+  shell?.addEventListener("mjj-archive-open", async event => {
+    const { id } = event.detail;
+    if (id === currentConversationId) return;
+    if (activeController) return;
+    const seq = ++openSeq;
+    const epoch = principalEpoch;
+    try {
+      const conversation = await requestJson(`/api/conversations/${encodeURIComponent(id)}`);
+      if (seq !== openSeq || epoch !== principalEpoch) return;
+      currentConversationId = id;
+      sessionStorage.setItem(CONVERSATION_STORAGE_KEY, id);
+      _pushUrlState(currentPanel, id);
       chat?.replaceMessages(conversation.turns);
+      archive?.setCurrentId(id);
+    } catch (error) {
+      if (seq !== openSeq || epoch !== principalEpoch) return;
+      archive?.setError(`Unable to open: ${error.message}`);
+    }
+  });
+
+  shell?.addEventListener("mjj-archive-delete", async event => {
+    const { id, trigger } = event.detail;
+    if (activeController) { trigger?.focus(); return; } /* block while streaming */
+    try {
+      const response = await mutatingFetch(
+        `/api/conversations/${encodeURIComponent(id)}`,
+        { method: "DELETE" },
+      );
+      if (response.ok || response.status === 404) {
+        /* Focus adjacent archive item or New button before removing the node */
+        const focusTarget = archive?.adjacentItemOrNewButton(id);
+        _renderedIds.delete(id);
+        archive?.removeConversation(id);
+        if (id === currentConversationId) {
+          currentConversationId = null;
+          sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
+          _replaceUrlState(currentPanel, null);
+          chat?.replaceMessages([]);
+        }
+        /* Focus surviving element — never the detached trigger */
+        requestAnimationFrame(() => {
+          (focusTarget || archive?.querySelector("[data-archive-new]"))?.focus();
+        });
+      } else {
+        const body = await response.json().catch(() => ({}));
+        archive?.setError(`Delete failed: ${body.error?.message || response.status}`);
+      }
+    } catch (error) {
+      archive?.setError(`Delete failed: ${error.message}`);
+      /* On error, the trigger is still attached — focus it */
+      trigger?.focus();
+    }
+  });
+
+  shell?.addEventListener("mjj-archive-load-more", async () => {
+    if (!archiveCursor || archiveLoadInFlight) return;
+    archiveLoadInFlight = true;
+    const epoch = principalEpoch;
+    archive?.setLoadMoreDisabled(true);
+    try {
+      const data = await fetchConversationList(archiveCursor);
+      if (epoch !== principalEpoch) return;
+      archiveCursor = data.cursor || null;
+      const newConvs = (data.conversations || []).filter(c => !_renderedIds.has(c.id));
+      newConvs.forEach(c => _renderedIds.add(c.id));
+      archive?.setConversations(newConvs, archiveCursor, true);
+    } catch (error) {
+      archive?.setError(`Load failed: ${error.message}`);
+    } finally {
+      archiveLoadInFlight = false;
+      archive?.setLoadMoreDisabled(false);
+    }
+  });
+
+  shell?.addEventListener("mjj-archive-claim", async () => {
+    const legacyId =
+      legacyClaimId || sessionStorage.getItem(CONVERSATION_LEGACY_KEY);
+    if (!legacyId || _sessionData?.kind !== "user" || _claimInFlight) return;
+    _claimInFlight = true;
+    archive?.showLegacyClaim("Claiming prior conversation...", false);
+    let _claimOutcome = null;
+    try {
+      const response = await mutatingFetch(
+        "/api/conversations/claim",
+        {
+          method: "POST",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify({ conversationId: legacyId }),
+        },
+      );
+      if (response.ok) {
+        sessionStorage.removeItem(CONVERSATION_LEGACY_KEY);
+        legacyClaimId = null;
+        _claimOutcome = "success";
+      } else if (response.status === 409) {
+        /* Definitive non-legacy conflict: clear key, no retry needed */
+        sessionStorage.removeItem(CONVERSATION_LEGACY_KEY);
+        legacyClaimId = null;
+        _claimOutcome = "conflict";
+      } else {
+        /* Retryable (401, 403, 429, 5xx): retain key */
+        sessionStorage.setItem(CONVERSATION_LEGACY_KEY, legacyId);
+        legacyClaimId = legacyId;
+        _claimOutcome = "retry";
+      }
     } catch {
-      sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
-      conversationId = null;
+      /* Network error: retain key */
+      sessionStorage.setItem(CONVERSATION_LEGACY_KEY, legacyId);
+      legacyClaimId = legacyId;
+      _claimOutcome = "network-error";
+    } finally {
+      /* Reset guard BEFORE re-rendering so the retry button is immediately usable */
+      _claimInFlight = false;
+      if (_claimOutcome === "success") {
+        void refreshArchiveFromScratch();
+      } else if (_claimOutcome === "conflict") {
+        archive?.showLegacyClaim("Prior conversation already claimed.", false);
+      } else if (_claimOutcome === "retry") {
+        selectPanel("conversations", false);
+        archive?.showLegacyClaim("Claim failed. Try again. ");
+      } else if (_claimOutcome === "network-error") {
+        selectPanel("conversations", false);
+        archive?.showLegacyClaim("Claim failed. Check connection. ");
+      }
+    }
+  });
+
+  async function refreshArchiveFromScratch() {
+    if (!archive) return;
+    const epoch = principalEpoch;
+    archive.setLoading(true);
+    _renderedIds.clear();
+    try {
+      const data = await fetchConversationList(null);
+      if (epoch !== principalEpoch) return;
+      archiveCursor = data.cursor || null;
+      const convs = data.conversations || [];
+      convs.forEach(c => _renderedIds.add(c.id));
+      archive.setConversations(convs, archiveCursor);
+      archive.setCurrentId(currentConversationId);
+    } catch (error) {
+      archive.setError(`Archive error: ${error.message}`);
     }
   }
 
+  async function refreshArchiveHead() {
+    if (!archive) return;
+    const epoch = principalEpoch;
+    try {
+      const data = await fetchConversationList(null);
+      if (epoch !== principalEpoch) return;
+      const fresh = data.conversations || [];
+      for (const conv of fresh) archive.updateConversation(conv);
+      if (fresh.length > 0) archive.setCurrentId(currentConversationId);
+    } catch { /* silent refresh failure */ }
+  }
+
+  /* ---- Parse and validate URL params at startup ---- */
+  /* Track whether ?conversation was explicitly present (even if malformed)
+     so we never migrate sessionStorage when the param was given but invalid. */
+  let _convParamWasExplicit = false;
+  {
+    const initParams = new URLSearchParams(window.location.search);
+    const rawPanel = initParams.get("panel");
+    const rawConvId = initParams.get("conversation");
+    _convParamWasExplicit = rawConvId !== null;
+    const validPanel = VALID_PANELS.includes(rawPanel) ? rawPanel : null;
+    const validConvId = (rawConvId && UUID_REGEX.test(rawConvId)) ? rawConvId : null;
+    /* Remove invalid params with replaceState */
+    if ((rawPanel && !validPanel) || (rawConvId && !validConvId)) {
+      const normalUrl = new URL(window.location.href);
+      if (!validPanel) normalUrl.searchParams.delete("panel");
+      if (!validConvId) normalUrl.searchParams.delete("conversation");
+      history.replaceState(
+        { panel: validPanel, conversation: validConvId },
+        "",
+        normalUrl,
+      );
+    }
+    currentPanel = validPanel || "resume";
+  }
+
+  /* ---- Load archive and restore conversation from URL or sessionStorage ---- */
+  const startupEpoch = principalEpoch;
+  const ensureStartupPrincipal = () => {
+    if (startupEpoch !== principalEpoch) {
+      const error = new Error("Principal changed during startup");
+      error.name = "StalePrincipalError";
+      throw error;
+    }
+  };
+  archive?.setLoading(true);
+  try {
+    const data = await fetchConversationList(null);
+    ensureStartupPrincipal();
+    archiveCursor = data.cursor || null;
+    const conversations = data.conversations || [];
+    conversations.forEach(c => _renderedIds.add(c.id));
+    archive?.setConversations(conversations, archiveCursor);
+
+    /* Determine target conversation: URL param is canonical; migrate sessionStorage once */
+    const initParams2 = new URLSearchParams(window.location.search);
+    const urlConvId = initParams2.get("conversation");
+    const urlConvValid = urlConvId && UUID_REGEX.test(urlConvId) ? urlConvId : null;
+
+    let resolvedConvId = urlConvValid;
+    let migratedFromStorage = false;
+    if (!resolvedConvId && !_convParamWasExplicit) {
+      const stored = sessionStorage.getItem(CONVERSATION_STORAGE_KEY);
+      if (stored && UUID_REGEX.test(stored)) {
+        resolvedConvId = stored;
+        migratedFromStorage = true;
+      }
+    }
+
+    if (resolvedConvId) {
+      const inArchive = conversations.find(c => c.id === resolvedConvId);
+      if (inArchive) {
+        try {
+          const conversation = await requestJson(
+            `/api/conversations/${encodeURIComponent(resolvedConvId)}`,
+          );
+          ensureStartupPrincipal();
+          currentConversationId = resolvedConvId;
+          sessionStorage.setItem(CONVERSATION_STORAGE_KEY, resolvedConvId);
+          chat?.replaceMessages(conversation.turns);
+          archive?.setCurrentId(resolvedConvId);
+        } catch (error) {
+          if (error.name === "StalePrincipalError" ||
+              startupEpoch !== principalEpoch) throw error;
+          currentConversationId = null;
+          sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
+        }
+      } else {
+        /* Not on first page — fetch directly to verify ownership */
+        let ownedConversation = null;
+        try {
+          ownedConversation = await requestJson(
+            `/api/conversations/${encodeURIComponent(resolvedConvId)}`,
+          );
+          ensureStartupPrincipal();
+        } catch (error) {
+          if (error.name === "StalePrincipalError" ||
+              startupEpoch !== principalEpoch) throw error;
+          ownedConversation = null;
+        }
+        if (ownedConversation) {
+          currentConversationId = resolvedConvId;
+          sessionStorage.setItem(CONVERSATION_STORAGE_KEY, resolvedConvId);
+          chat?.replaceMessages(ownedConversation.turns);
+          if (!_renderedIds.has(resolvedConvId)) {
+            _renderedIds.add(resolvedConvId);
+            archive?.addConversation({
+              id: resolvedConvId,
+              title: ownedConversation.title || "Conversation",
+              turn_count: ownedConversation.turns?.length || 0,
+            });
+          }
+          archive?.setCurrentId(resolvedConvId);
+        } else if (_sessionData?.kind === "user") {
+          /* Authenticated and 404 — offer legacy claim for sessionStorage migrations */
+          if (migratedFromStorage) {
+            sessionStorage.setItem(CONVERSATION_LEGACY_KEY, resolvedConvId);
+            legacyClaimId = resolvedConvId;
+            sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
+            archive?.showLegacyClaim();
+            if (conversations.length > 0) {
+              currentConversationId = conversations[0].id;
+              sessionStorage.setItem(CONVERSATION_STORAGE_KEY, currentConversationId);
+              try {
+                const conv = await requestJson(
+                  `/api/conversations/${encodeURIComponent(currentConversationId)}`,
+                );
+                ensureStartupPrincipal();
+                chat?.replaceMessages(conv.turns);
+                archive?.setCurrentId(currentConversationId);
+              } catch (error) {
+                if (error.name === "StalePrincipalError" ||
+                    startupEpoch !== principalEpoch) throw error;
+                currentConversationId = null;
+              }
+            }
+          } else {
+            /* URL-specified conv returned 404 — clear it */
+            currentConversationId = null;
+            sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
+          }
+        } else {
+          /* Guest — not owned, clear stale ID */
+          currentConversationId = null;
+          if (migratedFromStorage) sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
+        }
+      }
+    } else if (conversations.length > 0) {
+      /* No target conversation — open the most recent */
+      const most_recent = conversations[0];
+      try {
+        const conversation = await requestJson(
+          `/api/conversations/${encodeURIComponent(most_recent.id)}`,
+        );
+        ensureStartupPrincipal();
+        currentConversationId = most_recent.id;
+        sessionStorage.setItem(CONVERSATION_STORAGE_KEY, currentConversationId);
+        chat?.replaceMessages(conversation.turns);
+        archive?.setCurrentId(most_recent.id);
+      } catch (error) {
+        if (error.name === "StalePrincipalError" ||
+            startupEpoch !== principalEpoch) throw error;
+        currentConversationId = null;
+      }
+    }
+  } catch (error) {
+    if (error.name !== "StalePrincipalError" &&
+        startupEpoch === principalEpoch) {
+      archive?.setError(`Archive error: ${error.message}`);
+    }
+    /* Fall back to direct fetch of URL/stored ID */
+    const initParams3 = new URLSearchParams(window.location.search);
+    const fallbackId = (
+      initParams3.get("conversation") ||
+      (!_convParamWasExplicit
+        ? sessionStorage.getItem(CONVERSATION_STORAGE_KEY)
+        : null)
+    ) || null;
+    if (startupEpoch === principalEpoch &&
+        fallbackId && UUID_REGEX.test(fallbackId)) {
+      try {
+        const conversation = await requestJson(
+          `/api/conversations/${encodeURIComponent(fallbackId)}`,
+        );
+        ensureStartupPrincipal();
+        currentConversationId = fallbackId;
+        chat?.replaceMessages(conversation.turns);
+      } catch (fallbackError) {
+        if (fallbackError.name === "StalePrincipalError" ||
+            startupEpoch !== principalEpoch) {
+          // The new principal owns subsequent rendering.
+        } else {
+        sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
+        currentConversationId = null;
+        }
+      }
+    }
+  }
+
+  /* Apply startup panel and normalize URL (replaceState — not a user navigation) */
+  if (startupEpoch === principalEpoch) {
+    selectPanel(currentPanel, false);
+    _replaceUrlState(currentPanel, currentConversationId);
+  }
+
   shell?.addEventListener("mjj-jrpg-preview-change", event => {
-    preview?.show(event.detail.selection);
+    selectPanel(event.detail.selection);
     character?.setAttribute("state", "thinking");
     window.clearTimeout(characterTimer);
     characterTimer = window.setTimeout(() => {
@@ -1564,6 +2887,10 @@
 
   shell?.addEventListener("mjj-jrpg-submit", async event => {
     if (activeController) return;
+    /* Block sends when password change is required */
+    if (_sessionData?.kind === "user" && _sessionData?.mustChangePassword) {
+      return;
+    }
     chat?.appendMessage("June", event.detail.message);
     composer?.setBusy(true);
     character?.setAttribute("state", "thinking");
@@ -1579,51 +2906,130 @@
       assistantText.cancel();
     }, { once: true });
 
+    /* Disable archive/account mutations while stream is active (finding #7) */
+    archive?.setStreamActive(true);
+    setAccountActionsDisabled(true);
+
     try {
       const createConversation = async () => {
-        const conversation = await requestJson("/api/conversations", {
+        const resp = await mutatingFetch("/api/conversations", {
           method: "POST",
+          headers: { "Content-Type": "application/json" },
           body: JSON.stringify({ title: "Shiba Quest" }),
         });
-        conversationId = conversation.id;
-        sessionStorage.setItem(CONVERSATION_STORAGE_KEY, conversationId);
+        if (!resp.ok) {
+          let message = `Create failed (${resp.status})`;
+          try {
+            const body = await resp.json();
+            message = body.error?.message || message;
+          } catch { /* keep */ }
+          throw new Error(message);
+        }
+        const conversation = await resp.json();
+        currentConversationId = conversation.id;
+        sessionStorage.setItem(CONVERSATION_STORAGE_KEY, currentConversationId);
+        _pushUrlState(currentPanel, currentConversationId);
+        if (!_renderedIds.has(currentConversationId)) {
+          _renderedIds.add(currentConversationId);
+          archive?.addConversation({
+            id: currentConversationId,
+            title: "Shiba Quest",
+            turn_count: 0,
+            last_message_preview: event.detail.message.slice(0, 32),
+          });
+        }
+        archive?.setCurrentId(currentConversationId);
       };
-      if (!conversationId) {
+
+      if (!currentConversationId) {
         await createConversation();
       }
 
-      const postTurn = () => fetch(
-        `/api/conversations/${encodeURIComponent(conversationId)}/turns`, {
+      const postTurn = () => mutatingFetch(
+        `/api/conversations/${encodeURIComponent(currentConversationId)}/turns`,
+        {
           method: "POST",
           headers: { "Content-Type": "application/json" },
           body: JSON.stringify({ prompt: event.detail.message }),
           signal: activeController.signal,
         },
       );
+
       let response = await postTurn();
+
+      /* Handle 404: conversation may have expired — recreate it */
       if (response.status === 404) {
         sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
-        conversationId = null;
+        currentConversationId = null;
         await createConversation();
         response = await postTurn();
       }
+
+      /* Handle 429: distinguish permanent quota from temporary capacity */
+      if (response.status === 429) {
+        let errorBody = null;
+        try { errorBody = await response.json(); } catch { /* keep */ }
+        const code = errorBody?.error?.code || "";
+        const isQuotaExhausted =
+          code === "guest_quota_turns_exhausted" ||
+          code === "guest_quota_tokens_exhausted" ||
+          code === "guest_turn_quota_exhausted" ||
+          code === "guest_token_quota_exhausted";
+
+        if (isQuotaExhausted) {
+          const quotaState = errorBody?.error?.quota || null;
+          updateQuotaDisplay(quotaEl, quotaState);
+          composer?.setDisabled(true);
+          chat?.setMessageStreaming(assistantItem, false);
+          chat?.setMessageText(
+            assistantItem,
+            quotaState
+              ? `Quota exhausted. ${formatQuotaText(quotaState)?.text || ""}`
+              : "Quota exhausted. Try again later.",
+          );
+          character?.setAttribute("state", "sad");
+          setFrameStatus("QUOTA", "offline");
+          /* Re-enable when quota resets */
+          const resetsAt = quotaState?.resetsAt;
+          if (resetsAt) {
+            const delay = Math.max(0, resetsAt * 1000 - Date.now());
+            window.setTimeout(() => {
+              if (_sessionData?.kind !== "user" || !_sessionData?.mustChangePassword) {
+                composer?.setDisabled(false);
+                setFrameStatus("ONLINE", "online");
+              }
+            }, Math.min(delay + 1000, 24 * 3600 * 1000));
+          }
+        } else {
+          /* Temporary capacity 429 — use Retry-After or default 5 s */
+          const retryAfter = parseInt(response.headers.get("Retry-After") || "5", 10);
+          chat?.setMessageStreaming(assistantItem, false);
+          chat?.setMessageText(assistantItem, "Server busy. Retrying shortly…");
+          character?.setAttribute("state", "sad");
+          setFrameStatus("BUSY", "offline");
+          window.setTimeout(() => {
+            if (!activeController?.signal.aborted) {
+              setFrameStatus("ONLINE", "online");
+              composer?.setDisabled(false);
+            }
+          }, retryAfter * 1000);
+        }
+        return;
+      }
+
       if (!response.ok) {
         let message = `Turn failed (${response.status})`;
         try {
           const body = await response.json();
           message = body.error?.message || message;
-        } catch {
-          // Preserve the status-derived message.
-        }
+        } catch { /* keep */ }
         throw new Error(message);
       }
+
       await consumeSse(response, (eventName, data) => {
         shell?.dispatchEvent(new CustomEvent("mjj-jrpg-stream-event", {
           bubbles: true,
-          detail: {
-            data,
-            type: eventName,
-          },
+          detail: { data, type: eventName },
         }));
         if (eventName === "assistant.delta") {
           assistantText.append(data.delta || "");
@@ -1643,6 +3049,7 @@
           }
         }
       });
+
       if (!turnFinished) throw new Error("Inference stream ended unexpectedly");
       await assistantText.waitForIdle();
       if (activeController.signal.aborted) {
@@ -1651,6 +3058,13 @@
       chat?.setMessageStreaming(assistantItem, false);
       character?.setAttribute("state", "default");
       setFrameStatus("ONLINE", "online");
+
+      /* Refresh archive and quota after successful turn */
+      void refreshArchiveHead();
+      if (_sessionData?.kind === "guest") {
+        _csrfToken = await _fetchSession();
+        updateQuotaDisplay(quotaEl, _sessionData?.quota || null);
+      }
     } catch (error) {
       const aborted = error instanceof DOMException && error.name === "AbortError";
       assistantText.cancel();
@@ -1665,9 +3079,24 @@
       composer?.setBusy(false);
       composer?.querySelector("textarea")?.focus();
       activeController = null;
+      /* Re-enable archive/account mutations exactly once */
+      archive?.setStreamActive(false);
+      setAccountActionsDisabled(false);
+      /* Apply popstate that was queued during the stream (Fix 1) */
+      if (_pendingPopState) {
+        const pending = _pendingPopState;
+        _pendingPopState = null;
+        if (pending.seq === popSeq) {
+          await _applyPopState(pending.seq, pending.popPanel, pending.popConvId);
+        }
+      }
     }
   });
-  composer?.setDisabled(false);
+
+  /* Enable composer unless forced password change */
+  if (!isForced) {
+    composer?.setDisabled(false);
+  }
 }
 
 if (document.readyState === "loading") {