changeset 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 13d61401c57d
children e02e2036ef84
files design_system/src/components/icon.js mrjunejune/src/jrpg/index.html mrjunejune/src/jrpg/jrpg.css mrjunejune/src/jrpg/jrpg.js mrjunejune/src/public/composer-lab.html mrjunejune/src/public/jrpg/background-frame-mobile.webp mrjunejune/src/public/mjj-composer.css mrjunejune/src/public/mjj-composer.js mrjunejune/test/snapshots/jrpg.snapshot mrjunejune/test/theme_and_webp_test.js
diffstat 10 files changed, 1538 insertions(+), 857 deletions(-) [+]
line wrap: on
line diff
--- a/design_system/src/components/icon.js	Fri Aug 07 13:24:05 2026 -0700
+++ b/design_system/src/components/icon.js	Fri Aug 07 16:05:29 2026 -0700
@@ -4,10 +4,10 @@
   add: ["M12 5v14", "M5 12h14"],
   alert: ["M12 3 2.8 20h18.4L12 3Z", "M12 9v5", "M12 17h.01"],
   archive: ["M4 7h16v13H4Z", "M3 3h18v4H3Z", "M9 11h6"],
-  "arrow-down": ["M12 4v16", "m6 14-6 6-6-6"],
-  "arrow-left": ["M20 12H4", "m10 6-6-6 6-6"],
-  "arrow-right": ["M4 12h16", "m-6-6 6 6-6 6"],
-  "arrow-up": ["M12 20V4", "m-6 6 6-6 6 6"],
+  "arrow-down": ["M12 4v16", "M6 14l6 6 6-6"],
+  "arrow-left": ["M20 12H4", "M10 6l-6 6 6 6"],
+  "arrow-right": ["M4 12h16", "M14 6l6 6-6 6"],
+  "arrow-up": ["M12 20V4", "M6 10l6-6 6 6"],
   check: ["m5 12 4 4L19 6"],
   "chevron-down": ["m5 9 7 7 7-7"],
   "chevron-left": ["m15 5-7 7 7 7"],
--- a/mrjunejune/src/jrpg/index.html	Fri Aug 07 13:24:05 2026 -0700
+++ b/mrjunejune/src/jrpg/index.html	Fri Aug 07 16:05:29 2026 -0700
@@ -6,7 +6,9 @@
     <link rel="preload" href="/public/jrpg/background-frame-2.webp" as="image" media="not ((max-width: 52rem) and (orientation: portrait))">
     <link rel="preload" href="/public/jrpg/background-frame-mobile.webp" as="image" media="(max-width: 52rem) and (orientation: portrait)">
     <link rel="preload" href="/public/jrpg/bar-ink.webp" as="image">
+    <link rel="stylesheet" href="/public/mjj-composer.css">
     <link rel="stylesheet" href="/jrpg/jrpg.css">
+    <script type="module" src="/public/mjj-composer.js"></script>
     <script type="module" src="/jrpg/jrpg.js"></script>
   </head>
   <body class="jrpg-page">
@@ -47,17 +49,61 @@
               </a>
             </zen-button>
           </nav>
-          <zen-button class="jrpg-mobile-menu-btn" appearance="plain" size="xs">
-            <button
-              type="button"
-              data-mobile-menu-toggle
-              aria-label="Toggle destination menu"
-              aria-expanded="true"
-              aria-controls="jrpg-destination-menu"
+          <zen-dialog
+            class="jrpg-mobile-destination-owner"
+            data-mobile-destination-owner
+          >
+            <zen-button class="jrpg-mobile-menu-btn" appearance="plain" size="xs">
+              <button
+                type="button"
+                data-zen-trigger
+                data-mobile-menu-toggle
+                aria-label="Open destination menu"
+              >
+                <zen-icon name="menu"></zen-icon>
+              </button>
+            </zen-button>
+            <dialog
+              class="jrpg-mobile-destination-dialog"
+              data-mobile-destination-dialog
+              aria-labelledby="jrpg-mobile-destination-title"
             >
-              <zen-icon name="menu"></zen-icon>
-            </button>
-          </zen-button>
+              <header class="jrpg-mobile-destination-header">
+                <zen-button
+                  appearance="plain"
+                  size="sm"
+                  data-mobile-destination-back-owner
+                  hidden
+                >
+                  <button
+                    type="button"
+                    data-mobile-destination-back
+                    aria-label="Back to destination menu"
+                  >
+                    <zen-icon name="chevron-left"></zen-icon>
+                    Menu
+                  </button>
+                </zen-button>
+                <h2
+                  id="jrpg-mobile-destination-title"
+                  data-mobile-destination-title
+                >Menu</h2>
+                <zen-button appearance="plain" size="sm">
+                  <button
+                    type="button"
+                    data-zen-close
+                    aria-label="Close destination menu"
+                  >
+                    <zen-icon name="close"></zen-icon>
+                  </button>
+                </zen-button>
+              </header>
+              <div
+                class="jrpg-mobile-destination-content"
+                data-mobile-destination-content
+              ></div>
+            </dialog>
+          </zen-dialog>
           <footer class="jrpg-frame-telemetry" aria-label="System telemetry">
             <span aria-label="Temperature unavailable">N/A</span>
             <span data-frame-account aria-live="polite"><a href="/login?next=/jrpg">LOGIN</a></span>
@@ -70,10 +116,7 @@
               <img data-character alt="">
             </mjj-jrpg-character>
 
-            <mjj-jrpg-chat aria-labelledby="conversation-title">
-              <div class="jrpg-panel-heading">
-                <h1 id="conversation-title">Conversations</h1>
-              </div>
+            <mjj-jrpg-chat aria-label="Conversation">
               <zen-message-scroller>
                 <div
                   class="jrpg-message-viewport"
@@ -106,99 +149,98 @@
           <section class="jrpg-utility" aria-label="Selected destination">
             <mjj-jrpg-preview data-selection="resume">
               <article class="jrpg-preview-panel" aria-live="polite">
-                <zen-heading size="xl">
-                  <h2 data-preview-title>Resume</h2>
-                </zen-heading>
-                <p data-preview-copy>
-                  Member of Technical Staff and engineering leader with 10+ years building AI agent platforms and production systems.
-                </p>
-                <ul class="jrpg-work-showcase" data-work-showcase>
-                  <li>
-                    <a href="https://www.microsoft.com/en-us/microsoft-copilot/blog/2026/02/26/copilot-tasks-from-answers-to-actions/">
-                      <span>Copilot Tasks</span>
-                      <small>Agentic execution</small>
-                    </a>
-                  </li>
-                  <li>
-                    <a href="https://www.cio.com/article/3977098/microsoft-doubles-down-on-multi-model-ai-as-it-builds-a-copilot-super-app.html">
-                      <span>Copilot SuperApp</span>
-                      <small>AI platform</small>
-                    </a>
-                  </li>
-                </ul>
-                <zen-dialog class="jrpg-preview-dialog">
-                  <zen-button appearance="plain" size="xs" data-dialog-trigger-owner hidden>
-                    <button type="button" data-zen-trigger tabindex="-1" aria-hidden="true"></button>
-                  </zen-button>
-                  <dialog class="jrpg-detail-dialog" aria-labelledby="jrpg-dialog-title">
-                    <div class="jrpg-dialog-heading">
-                      <p>DESTINATION DATA</p>
-                      <div class="jrpg-dialog-heading-actions">
-                        <zen-button appearance="plain" size="md">
-                          <a href="/resume" data-preview-link>
-                            Open page
-                            <zen-icon name="external"></zen-icon>
+                <div class="jrpg-preview-content" data-preview-content>
+                  <zen-heading size="xl">
+                    <h2 data-preview-title>Resume</h2>
+                  </zen-heading>
+                  <p data-preview-copy>
+                    Member of Technical Staff and engineering leader with 10+ years building AI agent platforms and production systems.
+                  </p>
+                  <ul class="jrpg-work-showcase" data-work-showcase>
+                    <li>
+                      <a href="https://www.microsoft.com/en-us/microsoft-copilot/blog/2026/02/26/copilot-tasks-from-answers-to-actions/">
+                        <span>Copilot Tasks</span>
+                        <small>Agentic execution</small>
+                      </a>
+                    </li>
+                    <li>
+                      <a href="https://www.cio.com/article/3977098/microsoft-doubles-down-on-multi-model-ai-as-it-builds-a-copilot-super-app.html">
+                        <span>Copilot SuperApp</span>
+                        <small>AI platform</small>
+                      </a>
+                    </li>
+                  </ul>
+                  <zen-dialog class="jrpg-preview-dialog">
+                    <zen-button appearance="plain" size="xs" data-dialog-trigger-owner hidden>
+                      <button type="button" data-zen-trigger tabindex="-1" aria-hidden="true"></button>
+                    </zen-button>
+                    <dialog class="jrpg-detail-dialog" aria-labelledby="jrpg-dialog-title">
+                      <div class="jrpg-dialog-heading">
+                        <p>DESTINATION DATA</p>
+                        <div class="jrpg-dialog-heading-actions">
+                          <zen-button appearance="plain" size="md">
+                            <a href="/resume" data-preview-link>
+                              Open page
+                              <zen-icon name="external"></zen-icon>
+                            </a>
+                          </zen-button>
+                          <zen-button appearance="plain" size="md">
+                            <button type="button" data-zen-close aria-label="Close destination details">
+                              <zen-icon name="close"></zen-icon>
+                            </button>
+                          </zen-button>
+                        </div>
+                      </div>
+                      <h2 id="jrpg-dialog-title" data-dialog-title>Resume</h2>
+                      <p data-dialog-copy>Experience, projects, and the systems I have helped build.</p>
+                      <section
+                        class="jrpg-resume-dossier"
+                        data-resume-dossier
+                        aria-label="Resume dossier"
+                      >
+                        <p data-resume-status>Loading resume dossier...</p>
+                        <div data-resume-content></div>
+                      </section>
+                      <section
+                        class="jrpg-blog-browser"
+                        data-blog-browser
+                        aria-label="Blog archive"
+                        hidden
+                      >
+                        <aside>
+                          <h3>All blogs</h3>
+                          <ol data-blog-list>
+                            <li>Loading blog archive...</li>
+                          </ol>
+                        </aside>
+                        <article>
+                          <p data-blog-status>Select a blog to inspect.</p>
+                          <div data-blog-content></div>
+                        </article>
+                      </section>
+                      <section
+                        class="jrpg-tool-browser"
+                        data-tool-browser
+                        aria-label="Tool workspace"
+                        hidden
+                      >
+                        <article>
+                          <p data-tool-status>Select a tool to inspect.</p>
+                          <div data-tool-content></div>
+                        </article>
+                      </section>
+                      <div class="jrpg-dialog-actions" data-dialog-actions>
+                        <zen-button appearance="plain" size="lg" data-resume-download>
+                          <a href="/public/resume.pdf">
+                            Download PDF
+                            <zen-icon name="download"></zen-icon>
                           </a>
                         </zen-button>
-                        <zen-button appearance="plain" size="md">
-                          <button type="button" data-zen-close aria-label="Close destination details">
-                            <zen-icon name="close"></zen-icon>
-                          </button>
-                        </zen-button>
                       </div>
-                    </div>
-                    <h2 id="jrpg-dialog-title" data-dialog-title>Resume</h2>
-                    <p data-dialog-copy>Experience, projects, and the systems I have helped build.</p>
-                    <section
-                      class="jrpg-resume-dossier"
-                      data-resume-dossier
-                      aria-label="Resume dossier"
-                    >
-                      <p data-resume-status>Loading resume dossier...</p>
-                      <div data-resume-content></div>
-                    </section>
-                    <section
-                      class="jrpg-blog-browser"
-                      data-blog-browser
-                      aria-label="Blog archive"
-                      hidden
-                    >
-                      <aside>
-                        <h3>All blogs</h3>
-                        <ol data-blog-list>
-                          <li>Loading blog archive...</li>
-                        </ol>
-                      </aside>
-                      <article>
-                        <p data-blog-status>Select a blog to inspect.</p>
-                        <div data-blog-content></div>
-                      </article>
-                    </section>
-                    <section
-                      class="jrpg-tool-browser"
-                      data-tool-browser
-                      aria-label="Tool workspace"
-                      hidden
-                    >
-                      <article>
-                        <p data-tool-status>Select a tool to inspect.</p>
-                        <div data-tool-content></div>
-                      </article>
-                    </section>
-                    <div class="jrpg-dialog-actions" data-dialog-actions>
-                      <zen-button appearance="plain" size="lg" data-resume-download>
-                        <a href="/public/resume.pdf">
-                          Download PDF
-                          <zen-icon name="download"></zen-icon>
-                        </a>
-                      </zen-button>
-                    </div>
-                  </dialog>
-                </zen-dialog>
-              </article>
-            </mjj-jrpg-preview>
-
-            <mjj-conversation-archive hidden aria-label="Conversations">
+                    </dialog>
+                  </zen-dialog>
+                </div>
+                <mjj-conversation-archive hidden aria-label="Conversations">
               <div class="jrpg-archive-heading">
                 <h2>Archive</h2>
                 <zen-button appearance="plain" size="xs">
