diff mrjunejune/src/jrpg/jrpg.js @ 272:41a49c29a28f

polish JRPG conversation experience Integrate desktop conversations into the utility panel, simplify the mobile frame, add modal destinations and a reusable Zenbu composer lab, and preserve explicit conversation resume behavior. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Fri, 07 Aug 2026 16:05:29 -0700
parents 056790c4fb0d
children e02e2036ef84
line wrap: on
line diff
--- a/mrjunejune/src/jrpg/jrpg.js	Fri Aug 07 13:24:05 2026 -0700
+++ b/mrjunejune/src/jrpg/jrpg.js	Fri Aug 07 16:05:29 2026 -0700
@@ -119,11 +119,15 @@
   return loading;
 }
 
-function greetingForSession(sessionData) {
-  if (sessionData?.kind !== "user") return ROLE_GREETINGS.guest;
-  return sessionData.role === "admin"
-    ? ROLE_GREETINGS.admin
-    : ROLE_GREETINGS.invited;
+function greetingForSession(sessionData, hasConversations = false) {
+  const greeting = sessionData?.kind !== "user"
+    ? ROLE_GREETINGS.guest
+    : sessionData.role === "admin"
+      ? ROLE_GREETINGS.admin
+      : ROLE_GREETINGS.invited;
+  return hasConversations
+    ? `${greeting} We’ve talked before. Open Conversations if you’d like to continue one.`
+    : greeting;
 }
 
 class StreamingTextAnimator {
@@ -315,14 +319,25 @@
   _createMessage(speaker, text, group, select) {
     if (!this._messages) return;
     const item = document.createElement("li");
+    const speakerCell = document.createElement("span");
     const name = document.createElement("strong");
     const copy = document.createElement("p");
     item.className = "jrpg-message";
     item.dataset.speaker = speaker;
     item.dataset.turnGroup = group;
+    speakerCell.className = "jrpg-message-speaker";
+    if (speaker === "Epi") {
+      const avatar = document.createElement("img");
+      avatar.className = "jrpg-message-avatar";
+      avatar.src = "/public/jrpg/shiba-default.webp";
+      avatar.alt = "";
+      avatar.setAttribute("aria-hidden", "true");
+      speakerCell.append(avatar);
+    }
     name.textContent = speaker;
     copy.textContent = text;
-    item.append(name, copy);
+    speakerCell.append(name);
+    item.append(speakerCell, copy);
     this._messages.append(item);
     this._refreshTurnGroups(select ? group : this._currentGroup);
     return item;
@@ -441,63 +456,6 @@
   }
 }
 
-class MjjJrpgComposer extends HTMLElement {
-  connectedCallback() {
-    this._form = this.querySelector("[data-composer]");
-    this._textarea = this.querySelector("textarea");
-    this._button = this.querySelector('button[type="submit"]');
-    this._cancel = this.querySelector("[data-cancel]");
-    this._cancelControl = this.querySelector("[data-cancel-control]");
-    this._onSubmit = event => {
-      event.preventDefault();
-      const message = this._textarea?.value.trim();
-      if (!message || this._button?.disabled) return;
-      this.dispatchEvent(new CustomEvent("mjj-jrpg-submit", {
-        bubbles: true,
-        detail: { message },
-      }));
-      this._form.reset();
-    };
-    this._onKeydown = event => {
-      if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
-      event.preventDefault();
-      this._form?.requestSubmit();
-    };
-    this._form?.addEventListener("submit", this._onSubmit);
-    this._textarea?.addEventListener("keydown", this._onKeydown);
-    this._onCancel = () => {
-      this.dispatchEvent(new CustomEvent("mjj-jrpg-cancel", {
-        bubbles: true,
-      }));
-    };
-    this._cancel?.addEventListener("click", this._onCancel);
-  }
-
-  disconnectedCallback() {
-    this._form?.removeEventListener("submit", this._onSubmit);
-    this._textarea?.removeEventListener("keydown", this._onKeydown);
-    this._cancel?.removeEventListener("click", this._onCancel);
-  }
-
-  setBusy(busy) {
-    if (this._textarea) this._textarea.disabled = busy;
-    if (this._button) {
-      this._button.disabled = busy;
-      this._button.setAttribute("aria-busy", String(busy));
-    }
-    if (this._cancelControl) this._cancelControl.hidden = !busy;
-  }
-
-  setCancellable(cancellable) {
-    if (this._cancelControl) this._cancelControl.hidden = !cancellable;
-  }
-
-  setDisabled(disabled) {
-    if (this._textarea) this._textarea.disabled = disabled;
-    if (this._button) this._button.disabled = disabled;
-  }
-}
-
 class MjjJrpgMenu extends HTMLElement {
   connectedCallback() {
     this._onClick = event => {
@@ -1790,41 +1748,122 @@
     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");
+    this._mobileDestinationOwner = this.querySelector(
+      "[data-mobile-destination-owner]",
+    );
+    this._mobileDestinationDialog = this.querySelector(
+      "[data-mobile-destination-dialog]",
+    );
+    this._mobileDestinationContent = this.querySelector(
+      "[data-mobile-destination-content]",
+    );
+    this._mobileDestinationTitle = this.querySelector(
+      "[data-mobile-destination-title]",
+    );
+    this._mobileDestinationBackOwner = this.querySelector(
+      "[data-mobile-destination-back-owner]",
+    );
+    this._mobileDestinationBack = this.querySelector(
+      "[data-mobile-destination-back]",
+    );
+    this._utility = this.querySelector(".jrpg-utility");
+    this._menu = this.querySelector("mjj-jrpg-menu");
+    this._utilityPlaceholder = document.createComment("jrpg-utility");
+    this._menuPlaceholder = document.createComment("jrpg-menu");
+    this._utility?.before(this._utilityPlaceholder);
+    this._menu?.before(this._menuPlaceholder);
+
+    this._syncMobileLayout = () => {
+      if (!this._utility || !this._menu || !this._mobileDestinationContent) {
         return;
       }
-      button.setAttribute("aria-expanded", String(expanded));
-      menu.toggleAttribute("data-mobile-menu-collapsed", !expanded);
+      if (this._mobileMenuQuery.matches) {
+        this._mobileDestinationContent.append(this._menu, this._utility);
+        this.showMobileMenu();
+        return;
+      }
+      this._mobileDestinationOwner?.close();
+      this._utilityPlaceholder.after(this._utility);
+      this._menuPlaceholder.after(this._menu);
+      this._utility.hidden = false;
+      this._menu.hidden = false;
     };
-    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._onMobileMenuMediaChange = () => this._syncMobileLayout();
+    this._onMobileDestinationBack = () => this.showMobileMenu();
+    this._onMobileDestinationClose = () => this.showMobileMenu();
+    this._mobileDestinationBack?.addEventListener(
+      "click",
+      this._onMobileDestinationBack,
+    );
+    this._mobileDestinationDialog?.addEventListener(
+      "close",
+      this._onMobileDestinationClose,
+    );
     this._mobileMenuQuery.addEventListener(
       "change",
       this._onMobileMenuMediaChange,
     );
-    this._syncMobileMenu(true);
+    this._syncMobileLayout();
+  }
+
+  isMobileDestinationModal() {
+    return Boolean(this._mobileMenuQuery?.matches);
+  }
+
+  isMobileDestinationOpen() {
+    return this.isMobileDestinationModal() &&
+      Boolean(this._mobileDestinationDialog?.open);
+  }
+
+  showMobileMenu() {
+    if (!this.isMobileDestinationModal() || !this._menu || !this._utility) {
+      return;
+    }
+    this._menu.hidden = false;
+    this._utility.hidden = true;
+    if (this._mobileDestinationTitle) {
+      this._mobileDestinationTitle.textContent = "Menu";
+    }
+    if (this._mobileDestinationBackOwner) {
+      this._mobileDestinationBackOwner.hidden = true;
+    }
+  }
+
+  showMobileDestination(selection) {
+    if (!this.isMobileDestinationModal() || !this._menu || !this._utility) {
+      return;
+    }
+    const labels = {
+      blog: "Blogs",
+      conversations: "Conversations",
+      resume: "Resume",
+      tools: "Tools",
+    };
+    this._menu.hidden = true;
+    this._utility.hidden = false;
+    if (this._mobileDestinationTitle) {
+      this._mobileDestinationTitle.textContent = labels[selection] || "Destination";
+    }
+    if (this._mobileDestinationBackOwner) {
+      this._mobileDestinationBackOwner.hidden = false;
+    }
+  }
+
+  closeMobileDestinations() {
+    if (this.isMobileDestinationModal()) {
+      this._mobileDestinationOwner?.close();
+    }
   }
 
   disconnectedCallback() {
-    this.removeEventListener("click", this._onMenuToggle);
+    this._mobileDestinationBack?.removeEventListener(
+      "click",
+      this._onMobileDestinationBack,
+    );
+    this._mobileDestinationDialog?.removeEventListener(
+      "close",
+      this._onMobileDestinationClose,
+    );
     this._mobileMenuQuery?.removeEventListener(
       "change",
       this._onMobileMenuMediaChange,
@@ -1836,7 +1875,6 @@
   "mjj-conversation-archive": MjjConversationArchive,
   "mjj-jrpg-character": MjjJrpgCharacter,
   "mjj-jrpg-chat": MjjJrpgChat,
-  "mjj-jrpg-composer": MjjJrpgComposer,
   "mjj-jrpg-menu": MjjJrpgMenu,
   "mjj-jrpg-preview": MjjJrpgPreview,
   "mjj-jrpg-shell": MjjJrpgShell,
@@ -2136,7 +2174,7 @@
   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 composer = shell?.querySelector("mjj-composer");
   const preview = shell?.querySelector("mjj-jrpg-preview");
   const archive = shell?.querySelector("mjj-conversation-archive");
   const accountSlot = shell?.querySelector("[data-frame-account]");
@@ -2453,6 +2491,7 @@
 
   /* ---- Panel selection: shows archive or preview in utility ---- */
   const utilityEl = shell?.querySelector(".jrpg-utility");
+  const previewContent = preview?.querySelector("[data-preview-content]");
 
   const selectPanel = (panel, pushHistory = true) => {
     const validPanel = VALID_PANELS.includes(panel) ? panel : "resume";
@@ -2462,11 +2501,11 @@
     }
     if (validPanel === "conversations") {
       if (archive) archive.hidden = false;
-      if (preview) preview.hidden = true;
+      if (previewContent) previewContent.hidden = true;
       if (utilityEl) utilityEl.setAttribute("aria-label", "Conversations");
     } else {
       if (archive) archive.hidden = true;
-      if (preview) preview.hidden = false;
+      if (previewContent) previewContent.hidden = false;
       preview?.show(validPanel);
       if (utilityEl) utilityEl.setAttribute("aria-label", "Selected destination");
     }
@@ -2475,6 +2514,10 @@
 
   /* ---- Archive close button: navigate back to resume panel ---- */
   shell?.addEventListener("mjj-archive-toggle", () => {
+    if (shell?.isMobileDestinationModal()) {
+      shell.showMobileMenu();
+      return;
+    }
     selectPanel("resume");
   });
 
@@ -2485,6 +2528,7 @@
     _pushUrlState(currentPanel, null);
     chat?.setGreeting(greetingForSession(_sessionData), true);
     archive?.setCurrentId(null);
+    shell?.closeMobileDestinations();
     composer?.querySelector("textarea")?.focus();
   });
 
@@ -2497,6 +2541,9 @@
     const epoch = principalEpoch;
 
     selectPanel(popPanel, false);
+    if (shell?.isMobileDestinationOpen()) {
+      shell.showMobileDestination(popPanel);
+    }
 
     if (popConvId && popConvId !== currentConversationId) {
       try {
@@ -2586,6 +2633,7 @@
       _pushUrlState(currentPanel, id);
       chat?.replaceMessages(conversation.turns);
       archive?.setCurrentId(id);
+      shell?.closeMobileDestinations();
     } catch (error) {
       if (seq !== openSeq || epoch !== principalEpoch) return;
       archive?.setError(`Unable to open: ${error.message}`);
@@ -2754,7 +2802,7 @@
     currentPanel = validPanel || "resume";
   }
 
-  /* ---- Load archive and restore conversation from URL or sessionStorage ---- */
+  /* ---- Load archive; only an explicit URL restores a conversation ---- */
   const startupEpoch = principalEpoch;
   const ensureStartupPrincipal = () => {
     if (startupEpoch !== principalEpoch) {
@@ -2772,33 +2820,26 @@
     conversations.forEach(c => _renderedIds.add(c.id));
     archive?.setConversations(conversations, archiveCursor);
 
-    /* Determine target conversation: URL param is canonical; migrate sessionStorage once */
+    /* A conversation opens only through an explicit URL or a user archive action. */
     const initParams2 = new URLSearchParams(window.location.search);
     const urlConvId = initParams2.get("conversation");
     const urlConvValid = urlConvId && UUID_REGEX.test(urlConvId) ? urlConvId : null;
+    const storedConvId = !_convParamWasExplicit
+      ? sessionStorage.getItem(CONVERSATION_STORAGE_KEY)
+      : 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 (urlConvValid) {
+      const inArchive = conversations.find(c => c.id === urlConvValid);
       if (inArchive) {
         try {
           const conversation = await requestJson(
-            `/api/conversations/${encodeURIComponent(resolvedConvId)}`,
+            `/api/conversations/${encodeURIComponent(urlConvValid)}`,
           );
           ensureStartupPrincipal();
-          currentConversationId = resolvedConvId;
-          sessionStorage.setItem(CONVERSATION_STORAGE_KEY, resolvedConvId);
+          currentConversationId = urlConvValid;
+          sessionStorage.setItem(CONVERSATION_STORAGE_KEY, urlConvValid);
           chat?.replaceMessages(conversation.turns);
-          archive?.setCurrentId(resolvedConvId);
+          archive?.setCurrentId(urlConvValid);
         } catch (error) {
           if (error.name === "StalePrincipalError" ||
               startupEpoch !== principalEpoch) throw error;
@@ -2810,7 +2851,7 @@
         let ownedConversation = null;
         try {
           ownedConversation = await requestJson(
-            `/api/conversations/${encodeURIComponent(resolvedConvId)}`,
+            `/api/conversations/${encodeURIComponent(urlConvValid)}`,
           );
           ensureStartupPrincipal();
         } catch (error) {
@@ -2819,68 +2860,39 @@
           ownedConversation = null;
         }
         if (ownedConversation) {
-          currentConversationId = resolvedConvId;
-          sessionStorage.setItem(CONVERSATION_STORAGE_KEY, resolvedConvId);
+          currentConversationId = urlConvValid;
+          sessionStorage.setItem(CONVERSATION_STORAGE_KEY, urlConvValid);
           chat?.replaceMessages(ownedConversation.turns);
-          if (!_renderedIds.has(resolvedConvId)) {
-            _renderedIds.add(resolvedConvId);
+          if (!_renderedIds.has(urlConvValid)) {
+            _renderedIds.add(urlConvValid);
             archive?.addConversation({
-              id: resolvedConvId,
+              id: urlConvValid,
               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);
-          }
+          archive?.setCurrentId(urlConvValid);
         } else {
-          /* Guest — not owned, clear stale ID */
           currentConversationId = null;
-          if (migratedFromStorage) sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
+          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)}`,
+    } else {
+      currentConversationId = null;
+      archive?.setCurrentId(null);
+      if (conversations.length > 0) {
+        chat?.setGreeting(greetingForSession(_sessionData, true), true);
+      }
+      if (storedConvId && UUID_REGEX.test(storedConvId)) {
+        const storedConversationIsOwned = conversations.some(
+          conversation => conversation.id === storedConvId,
         );
-        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;
+        if (_sessionData?.kind === "user" && !storedConversationIsOwned) {
+          sessionStorage.setItem(CONVERSATION_LEGACY_KEY, storedConvId);
+          legacyClaimId = storedConvId;
+          archive?.showLegacyClaim();
+        }
+        sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
       }
     }
   } catch (error) {
@@ -2888,14 +2900,9 @@
         startupEpoch === principalEpoch) {
       archive?.setError(`Archive error: ${error.message}`);
     }
-    /* Fall back to direct fetch of URL/stored ID */
+    /* Fall back only for an explicit deep-linked conversation. */
     const initParams3 = new URLSearchParams(window.location.search);
-    const fallbackId = (
-      initParams3.get("conversation") ||
-      (!_convParamWasExplicit
-        ? sessionStorage.getItem(CONVERSATION_STORAGE_KEY)
-        : null)
-    ) || null;
+    const fallbackId = initParams3.get("conversation");
     if (startupEpoch === principalEpoch &&
         fallbackId && UUID_REGEX.test(fallbackId)) {
       try {
@@ -2925,6 +2932,7 @@
 
   shell?.addEventListener("mjj-jrpg-preview-change", event => {
     selectPanel(event.detail.selection);
+    shell?.showMobileDestination(event.detail.selection);
     character?.setAttribute("state", "thinking");
     window.clearTimeout(characterTimer);
     characterTimer = window.setTimeout(() => {
@@ -2932,11 +2940,11 @@
     }, 650);
   });
 
-  shell?.addEventListener("mjj-jrpg-cancel", () => {
+  shell?.addEventListener("mjj-composer-cancel", () => {
     activeController?.abort();
   });
 
-  shell?.addEventListener("mjj-jrpg-submit", async event => {
+  shell?.addEventListener("mjj-composer-submit", async event => {
     if (activeController) return;
     /* Block sends when password change is required */
     if (_sessionData?.kind === "user" && _sessionData?.mustChangePassword) {