diff mrjunejune/test/theme_and_webp_test.js @ 264:04fee26ecce0

add authenticated JRPG conversation platform Add reusable auth/session storage, owned conversation recovery, guest quotas, admin workflows, URL-routed conversation UI, mobile frame support, and parallel browser acceptance. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Fri, 07 Aug 2026 07:34:12 -0700
parents ee04e4e69fed
children 056790c4fb0d
line wrap: on
line diff
--- a/mrjunejune/test/theme_and_webp_test.js	Thu Aug 06 11:31:30 2026 -0700
+++ b/mrjunejune/test/theme_and_webp_test.js	Fri Aug 07 07:34:12 2026 -0700
@@ -15,6 +15,8 @@
   'hg-web/e2e/node_modules/playwright-core',
 );
 const { chromium } = require(playwrightPath);
+const browserSuite = process.env.MJJ_BROWSER_SUITE || 'all';
+const runsSuite = name => browserSuite === 'all' || browserSuite === name;
 
 function stopProcess(child) {
   if (!child || child.exitCode !== null) return Promise.resolve();
@@ -477,6 +479,10 @@
         request.failure()?.errorText === 'net::ERR_ABORTED') {
       return;
     }
+    if (request.url().includes('/api/auth/login') &&
+        request.failure()?.errorText === 'net::ERR_ABORTED') {
+      return;
+    }
     errors.push(`requestfailed: ${request.url()} ${request.failure()?.errorText || ''}`);
   });
   page.on('response', response => {
@@ -486,6 +492,9 @@
         )) {
       return;
     }
+    if (response.url().includes('/api/auth/login') && response.status() >= 400) {
+      return; /* expected: login attempt with invalid credentials in modal test */
+    }
     if (response.status() >= 400) {
       errors.push(`response: ${response.status()} ${response.url()}`);
     }
@@ -707,6 +716,10 @@
       noOverflow: document.documentElement.scrollWidth <= innerWidth,
       nonPixelText,
       questLabelAbsent: !document.body.textContent.includes('QUEST 01'),
+      previewLabelsRemoved:
+        !/CHARACTER RECORD|ITEM INVENTORY|QUEST ARCHIVE/.test(
+          document.querySelector('.jrpg-preview-panel').textContent,
+        ),
       statsRemoved:
         !document.querySelector('.jrpg-preview-stats') &&
         !document.querySelector('[data-preview-type]'),
@@ -741,6 +754,8 @@
         ),
       shibaInFrontOfBar:
         sceneBarStyle.backgroundImage.includes('bar-ink.webp') &&
+        sceneBarStyle.backgroundSize === 'cover' &&
+        Math.abs(parseFloat(sceneBarStyle.height) - scene.height) < 1 &&
         Number(getComputedStyle(character.parentElement).zIndex) >
           Number(sceneBarStyle.zIndex) &&
         characterBox.bottom > scene.top + scene.height * 0.65,
@@ -789,6 +804,7 @@
   assert.equal(desktop.noOverflow, true);
   assert.deepEqual(desktop.nonPixelText, []);
   assert.equal(desktop.questLabelAbsent, true);
+  assert.equal(desktop.previewLabelsRemoved, true);
   assert.equal(desktop.statsRemoved, true);
   assert.equal(desktop.telemetryCovered, true);
   assert.equal(desktop.telemetryValuesAligned, true);
@@ -823,6 +839,149 @@
     await page.locator('.jrpg-frame-controls a').getAttribute('href'),
     '/',
   );