@@ -267,40 +309,62 @@
                   </div>
                 </form>
               </dialog>
-            </mjj-conversation-archive>
+                </mjj-conversation-archive>
+              </article>
+            </mjj-jrpg-preview>
           </section>
 
-          <mjj-jrpg-composer>
+          <mjj-composer compact>
             <form class="jrpg-composer" data-composer>
-              <zen-field appearance="plain" size="lg">
-                <label for="jrpg-message">Message</label>
-                <textarea
-                  id="jrpg-message"
-                  name="message"
-                  rows="3"
-                  maxlength="500"
-                  placeholder="Ask Epi about this site..."
-                  required
-                ></textarea>
-              </zen-field>
-              <div class="jrpg-composer-actions">
-                <zen-button appearance="plain" size="sm">
-                  <button type="submit">
-                    Send
-                    <zen-icon name="arrow-right"></zen-icon>
-                  </button>
-                </zen-button>
-                <zen-button appearance="plain" size="sm" data-cancel-control hidden>
-                  <button type="button" data-cancel>
-                    Cancel
-                    <zen-icon name="close"></zen-icon>
-                  </button>
-                </zen-button>
+              <div data-composer-control>
+                <zen-field size="lg">
+                  <label for="jrpg-message" data-composer-label>Message</label>
+                  <textarea
+                    id="jrpg-message"
+                    name="message"
+                    rows="3"
+                    maxlength="500"
+                    placeholder="Ask Epi about this site..."
+                    required
+                  ></textarea>
+                </zen-field>
+                <div class="jrpg-composer-actions" data-composer-actions>
+                  <zen-button size="sm">
+                    <button type="submit" aria-label="Send message">
+                      <span data-composer-action-label>Send</span>
+                      <zen-icon name="arrow-right"></zen-icon>
+                    </button>
+                  </zen-button>
+                  <zen-button
+                    size="sm"
+                    variant="danger"
+                    data-composer-cancel-owner
+                    hidden
+                  >
+                    <button
+                      type="button"
+                      data-composer-cancel
+                      aria-label="Cancel response"
+                    >
+                      <span data-composer-action-label>Cancel</span>
+                      <zen-icon name="close"></zen-icon>
+                    </button>
+                  </zen-button>
+                </div>
               </div>
-              <p class="jrpg-composer-quota" data-quota aria-live="polite" hidden></p>
-              <p class="jrpg-composer-hint">Enter to send / Shift + Enter for a new line</p>
+              <p
+                class="jrpg-composer-quota"
+                data-composer-meta
+                data-quota
+                aria-live="polite"
+                hidden
+              ></p>
+              <p
+                class="jrpg-composer-hint"
+                data-composer-meta
+              >Enter to send / Shift + Enter for a new line</p>
             </form>
-          </mjj-jrpg-composer>
+          </mjj-composer>
 
           <mjj-jrpg-menu id="jrpg-destination-menu">
             <nav aria-label="Shiba Quest destinations">
--- a/mrjunejune/src/jrpg/jrpg.css	Fri Aug 07 13:24:05 2026 -0700
+++ b/mrjunejune/src/jrpg/jrpg.css	Fri Aug 07 16:05:29 2026 -0700
@@ -41,6 +41,7 @@
   --zenbu-sys-font-family-ui: "Pixel Mplus";
   --zenbu-sys-font-family-content: "Pixel Mplus";
   --zenbu-sys-font-family-code: "Pixel Mplus";
+  --zenbu-sys-type-label-family: "Pixel Mplus";
   min-width: 20rem;
   min-height: 100dvh;
   overflow: hidden;
