Mercurial
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);