+
+  // Account slot: guest session shows a login button (modal trigger)
+  assert.ok(
+    await page.locator('[data-frame-account]').count() > 0,
+    'account slot exists in telemetry',
+  );
+  await page.waitForFunction(() =>
+    document.querySelector('[data-frame-account]')?.querySelector('button[data-login-open]') !== null
+  );
+  const loginBtnState = await page.locator('[data-frame-account] button[data-login-open]').evaluate(btn => ({
+    text: btn.textContent.trim(),
+    ownerCount: btn.closest('zen-button') ? 1 : 0,
+    appearance: btn.closest('zen-button')?.getAttribute('appearance'),
+    size: btn.closest('zen-button')?.getAttribute('size'),
+  }));
+  assert.match(loginBtnState.text, /LOGIN/i);
+  assert.equal(loginBtnState.ownerCount, 1, 'login button has zen-button owner');
+  assert.equal(loginBtnState.appearance, 'plain');
+  assert.equal(loginBtnState.size, 'xs');
+
+  // Login modal: opens on button click, focuses username, closes on cancel
+  await page.locator('[data-frame-account] button[data-login-open]').click();
+  await page.locator('[data-login-dialog]').waitFor({ state: 'visible' });
+  assert.equal(
+    await page.locator('[data-login-dialog]').getAttribute('aria-modal'),
+    'true',
+    'login dialog is modal',
+  );
+  // Username input should receive initial focus
+  await page.waitForFunction(() => document.activeElement?.id === 'jrpg-login-username');
+  // Cancel closes dialog and clears password
+  await page.locator('#jrpg-login-password').fill('dummypassword');
+  await page.locator('[data-login-cancel]').click();
+  await page.locator('[data-login-dialog]').waitFor({ state: 'hidden' });
+  await page.waitForFunction(() => !document.querySelector('#jrpg-login-password')?.value);
+  assert.equal(
+    await page.locator('#jrpg-login-password').inputValue(),
+    '',
+    'password cleared on close',
+  );
+
+  // Invalid credentials show error message
+  await page.locator('[data-frame-account] button[data-login-open]').click();
+  await page.locator('[data-login-dialog]').waitFor({ state: 'visible' });
+  await page.locator('#jrpg-login-username').fill('nosuchuser');
+  await page.locator('#jrpg-login-password').fill('nosuchpassword123');
+  let loginErrorShown = false;
+  const loginResponsePromise = page.waitForResponse(resp =>
+    resp.url().includes('/api/auth/login')
+  );
+  await page.locator('[data-login-submit]').click();
+  const loginResp = await loginResponsePromise;
+  if (loginResp.status() >= 400) {
+    await page.waitForFunction(() => {
+      const el = document.querySelector('[data-login-error]');
+      return el && !el.hidden && el.textContent.trim().length > 0;
+    });
+    loginErrorShown = true;
+  }
+  assert.equal(loginErrorShown, true, 'error element shown on bad credentials');
+  assert.equal(
+    await page.locator('#jrpg-login-password').inputValue(),
+    '',
+    'password cleared after failed login attempt',
+  );
+  // Close modal via X button (data-zen-close)
+  await page.getByRole('button', { name: 'Close sign-in dialog' }).click();
+  await page.locator('[data-login-dialog]').waitFor({ state: 'hidden' });
+
+  // Archive opens only from the bottom destination menu.
+  assert.equal(
+    await page.locator('[data-toggle-archive]').count(),
+    0,
+    'redundant chat-heading archive shortcut 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();
+    return {
+      archive: {
+        bottom: archive.bottom,
+        height: archive.height,
+        left: archive.left,
+        right: archive.right,
+        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,
+      utility: {
+        bottom: utility.bottom,
+        height: utility.height,
+        left: utility.left,
+        right: utility.right,
+        top: utility.top,
+        width: utility.width,
+      },
+    };
+  });
+  assert.equal(
+    archiveGeometry.fits,
+    true,
+    `conversation archive fills the top-right utility aperture: ${JSON.stringify(archiveGeometry)}`,
+  );
+  // Archive status or empty message should be visible
+  await page.waitForFunction(() => {
+    const status = document.querySelector('[data-archive-status]');
+    return status && !status.hidden;
+  });
+  // New conversation button exists inside archive
+  assert.ok(
+    await page.locator('[data-archive-new]').isVisible(),
+    'archive new button visible',
+  );
+  // Conversations menu button should be pressed
+  assert.equal(
+    await page.locator('button[data-preview="conversations"]').getAttribute('aria-pressed'),
+    'true',
+  );
+
+  // Archive: close via close button, navigates back to resume panel
+  await page.locator('[data-archive-close]').click();
+  await page.locator('.jrpg-utility mjj-conversation-archive').waitFor({ state: 'hidden' });
+  // mjj-jrpg-chat must remain visible (always in scene)
+  assert.equal(
+    await page.locator('mjj-jrpg-chat').isVisible(),
+    true,
+  );
+
+  // Quota element is present (may be shown for guest session)
+  assert.ok(
+    await page.locator('[data-quota]').count() > 0,
+    'quota element exists',
+  );
+
   await page.getByRole('button', { name: 'Minimize interface' }).click();
   assert.equal(
     await page.locator('.jrpg-scene').evaluate(
@@ -837,7 +996,7 @@
     ),
     'visible',
   );
