diff mrjunejune/test/theme_and_webp_test.js @ 272:41a49c29a28f

polish JRPG conversation experience Integrate desktop conversations into the utility panel, simplify the mobile frame, add modal destinations and a reusable Zenbu composer lab, and preserve explicit conversation resume behavior. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Fri, 07 Aug 2026 16:05:29 -0700
parents 056790c4fb0d
children e02e2036ef84
line wrap: on
line diff
--- a/mrjunejune/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);