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