-  assert.equal(await page.locator('[data-work-showcase] a').count(), 7);
+  assert.equal(await page.locator('[data-work-showcase] a').count(), 8);
   assert.equal(
     await page.getByRole('button', { name: /Inspect/ }).count(),
     0,
@@ -848,7 +1007,7 @@
   );
   assert.match(
     await page.locator('[data-work-showcase]').textContent(),
-    /Copilot SuperApp/,
+    /AIX Harness \/ Copilot/,
   );
   assert.equal(
     await page.locator('[data-turn-position]').textContent(),
@@ -1284,32 +1443,213 @@
   });
   const mobilePage = await mobileContext.newPage();
   await mobilePage.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' });
+  await mobilePage.waitForFunction(() =>
+    customElements.get('mjj-jrpg-composer') &&
+    customElements.get('mjj-jrpg-menu')
+  );
   const mobile = await mobilePage.evaluate(() => {
+    const workspace = document.querySelector('.jrpg-workspace')
+      .getBoundingClientRect();
     const scene = document.querySelector('.jrpg-scene').getBoundingClientRect();
     const utility = document.querySelector('.jrpg-utility').getBoundingClientRect();
+    const composer = document.querySelector('mjj-jrpg-composer')
+      .getBoundingClientRect();
+    const menu = document.querySelector('mjj-jrpg-menu').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 closeTo = (value, expected, tol = 0.025) =>
+      Math.abs(value - expected) < tol;
+    const wsW = workspace.width;
+    const wsH = workspace.height;
+    const mobileArt = getComputedStyle(
+      document.querySelector('.jrpg-workspace'),
+    ).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),
+    ].every(Boolean);
+    const hamburgerPresent = hamburger !== null &&
+      hamburger.getAttribute('aria-expanded') === 'true' &&
+      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
+        button.getBoundingClientRect().right <= innerWidth + 1
       ),
+      composerVisible,
+      fillsViewport: shell.left === 0 &&
+        shell.top === 0 &&
+        Math.abs(shell.right - innerWidth) < 1 &&
+        Math.abs(shell.bottom - innerHeight) < 1,
       fillsWidth: shell.left === 0 &&
         Math.abs(shell.right - innerWidth) < 1,
+      hamburgerPresent,
       initialScroll: scrollY,
+      mobileArtIsCorrect,
+      menuVisible,
       noOverflow: document.documentElement.scrollWidth <= innerWidth,
-      utilityBelowScene: utility.top >= scene.bottom,
+      telemetryCount,
+      utilityBelowScene: utility.top >= scene.bottom - 1,
+      workspaceFills: Math.abs(workspace.left) < 1 &&
+        Math.abs(workspace.top) < 1 &&
+        Math.abs(workspace.right - innerWidth) < 1 &&
+        Math.abs(workspace.bottom - innerHeight) < 1,
     };
   });