@@ -254,7 +255,7 @@
 .jrpg-shell[data-minimized="true"] .jrpg-workspace > :is(
   .jrpg-scene,
   .jrpg-utility,
-  mjj-jrpg-composer,
+  mjj-composer,
   mjj-jrpg-menu
 ) {
   visibility: hidden;
@@ -262,7 +263,7 @@
 
 .jrpg-workspace > .jrpg-scene,
 .jrpg-workspace > .jrpg-utility,
-.jrpg-workspace > mjj-jrpg-composer,
+.jrpg-workspace > mjj-composer,
 .jrpg-workspace > mjj-jrpg-menu {
   position: absolute;
   margin: 0;
@@ -291,7 +292,7 @@
   height: var(--mjj-frame-utility-height);
 }
 
-.jrpg-workspace > mjj-jrpg-composer {
+.jrpg-workspace > mjj-composer {
   inset:
     var(--mjj-frame-composer-block)
     auto
@@ -313,7 +314,7 @@
 
 .jrpg-scene,
 .jrpg-utility,
-mjj-jrpg-composer,
+mjj-composer,
 mjj-jrpg-menu,
 .jrpg-preview-panel {
   box-sizing: border-box;
@@ -393,10 +394,11 @@
 }
 
 mjj-jrpg-chat {
+  box-sizing: border-box;
   position: relative;
   z-index: 1;
   display: grid;
-  grid-template-rows: auto minmax(0, 1fr) auto;
+  grid-template-rows: minmax(0, 1fr) auto;
   align-self: stretch;
   min-height: 0;
   padding: var(--zenbu-sys-padding-md);
@@ -407,15 +409,6 @@
     var(--mjj-jrpg-info);
 }
 
-.jrpg-panel-heading {
-  display: flex;
-  align-items: baseline;
-  justify-content: space-between;
-  gap: var(--zenbu-sys-space-group);
-  padding-bottom: var(--zenbu-sys-padding-sm);
-  border-bottom: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft);
-}
-
 .jrpg-menu-label,
 .jrpg-brand > p,
 .jrpg-dialog-heading p {
@@ -424,12 +417,6 @@
   letter-spacing: 0.08em;
 }
 
-.jrpg-panel-heading h1 {
-  margin: 0;
-  color: var(--mjj-jrpg-text);
-  font-size: var(--zenbu-sys-font-size-lg);
-}
-
 zen-message-scroller {
   display: block;
   overflow: hidden;
@@ -458,12 +445,27 @@
 
 .jrpg-message {
   display: grid;
-  grid-template-columns: minmax(4rem, auto) 1fr;
+  grid-template-columns: minmax(4rem, auto) minmax(0, 1fr);
   gap: var(--zenbu-sys-space-control);
   padding-left: var(--zenbu-sys-padding-sm);
   border-left: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-accent);
 }
 
+.jrpg-message-speaker {
+  display: flex;
+  align-items: center;
+  gap: var(--zenbu-sys-space-icon-label);
+  align-self: start;
+}
+
+.jrpg-message-avatar {
+  width: var(--zenbu-sys-control-height-sm);
+  height: var(--zenbu-sys-control-height-sm);
+  flex: none;
+  object-fit: contain;
+  image-rendering: pixelated;
+}
+
 .jrpg-message[hidden] {
   display: none;
 }
@@ -511,13 +513,23 @@
 
 .jrpg-turn-navigation {
   display: grid;
-  grid-template-columns: auto minmax(0, 1fr) auto;
+  grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
   align-items: center;
   gap: var(--zenbu-sys-space-icon-label);
   padding-top: var(--zenbu-sys-padding-sm);
   border-top: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft);
 }
 
+.jrpg-turn-navigation zen-button:first-child button {
+  justify-content: flex-start;
+  padding-inline-start: var(--zenbu-sys-padding-md);
+}
+
+.jrpg-turn-navigation zen-button:last-child button {
+  justify-content: flex-end;
+  padding-inline-end: var(--zenbu-sys-padding-md);
+}
+
 .jrpg-turn-navigation zen-button,
 .jrpg-turn-navigation button {
   width: 100%;
@@ -561,11 +573,8 @@
   overflow: hidden;
 }
 
-.jrpg-utility > mjj-jrpg-preview,
-.jrpg-utility > mjj-conversation-archive {
+.jrpg-utility > mjj-jrpg-preview {
   box-sizing: border-box;
-  grid-column: 1;
-  grid-row: 1;
   width: 100%;
   height: 100%;
   min-height: 0;
@@ -623,9 +632,7 @@
 }
 
 .jrpg-preview-panel {
-  display: grid;
-  grid-template-rows: auto auto minmax(0, 1fr) auto;
-  gap: var(--zenbu-sys-space-group);
+  display: block;
   height: 100%;
   padding: var(--zenbu-sys-padding-lg);
   background:
@@ -639,6 +646,24 @@
     var(--mjj-jrpg-accent);
 }
 
+.jrpg-preview-panel > :is(.jrpg-preview-content, mjj-conversation-archive) {
+  box-sizing: border-box;
+  width: 100%;
+  height: 100%;
+  min-width: 0;
+  min-height: 0;
+}
+
+.jrpg-preview-content {
+  display: grid;
+  grid-template-rows: auto auto minmax(0, 1fr) auto;
+  gap: var(--zenbu-sys-space-group);
+}
+
+.jrpg-preview-content[hidden] {
+  display: none;
+}
+
 .jrpg-preview-panel h2,
 .jrpg-preview-panel p {
   margin: 0;
@@ -648,7 +673,7 @@
   color: var(--mjj-jrpg-primary);
 }
 
-.jrpg-preview-panel > p {
+.jrpg-preview-content > p {
   color: var(--mjj-jrpg-text);
 }
 
@@ -1338,7 +1363,7 @@
   text-decoration-color: var(--mjj-jrpg-accent);
 }
 
-mjj-jrpg-composer {
+mjj-composer {
   grid-area: composer;
   display: block;
   min-height: 0;
@@ -1346,55 +1371,6 @@
   background: var(--mjj-jrpg-surface);
 }
 
-.jrpg-composer {
-  display: grid;
-  grid-template-columns: minmax(0, 1fr) auto;
-  grid-template-rows: minmax(0, 1fr) auto;
-  gap: var(--zenbu-sys-space-control);
-  height: 100%;
-  min-height: 0;
-}
-
-.jrpg-composer zen-field[appearance="plain"] {
-  display: grid;
-  grid-column: 1;
-  grid-row: 1;
-  grid-template-rows: auto minmax(0, 1fr);
-  gap: var(--zenbu-sys-space-icon-label);
-  min-height: 0;
-}
-
-.jrpg-composer label,
-.jrpg-composer-hint {
-  grid-column: 1 / -1;
-  grid-row: 2;
-  color: var(--mjj-jrpg-text-muted);
-  font-size: var(--zenbu-sys-font-size-xs);
-}
-
-.jrpg-composer zen-field label {
-  grid-column: 1;
-  grid-row: 1;
-}
-
-.jrpg-composer textarea {
-  box-sizing: border-box;
-  grid-column: 1;
-  grid-row: 2;
-  width: 100%;
-  height: 100%;
-  min-height: calc(var(--zenbu-control-height) * 2);
-  resize: none;
-  padding:
-    var(--zenbu-control-padding-block)
-    var(--zenbu-control-padding-inline);
-  border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-frame);
-  border-radius: 0;
-  background: var(--mjj-jrpg-surface-sunken);
-  color: var(--mjj-jrpg-text);
-  font: inherit;
-}
-
 .jrpg-composer textarea::placeholder {
   color: var(--mjj-jrpg-text-muted);
 }
@@ -1404,51 +1380,6 @@
   outline-offset: var(--zenbu-sys-focus-offset);
 }
 
-.jrpg-composer-actions {
-  display: flex;
-  grid-column: 2;
-  grid-row: 1;
-  flex-direction: column;
-  justify-content: flex-end;
-  gap: var(--zenbu-sys-space-control);
-}
-
-.jrpg-composer zen-button {
-  width: 100%;
-}
-
-.jrpg-composer zen-button[hidden] {
-  display: none;
-}
-
-.jrpg-composer zen-button > button {
-  width: 100%;
-  border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-frame);
-  border-radius: 0;
-  background: var(--mjj-jrpg-primary);
-  color: var(--mjj-jrpg-primary-foreground);
-  cursor: pointer;
-}
-
-.jrpg-composer zen-button > button:hover {
-  border-color: var(--mjj-jrpg-primary-hover);
-  background: var(--mjj-jrpg-primary-hover);
-}
-
-.jrpg-composer [data-cancel-control] > button {
-  border-color: var(--mjj-jrpg-accent);
-  background: var(--mjj-jrpg-accent);
-}
-
-.jrpg-composer button:disabled {
-  opacity: var(--zenbu-sys-opacity-disabled);
-  cursor: not-allowed;
-}
-
-.jrpg-composer-hint {
-  margin: 0;
-}
-
 mjj-jrpg-menu {
   grid-area: menu;
   display: block;
@@ -1514,12 +1445,11 @@
 .jrpg-workspace > :is(
   .jrpg-scene,
   .jrpg-utility,
-  mjj-jrpg-composer,
+  mjj-composer,
   mjj-jrpg-menu
 ),
 .jrpg-scene > mjj-jrpg-chat,
-.jrpg-utility > mjj-jrpg-preview > .jrpg-preview-panel,
-.jrpg-utility > mjj-conversation-archive {
+.jrpg-utility > mjj-jrpg-preview > .jrpg-preview-panel {
   border: 0;
   background-color: transparent;
   box-shadow: none;
@@ -1529,23 +1459,20 @@
   background-image: none;
 }
 
-.jrpg-workspace > mjj-jrpg-composer,
+.jrpg-workspace > mjj-composer,
 .jrpg-workspace > mjj-jrpg-menu {
   padding: var(--zenbu-sys-padding-sm);
 }
 
-.jrpg-workspace > mjj-jrpg-composer textarea {
+.jrpg-workspace > mjj-composer textarea {
   min-height: 0;
 }
 
-.jrpg-scene .jrpg-panel-heading,
 .jrpg-scene .jrpg-message,
 .jrpg-scene .jrpg-turn-navigation,
 .jrpg-brand,
 .jrpg-work-showcase a,
-.jrpg-preview-panel > .jrpg-preview-dialog > zen-button > button,
-mjj-jrpg-composer > .jrpg-composer textarea,
-mjj-jrpg-composer > .jrpg-composer zen-button > button,
+.jrpg-preview-content > .jrpg-preview-dialog > zen-button > button,
 mjj-jrpg-menu nav button {
   border: 0;
 }
@@ -1590,7 +1517,7 @@
 
   .jrpg-workspace > .jrpg-scene,
   .jrpg-workspace > .jrpg-utility,
-  .jrpg-workspace > mjj-jrpg-composer,
+  .jrpg-workspace > mjj-composer,
   .jrpg-workspace > mjj-jrpg-menu,
   .jrpg-workspace > .jrpg-brand,
   .jrpg-workspace > .jrpg-frame-controls,
@@ -1853,27 +1780,20 @@
 }
 
 mjj-conversation-archive {
-  position: relative;
-  z-index: 1;
   display: grid;
   grid-template-rows: auto auto minmax(0, 1fr) auto auto;
   align-self: stretch;
   min-height: 0;
-  padding: var(--zenbu-sys-padding-md);
+  padding: 0;
   overflow: hidden;
-  background: var(--mjj-jrpg-panel-strong);
-  box-shadow: inset 0 0 0 var(--zenbu-sys-stroke-width) var(--mjj-jrpg-info);
+  background: transparent;
+  box-shadow: none;
 }
 
 mjj-conversation-archive[hidden] {
   display: none;
 }
 
-.jrpg-utility > mjj-conversation-archive {
-  border: 0;
-  box-shadow: inset 0 0 0 var(--zenbu-sys-stroke-width) var(--mjj-jrpg-info);
-}
-
 .jrpg-archive-heading {
   display: flex;
   align-items: baseline;
@@ -2214,6 +2134,14 @@
   display: none;
 }
 
+.jrpg-mobile-destination-owner {
+  display: contents;
+}
+
+.jrpg-page zen-button[hidden] {
+  display: none;
+}
+
 /* ================================================================
    Mobile portrait frame layout (≤52rem + portrait)
    Overrides the generic stacked grid from the 52rem breakpoint.
@@ -2225,50 +2153,22 @@
     /* ── Aperture geometry: (inline=left%, block=top%, width%, height%) ── */
 
     /* Main conversation */
-    --mjj-mobile-scene-inline:  4.5%;
-    --mjj-mobile-scene-block:   7.5%;
-    --mjj-mobile-scene-width:  91%;
-    --mjj-mobile-scene-height: 45.5%;
-
-    /* Preview / utility */
-    --mjj-mobile-utility-inline:  4.5%;
-    --mjj-mobile-utility-block:  54.7%;
-    --mjj-mobile-utility-width:  91%;
-    --mjj-mobile-utility-height: 17.1%;
+    --mjj-mobile-scene-inline:  4.8%;
+    --mjj-mobile-scene-block:   7.3%;
+    --mjj-mobile-scene-width:  90.4%;
+    --mjj-mobile-scene-height: 71.2%;
 
     /* Composer */
-    --mjj-mobile-composer-inline:  4.5%;
-    --mjj-mobile-composer-block:  72.8%;
-    --mjj-mobile-composer-width:  60.3%;
-    --mjj-mobile-composer-height: 19.2%;
-
-    /* Menu */
-    --mjj-mobile-menu-inline:  66.5%;
-    --mjj-mobile-menu-block:   72.8%;
-    --mjj-mobile-menu-width:   29%;
-    --mjj-mobile-menu-height:  19.2%;
-
-    /* Brand / SYS-01 cover */
-    --mjj-mobile-brand-inline:  11%;
-    --mjj-mobile-brand-block:    2.4%;
-    --mjj-mobile-brand-width:   10%;
-    --mjj-mobile-brand-height:   3.5%;
+    --mjj-mobile-composer-inline:  4.8%;
+    --mjj-mobile-composer-block:  79.6%;
+    --mjj-mobile-composer-width:  90.4%;
+    --mjj-mobile-composer-height: 17.7%;
 
     /* Baked hamburger overlay */
-    --mjj-mobile-hamburger-inline: 89.5%;
-    --mjj-mobile-hamburger-block:   2%;
-    --mjj-mobile-hamburger-width:   7%;
-    --mjj-mobile-hamburger-height:  4%;
-
-    /* Telemetry value absolute positions (x = left-center, y = top-center) */
-    --mjj-mobile-telemetry-temp-x:   22%;
-    --mjj-mobile-telemetry-temp-y:   94.7%;
-    --mjj-mobile-telemetry-user-x:   45%;
-    --mjj-mobile-telemetry-user-y:   94.7%;
-    --mjj-mobile-telemetry-status-x: 73%;
-    --mjj-mobile-telemetry-status-y: 94.7%;
-    --mjj-mobile-telemetry-uptime-x: 22%;
-    --mjj-mobile-telemetry-uptime-y: 97.5%;
+    --mjj-mobile-hamburger-inline: 87.2%;
+    --mjj-mobile-hamburger-block:   1.3%;
+    --mjj-mobile-hamburger-width:   9.4%;
+    --mjj-mobile-hamburger-height:  5.3%;
 
     height: 100dvh;
     min-height: 0;
@@ -2303,12 +2203,9 @@
     display: none;
   }
 
-  /* Restore absolute positioning for all aperture elements */
+  /* Restore absolute positioning for the two frame apertures */
   .jrpg-workspace > .jrpg-scene,
-  .jrpg-workspace > .jrpg-utility,
-  .jrpg-workspace > mjj-jrpg-composer,
-  .jrpg-workspace > mjj-jrpg-menu,
-  .jrpg-workspace > .jrpg-brand {
+  .jrpg-workspace > mjj-composer {
     position: absolute;
     width: auto;
     height: auto;
@@ -2331,26 +2228,8 @@
     );
   }
 
-  /* Utility / preview aperture */
-  .jrpg-workspace > .jrpg-utility {
-    inset:
-      var(--mjj-mobile-utility-block)
-      auto
-      auto
-      var(--mjj-mobile-utility-inline);
-    width: var(--mjj-mobile-utility-width);
-    height: var(--mjj-mobile-utility-height);
-    min-height: 0;
-    overflow: hidden;
-    font-size: clamp(
-      var(--zenbu-sys-font-size-xs),
-      2.8vw,
-      var(--zenbu-sys-font-size-sm)
-    );
-  }
-
   /* Composer aperture */
-  .jrpg-workspace > mjj-jrpg-composer {
+  .jrpg-workspace > mjj-composer {
     inset:
       var(--mjj-mobile-composer-block)
       auto
@@ -2365,39 +2244,39 @@
     );
   }
 
-  /* Menu aperture */
-  .jrpg-workspace > mjj-jrpg-menu {
-    inset:
-      var(--mjj-mobile-menu-block)
-      auto
-      auto
-      var(--mjj-mobile-menu-inline);
-    width: var(--mjj-mobile-menu-width);
-    height: var(--mjj-mobile-menu-height);
-    overflow: hidden;
-    font-size: clamp(
-      var(--zenbu-sys-font-size-xs),
-      2.5vw,
-      var(--zenbu-sys-font-size-sm)
-    );
+  .jrpg-workspace > :is(.jrpg-utility, mjj-jrpg-menu) {
+    display: none;
   }
 
-  /* Brand cover (covers baked SYS-01 label) */
   .jrpg-workspace > .jrpg-brand {
-    inset:
-      var(--mjj-mobile-brand-block)
-      auto
-      auto
-      var(--mjj-mobile-brand-inline);
-    width: var(--mjj-mobile-brand-width);
-    height: var(--mjj-mobile-brand-height);
-    background: var(--mjj-jrpg-canvas);
+    position: absolute;
+    z-index: 4;
+    inset: 1.3% auto auto 50%;
+    display: grid;
+    place-items: center;
+    width: max-content;
+    max-width: 68%;
+    height: 5.3%;
     padding: 0;
+    border: 0;
+    background: transparent;
     box-shadow: none;
+    text-align: center;
+    transform: translateX(-50%);
+  }
+
+  body.jrpg-page .jrpg-brand header {
+    width: 100%;
+    text-align: center;
+  }
+
+  body.jrpg-page .jrpg-brand header h1 {
+    font-size: var(--zenbu-sys-font-size-sm);
+    text-shadow: none;
   }
 
   /* Hamburger button: transparent overlay over baked art */
-  .jrpg-workspace > .jrpg-mobile-menu-btn {
+  .jrpg-mobile-destination-owner > .jrpg-mobile-menu-btn {
     display: grid;
     place-items: center;
     position: absolute;
@@ -2438,73 +2317,107 @@
     opacity: 0.5;
   }
 
-  /* Telemetry: full-workspace container so children use workspace % coords */
   .jrpg-workspace > .jrpg-frame-telemetry {
-    position: absolute;
-    inset: 0;
-    display: block;
-    width: 100%;
-    height: 100%;
-    background: transparent;
-    pointer-events: none;
-    font-size: clamp(
-      var(--zenbu-sys-font-size-xs),
-      2.5vw,
-      var(--zenbu-sys-font-size-sm)
-    );
+    display: none;
+  }
+
+  .jrpg-mobile-destination-dialog {
+    box-sizing: border-box;
+    width: calc(100% - var(--zenbu-sys-space-content));
+    max-width: 42rem;
+    height: min(88dvh, 48rem);
+    max-height: calc(100dvh - var(--zenbu-sys-space-content));
+    margin: auto;
+    padding: var(--zenbu-sys-padding-md);
+    border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-frame);
+    border-radius: 0;
+    background: var(--mjj-jrpg-surface);
+    color: var(--mjj-jrpg-text);
+    box-shadow:
+      inset 0 0 0 var(--zenbu-sys-stroke-width) var(--mjj-jrpg-accent),
+      var(--zenbu-sys-space-control) var(--zenbu-sys-space-control) 0
+        var(--mjj-jrpg-accent);
   }
 
-  .jrpg-frame-telemetry > :nth-child(1) {
-    position: absolute;
-    left: var(--mjj-mobile-telemetry-temp-x);
-    top: var(--mjj-mobile-telemetry-temp-y);
-    transform: translate(-50%, -50%);
-    background: transparent;
-    padding-inline: 0;
-    pointer-events: auto;
-    white-space: nowrap;
+  .jrpg-mobile-destination-dialog[open] {
+    display: grid;
+    grid-template-rows: auto minmax(0, 1fr);
+    gap: var(--zenbu-sys-space-group);
+  }
+
+  .jrpg-mobile-destination-dialog::backdrop {
+    background: color-mix(in srgb, var(--mjj-jrpg-canvas) 82%, transparent);
+  }
+
+  .jrpg-mobile-destination-header {
+    display: grid;
+    grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
+    align-items: center;
+    gap: var(--zenbu-sys-space-control);
+    padding-bottom: var(--zenbu-sys-padding-sm);
+    border-bottom: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft);
+  }
+
+  .jrpg-mobile-destination-header h2 {
+    margin: 0;
+    color: var(--mjj-jrpg-primary);
+    font-size: var(--zenbu-sys-font-size-lg);
+    text-align: center;
   }
 
-  .jrpg-frame-telemetry > :nth-child(2) {
-    position: absolute;
-    left: var(--mjj-mobile-telemetry-user-x);
-    top: var(--mjj-mobile-telemetry-user-y);
-    transform: translate(-50%, -50%);
+  .jrpg-mobile-destination-header zen-button:first-child {
+    justify-self: start;
+  }
+
+  .jrpg-mobile-destination-header zen-button:last-child {
+    justify-self: end;
+  }
+
+  .jrpg-mobile-destination-header button {
+    border: 0;
+    border-radius: 0;
     background: transparent;
-    padding-inline: 0;
-    pointer-events: auto;
-    white-space: nowrap;
+    color: var(--mjj-jrpg-text);
+    cursor: pointer;
+  }
+
+  .jrpg-mobile-destination-content {
+    min-width: 0;
+    min-height: 0;
+    overflow: hidden;
   }
 
-  .jrpg-frame-telemetry > :nth-child(3) {
-    position: absolute;
-    left: var(--mjj-mobile-telemetry-status-x);
-    top: var(--mjj-mobile-telemetry-status-y);
-    transform: translate(-50%, -50%);
-    background: transparent;
-    padding-inline: 0;
-    pointer-events: auto;
-    white-space: nowrap;
+  .jrpg-mobile-destination-content > :is(mjj-jrpg-menu, .jrpg-utility) {
+    box-sizing: border-box;
+    position: static;
+    width: 100%;
+    height: 100%;
+    min-width: 0;
+    min-height: 0;
+  }
+
+  .jrpg-mobile-destination-content > [hidden],
+  [data-mobile-destination-back-owner][hidden] {
+    display: none;
   }
 
-  .jrpg-frame-telemetry > :nth-child(4) {
-    position: absolute;
-    left: var(--mjj-mobile-telemetry-uptime-x);
-    top: var(--mjj-mobile-telemetry-uptime-y);
-    transform: translate(-50%, -50%);
-    background: transparent;
-    padding-inline: 0;
-    pointer-events: auto;
-    white-space: nowrap;
+  .jrpg-mobile-destination-content > mjj-jrpg-menu {
+    display: block;
+    padding: var(--zenbu-sys-padding-md);
+    overflow-y: auto;
   }
 
-  /* Compact the brand header text for the narrow cover area */
-  .jrpg-workspace > .jrpg-brand header h1 {
-    font-size: clamp(
-      var(--zenbu-sys-font-size-xs),
-      2vw,
-      var(--zenbu-sys-font-size-xs)
-    );
+  .jrpg-mobile-destination-content > .jrpg-utility {
+    display: grid;
+    padding: 0;
+    border: 0;
+    overflow: hidden;
+    background: transparent;
+    box-shadow: none;
+  }
+
+  .jrpg-mobile-destination-content .jrpg-archive-heading {
+    display: none;
   }
 
   /* Compact scene internals for mobile aperture */
@@ -2520,6 +2433,10 @@
     display: none;
   }
 
+  .jrpg-scene::before {
+    display: none;
+  }
+
   mjj-jrpg-chat {
     grid-column: 1;
     grid-row: 1;
@@ -2530,35 +2447,22 @@
     padding: var(--zenbu-sys-padding-sm);
   }
 
-  .jrpg-utility > mjj-conversation-archive {
+  .jrpg-preview-panel > mjj-conversation-archive {
     width: 100%;
     height: 100%;
     max-height: 100%;
     overflow: auto;
   }
 
-  /* Utility compact padding */
-  .jrpg-utility {
-    padding: var(--zenbu-sys-padding-sm);
-  }
-
   /* Composer: compact single-row within aperture */
-  .jrpg-composer {
-    gap: var(--zenbu-sys-space-icon-label);
-    padding: var(--zenbu-sys-padding-sm);
+  .jrpg-workspace > mjj-composer {
+    padding: var(--zenbu-sys-space-icon-label);
   }
 
   .jrpg-composer-hint {
     display: none;
   }
 
-  /* Menu compact: single column stacking */
-  .jrpg-workspace > mjj-jrpg-menu {
-    padding: var(--zenbu-sys-padding-xs, var(--zenbu-sys-padding-sm));
-    overflow-y: auto;
-    scrollbar-width: thin;
-  }
-
   mjj-jrpg-menu nav {
     height: 100%;
     grid-template-columns: 1fr;
@@ -2580,36 +2484,21 @@
     font-size: var(--zenbu-sys-font-size-xs);
   }
 
-  mjj-jrpg-menu[data-mobile-menu-collapsed] {
-    overflow: hidden;
-  }
-
-  mjj-jrpg-menu[data-mobile-menu-collapsed] nav {
-    display: grid;
-    place-items: center;
+  /* Preview panel compact for narrow utility aperture */
+  .jrpg-preview-panel {
+    padding: var(--zenbu-sys-padding-sm);
+    overflow-y: auto;
   }
 
-  mjj-jrpg-menu[data-mobile-menu-collapsed] .jrpg-menu-label {
-    display: block;
-    writing-mode: initial;
-  }
-
-  mjj-jrpg-menu[data-mobile-menu-collapsed] ul {
-    display: none;
-  }
-
-  /* Preview panel compact for narrow utility aperture */
-  .jrpg-preview-panel {
+  .jrpg-preview-content {
     gap: var(--zenbu-sys-space-icon-label);
-    padding: var(--zenbu-sys-padding-sm);
-    overflow-y: auto;
   }
 
   .jrpg-preview-panel h2 {
     font-size: var(--zenbu-sys-font-size-sm);
   }
 
-  .jrpg-preview-panel > p {
+  .jrpg-preview-content > p {
     font-size: var(--zenbu-sys-font-size-xs);
     display: -webkit-box;
     -webkit-line-clamp: 2;
--- 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) {
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/src/public/composer-lab.html	Fri Aug 07 16:05:29 2026 -0700
@@ -0,0 +1,119 @@
+<!doctype html>
+<html lang="en" data-zen-theme="cyberpunk">
+  <head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Composer Lab | MrJuneJune</title>
+    <link rel="stylesheet" href="/public/design-system/styles/tokens.css">
+    <link rel="stylesheet" href="/public/design-system/styles/themes.css">
+    <link rel="stylesheet" href="/public/design-system/styles/components.css">
+    <link rel="stylesheet" href="/public/mjj-composer.css">
+    <script type="module" src="/public/design-system/components/index.js"></script>
+    <script type="module" src="/public/mjj-composer.js"></script>
+    <style>
+      body {
+        box-sizing: border-box;
+        min-height: 100vh;
+        margin: 0;
+        padding: var(--zenbu-sys-padding-xl);
+        background: var(--zenbu-sys-color-surface-page);
+        color: var(--zenbu-sys-color-text-primary);
+        font-family: var(--zenbu-sys-font-family-ui);
+      }
+
+      main {
+        display: grid;
+        gap: var(--zenbu-sys-space-container);
+        width: min(100%, 48rem);
+        margin-inline: auto;
+      }
+
+      section {
+        display: grid;
+        gap: var(--zenbu-sys-space-group);
+      }
+
+      h1,
+      h2,
+      p {
+        margin: 0;
+      }
+
+      mjj-composer {
+        min-height: 10rem;
+      }
+
+      mjj-composer[compact] {
+        min-height: 7rem;
+      }
+
+      output {
+        min-height: 1.5em;
+        color: var(--zenbu-sys-color-text-secondary);
+      }
+    </style>
+  </head>
+  <body>
+    <main>
+      <header>
+        <h1>Composer Lab</h1>
+        <p>Edit the public composer module or stylesheet, then refresh this page.</p>
+      </header>
+
+      <section aria-labelledby="default-composer-title">
+        <h2 id="default-composer-title">Default</h2>
+        <mjj-composer>
+          <form>
+            <div data-composer-control>
+              <zen-field size="lg">
+                <label data-composer-label>Message</label>
+                <textarea rows="4" placeholder="Write a message..." required></textarea>
+              </zen-field>
+              <div data-composer-actions>
+                <zen-button size="sm">
+                  <button type="submit">
+                    Send
+                    <zen-icon name="arrow-right"></zen-icon>
+                  </button>
+                </zen-button>
+              </div>
+            </div>
+            <p data-composer-meta>Enter to send / Shift + Enter for a new line</p>
+          </form>
+        </mjj-composer>
+        <output data-output>Ready.</output>
+      </section>
+
+      <section aria-labelledby="compact-composer-title">
+        <h2 id="compact-composer-title">Compact</h2>
+        <mjj-composer compact>
+          <form>
+            <div data-composer-control>
+              <zen-field size="lg">
+                <label data-composer-label>Message</label>
+                <textarea rows="3" placeholder="Ask Epi..." required></textarea>
+              </zen-field>
+              <div data-composer-actions>
+                <zen-button size="sm">
+                  <button type="submit" aria-label="Send message">
+                    <span data-composer-action-label>Send</span>
+                    <zen-icon name="arrow-right"></zen-icon>
+                  </button>
+                </zen-button>
+              </div>
+            </div>
+          </form>
+        </mjj-composer>
+        <output data-output>Ready.</output>
+      </section>
+    </main>
+    <script type="module">
+      for (const composer of document.querySelectorAll("mjj-composer")) {
+        composer.addEventListener("mjj-composer-submit", event => {
+          composer.nextElementSibling.textContent =
+            `Submitted: ${event.detail.message}`;
+        });
+      }
+    </script>
+  </body>
+</html>
Binary file mrjunejune/src/public/jrpg/background-frame-mobile.webp has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/src/public/mjj-composer.css	Fri Aug 07 16:05:29 2026 -0700
@@ -0,0 +1,109 @@
+mjj-composer {
+  --zenbu-composer-action-reserve: calc(
+    var(--zenbu-sys-control-height-sm) + var(--zenbu-sys-space-container)
+  );
+  display: block;
+  min-width: 0;
+  min-height: 0;
+}
+
+mjj-composer > form {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr);
+  grid-template-rows: minmax(0, 1fr) auto;
+  gap: var(--zenbu-sys-space-control);
+  height: 100%;
+  min-height: 0;
+}
+
+mjj-composer [data-composer-control] {
+  position: relative;
+  grid-column: 1;
+  grid-row: 1;
+  min-width: 0;
+  min-height: 0;
+}
+
+mjj-composer [data-composer-control] > zen-field {
+  height: 100%;
+  min-height: 0;
+}
+
+mjj-composer [data-composer-label] {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  margin: -1px;
+  padding: 0;
+  overflow: hidden;
+  clip-path: inset(50%);
+  white-space: nowrap;
+}
+
+mjj-composer [data-composer-control] > zen-field:not(
+  [appearance="plain"]
+) > textarea {
+  box-sizing: border-box;
+  height: 100%;
+  min-height: var(--zenbu-control-height);
+  padding-inline-end: var(--zenbu-composer-action-reserve);
+  resize: none;
+}
+
+mjj-composer [data-composer-actions] {
+  position: absolute;
+  z-index: 1;
+  inset-inline-end: var(--zenbu-sys-padding-sm);
+  inset-block-end: var(--zenbu-sys-padding-sm);
+  display: flex;
+  flex-direction: column;
+  gap: var(--zenbu-sys-space-control);
+}
+
+mjj-composer [data-composer-actions] zen-button,
+mjj-composer [data-composer-actions] button {
+  width: 100%;
+}
+
+mjj-composer [data-composer-meta] {
+  grid-column: 1 / -1;
+  grid-row: 2;
+  margin: 0;
+  color: var(--zenbu-sys-color-text-secondary);
+  font-size: var(--zenbu-sys-font-size-xs);
+}
+
+mjj-composer [data-composer-meta][hidden],
+mjj-composer [data-composer-cancel-owner][hidden] {
+  display: none;
+}
+
+mjj-composer[compact] {
+  --zenbu-composer-action-size: var(--zenbu-sys-control-height-sm);
+  --zenbu-composer-action-reserve: calc(
+    var(--zenbu-composer-action-size) + var(--zenbu-sys-padding-md)
+  );
+}
+
+mjj-composer[compact] > form {
+  gap: var(--zenbu-sys-space-icon-label);
+}
+
+mjj-composer[compact] [data-composer-actions] {
+  gap: var(--zenbu-sys-space-icon-label);
+}
+
+mjj-composer[compact] [data-composer-actions] zen-button,
+mjj-composer[compact] [data-composer-actions] button {
+  width: var(--zenbu-composer-action-size);
+}
+
+mjj-composer[compact] [data-composer-actions] button {
+  height: var(--zenbu-composer-action-size);
+  min-height: var(--zenbu-composer-action-size);
+  padding: 0;
+}
+
+mjj-composer[compact] [data-composer-action-label] {
+  display: none;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/src/public/mjj-composer.js	Fri Aug 07 16:05:29 2026 -0700
@@ -0,0 +1,80 @@
+/**
+ * Coordinates a native composer form built from Zenbu controls.
+ *
+ * @extends {HTMLElement}
+ */
+export class MjjComposer extends HTMLElement {
+  connectedCallback() {
+    this._form = this.querySelector(":scope > form");
+    this._textarea = this._form?.querySelector("textarea");
+    this._submit = this._form?.querySelector('button[type="submit"]');
+    this._cancel = this._form?.querySelector("[data-composer-cancel]");
+    this._cancelOwner = this._form?.querySelector("[data-composer-cancel-owner]");
+    this._onSubmit = event => {
+      event.preventDefault();
+      const message = this._textarea?.value.trim();
+      if (!message || this._submit?.disabled) return;
+      this.dispatchEvent(new CustomEvent("mjj-composer-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._onCancel = () => {
+      this.dispatchEvent(new CustomEvent("mjj-composer-cancel", {
+        bubbles: true,
+      }));
+    };
+    this._form?.addEventListener("submit", this._onSubmit);
+    this._textarea?.addEventListener("keydown", this._onKeydown);
+    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);
+  }
+
+  /**
+   * Sets the active request state.
+   *
+   * @param {boolean} busy
+   */
+  setBusy(busy) {
+    if (this._textarea) this._textarea.disabled = busy;
+    if (this._submit) {
+      this._submit.disabled = busy;
+      this._submit.setAttribute("aria-busy", String(busy));
+    }
+    if (this._cancelOwner) this._cancelOwner.hidden = !busy;
+  }
+
+  /**
+   * Shows or hides the cancellation action.
+   *
+   * @param {boolean} cancellable
+   */
+  setCancellable(cancellable) {
+    if (this._cancelOwner) this._cancelOwner.hidden = !cancellable;
+  }
+
+  /**
+   * Enables or disables composer input.
+   *
+   * @param {boolean} disabled
+   */
+  setDisabled(disabled) {
+    if (this._textarea) this._textarea.disabled = disabled;
+    if (this._submit) this._submit.disabled = disabled;
+  }
+}
+
+if (!customElements.get("mjj-composer")) {
+  customElements.define("mjj-composer", MjjComposer);
+}
--- a/mrjunejune/test/snapshots/jrpg.snapshot	Fri Aug 07 13:24:05 2026 -0700
+++ b/mrjunejune/test/snapshots/jrpg.snapshot	Fri Aug 07 16:05:29 2026 -0700
@@ -35,7 +35,9 @@
     <link rel="preload" href="/public/jrpg/background-frame-2.webp" as="image" media="not ((max-width: 52rem) and (orientation: portrait))">
     <link rel="preload" href="/public/jrpg/background-frame-mobile.webp" as="image" media="(max-width: 52rem) and (orientation: portrait)">
     <link rel="preload" href="/public/jrpg/bar-ink.webp" as="image">
+    <link rel="stylesheet" href="/public/mjj-composer.css">
     <link rel="stylesheet" href="/jrpg/jrpg.css">
+    <script type="module" src="/public/mjj-composer.js"></script>
     <script type="module" src="/jrpg/jrpg.js"></script>
   </head>
   <body class="jrpg-page">
@@ -76,17 +78,61 @@
               </a>
             </zen-button>
           </nav>
-          <zen-button class="jrpg-mobile-menu-btn" appearance="plain" size="xs">
-            <button
-              type="button"
-              data-mobile-menu-toggle
-              aria-label="Toggle destination menu"
-              aria-expanded="true"
-              aria-controls="jrpg-destination-menu"
+          <zen-dialog
+            class="jrpg-mobile-destination-owner"
+            data-mobile-destination-owner
+          >
+            <zen-button class="jrpg-mobile-menu-btn" appearance="plain" size="xs">
+              <button
+                type="button"
+                data-zen-trigger
+                data-mobile-menu-toggle
+                aria-label="Open destination menu"
+              >
+                <zen-icon name="menu"></zen-icon>
+              </button>
+            </zen-button>
+            <dialog
+              class="jrpg-mobile-destination-dialog"
+              data-mobile-destination-dialog
+              aria-labelledby="jrpg-mobile-destination-title"
             >
-              <zen-icon name="menu"></zen-icon>
-            </button>
-          </zen-button>
+              <header class="jrpg-mobile-destination-header">
+                <zen-button
+                  appearance="plain"
+                  size="sm"
+                  data-mobile-destination-back-owner
+                  hidden
+                >
+                  <button
+                    type="button"
+                    data-mobile-destination-back
+                    aria-label="Back to destination menu"
+                  >
+                    <zen-icon name="chevron-left"></zen-icon>
+                    Menu
+                  </button>
+                </zen-button>
+                <h2
+                  id="jrpg-mobile-destination-title"
+                  data-mobile-destination-title
+                >Menu</h2>
+                <zen-button appearance="plain" size="sm">
+                  <button
+                    type="button"
+                    data-zen-close
+                    aria-label="Close destination menu"
+                  >
+                    <zen-icon name="close"></zen-icon>
+                  </button>
+                </zen-button>
+              </header>
+              <div
+                class="jrpg-mobile-destination-content"
+                data-mobile-destination-content
+              ></div>
+            </dialog>
+          </zen-dialog>
           <footer class="jrpg-frame-telemetry" aria-label="System telemetry">
             <span aria-label="Temperature unavailable">N/A</span>
             <span data-frame-account aria-live="polite"><a href="/login?next=/jrpg">LOGIN</a></span>
@@ -99,10 +145,7 @@
               <img data-character alt="">
             </mjj-jrpg-character>
 
-            <mjj-jrpg-chat aria-labelledby="conversation-title">
-              <div class="jrpg-panel-heading">
-                <h1 id="conversation-title">Conversations</h1>
-              </div>
+            <mjj-jrpg-chat aria-label="Conversation">
               <zen-message-scroller>
                 <div
                   class="jrpg-message-viewport"
@@ -135,99 +178,98 @@
           <section class="jrpg-utility" aria-label="Selected destination">
             <mjj-jrpg-preview data-selection="resume">
               <article class="jrpg-preview-panel" aria-live="polite">
-                <zen-heading size="xl">
-                  <h2 data-preview-title>Resume</h2>
-                </zen-heading>
-                <p data-preview-copy>
-                  Member of Technical Staff and engineering leader with 10+ years building AI agent platforms and production systems.
-                </p>
-                <ul class="jrpg-work-showcase" data-work-showcase>
-                  <li>
-                    <a href="https://www.microsoft.com/en-us/microsoft-copilot/blog/2026/02/26/copilot-tasks-from-answers-to-actions/">
-                      <span>Copilot Tasks</span>
-                      <small>Agentic execution</small>
-                    </a>
-                  </li>
-                  <li>
-                    <a href="https://www.cio.com/article/3977098/microsoft-doubles-down-on-multi-model-ai-as-it-builds-a-copilot-super-app.html">
-                      <span>Copilot SuperApp</span>
-                      <small>AI platform</small>
-                    </a>
-                  </li>
-                </ul>
-                <zen-dialog class="jrpg-preview-dialog">
-                  <zen-button appearance="plain" size="xs" data-dialog-trigger-owner hidden>
-                    <button type="button" data-zen-trigger tabindex="-1" aria-hidden="true"></button>
-                  </zen-button>
-                  <dialog class="jrpg-detail-dialog" aria-labelledby="jrpg-dialog-title">
-                    <div class="jrpg-dialog-heading">
-                      <p>DESTINATION DATA</p>
-                      <div class="jrpg-dialog-heading-actions">
-                        <zen-button appearance="plain" size="md">
-                          <a href="/resume" data-preview-link>
-                            Open page
-                            <zen-icon name="external"></zen-icon>
+                <div class="jrpg-preview-content" data-preview-content>
+                  <zen-heading size="xl">
+                    <h2 data-preview-title>Resume</h2>
+                  </zen-heading>
+                  <p data-preview-copy>
+                    Member of Technical Staff and engineering leader with 10+ years building AI agent platforms and production systems.
+                  </p>
+                  <ul class="jrpg-work-showcase" data-work-showcase>
+                    <li>
+                      <a href="https://www.microsoft.com/en-us/microsoft-copilot/blog/2026/02/26/copilot-tasks-from-answers-to-actions/">
+                        <span>Copilot Tasks</span>
+                        <small>Agentic execution</small>
+                      </a>
+                    </li>
+                    <li>
+                      <a href="https://www.cio.com/article/3977098/microsoft-doubles-down-on-multi-model-ai-as-it-builds-a-copilot-super-app.html">
+                        <span>Copilot SuperApp</span>
+                        <small>AI platform</small>
+                      </a>
+                    </li>
+                  </ul>
+                  <zen-dialog class="jrpg-preview-dialog">
+                    <zen-button appearance="plain" size="xs" data-dialog-trigger-owner hidden>
+                      <button type="button" data-zen-trigger tabindex="-1" aria-hidden="true"></button>
+                    </zen-button>
+                    <dialog class="jrpg-detail-dialog" aria-labelledby="jrpg-dialog-title">
+                      <div class="jrpg-dialog-heading">
+                        <p>DESTINATION DATA</p>
+                        <div class="jrpg-dialog-heading-actions">
+                          <zen-button appearance="plain" size="md">
+                            <a href="/resume" data-preview-link>
+                              Open page
+                              <zen-icon name="external"></zen-icon>
+                            </a>
+                          </zen-button>
+                          <zen-button appearance="plain" size="md">
+                            <button type="button" data-zen-close aria-label="Close destination details">
+                              <zen-icon name="close"></zen-icon>
+                            </button>
+                          </zen-button>
+                        </div>
+                      </div>
+                      <h2 id="jrpg-dialog-title" data-dialog-title>Resume</h2>
+                      <p data-dialog-copy>Experience, projects, and the systems I have helped build.</p>
+                      <section
+                        class="jrpg-resume-dossier"
+                        data-resume-dossier
+                        aria-label="Resume dossier"
+                      >
+                        <p data-resume-status>Loading resume dossier...</p>
+                        <div data-resume-content></div>
+                      </section>
+                      <section
+                        class="jrpg-blog-browser"
+                        data-blog-browser
+                        aria-label="Blog archive"
+                        hidden
+                      >
+                        <aside>
+                          <h3>All blogs</h3>
+                          <ol data-blog-list>
+                            <li>Loading blog archive...</li>
+                          </ol>
+                        </aside>
+                        <article>
+                          <p data-blog-status>Select a blog to inspect.</p>
+                          <div data-blog-content></div>
+                        </article>
+                      </section>
+                      <section
+                        class="jrpg-tool-browser"
+                        data-tool-browser
+                        aria-label="Tool workspace"
+                        hidden
+                      >
+                        <article>
+                          <p data-tool-status>Select a tool to inspect.</p>
+                          <div data-tool-content></div>
+                        </article>
+                      </section>
+                      <div class="jrpg-dialog-actions" data-dialog-actions>
+                        <zen-button appearance="plain" size="lg" data-resume-download>
+                          <a href="/public/resume.pdf">
+                            Download PDF
+                            <zen-icon name="download"></zen-icon>
                           </a>
                         </zen-button>
-                        <zen-button appearance="plain" size="md">
-                          <button type="button" data-zen-close aria-label="Close destination details">
-                            <zen-icon name="close"></zen-icon>
-                          </button>
-                        </zen-button>
                       </div>
-                    </div>
-                    <h2 id="jrpg-dialog-title" data-dialog-title>Resume</h2>
-                    <p data-dialog-copy>Experience, projects, and the systems I have helped build.</p>
-                    <section
-                      class="jrpg-resume-dossier"
-                      data-resume-dossier
-                      aria-label="Resume dossier"
-                    >
-                      <p data-resume-status>Loading resume dossier...</p>
-                      <div data-resume-content></div>
-                    </section>
-                    <section
-                      class="jrpg-blog-browser"
-                      data-blog-browser
-                      aria-label="Blog archive"
-                      hidden
-                    >
-                      <aside>
-                        <h3>All blogs</h3>
-                        <ol data-blog-list>
-                          <li>Loading blog archive...</li>
-                        </ol>
-                      </aside>
-                      <article>
-                        <p data-blog-status>Select a blog to inspect.</p>
-                        <div data-blog-content></div>
-                      </article>
-                    </section>
-                    <section
-                      class="jrpg-tool-browser"
-                      data-tool-browser
-                      aria-label="Tool workspace"
-                      hidden
-                    >
-                      <article>
-                        <p data-tool-status>Select a tool to inspect.</p>
-                        <div data-tool-content></div>
-                      </article>
-                    </section>
-                    <div class="jrpg-dialog-actions" data-dialog-actions>
-                      <zen-button appearance="plain" size="lg" data-resume-download>
-                        <a href="/public/resume.pdf">
-                          Download PDF
-                          <zen-icon name="download"></zen-icon>
-                        </a>
-                      </zen-button>
-                    </div>
-                  </dialog>
-                </zen-dialog>
-              </article>
-            </mjj-jrpg-preview>
-
-            <mjj-conversation-archive hidden aria-label="Conversations">
+                    </dialog>
+                  </zen-dialog>
+                </div>
+                <mjj-conversation-archive hidden aria-label="Conversations">
               <div class="jrpg-archive-heading">
                 <h2>Archive</h2>
                 <zen-button appearance="plain" size="xs">
@@ -296,40 +338,62 @@
                   </div>
                 </form>
               </dialog>
-            </mjj-conversation-archive>
+                </mjj-conversation-archive>
+              </article>
+            </mjj-jrpg-preview>
           </section>
 
-          <mjj-jrpg-composer>
+          <mjj-composer compact>
             <form class="jrpg-composer" data-composer>
-              <zen-field appearance="plain" size="lg">
-                <label for="jrpg-message">Message</label>
-                <textarea
-                  id="jrpg-message"
-                  name="message"
-                  rows="3"
-                  maxlength="500"
-                  placeholder="Ask Epi about this site..."
-                  required
-                ></textarea>
-              </zen-field>
-              <div class="jrpg-composer-actions">
-                <zen-button appearance="plain" size="sm">
-                  <button type="submit">
-                    Send
-                    <zen-icon name="arrow-right"></zen-icon>
-                  </button>
-                </zen-button>
-                <zen-button appearance="plain" size="sm" data-cancel-control hidden>
-                  <button type="button" data-cancel>
-                    Cancel
-                    <zen-icon name="close"></zen-icon>
-                  </button>
-                </zen-button>
+              <div data-composer-control>
+                <zen-field size="lg">
+                  <label for="jrpg-message" data-composer-label>Message</label>
+                  <textarea
+                    id="jrpg-message"
+                    name="message"
+                    rows="3"
+                    maxlength="500"
+                    placeholder="Ask Epi about this site..."
+                    required
+                  ></textarea>
+                </zen-field>
+                <div class="jrpg-composer-actions" data-composer-actions>
+                  <zen-button size="sm">
+                    <button type="submit" aria-label="Send message">
+                      <span data-composer-action-label>Send</span>
+                      <zen-icon name="arrow-right"></zen-icon>
+                    </button>
+                  </zen-button>
+                  <zen-button
+                    size="sm"
+                    variant="danger"
+                    data-composer-cancel-owner
+                    hidden
+                  >
+                    <button
+                      type="button"
+                      data-composer-cancel
+                      aria-label="Cancel response"
+                    >
+                      <span data-composer-action-label>Cancel</span>
+                      <zen-icon name="close"></zen-icon>
+                    </button>
+                  </zen-button>
+                </div>
               </div>
-              <p class="jrpg-composer-quota" data-quota aria-live="polite" hidden></p>
-              <p class="jrpg-composer-hint">Enter to send / Shift + Enter for a new line</p>
+              <p
+                class="jrpg-composer-quota"
+                data-composer-meta
+                data-quota
+                aria-live="polite"
+                hidden
+              ></p>
+              <p
+                class="jrpg-composer-hint"
+                data-composer-meta
+              >Enter to send / Shift + Enter for a new line</p>
             </form>
-          </mjj-jrpg-composer>
+          </mjj-composer>
 
           <mjj-jrpg-menu id="jrpg-destination-menu">
             <nav aria-label="Shiba Quest destinations">
--- a/mrjunejune/test/theme_and_webp_test.js	Fri Aug 07 13:24:05 2026 -0700
+++ b/mrjunejune/test/theme_and_webp_test.js	Fri Aug 07 16:05:29 2026 -0700
@@ -444,6 +444,53 @@
   await page.close();
 }
 
+async function testComposerLab(browser) {
+  const context = await browser.newContext({
+    serviceWorkers: 'block',
+    viewport: { width: 390, height: 844 },
+  });
+  const page = await context.newPage();
+  await page.goto(`${baseUrl}/public/composer-lab.html`, {
+    waitUntil: 'networkidle',
+  });
+  await page.waitForFunction(() =>
+    customElements.get('mjj-composer') &&
+    customElements.get('zen-field') &&
+    customElements.get('zen-button')
+  );
+
+  const compact = page.locator('mjj-composer[compact]');
+  const textarea = compact.locator('textarea');
+  const submit = compact.locator('button[type="submit"]');
+  await textarea.fill('Composer lab message');
+  await textarea.press('Enter');
+  await page.waitForFunction(() =>
+    [...document.querySelectorAll('[data-output]')]
+      .some(output => output.textContent.includes('Composer lab message'))
+  );
+
+  const geometry = await compact.evaluate(element => {
+    const field = element.querySelector('zen-field');
+    const button = element.querySelector('zen-button');
+    const nativeButton = button.querySelector('button');
+    const buttonBox = nativeButton.getBoundingClientRect();
+    return {
+      compactButton: Math.abs(buttonBox.width - buttonBox.height) < 1,
+      fieldStyled: !field.hasAttribute('appearance'),
+      oneButtonOwner: nativeButton.closest('zen-button') === button,
+      textareaOwned: element.querySelector('textarea').closest('zen-field') === field,
+    };
+  });
+  assert.deepEqual(geometry, {
+    compactButton: true,
+    fieldStyled: true,
+    oneButtonOwner: true,
+    textareaOwned: true,
+  });
+  assert.equal(await submit.getAttribute('aria-label'), 'Send message');
+  await context.close();
+}
+
 async function testJrpgPage(browser) {
   const context = await browser.newContext({
     colorScheme: 'light',
@@ -502,7 +549,7 @@
 
   await page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' });
   await page.waitForFunction(() =>
-    customElements.get('mjj-jrpg-composer') &&
+    customElements.get('mjj-composer') &&
     customElements.get('mjj-jrpg-menu') &&
     customElements.get('mjj-jrpg-chat') &&
     customElements.get('mjj-jrpg-preview')
@@ -511,7 +558,7 @@
   const desktop = await page.evaluate(() => {
     const scene = document.querySelector('.jrpg-scene').getBoundingClientRect();
     const utility = document.querySelector('.jrpg-utility').getBoundingClientRect();
-    const composer = document.querySelector('mjj-jrpg-composer')
+    const composer = document.querySelector('mjj-composer')
       .getBoundingClientRect();
     const composerField = document.querySelector('.jrpg-composer zen-field')
       .getBoundingClientRect();
@@ -522,8 +569,12 @@
       .getBoundingClientRect();
     const composerTextarea = document.querySelector('#jrpg-message')
       .getBoundingClientRect();
-    const menu = document.querySelector('mjj-jrpg-menu').getBoundingClientRect();
+    const menuElement = document.querySelector('mjj-jrpg-menu');
+    const menu = menuElement.getBoundingClientRect();
     const character = document.querySelector('[data-character]');
+    const epiAvatar = document.querySelector(
+      '.jrpg-message[data-speaker="Epi"] .jrpg-message-avatar',
+    );
     const characterBox = character.getBoundingClientRect();
     const sceneBarStyle = getComputedStyle(
       document.querySelector('.jrpg-scene'),
@@ -636,13 +687,11 @@
       '.jrpg-scene',
       '.jrpg-utility',
       'mjj-jrpg-chat',
-      'mjj-jrpg-composer',
+      'mjj-composer',
       'mjj-jrpg-menu',
       '.jrpg-preview-panel',
       '.jrpg-message',
       '.jrpg-work-showcase a',
-      '.jrpg-composer textarea',
-      '.jrpg-composer button[type="submit"]',
       'mjj-jrpg-menu button',
     ].every(selector => {
       const style = getComputedStyle(document.querySelector(selector));
@@ -666,6 +715,10 @@
         brand.top <= workspace.top + workspace.height * 0.03 &&
         brand.bottom >= workspace.top + workspace.height * 0.06,
       characterLoaded: character.complete && character.naturalWidth > 0,
+      epiAvatarLoaded:
+        epiAvatar?.complete &&
+        epiAvatar.naturalWidth > 0 &&
+        epiAvatar.getAttribute('aria-hidden') === 'true',
       colors,
       composerBelowScene: composer.top >= scene.bottom,
       composerTextareaFills: (
@@ -674,6 +727,10 @@
         Math.abs(composerTextarea.bottom - composerField.bottom) < 1 &&
         composerTextarea.height > 60
       ),
+      composerUsesZenStyles:
+        getComputedStyle(document.querySelector('.jrpg-composer textarea'))
+          .borderTopWidth !== '0px' &&
+        submitStyle.borderTopWidth !== '0px',
       composerTextareaGeometry: {
         fieldBottom: composerField.bottom,
         fieldDisplay: composerFieldStyle.display,
@@ -781,12 +838,14 @@
   assert.equal(desktop.brandInSystemSlot, true);
   assert.equal(desktop.brandCoversSystemLabel, true);
   assert.equal(desktop.characterLoaded, true);
+  assert.equal(desktop.epiAvatarLoaded, true);
   assert.equal(desktop.colors.page, desktop.colors.canvas);
   assert.equal(desktop.colors.conversation, 'rgba(0, 0, 0, 0)');
   assert.equal(desktop.colors.conversationText, desktop.colors.text);
   assert.equal(desktop.colors.menu, 'rgba(0, 0, 0, 0)');
   assert.equal(desktop.colors.primaryText, desktop.colors.primary);
   assert.equal(desktop.colors.submit, desktop.colors.primary);
+  assert.equal(desktop.composerUsesZenStyles, true);
   assert.equal(desktop.colors.utility, 'rgba(0, 0, 0, 0)');
   assert.equal(desktop.composerBelowScene, true);
   assert.equal(
@@ -914,13 +973,32 @@
     0,
     'redundant chat-heading archive shortcut is removed',
   );
+  assert.equal(
+    await page.locator('.jrpg-panel-heading').count(),
+    0,
+    'visible conversation heading is removed',
+  );
   await page.locator('button[data-preview="conversations"]').click();
   await page.locator('.jrpg-utility mjj-conversation-archive').waitFor({ state: 'visible' });
   const archiveGeometry = await page.evaluate(() => {
-    const utility = document.querySelector('.jrpg-utility').getBoundingClientRect();
-    const archive = document.querySelector(
-      '.jrpg-utility mjj-conversation-archive',
-    ).getBoundingClientRect();
+    const utilityElement = document.querySelector('.jrpg-utility');
+    const archiveElement = utilityElement.querySelector('mjj-conversation-archive');
+    const utility = utilityElement.getBoundingClientRect();
+    const archive = archiveElement.getBoundingClientRect();
+    const archiveStyle = getComputedStyle(archiveElement);
+    const previewPanel = utilityElement.querySelector('.jrpg-preview-panel');
+    const panel = previewPanel.getBoundingClientRect();
+    const previewPanelStyle = getComputedStyle(previewPanel);
+    const panelPadding = {
+      bottom: parseFloat(previewPanelStyle.paddingBottom),
+      left: parseFloat(previewPanelStyle.paddingLeft),
+      right: parseFloat(previewPanelStyle.paddingRight),
+      top: parseFloat(previewPanelStyle.paddingTop),
+    };
+    const previewContent = previewPanel.querySelector('[data-preview-content]');
+    const renderedPanelChildren = Array.from(previewPanel.children)
+      .filter(element => getComputedStyle(element).display !== 'none')
+      .map(element => element.localName);
     return {
       archive: {
         bottom: archive.bottom,
@@ -930,12 +1008,30 @@
         top: archive.top,
         width: archive.width,
       },
-      fits: archive.left >= utility.left &&
-      archive.top >= utility.top &&
-      archive.right <= utility.right + 1 &&
-      archive.bottom <= utility.bottom + 1 &&
-      archive.width / utility.width > 0.85 &&
-      archive.height / utility.height > 0.85,
+      fits: Math.abs(archive.left - panel.left - panelPadding.left) < 1 &&
+        Math.abs(archive.top - panel.top - panelPadding.top) < 1 &&
+        Math.abs(panel.right - panelPadding.right - archive.right) < 1 &&
+        Math.abs(panel.bottom - panelPadding.bottom - archive.bottom) < 1,
+      integrated: archiveStyle.position === 'static' &&
+        archiveStyle.zIndex === 'auto' &&
+        archiveStyle.backgroundColor === 'rgba(0, 0, 0, 0)' &&
+        archiveStyle.backgroundImage === 'none' &&
+        archiveStyle.boxShadow === 'none' &&
+        previewPanelStyle.display === 'block' &&
+        archiveElement.parentElement === previewPanel &&
+        previewContent.hidden &&
+        renderedPanelChildren.length === 1 &&
+        renderedPanelChildren[0] === 'mjj-conversation-archive',
+      renderedPanelChildren,
+      panel: {
+        bottom: panel.bottom,
+        height: panel.height,
+        left: panel.left,
+        right: panel.right,
+        top: panel.top,
+        width: panel.width,
+      },
+      panelPadding,
       utility: {
         bottom: utility.bottom,
         height: utility.height,
@@ -949,7 +1045,12 @@
   assert.equal(
     archiveGeometry.fits,
     true,
-    `conversation archive fills the top-right utility aperture: ${JSON.stringify(archiveGeometry)}`,
+    `conversation archive fills the existing utility content panel: ${JSON.stringify(archiveGeometry)}`,
+  );
+  assert.equal(
+    archiveGeometry.integrated,
+    true,
+    `conversation archive renders as utility content instead of an overlay: ${JSON.stringify(archiveGeometry)}`,
   );
   // Archive status or empty message should be visible
   await page.waitForFunction(() => {
@@ -1331,7 +1432,7 @@
     '.jrpg-message[data-speaker="Epi"][data-streaming="true"]',
   ).last();
   await streamingMessage.waitFor();
-  await page.locator('[data-cancel-control]').waitFor({ state: 'hidden' });
+  await page.locator('[data-composer-cancel-owner]').waitFor({ state: 'hidden' });
   await streamingMessage.waitFor();
   assert.notEqual(
     await streamingMessage.locator('p').textContent(),
@@ -1396,7 +1497,7 @@
     0,
   );
   assert.equal(
-    await page.locator('.jrpg-message[data-speaker="System"]:visible').count(),
+    await page.locator('.jrpg-message[data-speaker="Epi"]:visible').count(),
     1,
   );
   await page.getByRole('button', { name: 'Next turn' }).click();
@@ -1444,7 +1545,7 @@
   const mobilePage = await mobileContext.newPage();
   await mobilePage.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' });
   await mobilePage.waitForFunction(() =>
-    customElements.get('mjj-jrpg-composer') &&
+    customElements.get('mjj-composer') &&
     customElements.get('mjj-jrpg-menu')
   );
   const mobile = await mobilePage.evaluate(() => {
@@ -1452,18 +1553,41 @@
       .getBoundingClientRect();
     const scene = document.querySelector('.jrpg-scene').getBoundingClientRect();
     const utility = document.querySelector('.jrpg-utility').getBoundingClientRect();
-    const composer = document.querySelector('mjj-jrpg-composer')
+    const composer = document.querySelector('mjj-composer')
+      .getBoundingClientRect();
+    const brand = document.querySelector('.jrpg-brand').getBoundingClientRect();
+    const brandLink = document.querySelector('.jrpg-brand a').getBoundingClientRect();
+    const turnNavigation = document.querySelector('.jrpg-turn-navigation');
+    const previousTurn = turnNavigation.querySelector('[data-turn-previous]')
+      .getBoundingClientRect();
+    const turnPosition = turnNavigation.querySelector('[data-turn-position]')
+      .getBoundingClientRect();
+    const nextTurn = turnNavigation.querySelector('[data-turn-next]')
+      .getBoundingClientRect();
+    const composerForm = document.querySelector('.jrpg-composer')
       .getBoundingClientRect();
-    const menu = document.querySelector('mjj-jrpg-menu').getBoundingClientRect();
+    const composerTextarea = document.querySelector('#jrpg-message')
+      .getBoundingClientRect();
+    const sendButton = document.querySelector(
+      '.jrpg-composer button[type="submit"]',
+    ).getBoundingClientRect();
+    const sendOwner = document.querySelector(
+      '.jrpg-composer zen-button:has(button[type="submit"])',
+    );
+    const composerField = document.querySelector(
+      '.jrpg-composer [data-composer-control] > zen-field',
+    );
+    const composerLabel = composerField.querySelector('label');
+    const sendLabel = document.querySelector('[data-composer-action-label]');
+    const menuElement = document.querySelector('mjj-jrpg-menu');
+    const menu = menuElement.getBoundingClientRect();
     const shell = document.querySelector('.jrpg-shell').getBoundingClientRect();
     const hamburger = document.querySelector('[data-mobile-menu-toggle]');
     const hamburgerBox = hamburger?.getBoundingClientRect();
-    const menuButtons = [...document.querySelectorAll(
-      'mjj-jrpg-menu button[data-preview]',
-    )];
-    const telemetryItems = [
-      ...document.querySelectorAll('.jrpg-frame-telemetry > *'),
-    ];
+    const dialog = document.querySelector('[data-mobile-destination-dialog]');
+    const destinationContent = document.querySelector(
+      '[data-mobile-destination-content]',
+    );
     const closeTo = (value, expected, tol = 0.025) =>
       Math.abs(value - expected) < tol;
     const wsW = workspace.width;
@@ -1473,42 +1597,85 @@
     ).backgroundImage;
     const mobileArtIsCorrect = mobileArt.includes('background-frame-mobile.webp');
     const aperturesTolerance = [
-      closeTo((scene.left - workspace.left) / wsW, 0.045),
-      closeTo((scene.top - workspace.top) / wsH, 0.075),
-      closeTo(scene.width / wsW, 0.91),
-      closeTo(scene.height / wsH, 0.455),
-      closeTo((utility.left - workspace.left) / wsW, 0.045),
-      closeTo((utility.top - workspace.top) / wsH, 0.547),
-      closeTo(utility.width / wsW, 0.91),
-      closeTo(utility.height / wsH, 0.171),
-      closeTo((composer.left - workspace.left) / wsW, 0.045),
-      closeTo((composer.top - workspace.top) / wsH, 0.728),
-      closeTo(composer.width / wsW, 0.603),
-      closeTo(composer.height / wsH, 0.192),
-      closeTo((menu.left - workspace.left) / wsW, 0.665),
-      closeTo((menu.top - workspace.top) / wsH, 0.728),
-      closeTo(menu.width / wsW, 0.29),
-      closeTo(menu.height / wsH, 0.192),
+      closeTo((scene.left - workspace.left) / wsW, 0.048),
+      closeTo((scene.top - workspace.top) / wsH, 0.073),
+      closeTo(scene.width / wsW, 0.904),
+      closeTo(scene.height / wsH, 0.712),
+      closeTo((composer.left - workspace.left) / wsW, 0.048),
+      closeTo((composer.top - workspace.top) / wsH, 0.796),
+      closeTo(composer.width / wsW, 0.904),
+      closeTo(composer.height / wsH, 0.177),
+      closeTo((hamburgerBox.left - workspace.left) / wsW, 0.872),
+      closeTo((hamburgerBox.top - workspace.top) / wsH, 0.013),
+      closeTo(hamburgerBox.width / wsW, 0.094),
+      closeTo(hamburgerBox.height / wsH, 0.053),
     ].every(Boolean);
     const hamburgerPresent = hamburger !== null &&
-      hamburger.getAttribute('aria-expanded') === 'true' &&
+      hamburger.getAttribute('aria-expanded') === 'false' &&
       hamburgerBox !== null &&
       hamburgerBox.width > 0 &&
       hamburgerBox.height > 0;
-    const telemetryCount = telemetryItems.length === 4;
     const composerVisible = composer.width > 0 && composer.height > 0;
-    const menuVisible = menu.width > 0 && menu.height > 0;
-    const conversationsButton = document.querySelector(
-      'button[data-preview="conversations"]',
-    );
     return {
       aperturesTolerance,
-      conversationsButtonVisible: conversationsButton !== null &&
-        conversationsButton.offsetParent !== null,
-      buttonsFit: menuButtons.every(button =>
-        button.getBoundingClientRect().right <= innerWidth + 1
-      ),
+      brandAligned:
+        closeTo((brand.top - workspace.top) / wsH, 0.013) &&
+        closeTo(brand.height / wsH, 0.053) &&
+        Math.abs(
+          brandLink.left + brandLink.width / 2 -
+          (workspace.left + workspace.width / 2)
+        ) < 2,
+      brandVisible:
+        getComputedStyle(document.querySelector('.jrpg-brand')).display ===
+          'grid' &&
+        document.querySelector('.jrpg-brand').textContent.includes('MrJuneJune'),
+      composerContentAligned:
+        composerForm.left >= composer.left &&
+        composerForm.top >= composer.top &&
+        composer.right - composerForm.right <= 8 &&
+        composer.bottom - composerForm.bottom <= 8 &&
+        composerForm.left - composer.left <= 8 &&
+        composerForm.top - composer.top <= 8 &&
+        sendButton.bottom < composerTextarea.bottom &&
+        composerTextarea.bottom - sendButton.bottom <= 16,
+      composerGeometry: {
+        composer: {
+          bottom: composer.bottom,
+          left: composer.left,
+          right: composer.right,
+          top: composer.top,
+        },
+        form: {
+          bottom: composerForm.bottom,
+          left: composerForm.left,
+          right: composerForm.right,
+          top: composerForm.top,
+        },
+        send: {
+          bottom: sendButton.bottom,
+          left: sendButton.left,
+          right: sendButton.right,
+          top: sendButton.top,
+        },
+        textarea: {
+          bottom: composerTextarea.bottom,
+          left: composerTextarea.left,
+          right: composerTextarea.right,
+          top: composerTextarea.top,
+        },
+      },
+      composerFieldUsesSpace:
+        composerTextarea.left === composerForm.left &&
+        composerTextarea.right === composerForm.right &&
+        sendButton.left > composerTextarea.left &&
+        sendButton.right < composerTextarea.right &&
+        sendButton.top > composerTextarea.top &&
+        sendButton.bottom < composerTextarea.bottom,
+      composerLabelHidden:
+        composerLabel.getBoundingClientRect().width <= 1 &&
+        composerLabel.getBoundingClientRect().height <= 1,
       composerVisible,
+      dialogClosed: !dialog.open,
       fillsViewport: shell.left === 0 &&
         shell.top === 0 &&
         Math.abs(shell.right - innerWidth) < 1 &&
@@ -1517,11 +1684,45 @@
         Math.abs(shell.right - innerWidth) < 1,
       hamburgerPresent,
       initialScroll: scrollY,
+      mediaMatches: matchMedia(
+        '(max-width: 52rem) and (orientation: portrait)',
+      ).matches,
+      menuInModal: menuElement.parentElement === destinationContent,
+      menuHidden: menu.width === 0 && menu.height === 0,
       mobileArtIsCorrect,
-      menuVisible,
       noOverflow: document.documentElement.scrollWidth <= innerWidth,
-      telemetryCount,
-      utilityBelowScene: utility.top >= scene.bottom - 1,
+      sendButtonCompact:
+        Math.abs(sendButton.width - sendButton.height) < 1 &&
+        sendButton.width <= 44 &&
+        getComputedStyle(sendLabel).display === 'none',
+      telemetryHidden: getComputedStyle(
+        document.querySelector('.jrpg-frame-telemetry'),
+      ).display === 'none',
+      turnNavigationAligned:
+        Math.abs(
+          turnPosition.left + turnPosition.width / 2 -
+          (workspace.left + workspace.width / 2)
+        ) < 2 &&
+        Math.abs(
+          previousTurn.top + previousTurn.height / 2 -
+          (nextTurn.top + nextTurn.height / 2)
+        ) < 1 &&
+        previousTurn.left < turnPosition.left &&
+        nextTurn.right > turnPosition.right,
+      turnNavigationGeometry: {
+        nextCenter: nextTurn.left + nextTurn.width / 2,
+        positionCenter: turnPosition.left + turnPosition.width / 2,
+        previousCenter: previousTurn.left + previousTurn.width / 2,
+        workspaceCenter: workspace.left + workspace.width / 2,
+      },
+      topLeftClear: document.elementFromPoint(10, 10)?.classList
+        .contains('jrpg-workspace'),
+      utilityHidden: utility.width === 0 && utility.height === 0,
+      utilityInModal: document.querySelector('.jrpg-utility').parentElement ===
+        destinationContent,
+      usesDesignSystemControls:
+        !composerField.hasAttribute('appearance') &&
+        !sendOwner.hasAttribute('appearance'),
       workspaceFills: Math.abs(workspace.left) < 1 &&
         Math.abs(workspace.top) < 1 &&
         Math.abs(workspace.right - innerWidth) < 1 &&
@@ -1529,52 +1730,119 @@
     };
   });
   assert.equal(mobile.aperturesTolerance, true, 'mobile apertures within tolerance at 360x640');
+  assert.equal(mobile.brandAligned, true, 'MrJuneJune aligns in the mobile top bar');
+  assert.equal(mobile.brandVisible, true, 'MrJuneJune is visible on mobile');
   assert.equal(
-    mobile.conversationsButtonVisible,
+    mobile.composerContentAligned,
     true,
-    'Conversations destination visible on mobile',
+    `composer content aligns inside frame aperture: ${JSON.stringify(mobile.composerGeometry)}`,
   );
-  assert.equal(mobile.buttonsFit, true, 'menu buttons fit within viewport width');
+  assert.equal(mobile.composerFieldUsesSpace, true, 'composer field fills space beside send control');
+  assert.equal(mobile.composerLabelHidden, true, 'Message label remains accessible but not visible');
   assert.equal(mobile.composerVisible, true, 'composer visible');
+  assert.equal(mobile.dialogClosed, true, 'destination modal starts closed');
   assert.equal(mobile.fillsViewport, true, 'shell fills viewport');
   assert.equal(mobile.fillsWidth, true, 'shell fills width');
-  assert.equal(mobile.hamburgerPresent, true, 'hamburger button present and has aria-expanded=true');
+  assert.equal(mobile.hamburgerPresent, true, 'top-right menu trigger starts collapsed');
   assert.equal(mobile.initialScroll, 0, 'no initial scroll');
   assert.equal(mobile.mobileArtIsCorrect, true, 'mobile background URL is background-frame-mobile.webp');
-  assert.equal(mobile.menuVisible, true, 'menu visible');
+  assert.equal(mobile.menuHidden, true, 'menu stays out of the two-aperture frame');
+  assert.equal(
+    mobile.menuInModal,
+    true,
+    `menu is hosted by the mobile modal: ${JSON.stringify(mobile)}`,
+  );
   assert.equal(mobile.noOverflow, true, 'no horizontal overflow at 360x640');
-  assert.equal(mobile.telemetryCount, true, 'four telemetry items present');
-  assert.equal(mobile.utilityBelowScene, true, 'utility aperture below scene');
-  assert.equal(mobile.workspaceFills, true, 'workspace fills full viewport');
-
-  // Test hamburger disclosure behavior.
-  await mobilePage.locator('[data-mobile-menu-toggle]').click();
+  assert.equal(mobile.sendButtonCompact, true, 'mobile send control is a compact square icon button');
+  assert.equal(mobile.telemetryHidden, true, 'mobile frame omits desktop telemetry');
   assert.equal(
-    await mobilePage.locator('[data-mobile-menu-toggle]')
-      .getAttribute('aria-expanded'),
-    'false',
-    'hamburger collapses the destination menu',
+    mobile.turnNavigationAligned,
+    true,
+    `turn navigation is centered and level: ${JSON.stringify(mobile.turnNavigationGeometry)}`,
   );
-  assert.equal(
-    await mobilePage.locator('mjj-jrpg-menu')
-      .getAttribute('data-mobile-menu-collapsed'),
-    '',
-    'menu exposes its collapsed state',
-  );
-  // Second click expands.
+  assert.equal(mobile.utilityHidden, true, 'destination view stays out of the frame');
+  assert.equal(mobile.utilityInModal, true, 'destination view is hosted by the mobile modal');
+  assert.equal(mobile.usesDesignSystemControls, true, 'composer uses styled Zenbu controls');
+  assert.equal(mobile.workspaceFills, true, 'workspace fills full viewport');
+  assert.equal(mobile.topLeftClear, true, 'hidden dialog triggers do not cover frame art');
+
+  // The top-right trigger opens the modal menu.
   await mobilePage.locator('[data-mobile-menu-toggle]').click();
+  await mobilePage.locator('[data-mobile-destination-dialog]').waitFor({
+    state: 'visible',
+  });
   assert.equal(
     await mobilePage.locator('[data-mobile-menu-toggle]')
       .getAttribute('aria-expanded'),
     'true',
-    'hamburger expands the destination menu',
+    'menu trigger reflects the open modal',
+  );
+  assert.equal(
+    await mobilePage.locator('mjj-jrpg-menu').isVisible(),
+    true,
+    'destination menu is visible inside the modal',
+  );
+
+  // Every destination replaces the menu inside the same modal.
+  await mobilePage.locator('button[data-preview="conversations"]').click();
+  await mobilePage.locator('mjj-conversation-archive').waitFor({
+    state: 'visible',
+  });
+  assert.equal(
+    await mobilePage.locator('[data-mobile-destination-title]').textContent(),
+    'Conversations',
+    'conversation archive is a named modal destination',
+  );
+  assert.equal(
+    await mobilePage.locator('[data-mobile-destination-dialog]').isVisible(),
+    true,
+    'conversation archive remains in the modal',
   );
-  // Hamburger keyboard accessible
+  assert.equal(
+    await mobilePage.locator('.jrpg-mobile-destination-header').evaluate(
+      header => {
+        const items = [...header.children].filter(
+          item => getComputedStyle(item).display !== 'none',
+        );
+        const centers = items.map(item => {
+          const rect = item.getBoundingClientRect();
+          return rect.top + rect.height / 2;
+        });
+        return Math.max(...centers) - Math.min(...centers) < 2;
+      },
+    ),
+    true,
+    'mobile modal header controls share one row',
+  );
+
+  await mobilePage.locator('[data-mobile-destination-back]').click();
+  assert.equal(
+    await mobilePage.locator('mjj-jrpg-menu').isVisible(),
+    true,
+    'modal back action returns to destinations',
+  );
+  await mobilePage.locator('button[data-preview="resume"]').click();
+  assert.equal(
+    await mobilePage.locator('[data-preview-content]').isVisible(),
+    true,
+    'resume view renders in the mobile modal',
+  );
+  assert.equal(
+    await mobilePage.locator('[data-mobile-destination-title]').textContent(),
+    'Resume',
+    'modal title follows the selected destination',
+  );
+  await mobilePage.getByRole('button', {
+    name: 'Close destination menu',
+  }).click();
+  await mobilePage.locator('[data-mobile-destination-dialog]').waitFor({
+    state: 'hidden',
+  });
   assert.ok(
     await mobilePage.locator('[data-mobile-menu-toggle]').evaluate(el =>
-      el.tabIndex >= 0 || el.tabIndex === -1
+      el.tabIndex >= 0
     ),
-    'hamburger button is in document',
+    'mobile menu trigger remains keyboard accessible',
   );
   await mobileContext.close();
 
@@ -1622,7 +1890,7 @@
     const workspace = document.querySelector('.jrpg-workspace')
       .getBoundingClientRect();
     const shell = document.querySelector('.jrpg-shell').getBoundingClientRect();
-    const composer = document.querySelector('mjj-jrpg-composer')
+    const composer = document.querySelector('mjj-composer')
       .getBoundingClientRect();
     const menu = document.querySelector('mjj-jrpg-menu').getBoundingClientRect();
     const mobileArt = getComputedStyle(
@@ -1634,7 +1902,7 @@
         shell.top === 0 &&
         Math.abs(shell.right - innerWidth) < 1 &&
         Math.abs(shell.bottom - innerHeight) < 1,
-      menuPresent: menu.width > 0 && menu.height > 0,
+      menuHidden: menu.width === 0 && menu.height === 0,
       mobileArtIsCorrect: mobileArt.includes('background-frame-mobile.webp'),
       noOverflow: document.documentElement.scrollWidth <= innerWidth,
       workspaceFills: Math.abs(workspace.left) < 1 &&
@@ -1645,7 +1913,7 @@
   });
   assert.equal(mobile320.composerPresent, true, 'composer present at 320x568');
   assert.equal(mobile320.fillsViewport, true, 'fills viewport at 320x568');
-  assert.equal(mobile320.menuPresent, true, 'menu present at 320x568');
+  assert.equal(mobile320.menuHidden, true, 'menu remains modal-only at 320x568');
   assert.equal(mobile320.mobileArtIsCorrect, true, 'mobile art at 320x568');
   assert.equal(mobile320.noOverflow, true, 'no overflow at 320x568');
   assert.equal(mobile320.workspaceFills, true, 'workspace fills at 320x568');
@@ -2026,6 +2294,85 @@
     },
   };
 
+  /* Returning without a conversation URL stays fresh until the user chooses one. */
+  {
+    const context = await browser.newContext({ serviceWorkers: 'block' });
+    await context.addInitScript(id => {
+      sessionStorage.setItem('mjj-jrpg-conversation-id', id);
+    }, CONV_A);
+    const page = await context.newPage();
+    let detailRequestCount = 0;
+    await page.route('**/api/auth/session', route => route.fulfill({
+      status: 200, contentType: 'application/json',
+      body: JSON.stringify(GUEST_SESSION),
+    }));
+    await page.route('**/api/conversations**', route => {
+      const url = new URL(route.request().url());
+      if (url.pathname === '/api/conversations') {
+        return route.fulfill({
+          status: 200, contentType: 'application/json',
+          body: JSON.stringify({
+            conversations: [{
+              id: CONV_A,
+              title: 'Earlier quest',
+              turn_count: 2,
+            }],
+            cursor: null,
+          }),
+        });
+      }
+      return route.fulfill({
+        status: 404, contentType: 'application/json',
+        body: JSON.stringify({ error: { message: 'Not found' } }),
+      });
+    });
+    await page.route(`**/api/conversations/${CONV_A}`, route => {
+      detailRequestCount += 1;
+      return route.fulfill({
+        status: 200, contentType: 'application/json',
+        body: JSON.stringify({
+          id: CONV_A,
+          title: 'Earlier quest',
+          turns: [{ role: 'assistant', content: 'Welcome back.' }],
+        }),
+      });
+    });
+
+    await page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' });
+    assert.equal(detailRequestCount, 0, 'latest conversation is not opened automatically');
+    assert.equal(
+      new URL(page.url()).searchParams.has('conversation'),
+      false,
+      'returning visit remains a fresh conversation URL',
+    );
+    await page.waitForFunction(() => {
+      const greeting = document.querySelector('[data-greeting] p');
+      return greeting?.textContent.includes('Open Conversations');
+    });
+    assert.equal(
+      await page.evaluate(() =>
+        sessionStorage.getItem('mjj-jrpg-conversation-id')),
+      null,
+      'stale active conversation state is cleared',
+    );
+
+    await page.locator('button[data-preview="conversations"]').click();
+    const selectedConversation = page.waitForResponse(response =>
+      new URL(response.url()).pathname === `/api/conversations/${CONV_A}`);
+    await page.locator('[data-conv-open]').click();
+    assert.equal((await selectedConversation).status(), 200);
+    assert.ok(detailRequestCount >= 1, 'conversation opens after explicit selection');
+    await page.waitForFunction(id =>
+      new URL(window.location.href).searchParams.get('conversation') === id,
+    CONV_A);
+    assert.equal(
+      new URL(page.url()).searchParams.get('conversation'),
+      CONV_A,
+      'explicit selection writes the conversation URL',
+    );
+    await context.close();
+  }
+
   /* ---- Fix 3: malformed ?conversation param must not fall back to sessionStorage ---- */
   {
     const storedId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
@@ -2241,7 +2588,7 @@
 
     await page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' });
     await page.waitForFunction(
-      () => !document.querySelector('mjj-jrpg-composer textarea')?.disabled,
+      () => !document.querySelector('mjj-composer textarea')?.disabled,
     );
 
     /* Submit — creates conversation via pushState and starts stream */
@@ -2249,7 +2596,7 @@
     await page.locator('#jrpg-message').press('Enter');
 
     /* Stream is now active */
-    await page.locator('[data-cancel-control]').waitFor({ state: 'visible' });
+    await page.locator('[data-composer-cancel-owner]').waitFor({ state: 'visible' });
 
     const urlDuringStream = page.url();
     assert.ok(
@@ -2261,7 +2608,7 @@
     await page.goBack();
 
     /* Stream abort + pending-popstate application: cancel control disappears */
-    await page.locator('[data-cancel-control]').waitFor({ state: 'hidden' });
+    await page.locator('[data-composer-cancel-owner]').waitFor({ state: 'hidden' });
 
     /* URL and UI must converge to the pre-stream state */
     const finalUrl = page.url();
@@ -2529,6 +2876,7 @@
       await testButtonScale(browser);
       await testDynamicButtonOwnership(browser);
       await testResumePrint(browser);
+      await testComposerLab(browser);
     }
     if (runsSuite('jrpg')) await testJrpgPage(browser);
     if (runsSuite('routing')) await testConversationRouting(browser);