-  assert.deepEqual(mobile, {
-    buttonsFit: true,
-    fillsWidth: true,
-    initialScroll: 0,
-    noOverflow: true,
-    utilityBelowScene: true,
+  assert.equal(mobile.aperturesTolerance, true, 'mobile apertures within tolerance at 360x640');
+  assert.equal(
+    mobile.conversationsButtonVisible,
+    true,
+    'Conversations destination visible on mobile',
+  );
+  assert.equal(mobile.buttonsFit, true, 'menu buttons fit within viewport width');
+  assert.equal(mobile.composerVisible, true, 'composer visible');
+  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.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.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(
+    await mobilePage.locator('[data-mobile-menu-toggle]')
+      .getAttribute('aria-expanded'),
+    'false',
+    'hamburger collapses the destination menu',
+  );
+  assert.equal(
+    await mobilePage.locator('mjj-jrpg-menu')
+      .getAttribute('data-mobile-menu-collapsed'),
+    '',
+    'menu exposes its collapsed state',
+  );
+  // Second click expands.
+  await mobilePage.locator('[data-mobile-menu-toggle]').click();
+  assert.equal(
+    await mobilePage.locator('[data-mobile-menu-toggle]')
+      .getAttribute('aria-expanded'),
+    'true',
+    'hamburger expands the destination menu',
+  );
+  // Hamburger keyboard accessible
+  assert.ok(
+    await mobilePage.locator('[data-mobile-menu-toggle]').evaluate(el =>
+      el.tabIndex >= 0 || el.tabIndex === -1
+    ),
+    'hamburger button is in document',
+  );
+  await mobileContext.close();
+
+  // Test at 390x844
+  const mobile390Context = await browser.newContext({
+    colorScheme: 'light',
+    viewport: { width: 390, height: 844 },
   });
-  await mobileContext.close();
+  const mobile390Page = await mobile390Context.newPage();
+  await mobile390Page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' });
+  const mobile390 = await mobile390Page.evaluate(() => {
+    const workspace = document.querySelector('.jrpg-workspace')
+      .getBoundingClientRect();
+    const shell = document.querySelector('.jrpg-shell').getBoundingClientRect();
+    const mobileArt = getComputedStyle(
+      document.querySelector('.jrpg-workspace'),
+    ).backgroundImage;
+    return {
+      fillsViewport: shell.left === 0 &&
+        shell.top === 0 &&
+        Math.abs(shell.right - innerWidth) < 1 &&
+        Math.abs(shell.bottom - innerHeight) < 1,
+      mobileArtIsCorrect: mobileArt.includes('background-frame-mobile.webp'),
+      noOverflow: document.documentElement.scrollWidth <= innerWidth,
+      workspaceFills: Math.abs(workspace.left) < 1 &&
+        Math.abs(workspace.top) < 1 &&
+        Math.abs(workspace.right - innerWidth) < 1 &&
+        Math.abs(workspace.bottom - innerHeight) < 1,
+    };
+  });
+  assert.equal(mobile390.fillsViewport, true, 'fills viewport at 390x844');
+  assert.equal(mobile390.mobileArtIsCorrect, true, 'mobile art at 390x844');
+  assert.equal(mobile390.noOverflow, true, 'no overflow at 390x844');
+  assert.equal(mobile390.workspaceFills, true, 'workspace fills at 390x844');
+  await mobile390Context.close();
+
+  // Test at 320x568 (narrow)
+  const mobile320Context = await browser.newContext({
+    colorScheme: 'light',
+    viewport: { width: 320, height: 568 },
+  });
+  const mobile320Page = await mobile320Context.newPage();
+  await mobile320Page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' });
+  const mobile320 = await mobile320Page.evaluate(() => {
+    const workspace = document.querySelector('.jrpg-workspace')
+      .getBoundingClientRect();
+    const shell = document.querySelector('.jrpg-shell').getBoundingClientRect();
+    const composer = document.querySelector('mjj-jrpg-composer')
+      .getBoundingClientRect();
+    const menu = document.querySelector('mjj-jrpg-menu').getBoundingClientRect();
+    const mobileArt = getComputedStyle(
+      document.querySelector('.jrpg-workspace'),
+    ).backgroundImage;
+    return {
+      composerPresent: composer.width > 0 && composer.height > 0,
+      fillsViewport: shell.left === 0 &&
+        shell.top === 0 &&
+        Math.abs(shell.right - innerWidth) < 1 &&
+        Math.abs(shell.bottom - innerHeight) < 1,
+      menuPresent: menu.width > 0 && menu.height > 0,
+      mobileArtIsCorrect: mobileArt.includes('background-frame-mobile.webp'),
+      noOverflow: document.documentElement.scrollWidth <= innerWidth,
+      workspaceFills: Math.abs(workspace.left) < 1 &&
+        Math.abs(workspace.top) < 1 &&
+        Math.abs(workspace.right - innerWidth) < 1 &&
+        Math.abs(workspace.bottom - innerHeight) < 1,
+    };
+  });
+  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.mobileArtIsCorrect, true, 'mobile art at 320x568');
+  assert.equal(mobile320.noOverflow, true, 'no overflow at 320x568');
+  assert.equal(mobile320.workspaceFills, true, 'workspace fills at 320x568');
+  await mobile320Context.close();
 
   const shortContext = await browser.newContext({
     colorScheme: 'dark',
@@ -1598,6 +1938,373 @@
   });
 }
 
+/* ------------------------------------------------------------------ */
+/* Conversation URL routing regression tests (Fixes 1–4)              */
+/* ------------------------------------------------------------------ */
+
+async function testConversationRouting(browser) {
+  const CONV_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
+  const CONV_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
+  const GUEST_SESSION = {
+    kind: 'guest', csrfToken: 'test-csrf',
+    quota: {
+      turnsLimit: 10, turnsUsed: 0, turnsRemaining: 10,
+      outputTokensLimit: 10000, outputTokensUsed: 0,
+      outputTokensReserved: 0, outputTokensRemaining: 10000,
+      resetsAt: Math.floor(Date.now() / 1000) + 86400,
+    },
+  };
+
+  /* ---- Fix 3: malformed ?conversation param must not fall back to sessionStorage ---- */
+  {
+    const storedId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
+    const context = await browser.newContext({ serviceWorkers: 'block' });
+    await context.addInitScript(id => {
+      sessionStorage.setItem('mjj-jrpg-conversation-id', id);
+    }, storedId);
+    const page = await context.newPage();
+    const storedIdFetched = [];
+    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.endsWith(`/${storedId}`)) {
+        storedIdFetched.push(route.request().url());
+      }
+      if (url.pathname === '/api/conversations') {
+        return route.fulfill({
+          status: 200, contentType: 'application/json',
+          body: JSON.stringify({ conversations: [], cursor: null }),
+        });
+      }
+      return route.fulfill({
+        status: 404, contentType: 'application/json',
+        body: JSON.stringify({ error: { message: 'Not found' } }),
+      });
+    });
+    await page.route('**/api/conversations/claim', route => route.fulfill({
+      status: 503,
+      contentType: 'application/json',
+      body: JSON.stringify({ error: { message: 'Service unavailable' } }),
+    }));
+    await page.goto(`${baseUrl}/jrpg?conversation=not-a-valid-uuid`, {
+      waitUntil: 'networkidle',
+    });
+    assert.equal(
+      storedIdFetched.length, 0,
+      'Fix 3: malformed ?conversation param must not trigger sessionStorage fallback',
+    );
+    await context.close();
+  }
+
+  /* ---- Fix 4: legacy claim: key retained on 5xx, button re-renders and re-fires ---- */
+  {
+    const legacyStoredId = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
+    const context = await browser.newContext({ serviceWorkers: 'block' });
+    /* Seed CONVERSATION_STORAGE_KEY so init migrates it to CONVERSATION_LEGACY_KEY */
+    await context.addInitScript(id => {
+      sessionStorage.setItem('mjj-jrpg-conversation-id', id);
+    }, legacyStoredId);
+    const page = await context.newPage();
+    const pageErrors = [];
+    page.on('pageerror', e => {
+      if (!e.message.startsWith('Failed to load resource:')) {
+        pageErrors.push(`pageerror: ${e.message}`);
+      }
+    });
+    await page.route('**/api/auth/session', route => route.fulfill({
+      status: 200, contentType: 'application/json',
+      body: JSON.stringify({
+        kind: 'user', username: 'tester', role: 'member',
+        csrfToken: 'test-csrf', quota: null,
+      }),
+    }));
+    await page.route('**/api/conversations**', route => {
+      const url = new URL(route.request().url());
+      const req = route.request();
+      if (url.pathname === '/api/conversations' && req.method() === 'GET') {
+        return route.fulfill({
+          status: 200, contentType: 'application/json',
+          body: JSON.stringify({ conversations: [], cursor: null }),
+        });
+      }
+      if (url.pathname === `/api/conversations/${legacyStoredId}` && req.method() === 'GET') {
+        return route.fulfill({
+          status: 404, contentType: 'application/json',
+          body: JSON.stringify({ error: { message: 'Not found' } }),
+        });
+      }
+      if (url.pathname === '/api/conversations/claim' && req.method() === 'POST') {
+        return route.fulfill({
+          status: 503, contentType: 'application/json',
+          body: JSON.stringify({ error: { message: 'Service unavailable' } }),
+        });
+      }
+      return route.fulfill({
+        status: 404, contentType: 'application/json',
+        body: JSON.stringify({ error: { message: 'Not found' } }),
+      });
+    });
+    await page.route('**/api/conversations/claim', route => route.fulfill({
+      status: 503,
+      contentType: 'application/json',
+      body: JSON.stringify({ error: { message: 'Service unavailable' } }),
+    }));
+    await page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' });
+
+    /* Open archive panel so the legacy claim status is visible */
+    await page.locator('button[data-preview="conversations"]').click();
+    await page.locator('mjj-conversation-archive').waitFor({ state: 'visible' });
+
+    /* Init should have called showLegacyClaim() — wait for the button */
+    await page.waitForFunction(
+      () => document.querySelector('[data-archive-claim]') !== null,
+      null, { timeout: 5000 },
+    );
+
+    /* First claim attempt — returns 503 */
+    const firstClaimResponse = page.waitForResponse(
+      response => response.url().includes('/api/conversations/claim'),
+    );
+    await page.locator('[data-archive-claim]').click();
+    const firstClaimStatus = (await firstClaimResponse).status();
+    assert.equal(
+      firstClaimStatus,
+      503,
+      `claim fixture returns the mocked service failure, got ${firstClaimStatus}`,
+    );
+
+    /* Wait for the retry state to appear: showLegacyClaim re-rendered with error msg */
+    await page.waitForFunction(
+      () => {
+        const status = document.querySelector('[data-archive-status]');
+        return status && !status.hidden && status.textContent.includes('Claim failed');
+      },
+      null, { timeout: 5000 },
+    );
+
+    /* Key must be retained in sessionStorage on 5xx */
+    const keyAfterFail = await page.evaluate(
+      () => sessionStorage.getItem('mjj-jrpg-legacy-claim'),
+    );
+    assert.equal(
+      keyAfterFail, legacyStoredId,
+      'Fix 4: CONVERSATION_LEGACY_KEY must be retained after 5xx failure',
+    );
+
+    /* Button must be present and enabled for retry */
+    await page.locator('[data-archive-claim]').waitFor({ state: 'visible' });
+
+    /* Tag the current button so we can detect when it's replaced by a second re-render */
+    await page.locator('[data-archive-claim]').evaluate(btn => {
+      btn.dataset.claimGen = '1';
+    });
+
+    /* Second click: must dispatch event again — proves no {once:true} on button */
+    await page.locator('[data-archive-claim]').click();
+
+    /* Wait for the button to be replaced (showLegacyClaim called again = handler ran) */
+    await page.waitForFunction(
+      () => {
+        const btn = document.querySelector('[data-archive-claim]');
+        return btn !== null && btn.dataset.claimGen !== '1';
+      },
+      null, { timeout: 5000 },
+    );
+
+    assert.deepEqual(pageErrors, [], 'no page errors in legacy claim test');
+    await context.close();
+  }
+
+  /* ---- Fix 1: popstate during active stream converges URL and UI ---- */
+  {
+    let releaseStream = () => {};
+    const streamGate = new Promise(resolve => { releaseStream = resolve; });
+
+    const context = await browser.newContext();
+    const page = await context.newPage();
+    const pageErrors = [];
+    page.on('pageerror', e => {
+      if (!e.message.startsWith('Failed to load resource:')) {
+        pageErrors.push(`pageerror: ${e.message}`);
+      }
+    });
+    /* Suppress expected abort-network errors from the held-then-aborted stream */
+    page.on('requestfailed', request => {
+      if (request.failure()?.errorText === 'net::ERR_ABORTED') return;
+      pageErrors.push(`requestfailed: ${request.url()} ${request.failure()?.errorText || ''}`);
+    });
+
+    await page.route('**/api/auth/session', route => route.fulfill({
+      status: 200, contentType: 'application/json',
+      body: JSON.stringify(GUEST_SESSION),
+    }));
+    await page.route('**/api/conversations**', async route => {
+      const url = new URL(route.request().url());
+      const req = route.request();
+      if (url.pathname === '/api/conversations' && req.method() === 'GET') {
+        return route.fulfill({
+          status: 200, contentType: 'application/json',
+          body: JSON.stringify({ conversations: [], cursor: null }),
+        });
+      }
+      if (url.pathname === '/api/conversations' && req.method() === 'POST') {
+        return route.fulfill({
+          status: 201, contentType: 'application/json',
+          body: JSON.stringify({ id: CONV_A }),
+        });
+      }
+      if (url.pathname.endsWith('/turns') && req.method() === 'POST') {
+        /* Hold the SSE stream until the test releases it */
+        await streamGate;
+        try { await route.abort(); } catch { /* request may already be aborted */ }
+        return;
+      }
+      return route.fulfill({
+        status: 404, contentType: 'application/json',
+        body: JSON.stringify({ error: { message: 'Not found' } }),
+      });
+    });
+
+    await page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' });
+    await page.waitForFunction(
+      () => !document.querySelector('mjj-jrpg-composer textarea')?.disabled,
+    );
+
+    /* Submit — creates conversation via pushState and starts stream */
+    await page.locator('#jrpg-message').fill('routing race test');
+    await page.locator('#jrpg-message').press('Enter');
+
+    /* Stream is now active */
+    await page.locator('[data-cancel-control]').waitFor({ state: 'visible' });
+
+    const urlDuringStream = page.url();
+    assert.ok(
+      urlDuringStream.includes('conversation='),
+      `Fix 1: URL must have ?conversation during stream: ${urlDuringStream}`,
+    );
+
+    /* Navigate back while stream is active — URL already changes here */
+    await page.goBack();
+
+    /* Stream abort + pending-popstate application: cancel control disappears */
+    await page.locator('[data-cancel-control]').waitFor({ state: 'hidden' });
+
+    /* URL and UI must converge to the pre-stream state */
+    const finalUrl = page.url();
+    assert.ok(
+      !finalUrl.includes('conversation='),
+      `Fix 1: URL must not have ?conversation after popstate back: ${finalUrl}`,
+    );
+    assert.equal(
+      await page.locator('.jrpg-message[data-streaming="true"]').count(), 0,
+      'Fix 1: no streaming message must remain after popstate back',
+    );
+
+    releaseStream(); /* clean up the held route */
+    assert.deepEqual(pageErrors, [], 'no page errors in popstate-during-stream test');
+    await context.close();
+  }
+
+  /* ---- Fix 2: openSeq incremented on no-conv popstate, invalidates pending open ---- */
+  {
+    let resolveConvBFetch = () => {};
+    const convBFetchGate = new Promise(resolve => { resolveConvBFetch = resolve; });
+
+    const context = await browser.newContext();
+    const page = await context.newPage();
+    const pageErrors = [];
+    page.on('pageerror', e => {
+      if (!e.message.startsWith('Failed to load resource:')) {
+        pageErrors.push(`pageerror: ${e.message}`);
+      }
+    });
+
+    await page.route('**/api/auth/session', route => route.fulfill({
+      status: 200, contentType: 'application/json',
+      body: JSON.stringify(GUEST_SESSION),
+    }));
+    await page.route('**/api/conversations**', async route => {
+      const url = new URL(route.request().url());
+      const req = route.request();
+      /* Empty archive: no auto-open, no gate deadlock */
+      if (url.pathname === '/api/conversations' && req.method() === 'GET') {
+        return route.fulfill({
+          status: 200, contentType: 'application/json',
+          body: JSON.stringify({ conversations: [], cursor: null }),
+        });
+      }
+      /* The archive-open click will trigger this; hold it with the gate */
+      if (url.pathname === `/api/conversations/${CONV_B}` && req.method() === 'GET') {
+        await convBFetchGate;
+        return route.fulfill({
+          status: 200, contentType: 'application/json',
+          body: JSON.stringify({ id: CONV_B, title: 'Conv B', turns: [] }),
+        });
+      }
+      return route.fulfill({
+        status: 404, contentType: 'application/json',
+        body: JSON.stringify({ error: { message: 'Not found' } }),
+      });
+    });
+
+    /* Empty archive so init completes without auto-opening any conversation */
+    await page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' });
+
+    /* Open archive panel — this pushes a history entry (conversations panel) */
+    await page.locator('button[data-preview="conversations"]').click();
+    await page.locator('mjj-conversation-archive').waitFor({ state: 'visible' });
+
+    /* Inject Conv B into the archive list without triggering an API fetch */
+    await page.evaluate(id => {
+      document.querySelector('mjj-conversation-archive')
+        ?.addConversation({ id, title: 'Conv B', turn_count: 0 });
+    }, CONV_B);
+
+    await page.waitForFunction(
+      id => [...document.querySelectorAll('[data-conv-item]')]
+        .some(item => item._convId === id),
+      CONV_B,
+    );
+
+    /* Click Conv B — archive-open fires, fetch held by convBFetchGate */
+    await page.evaluate(id => {
+      for (const item of document.querySelectorAll('[data-conv-item]')) {
+        if (item._convId === id) {
+          item.querySelector('[data-conv-open]')?.click();
+          break;
+        }
+      }
+    }, CONV_B);
+
+    /* Brief pause so archive-open can increment openSeq and start the fetch */
+    await page.waitForTimeout(80);
+
+    /* Navigate back — popstate fires with no-conv state, openSeq is incremented */
+    await page.goBack();
+    await page.waitForTimeout(80);
+
+    /* Release the held fetch — the archive-open handler should ignore it */
+    resolveConvBFetch();
+    await page.waitForTimeout(300);
+
+    /* currentConversationId must remain null (no aria-current item) */
+    const currentAriaItem = await page.evaluate(() => {
+      const btn = document.querySelector('[data-conv-open][aria-current="true"]');
+      return btn ? (btn.closest('[data-conv-item]')?._convId ?? null) : null;
+    });
+    assert.equal(
+      currentAriaItem, null,
+      'Fix 2: pending archive-open must not overwrite no-conv popstate',
+    );
+
+    assert.deepEqual(pageErrors, [], 'no page errors in openSeq test');
+    await context.close();
+  }
+}
+
 (async () => {
   assert.ok(RUNFILES);
   assert.ok(WORKSPACE);
@@ -1682,7 +2389,7 @@
     for (const source of [home, dogGame, manifest]) {
       assert.doesNotMatch(source, /\.png(?:["')]|$)/i);
     }
-    assert.match(serviceWorker, /v30-card-driven-details/);
+    assert.match(serviceWorker, /v34-login-modal/);
     assert.match(
       await (
         await fetch(`${baseUrl}/public/pwa-register.js`)
@@ -1711,44 +2418,47 @@
       headless: true,
       args: ['--no-sandbox'],
     });
-    const paper = await sampleTheme(browser, 'paper');
-    const ink = await sampleTheme(browser, 'ink');
-    const playful = await sampleTheme(browser, 'playful');
-    const automatic = await sampleTheme(browser, 'auto');
-    assert.equal(paper.rootTheme, 'paper');
-    assert.equal(ink.rootTheme, 'ink');
-    assert.equal(playful.rootTheme, 'playful');
-    assert.equal(automatic.rootTheme, 'auto');
-    assert.equal(paper.themeLabel, 'Paper');
-    assert.equal(ink.themeLabel, 'Ink');
-    assert.equal(playful.themeLabel, 'Playful');
-    assert.equal(automatic.themeLabel, 'Auto');
-    for (const sample of [paper, ink, playful, automatic]) {
-      assert.ok(sample.count > 0);
-      assert.equal(sample.backgroundRepeat, 'no-repeat');
-      assert.equal(sample.backgroundSize, 'cover');
-      assert.equal(sample.bodyCoversViewport, true);
-      assert.equal(sample.componentReady, true);
-      assert.ok(sample.links > 0);
-      assert.equal(sample.enhancedLinks, sample.pawLinks);
-      assert.match(sample.fontFamily, /More/);
-      assert.equal(sample.headerPaws, 0);
-      assert.ok(sample.textContrast >= 4.5, JSON.stringify(sample));
-      assert.equal(sample.mainBackground, 'rgba(0, 0, 0, 0)');
-      assert.equal(sample.themeButtonBackground, 'rgba(0, 0, 0, 0)');
-      assert.equal(sample.themeButtonBorder, '0px');
-      assert.equal(sample.themeButtonShadow, 'none');
+    if (runsSuite('core')) {
+      const paper = await sampleTheme(browser, 'paper');
+      const ink = await sampleTheme(browser, 'ink');
+      const playful = await sampleTheme(browser, 'playful');
+      const automatic = await sampleTheme(browser, 'auto');
+      assert.equal(paper.rootTheme, 'paper');
+      assert.equal(ink.rootTheme, 'ink');
+      assert.equal(playful.rootTheme, 'playful');
+      assert.equal(automatic.rootTheme, 'auto');
+      assert.equal(paper.themeLabel, 'Paper');
+      assert.equal(ink.themeLabel, 'Ink');
+      assert.equal(playful.themeLabel, 'Playful');
+      assert.equal(automatic.themeLabel, 'Auto');
+      for (const sample of [paper, ink, playful, automatic]) {
+        assert.ok(sample.count > 0);
+        assert.equal(sample.backgroundRepeat, 'no-repeat');
+        assert.equal(sample.backgroundSize, 'cover');
+        assert.equal(sample.bodyCoversViewport, true);
+        assert.equal(sample.componentReady, true);
+        assert.ok(sample.links > 0);
+        assert.equal(sample.enhancedLinks, sample.pawLinks);
+        assert.match(sample.fontFamily, /More/);
+        assert.equal(sample.headerPaws, 0);
+        assert.ok(sample.textContrast >= 4.5, JSON.stringify(sample));
+        assert.equal(sample.mainBackground, 'rgba(0, 0, 0, 0)');
+        assert.equal(sample.themeButtonBackground, 'rgba(0, 0, 0, 0)');
+        assert.equal(sample.themeButtonBorder, '0px');
+        assert.equal(sample.themeButtonShadow, 'none');
+      }
+      assert.ok(paper.luminance < 175, JSON.stringify(paper));
+      assert.ok(playful.luminance < 175, JSON.stringify(playful));
+      assert.ok(ink.luminance > 200, JSON.stringify(ink));
+      await testThemeCycle(browser);
+      await testPlainField(browser);
+      await testButtonScale(browser);
+      await testDynamicButtonOwnership(browser);
+      await testResumePrint(browser);
     }
-    assert.ok(paper.luminance < 175, JSON.stringify(paper));
-    assert.ok(playful.luminance < 175, JSON.stringify(playful));
-    assert.ok(ink.luminance > 200, JSON.stringify(ink));
-    await testThemeCycle(browser);
-    await testPlainField(browser);
-    await testButtonScale(browser);
-    await testDynamicButtonOwnership(browser);
-    await testResumePrint(browser);
-    await testJrpgPage(browser);
-    await testHlsPlayer(browser, siteRoot);
+    if (runsSuite('jrpg')) await testJrpgPage(browser);
+    if (runsSuite('routing')) await testConversationRouting(browser);
+    if (runsSuite('hls')) await testHlsPlayer(browser, siteRoot);
   } finally {
     if (browser) await browser.close();
     await stopProcess(server);