Mercurial
changeset 273:e02e2036ef84 default tip
add Layer 2 JRPG component system
Add reusable content and window modals, an isolated component sandbox, shared cyberpunk scroll areas, production-safe cache freshness, and server-rendered JRPG panel state.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Sat, 08 Aug 2026 02:08:08 -0700 |
| parents | 41a49c29a28f |
| children | |
| files | mrjunejune/PWA_SETUP.md mrjunejune/main.c mrjunejune/src/jrpg/index.html mrjunejune/src/jrpg/jrpg.css mrjunejune/src/jrpg/jrpg.js mrjunejune/src/public/component-sandbox.css mrjunejune/src/public/component-sandbox.html mrjunejune/src/public/composer-lab.html mrjunejune/src/public/mjj-composer.js mrjunejune/src/public/mjj-modal.css mrjunejune/src/public/mjj-modal.js mrjunejune/src/public/pwa-register.js mrjunejune/src/sw.js mrjunejune/test/snapshots/jrpg.snapshot mrjunejune/test/theme_and_webp_test.js |
| diffstat | 15 files changed, 2067 insertions(+), 610 deletions(-) [+] |
line wrap: on
line diff
--- a/mrjunejune/PWA_SETUP.md Fri Aug 07 16:05:29 2026 -0700 +++ b/mrjunejune/PWA_SETUP.md Sat Aug 08 02:08:08 2026 -0700 @@ -58,7 +58,7 @@ ✅ **Offline Support** - Caches pages, CSS, JS, fonts, images ✅ **App Shortcuts** - Quick access to Blog and Notes ✅ **Install Prompt** - Automatic install button -✅ **Auto-updates** - Service worker updates on reload +✅ **Auto-updates** - New service workers activate and reload clients automatically ✅ **Fast Loading** - Cached resources load instantly ## Customization @@ -72,7 +72,13 @@ Edit `sw.js` to change: - `CACHE_VERSION` - Increment to force cache refresh - `STATIC_CACHE` - Files to cache immediately -- Caching strategy (currently: cache-first with network fallback) +- Caching strategy: + - HTML, CSS, JS, JSON, and WASM are network-first with offline cache fallback. + - Fonts and images are stale-while-revalidate. + - Component sandbox assets always bypass service-worker caching. + +Unversioned application code must not use cache-first. That can preserve stale +production CSS or JavaScript indefinitely when a deployment reuses the same URL. ## Testing on Mobile
--- a/mrjunejune/main.c Fri Aug 07 16:05:29 2026 -0700 +++ b/mrjunejune/main.c Sat Aug 08 02:08:08 2026 -0700 @@ -1214,12 +1214,145 @@ return resp; } +typedef struct { + const char *name; + const char *title; + const char *copy; + const char *works; +} Jrpg_Initial_Panel; + +static boolean Mjj_Replace_All( + char *buffer, + size_t capacity, + const char *needle, + const char *replacement) +{ + if (!buffer || !needle || !replacement || needle[0] == '\0') + return FALSE; + + size_t needle_length = strlen(needle); + size_t replacement_length = strlen(replacement); + char *match = strstr(buffer, needle); + while (match) + { + size_t current_length = strlen(buffer); + size_t offset = (size_t)(match - buffer); + size_t new_length = + current_length - needle_length + replacement_length; + if (new_length >= capacity) + return FALSE; + + memmove( + match + replacement_length, + match + needle_length, + current_length - offset - needle_length + 1); + memcpy(match, replacement, replacement_length); + match = strstr(match + replacement_length, needle); + } + return TRUE; +} + +static const Jrpg_Initial_Panel *Jrpg_Resolve_Initial_Panel( + Seobeo_Request_Entry *req) +{ + static const Jrpg_Initial_Panel panels[] = { + { + "resume", + "Resume", + "Member of Technical Staff and engineering leader with 10+ years " + "building AI agent platforms and production systems across Microsoft, " + "Meta, Google, and growth-stage companies.", + "<li><a href=\"/resume\" data-resume-modal>" + "<span>Full resume</span><small>Career dossier</small></a></li>" + "<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>" + }, + { + "tools", + "Tools", + "Useful browser tools backed by first-party C, WASM, media, and " + "document-processing systems.", + "<li><a href=\"/tools/markdown_to_html\" data-latest-tool " + "data-tool-url=\"/tools/markdown_to_html\">" + "<span>Markdown</span><small>Writing</small></a></li>" + "<li><a href=\"/tools/file_converter\" data-latest-tool " + "data-tool-url=\"/tools/file_converter\">" + "<span>Converter</span><small>Media</small></a></li>" + "<li><a href=\"/tools/hls_player\" data-latest-tool " + "data-tool-url=\"/tools/hls_player\">" + "<span>HLS Player</span><small>Streaming</small></a></li>" + }, + { + "blog", + "Blogs", + "Technical writing about networking, rendering, performance, developer " + "tooling, and experiments.", + "<li><a href=\"/blog\" data-blog-archive>" + "<span>All posts</span><small>Archive</small></a></li>" + }, + { + "conversations", + "Resume", + "", + "" + }, + }; + + const char *requested = NULL; + void *panel_kv = Dowa_HashMap_Get_Ptr(req, "query_panel"); + if (panel_kv) + requested = ((Seobeo_Request_Entry *)panel_kv)->value; + + for (size_t i = 0; i < sizeof(panels) / sizeof(panels[0]); ++i) + { + if (requested && strcmp(requested, panels[i].name) == 0) + return &panels[i]; + } + return &panels[0]; +} + Seobeo_Request_Entry *GetJrpg(Seobeo_Request_Entry *req, Dowa_Arena *arena) { Seobeo_Request_Entry *resp = NULL; char *final_body = Dowa_Arena_Allocate(arena, HTML_PAGE_CAP); if (!final_body || !Mjj_Template_Render_File(final_body, HTML_PAGE_CAP, "/jrpg/index.html", arena)) return html_render_error(arena, "Internal Server Error"); + + const Jrpg_Initial_Panel *panel = Jrpg_Resolve_Initial_Panel(req); + boolean conversations = + strcmp(panel->name, "conversations") == 0 ? TRUE : FALSE; + const char *preview_selection = conversations ? "resume" : panel->name; + struct { + const char *token; + const char *value; + } replacements[] = { + {"__MJJ_PANEL__", panel->name}, + {"__MJJ_PREVIEW_SELECTION__", preview_selection}, + {"__MJJ_PREVIEW_TITLE__", panel->title}, + {"__MJJ_PREVIEW_COPY__", panel->copy}, + {"__MJJ_PREVIEW_WORKS__", panel->works}, + {"__MJJ_PREVIEW_HIDDEN__", conversations ? "hidden" : ""}, + {"__MJJ_ARCHIVE_HIDDEN__", conversations ? "" : "hidden"}, + {"__MJJ_RESUME_PRESSED__", + strcmp(panel->name, "resume") == 0 ? "true" : "false"}, + {"__MJJ_TOOLS_PRESSED__", + strcmp(panel->name, "tools") == 0 ? "true" : "false"}, + {"__MJJ_BLOG_PRESSED__", + strcmp(panel->name, "blog") == 0 ? "true" : "false"}, + {"__MJJ_CONVERSATIONS_PRESSED__", conversations ? "true" : "false"}, + }; + for (size_t i = 0; + i < sizeof(replacements) / sizeof(replacements[0]); + ++i) + { + if (!Mjj_Replace_All( + final_body, + HTML_PAGE_CAP, + replacements[i].token, + replacements[i].value)) + return html_render_error(arena, "Internal Server Error"); + } Dowa_HashMap_Push_Arena(resp, "body", final_body, arena); Dowa_HashMap_Push_Arena(resp, "referrer-policy", "no-referrer", arena); return resp;
--- a/mrjunejune/src/jrpg/index.html Fri Aug 07 16:05:29 2026 -0700 +++ b/mrjunejune/src/jrpg/index.html Sat Aug 08 02:08:08 2026 -0700 @@ -7,13 +7,18 @@ <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="/public/mjj-modal.css"> <link rel="stylesheet" href="/jrpg/jrpg.css"> <script type="module" src="/public/mjj-composer.js"></script> + <script type="module" src="/public/mjj-modal.js"></script> <script type="module" src="/jrpg/jrpg.js"></script> </head> <body class="jrpg-page"> <main class="jrpg-main"> - <mjj-jrpg-shell class="jrpg-shell"> + <mjj-jrpg-shell + class="jrpg-shell" + data-initial-panel="__MJJ_PANEL__" + > <div class="jrpg-workspace"> <div class="jrpg-brand"> <header> @@ -49,61 +54,67 @@ </a> </zen-button> </nav> - <zen-dialog + <mjj-window-modal 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" - > - <header class="jrpg-mobile-destination-header"> - <zen-button - appearance="plain" - size="sm" - data-mobile-destination-back-owner - hidden + <zen-dialog> + <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" > - <button - type="button" - data-mobile-destination-back - aria-label="Back to destination menu" + <zen-icon name="menu"></zen-icon> + </button> + </zen-button> + <dialog + class="jrpg-mobile-destination-dialog" + data-mobile-destination-dialog + > + <header class="jrpg-mobile-destination-header" data-modal-header> + <zen-button + appearance="plain" + size="sm" + data-mobile-destination-back-owner + hidden > - <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> + <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 + data-modal-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> + <zen-scroll-area + class="jrpg-mobile-destination-content" + data-mobile-destination-content + data-modal-body + data-cyber-scroll + tabindex="0" + aria-label="Destination content" + ></zen-scroll-area> + </dialog> + </zen-dialog> + </mjj-window-modal> <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> @@ -147,52 +158,62 @@ </section> <section class="jrpg-utility" aria-label="Selected destination"> - <mjj-jrpg-preview data-selection="resume"> + <mjj-jrpg-preview data-selection="__MJJ_PREVIEW_SELECTION__"> <article class="jrpg-preview-panel" aria-live="polite"> - <div class="jrpg-preview-content" data-preview-content> + <div + class="jrpg-preview-content" + data-preview-content + __MJJ_PREVIEW_HIDDEN__ + > <zen-heading size="xl"> - <h2 data-preview-title>Resume</h2> + <h2 data-preview-title>__MJJ_PREVIEW_TITLE__</h2> </zen-heading> <p data-preview-copy> - Member of Technical Staff and engineering leader with 10+ years building AI agent platforms and production systems. + __MJJ_PREVIEW_COPY__ </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> + <ul class="jrpg-work-showcase" data-work-showcase>__MJJ_PREVIEW_WORKS__</ul> + <mjj-content-modal data-detail-modal-owner> + <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"> + <header class="jrpg-dialog-heading" data-modal-header> + <div data-modal-heading> + <p data-modal-kicker>DESTINATION DATA</p> + <h2 + id="jrpg-dialog-title" + data-dialog-title + data-modal-title + >Resume</h2> + </div> <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> + <zen-button + appearance="plain" + size="sm" + 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"> + <zen-button appearance="plain" size="sm"> <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> + </div> + </header> + <zen-scroll-area + class="jrpg-detail-body" + data-modal-body + data-cyber-scroll + tabindex="0" + aria-label="Destination details" + > + <div class="jrpg-detail-content" data-modal-article> + <p data-dialog-copy>Experience, projects, and the systems I have helped build.</p> <section class="jrpg-resume-dossier" data-resume-dossier @@ -229,18 +250,16 @@ <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> + </div> + </zen-scroll-area> + </dialog> + </zen-dialog> + </mjj-content-modal> </div> - <mjj-conversation-archive hidden aria-label="Conversations"> + <mjj-conversation-archive + __MJJ_ARCHIVE_HIDDEN__ + aria-label="Conversations" + > <div class="jrpg-archive-heading"> <h2>Archive</h2> <zen-button appearance="plain" size="xs"> @@ -263,52 +282,83 @@ <button type="button" data-archive-load-more>Load more</button> </zen-button> <p class="jrpg-archive-status" data-archive-status aria-live="polite">Loading conversations...</p> - <dialog - class="jrpg-archive-dialog" - data-archive-delete-dialog - aria-labelledby="archive-delete-title" - aria-modal="true" - > - <p id="archive-delete-title" class="jrpg-archive-dialog-title">Delete this conversation?</p> - <p data-archive-delete-name class="jrpg-archive-dialog-subtitle"></p> - <div class="jrpg-archive-dialog-actions"> - <zen-button appearance="plain" size="md"> - <button type="button" data-archive-delete-cancel>Cancel</button> - </zen-button> - <zen-button appearance="plain" size="md"> - <button type="button" data-archive-delete-confirm>Delete</button> + <mjj-content-modal data-archive-delete-owner> + <zen-dialog> + <zen-button appearance="plain" size="xs" hidden> + <button type="button" data-zen-trigger tabindex="-1" aria-hidden="true"></button> </zen-button> - </div> - </dialog> - <dialog - class="jrpg-archive-dialog" - data-archive-rename-dialog - aria-labelledby="archive-rename-title" - aria-modal="true" - > - <p id="archive-rename-title" class="jrpg-archive-dialog-title">Rename conversation</p> - <form data-archive-rename-form novalidate> - <zen-field appearance="plain" size="md"> - <label for="archive-rename-input">New title</label> - <input - id="archive-rename-input" - data-archive-rename-input - type="text" - maxlength="200" - required - autocomplete="off" + <dialog + class="jrpg-archive-dialog" + data-archive-delete-dialog + > + <header data-modal-header> + <h2 + id="archive-delete-title" + class="jrpg-archive-dialog-title" + data-modal-title + >Delete this conversation?</h2> + </header> + <div data-modal-body> + <div data-modal-article> + <p data-archive-delete-name class="jrpg-archive-dialog-subtitle"></p> + </div> + </div> + <footer class="jrpg-archive-dialog-actions" data-modal-footer> + <zen-button appearance="plain" size="md"> + <button type="button" data-archive-delete-cancel>Cancel</button> + </zen-button> + <zen-button appearance="plain" size="md"> + <button type="button" data-archive-delete-confirm>Delete</button> + </zen-button> + </footer> + </dialog> + </zen-dialog> + </mjj-content-modal> + <mjj-window-modal data-archive-rename-owner> + <zen-dialog> + <zen-button appearance="plain" size="xs" hidden> + <button type="button" data-zen-trigger tabindex="-1" aria-hidden="true"></button> + </zen-button> + <dialog + class="jrpg-archive-dialog" + data-archive-rename-dialog + > + <header data-modal-header> + <h2 + id="archive-rename-title" + class="jrpg-archive-dialog-title" + data-modal-title + >Rename conversation</h2> + </header> + <form + id="archive-rename-form" + data-modal-body + data-archive-rename-form + novalidate > - </zen-field> - <div class="jrpg-archive-dialog-actions"> + <zen-field appearance="plain" size="md"> + <label for="archive-rename-input">New title</label> + <input + id="archive-rename-input" + data-archive-rename-input + type="text" + maxlength="200" + required + autocomplete="off" + > + </zen-field> + </form> + <footer class="jrpg-archive-dialog-actions" data-modal-footer> <zen-button appearance="plain" size="md"> <button type="button" data-archive-rename-cancel>Cancel</button> </zen-button> <zen-button appearance="plain" size="md"> - <button type="submit">Save</button> + <button type="submit" form="archive-rename-form">Save</button> </zen-button> - </div> - </form> - </dialog> + </footer> + </dialog> + </zen-dialog> + </mjj-window-modal> </mjj-conversation-archive> </article> </mjj-jrpg-preview> @@ -372,7 +422,7 @@ <ul> <li> <zen-button appearance="plain" size="sm"> - <button type="button" data-preview="resume" aria-pressed="true"> + <button type="button" data-preview="resume" aria-pressed="__MJJ_RESUME_PRESSED__"> <zen-icon name="chevron-right"></zen-icon> Resume </button> @@ -380,7 +430,7 @@ </li> <li> <zen-button appearance="plain" size="sm"> - <button type="button" data-preview="tools" aria-pressed="false"> + <button type="button" data-preview="tools" aria-pressed="__MJJ_TOOLS_PRESSED__"> <zen-icon name="chevron-right"></zen-icon> Tools </button> @@ -388,7 +438,7 @@ </li> <li> <zen-button appearance="plain" size="sm"> - <button type="button" data-preview="blog" aria-pressed="false"> + <button type="button" data-preview="blog" aria-pressed="__MJJ_BLOG_PRESSED__"> <zen-icon name="chevron-right"></zen-icon> Blogs </button> @@ -396,7 +446,7 @@ </li> <li> <zen-button appearance="plain" size="sm"> - <button type="button" data-preview="conversations" aria-pressed="false"> + <button type="button" data-preview="conversations" aria-pressed="__MJJ_CONVERSATIONS_PRESSED__"> <zen-icon name="chevron-right"></zen-icon> Conversations </button> @@ -406,26 +456,30 @@ </nav> </mjj-jrpg-menu> - <zen-dialog class="jrpg-login-dialog-owner" data-login-dialog-owner> - <zen-button appearance="plain" size="md" hidden> - <button type="button" data-zen-trigger tabindex="-1" aria-hidden="true"></button> - </zen-button> - <dialog - class="jrpg-login-dialog" - data-login-dialog - aria-labelledby="jrpg-login-title" - aria-modal="true" - > - <div class="jrpg-login-dialog-header"> - <h2 id="jrpg-login-title">SIGN IN</h2> - <zen-button appearance="plain" size="md"> - <button type="button" data-zen-close aria-label="Close sign-in dialog"> - <zen-icon name="close"></zen-icon> - </button> - </zen-button> - </div> - <div role="alert" data-login-error aria-live="assertive" hidden></div> - <form data-login-form novalidate> + <mjj-window-modal class="jrpg-login-dialog-owner" data-login-dialog-owner> + <zen-dialog> + <zen-button appearance="plain" size="md" hidden> + <button type="button" data-zen-trigger tabindex="-1" aria-hidden="true"></button> + </zen-button> + <dialog + class="jrpg-login-dialog" + data-login-dialog + > + <header class="jrpg-login-dialog-header" data-modal-header> + <h2 id="jrpg-login-title" data-modal-title>SIGN IN</h2> + <zen-button appearance="plain" size="md"> + <button type="button" data-zen-close aria-label="Close sign-in dialog"> + <zen-icon name="close"></zen-icon> + </button> + </zen-button> + </header> + <form + id="jrpg-login-form" + data-login-form + data-modal-body + novalidate + > + <div role="alert" data-login-error aria-live="assertive" hidden></div> <zen-field appearance="plain" size="md"> <label for="jrpg-login-username">Username</label> <input @@ -451,17 +505,22 @@ maxlength="1024" > </zen-field> - <div class="jrpg-login-dialog-actions"> + </form> + <footer class="jrpg-login-dialog-actions" data-modal-footer> <zen-button appearance="plain" size="md"> <button type="button" data-login-cancel data-zen-close>Cancel</button> </zen-button> <zen-button appearance="plain" size="md"> - <button type="submit" data-login-submit>Sign in</button> + <button + type="submit" + form="jrpg-login-form" + data-login-submit + >Sign in</button> </zen-button> - </div> - </form> - </dialog> - </zen-dialog> + </footer> + </dialog> + </zen-dialog> + </mjj-window-modal> </div> </mjj-jrpg-shell> </main>
--- a/mrjunejune/src/jrpg/jrpg.css Fri Aug 07 16:05:29 2026 -0700 +++ b/mrjunejune/src/jrpg/jrpg.css Sat Aug 08 02:08:08 2026 -0700 @@ -41,7 +41,15 @@ --zenbu-sys-font-family-ui: "Pixel Mplus"; --zenbu-sys-font-family-content: "Pixel Mplus"; --zenbu-sys-font-family-code: "Pixel Mplus"; + --zenbu-sys-type-caption-family: "Pixel Mplus"; --zenbu-sys-type-label-family: "Pixel Mplus"; + --zenbu-sys-type-body-family: "Pixel Mplus"; + --zenbu-sys-type-body-large-family: "Pixel Mplus"; + --zenbu-sys-type-subtitle-family: "Pixel Mplus"; + --zenbu-sys-type-title-family: "Pixel Mplus"; + --zenbu-sys-type-page-title-family: "Pixel Mplus"; + --zenbu-sys-type-reading-family: "Pixel Mplus"; + --zenbu-sys-type-code-family: "Pixel Mplus"; min-width: 20rem; min-height: 100dvh; overflow: hidden; @@ -741,38 +749,6 @@ box-sizing: border-box; width: min(82vw, 46rem); max-width: calc(100% - var(--zenbu-sys-space-content)); - padding: var(--zenbu-sys-padding-xl); - border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-frame); - border-radius: 0; - background: var(--mjj-jrpg-surface-raised); - 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-preview-dialog dialog::backdrop { - background: color-mix(in srgb, var(--mjj-jrpg-canvas) 84%, transparent); -} - -.jrpg-preview-dialog dialog[open] { - animation: mjj-jrpg-dialog-open var(--zenbu-sys-motion-duration-layout) - var(--zenbu-sys-motion-ease-enter); -} - -@keyframes mjj-jrpg-dialog-open { - from { - clip-path: inset(48% 25%); - transform: scaleX(0.35); - } - to { - clip-path: inset(0); - transform: scaleX(1); - } } .jrpg-dialog-heading { @@ -780,8 +756,6 @@ align-items: center; justify-content: space-between; gap: var(--zenbu-sys-space-group); - padding-bottom: var(--zenbu-sys-padding-md); - border-bottom: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft); } .jrpg-dialog-heading p, @@ -800,8 +774,13 @@ } .jrpg-dialog-heading zen-button > :where(button, a) { + box-sizing: border-box; width: auto; min-width: var(--zenbu-control-height); + height: var(--zenbu-control-height); + padding: + var(--zenbu-control-padding-block) + var(--zenbu-control-padding-inline); border-color: var(--mjj-jrpg-accent); background: var(--mjj-jrpg-surface); color: var(--mjj-jrpg-text); @@ -812,40 +791,32 @@ padding: 0; } -.jrpg-preview-dialog dialog h2 { - margin-top: var(--zenbu-sys-space-content); -} - -.jrpg-preview-dialog dialog > p { - margin-bottom: var(--zenbu-sys-space-content); -} - .jrpg-preview-dialog dialog.jrpg-detail-dialog { - width: min(94vw, 72rem); + width: min(92vw, 60rem); max-height: calc(100dvh - var(--zenbu-sys-space-container)); overflow: hidden; } .jrpg-preview-dialog dialog.jrpg-detail-dialog[open] { display: grid; - grid-template-rows: auto auto minmax(0, 1fr) auto; + grid-template-rows: auto minmax(0, 1fr) auto; gap: var(--zenbu-sys-space-group); } +mjj-window-modal .jrpg-detail-content { + box-sizing: border-box; + height: 100%; + min-height: 0; + padding: var(--zenbu-sys-padding-lg); +} + +mjj-content-modal .jrpg-detail-content { + padding: var(--zenbu-sys-padding-lg); +} + .jrpg-preview-dialog dialog.jrpg-detail-dialog[data-detail-mode="tools"] { - width: calc(100vw - var(--zenbu-sys-space-content)); - max-width: none; - height: calc(100dvh - var(--zenbu-sys-space-content)); - max-height: none; - padding: 0; - border: 0; - background: transparent; - box-shadow: none; -} - -.jrpg-preview-dialog dialog.jrpg-detail-dialog[data-detail-mode="tools"][open] { - grid-template-rows: auto minmax(0, 1fr); - gap: var(--zenbu-sys-space-control); + width: min(90vw, 56rem); + height: min(78dvh, 44rem); } .jrpg-detail-dialog[data-detail-mode="tools"] > h2 { @@ -854,19 +825,14 @@ .jrpg-detail-dialog[data-detail-mode="tools"] .jrpg-dialog-heading { padding: var(--zenbu-sys-padding-sm); - border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-frame); - background: var(--mjj-jrpg-surface-raised); - box-shadow: - inset 0 0 0 var(--zenbu-sys-stroke-width) - var(--mjj-jrpg-accent); } .jrpg-resume-dossier { min-height: 0; - padding: var(--zenbu-sys-padding-lg); + padding: 0; overflow: auto; - border: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft); - background: var(--mjj-jrpg-surface-sunken); + border: 0; + background: transparent; scrollbar-color: var(--mjj-jrpg-frame) transparent; } @@ -901,9 +867,10 @@ .jrpg-resume-dossier .info { display: grid; gap: var(--zenbu-sys-space-control); - padding: var(--zenbu-sys-padding-lg); - border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-primary); - background: var(--mjj-jrpg-panel); + padding: 0 0 var(--zenbu-sys-padding-lg); + border: 0; + border-bottom: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft); + background: transparent; } .jrpg-resume-dossier .info > p { @@ -972,6 +939,10 @@ text-underline-offset: var(--zenbu-sys-space-icon-label); } +.jrpg-resume-dossier a[href="/public/resume.pdf"] { + display: none; +} + .jrpg-dialog-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -1127,56 +1098,18 @@ } .jrpg-tool-panes { - position: relative; box-sizing: border-box; display: grid; - grid-template-columns: - minmax(0, 1fr) - calc(var(--zenbu-sys-space-container) * 2) - minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(18rem, auto)); gap: var(--zenbu-sys-space-control); - height: 100%; + height: auto; min-height: 0; padding: var(--zenbu-sys-padding-sm); } .jrpg-tool-panes::before { - grid-column: 2; - grid-row: 1; - border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-frame); - background-image: - radial-gradient( - circle at 50% var(--zenbu-sys-space-content), - var(--mjj-jrpg-highlight) 0 var(--zenbu-sys-stroke-width-emphasis), - var(--mjj-jrpg-frame) var(--zenbu-sys-stroke-width-emphasis) - calc(var(--zenbu-sys-stroke-width-emphasis) * 2), - transparent calc(var(--zenbu-sys-stroke-width-emphasis) * 2) - ), - radial-gradient( - circle at 50% calc(100% - var(--zenbu-sys-space-content)), - var(--mjj-jrpg-highlight) 0 var(--zenbu-sys-stroke-width-emphasis), - var(--mjj-jrpg-frame) var(--zenbu-sys-stroke-width-emphasis) - calc(var(--zenbu-sys-stroke-width-emphasis) * 2), - transparent calc(var(--zenbu-sys-stroke-width-emphasis) * 2) - ), - linear-gradient( - 90deg, - var(--mjj-jrpg-surface-sunken), - var(--mjj-jrpg-surface-raised) 45%, - var(--mjj-jrpg-frame) 50%, - var(--mjj-jrpg-surface-raised) 55%, - var(--mjj-jrpg-surface-sunken) - ), - var(--mjj-jrpg-art); - background-position: center; - background-size: auto, auto, auto, cover; - box-shadow: - inset 0 0 0 var(--zenbu-sys-stroke-width) - var(--mjj-jrpg-info), - 0 0 var(--zenbu-sys-space-related) - color-mix(in srgb, var(--mjj-jrpg-primary) 65%, transparent); - content: ""; - image-rendering: pixelated; + display: none; } .jrpg-tool-window:first-of-type { @@ -1185,8 +1118,8 @@ } .jrpg-tool-window:last-of-type { - grid-column: 3; - grid-row: 1; + grid-column: 1; + grid-row: 2; } .jrpg-tool-pane { @@ -1981,26 +1914,10 @@ box-sizing: border-box; min-width: min(22rem, calc(100vw - var(--zenbu-sys-space-content))); max-width: min(26rem, calc(100vw - var(--zenbu-sys-space-content))); - padding: var(--zenbu-sys-padding-lg); - border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-frame); - border-radius: 0; - background: var(--mjj-jrpg-surface-raised); - 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-archive-dialog::backdrop { - background: color-mix(in srgb, var(--mjj-jrpg-canvas) 84%, transparent); -} - -.jrpg-archive-dialog[open] { - display: grid; - grid-template-rows: auto auto auto; - gap: var(--zenbu-sys-space-group); - animation: mjj-jrpg-dialog-open var(--zenbu-sys-motion-duration-layout) - var(--zenbu-sys-motion-ease-enter); +mjj-window-modal [data-archive-rename-dialog] { + height: auto; } .jrpg-archive-dialog-title { @@ -2276,7 +2193,7 @@ } /* Hamburger button: transparent overlay over baked art */ - .jrpg-mobile-destination-owner > .jrpg-mobile-menu-btn { + .jrpg-mobile-destination-owner > zen-dialog > .jrpg-mobile-menu-btn { display: grid; place-items: center; position: absolute; @@ -2381,10 +2298,13 @@ cursor: pointer; } - .jrpg-mobile-destination-content { + mjj-window-modal .jrpg-mobile-destination-content { min-width: 0; min-height: 0; - overflow: hidden; + padding: 0; + overflow-x: auto; + overflow-y: scroll; + scrollbar-gutter: stable; } .jrpg-mobile-destination-content > :is(mjj-jrpg-menu, .jrpg-utility) { @@ -2538,27 +2458,7 @@ .jrpg-login-dialog { box-sizing: border-box; width: min(24rem, calc(100vw - var(--zenbu-sys-space-content))); - padding: var(--zenbu-sys-padding-xl); - border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-frame); - border-radius: 0; - background: var(--mjj-jrpg-surface-raised); - 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-login-dialog::backdrop { - background: color-mix(in srgb, var(--mjj-jrpg-canvas) 84%, transparent); -} - -.jrpg-login-dialog[open] { - display: grid; - grid-template-rows: auto auto minmax(0, 1fr); - gap: var(--zenbu-sys-space-group); - animation: mjj-jrpg-dialog-open var(--zenbu-sys-motion-duration-layout) - var(--zenbu-sys-motion-ease-enter); + height: auto; } .jrpg-login-dialog-header { @@ -2566,8 +2466,6 @@ align-items: center; justify-content: space-between; gap: var(--zenbu-sys-space-group); - padding-bottom: var(--zenbu-sys-padding-md); - border-bottom: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft); } .jrpg-login-dialog-header h2 {
--- a/mrjunejune/src/jrpg/jrpg.js Fri Aug 07 16:05:29 2026 -0700 +++ b/mrjunejune/src/jrpg/jrpg.js Sat Aug 08 02:08:08 2026 -0700 @@ -543,6 +543,7 @@ class MjjJrpgPreview extends HTMLElement { connectedCallback() { + this._modalOwner = this.querySelector("[data-detail-modal-owner]"); this._dialog = this.querySelector("dialog"); this._onDialogClose = () => { this._toolRequest = (this._toolRequest || 0) + 1; @@ -570,30 +571,32 @@ const latestBlog = event.target.closest("[data-latest-blog]"); if (latestBlog && !event.metaKey && !event.ctrlKey && !event.shiftKey) { event.preventDefault(); - this.querySelector("zen-dialog").open(); + this.show("blog"); + this._modalOwner?.open(); void this.loadBlogDetail(latestBlog.dataset.blogUrl); return; } const blogArchive = event.target.closest("[data-blog-archive]"); if (blogArchive) { event.preventDefault(); - this.querySelector("zen-dialog").open(); this.show("blog"); + this._modalOwner?.open(); void this.loadBlogs(); return; } const resumeModal = event.target.closest("[data-resume-modal]"); if (resumeModal) { event.preventDefault(); - this.querySelector("zen-dialog").open(); this.show("resume"); + this._modalOwner?.open(); void this.loadResume(); return; } const latestTool = event.target.closest("[data-latest-tool]"); if (latestTool && !event.metaKey && !event.ctrlKey && !event.shiftKey) { event.preventDefault(); - this.querySelector("zen-dialog").open(); + this.show("tools"); + this._modalOwner?.open(); void this.loadToolDetail(latestTool.dataset.toolUrl); } }; @@ -608,6 +611,18 @@ this.cleanupTool(); } + _setDetailModalType(useWindow) { + const targetName = useWindow ? "mjj-window-modal" : "mjj-content-modal"; + if (!this._modalOwner || this._modalOwner.localName === targetName) return; + const replacement = document.createElement(targetName); + for (const attribute of [...this._modalOwner.attributes]) { + replacement.setAttribute(attribute.name, attribute.value); + } + replacement.append(...this._modalOwner.childNodes); + this._modalOwner.replaceWith(replacement); + this._modalOwner = replacement; + } + show(selection) { this.cleanupTool(); const preview = PREVIEWS[selection] || PREVIEWS.resume; @@ -617,9 +632,6 @@ this.querySelector("[data-dialog-title]").textContent = preview.title; this.querySelector("[data-dialog-copy]").textContent = preview.copy; this.renderShowcase(preview.works); - const link = this.querySelector("[data-preview-link]"); - link.href = preview.url; - link.setAttribute("aria-label", `Open ${preview.title}`); const resumeDossier = this.querySelector("[data-resume-dossier]"); const resumeDownload = this.querySelector("[data-resume-download]"); const blogBrowser = this.querySelector("[data-blog-browser]"); @@ -627,10 +639,10 @@ const isResume = selection === "resume"; const isBlog = selection === "blog"; const isTools = selection === "tools"; + this._setDetailModalType(isTools); this._dialog.dataset.detailMode = isTools ? "tools" : "content"; resumeDossier.hidden = !isResume; resumeDownload.hidden = !isResume; - this.querySelector("[data-dialog-actions]").hidden = !isResume; blogBrowser.hidden = !isBlog; toolBrowser.hidden = !isTools; this.querySelector("[data-dialog-copy]").hidden = @@ -711,6 +723,12 @@ const resume = documentCopy.querySelector("main"); if (!resume) throw new Error("Resume content is unavailable"); sanitizeFetchedMain(resume); + for (const duplicateDownload of resume.querySelectorAll( + 'a[href="/public/resume.pdf"]', + )) { + duplicateDownload.closest("zen-button")?.remove(); + duplicateDownload.remove(); + } content.replaceChildren(...resume.childNodes); status.hidden = true; this._resumeLoaded = true; @@ -823,9 +841,6 @@ status.textContent = `Loading ${blog.label}...`; content.replaceChildren(); this.querySelector("[data-dialog-title]").textContent = blog.label; - const fullPage = this.querySelector("[data-preview-link]"); - fullPage.href = blog.url; - fullPage.setAttribute("aria-label", `Open ${blog.label}`); for (const button of this.querySelectorAll("[data-blog-entry]")) { button.setAttribute( "aria-pressed", @@ -928,9 +943,6 @@ this.cleanupTool(); content.replaceChildren(); this.querySelector("[data-dialog-title]").textContent = tool.label; - const fullPage = this.querySelector("[data-preview-link]"); - fullPage.href = tool.url; - fullPage.setAttribute("aria-label", `Open ${tool.label}`); if (tool.url === "/tools/markdown_to_html") { await this.renderMarkdownTool(content, status, request); return; @@ -1426,10 +1438,12 @@ this._loadMoreButton = this.querySelector("[data-archive-load-more]"); this._newButton = this.querySelector("[data-archive-new]"); this._closeButton = this.querySelector("[data-archive-close]"); + this._deleteModal = this.querySelector("[data-archive-delete-owner]"); this._deleteDialog = this.querySelector("[data-archive-delete-dialog]"); this._deleteNameEl = this.querySelector("[data-archive-delete-name]"); this._deleteCancelButton = this.querySelector("[data-archive-delete-cancel]"); this._deleteConfirmButton = this.querySelector("[data-archive-delete-confirm]"); + this._renameModal = this.querySelector("[data-archive-rename-owner]"); this._renameDialog = this.querySelector("[data-archive-rename-dialog]"); this._renameInput = this.querySelector("[data-archive-rename-input]"); this._renameForm = this.querySelector("[data-archive-rename-form]"); @@ -1452,7 +1466,7 @@ }); this._deleteCancelButton?.addEventListener("click", () => { - this._deleteDialog?.close(); + this._deleteModal?.close(); const t = this._pendingDeleteTrigger; this._pendingDeleteId = null; this._pendingDeleteTrigger = null; @@ -1462,7 +1476,7 @@ this._deleteConfirmButton?.addEventListener("click", () => { const id = this._pendingDeleteId; const trigger = this._pendingDeleteTrigger; - this._deleteDialog?.close(); + this._deleteModal?.close(); this._pendingDeleteId = null; this._pendingDeleteTrigger = null; if (id) { @@ -1481,7 +1495,7 @@ }); this._renameCancelButton?.addEventListener("click", () => { - this._renameDialog?.close(); + this._renameModal?.close(); const t = this._pendingRenameTrigger; this._pendingRenameId = null; this._pendingRenameTrigger = null; @@ -1494,7 +1508,7 @@ const trigger = this._pendingRenameTrigger; const newTitle = this._renameInput?.value.trim(); if (!id || !newTitle) return; - this._renameDialog?.close(); + this._renameModal?.close(); this._pendingRenameId = null; this._pendingRenameTrigger = null; this.dispatchEvent(new CustomEvent("mjj-archive-rename", { @@ -1529,7 +1543,7 @@ this._pendingRenameId = item._convId; this._pendingRenameTrigger = renameButton; if (this._renameInput) this._renameInput.value = item._convTitle || ""; - this._renameDialog?.showModal(); + this._renameModal?.open(); requestAnimationFrame(() => { this._renameInput?.select(); }); } return; @@ -1541,7 +1555,7 @@ this._pendingDeleteId = item._convId; this._pendingDeleteTrigger = deleteButton; if (this._deleteNameEl) this._deleteNameEl.textContent = item._convTitle || ""; - this._deleteDialog?.showModal(); + this._deleteModal?.open(); requestAnimationFrame(() => { this._deleteConfirmButton?.focus(); }); } return; @@ -2186,7 +2200,10 @@ let characterTimer = 0; let currentConversationId = null; - let currentPanel = "resume"; + const serverInitialPanel = VALID_PANELS.includes(shell?.dataset.initialPanel) + ? shell.dataset.initialPanel + : "resume"; + let currentPanel = serverInitialPanel; let archiveCursor = null; let activeController = null; let archiveLoadInFlight = false; /* serialize pagination requests */ @@ -2799,7 +2816,7 @@ normalUrl, ); } - currentPanel = validPanel || "resume"; + currentPanel = validPanel || serverInitialPanel; } /* ---- Load archive; only an explicit URL restores a conversation ---- */
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/public/component-sandbox.css Sat Aug 08 02:08:08 2026 -0700 @@ -0,0 +1,203 @@ +@font-face { + font-family: "Pixel Mplus"; + src: url("/public/fonts/pixel-mplus-12-regular.ttf") format("truetype"); + font-display: block; +} + +:root { + --zenbu-sys-font-family-ui: "Pixel Mplus"; + --zenbu-sys-font-family-content: "Pixel Mplus"; + --zenbu-sys-font-family-code: "Pixel Mplus"; + --zenbu-sys-type-caption-family: "Pixel Mplus"; + --zenbu-sys-type-label-family: "Pixel Mplus"; + --zenbu-sys-type-body-family: "Pixel Mplus"; + --zenbu-sys-type-body-large-family: "Pixel Mplus"; + --zenbu-sys-type-subtitle-family: "Pixel Mplus"; + --zenbu-sys-type-title-family: "Pixel Mplus"; + --zenbu-sys-type-page-title-family: "Pixel Mplus"; + --zenbu-sys-type-reading-family: "Pixel Mplus"; + --zenbu-sys-type-code-family: "Pixel Mplus"; +} + +body { + box-sizing: border-box; + min-height: 100dvh; + 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); +} + +body * { + font-family: inherit; +} + +zen-icon svg { + shape-rendering: crispEdges; + stroke-linecap: square; + stroke-linejoin: miter; +} + +main { + display: grid; + gap: var(--zenbu-sys-space-container); + width: min(100%, 64rem); + margin-inline: auto; +} + +.sandbox-heading { + display: grid; + gap: var(--zenbu-sys-space-control); +} + +.sandbox-heading h1 { + color: var(--zenbu-sys-color-action-primary-background); + text-shadow: + var(--zenbu-sys-space-icon-label) + var(--zenbu-sys-space-icon-label) + 0 + var(--zenbu-sys-color-danger-foreground); +} + +.sandbox-heading :where(h1, p), +.sandbox-card :where(h2, p), +.sandbox-pane :where(h3, p) { + margin: 0; +} + +.sandbox-heading p, +.sandbox-card > p, +.sandbox-muted { + color: var(--zenbu-sys-color-text-secondary); +} + +.sandbox-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--zenbu-sys-space-group); +} + +.sandbox-layers { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--zenbu-sys-space-control); + margin: 0; +} + +.sandbox-layers > div { + display: grid; + gap: var(--zenbu-sys-space-icon-label); + padding: var(--zenbu-sys-padding-md); + border: var(--zenbu-sys-stroke-width) solid + var(--zenbu-sys-color-border-default); + border-radius: var(--zenbu-sys-radius-control); + background: var(--zenbu-sys-color-surface-sunken); +} + +.sandbox-layers > div:nth-child(1) dt { + color: var(--zenbu-sys-color-warning-foreground); +} + +.sandbox-layers > div:nth-child(2) dt { + color: var(--zenbu-sys-color-info-foreground); +} + +.sandbox-layers > div:nth-child(3) dt { + color: var(--zenbu-sys-color-action-primary-background); +} + +.sandbox-layers dt { + color: var(--zenbu-sys-color-text-primary); + font-weight: var(--zenbu-sys-font-weight-strong); +} + +.sandbox-layers dd { + margin: 0; + color: var(--zenbu-sys-color-text-secondary); + font-size: var(--zenbu-sys-font-size-sm); +} + +.sandbox-rule { + padding: var(--zenbu-sys-padding-md); + border-inline-start: var(--zenbu-sys-stroke-width-emphasis) solid + var(--zenbu-sys-color-action-primary-border); + background: var(--zenbu-sys-color-surface-sunken); + color: var(--zenbu-sys-color-text-secondary); +} + +.sandbox-card { + display: grid; + align-content: start; + gap: var(--zenbu-sys-space-group); + padding: var(--zenbu-sys-padding-xl); + border: var(--zenbu-sys-stroke-width) solid + var(--zenbu-sys-color-border-default); + border-radius: var(--zenbu-sys-radius-container); + background: var(--zenbu-sys-color-surface-raised); + box-shadow: + var(--zenbu-sys-space-control) + var(--zenbu-sys-space-control) + 0 + var(--zenbu-sys-color-danger-foreground); +} + +.sandbox-card > h2 { + color: var(--zenbu-sys-color-action-primary-background); +} + +.sandbox-list { + display: grid; + gap: var(--zenbu-sys-space-control); + margin: 0; + padding: 0; + list-style: none; +} + +.sandbox-list li { + padding-bottom: var(--zenbu-sys-padding-sm); + border-bottom: var(--zenbu-sys-stroke-width) solid + var(--zenbu-sys-color-border-subtle); +} + +.sandbox-audit { + display: grid; + gap: var(--zenbu-sys-space-control); + margin: var(--zenbu-sys-space-group) 0 0; + padding: 0; + list-style: none; +} + +.sandbox-audit li { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: var(--zenbu-sys-space-control); + padding-block: var(--zenbu-sys-padding-sm); + border-top: var(--zenbu-sys-stroke-width) solid + var(--zenbu-sys-color-border-subtle); + color: var(--zenbu-sys-color-text-secondary); +} + +.sandbox-audit strong { + color: var(--zenbu-sys-color-warning-foreground); +} + +.sandbox-pane { + display: grid; + align-content: start; + gap: var(--zenbu-sys-space-control); +} + +@media (max-width: 42rem) { + body { + padding: var(--zenbu-sys-padding-lg); + } + + .sandbox-grid { + grid-template-columns: minmax(0, 1fr); + } + + .sandbox-layers { + grid-template-columns: minmax(0, 1fr); + } +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/public/component-sandbox.html Sat Aug 08 02:08:08 2026 -0700 @@ -0,0 +1,263 @@ +<!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>AI Component Sandbox | 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"> + <script type="module" src="/public/design-system/components/index.js"></script> + <script type="module"> + const version = Date.now(); + const styles = [ + "/public/mjj-modal.css", + "/public/component-sandbox.css", + ]; + await Promise.all(styles.map(href => new Promise((resolve, reject) => { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = `${href}?sandbox=${version}`; + link.addEventListener("load", resolve, { once: true }); + link.addEventListener("error", reject, { once: true }); + document.head.append(link); + }))); + await import(`/public/mjj-modal.js?sandbox=${version}`); + void navigator.serviceWorker?.getRegistration() + .then(registration => registration?.update()); + </script> + </head> + <body> + <main> + <header class="sandbox-heading"> + <p class="sandbox-muted">Isolated browser fixture</p> + <h1>AI Component Sandbox</h1> + <p> + Edit one component family, refresh this page, and inspect desktop or + mobile behavior without application chrome. + </p> + <dl class="sandbox-layers" aria-label="Component dependency layers"> + <div> + <dt>Layer 0 · Foundation</dt> + <dd>API mapping, i18n, and semantic themes.</dd> + </div> + <div> + <dt>Layer 1 · Primitives</dt> + <dd>Zenbu icons, fields, buttons, and dialog behavior.</dd> + </div> + <div> + <dt>Layer 2 · Composites</dt> + <dd>MrJuneJune composers and modal families.</dd> + </div> + </dl> + <p class="sandbox-rule"> + Dependency rule: Layer 2 may consume Layers 1 and 0. Application + routes, persistence, and business state stay above this sandbox. + </p> + </header> + + <section class="sandbox-grid" aria-label="Modal component variants"> + <article class="sandbox-card"> + <h2>Content modal</h2> + <p> + Editorial reading surface for blogs, notes, release details, and + long-form content. + </p> + <mjj-content-modal data-component-layer="2"> + <zen-dialog> + <zen-button size="md"> + <button type="button" data-zen-trigger> + Open article + <zen-icon name="external"></zen-icon> + </button> + </zen-button> + <dialog> + <header data-modal-header> + <div data-modal-heading> + <p data-modal-kicker>Engineering note · 8 min read</p> + <h2 data-modal-title>Designing durable agent interfaces</h2> + </div> + <div data-modal-header-actions> + <zen-button variant="quiet" size="sm"> + <button type="button" data-zen-close aria-label="Close article"> + <zen-icon name="close"></zen-icon> + </button> + </zen-button> + </div> + </header> + <zen-scroll-area + data-modal-body + data-cyber-scroll + tabindex="0" + aria-label="Article content" + > + <article data-modal-article> + <p data-modal-description> + A content modal should feel like a focused reading room: + enough structure to orient the reader, but no window chrome + competing with the article. + </p> + <h2>Start with a strict shell</h2> + <p> + Header, body, and footer are stable regions. The article + owns its semantic headings, paragraphs, lists, and code. + The modal only supplies rhythm, containment, scrolling, + and depth. + </p> + <blockquote> + Content should remain ordinary HTML even when the + presentation becomes cinematic. + </blockquote> + <h3>Keep the body readable</h3> + <p> + The reading column stays bounded while the dialog itself + can grow. This avoids very long lines on desktop and keeps + padding deliberate on small screens. + </p> + <h3>Use a visible scroll channel</h3> + <p> + Cyan marks the active rail, violet separates the track, + and magenta appears on hover. Keyboard users can focus the + region and scroll without moving the modal chrome. + </p> + <p> + The header and footer stay fixed while this article moves. + That stable frame keeps context visible during longer blog + posts, documentation, and generated explanations. + </p> + <p> + Layer 2 only chooses layout and presentation. The Layer 1 + scroll primitive still owns the native overflow behavior. + </p> + <pre><code><mjj-content-modal> + <header data-modal-header>...</header> + <zen-scroll-area data-modal-body data-cyber-scroll> + ... + </zen-scroll-area> + <footer data-modal-footer>...</footer> +</mjj-content-modal></code></pre> + </article> + </zen-scroll-area> + <footer data-modal-footer> + <p>Updated August 7, 2026</p> + <div data-modal-footer-actions> + <zen-button variant="quiet" size="md"> + <button type="button" data-zen-close>Done reading</button> + </zen-button> + </div> + </footer> + </dialog> + </zen-dialog> + </mjj-content-modal> + </article> + + <article class="sandbox-card"> + <h2>Window modal</h2> + <p> + Structured workspace for tools and multi-region interfaces with + fixed chrome and a bounded scrolling body. + </p> + <mjj-window-modal data-component-layer="2"> + <zen-dialog> + <zen-button size="md"> + <button type="button" data-zen-trigger> + Open workspace + <zen-icon name="external"></zen-icon> + </button> + </zen-button> + <dialog> + <header data-modal-header> + <div data-modal-heading> + <p data-modal-kicker>Workspace · Read only</p> + <h2 data-modal-title>Component inspector</h2> + </div> + <div data-modal-header-actions> + <zen-button variant="quiet" size="sm"> + <button type="button" aria-label="Component settings"> + <zen-icon name="settings"></zen-icon> + </button> + </zen-button> + <zen-button variant="quiet" size="sm"> + <button type="button" data-zen-close aria-label="Close workspace"> + <zen-icon name="close"></zen-icon> + </button> + </zen-button> + </div> + </header> + <zen-scroll-area + data-modal-body + data-modal-layout="split" + data-cyber-scroll + tabindex="0" + aria-label="Workspace content" + > + <zen-scroll-area + data-modal-pane + data-cyber-scroll + tabindex="0" + aria-label="Inspector regions" + > + <aside class="sandbox-pane"> + <h3>Regions</h3> + <ul class="sandbox-list"> + <li>Header</li> + <li>Body</li> + <li>Footer</li> + </ul> + </aside> + </zen-scroll-area> + <zen-scroll-area + data-modal-pane="primary" + data-cyber-scroll + tabindex="0" + aria-label="Inspector body content" + > + <article class="sandbox-pane"> + <h3>Body content</h3> + <p data-modal-description> + The window body can host split panes, editors, previews, + or tools without changing the surrounding contract. + </p> + <zen-field size="md"> + <label for="sandbox-component-name">Component name</label> + <input + id="sandbox-component-name" + value="mjj-window-modal" + readonly + > + <small>The active light-DOM fixture.</small> + </zen-field> + <ol class="sandbox-audit"> + <li><strong>01</strong> Header region validated</li> + <li><strong>02</strong> Title association generated</li> + <li><strong>03</strong> Body region bounded</li> + <li><strong>04</strong> Scroll primitive connected</li> + <li><strong>05</strong> Footer actions discovered</li> + <li><strong>06</strong> Pixel typography inherited</li> + <li><strong>07</strong> Cyberpunk theme resolved</li> + <li><strong>08</strong> Keyboard scrolling enabled</li> + <li><strong>09</strong> Responsive split verified</li> + <li><strong>10</strong> Layer contract ready</li> + </ol> + </article> + </zen-scroll-area> + </zen-scroll-area> + <footer data-modal-footer> + <p>3 required regions · contract valid</p> + <div data-modal-footer-actions> + <zen-button variant="quiet" size="md"> + <button type="button" data-zen-close>Cancel</button> + </zen-button> + <zen-button size="md"> + <button type="button" data-zen-close>Apply</button> + </zen-button> + </div> + </footer> + </dialog> + </zen-dialog> + </mjj-window-modal> + </article> + </section> + </main> + </body> +</html>
--- a/mrjunejune/src/public/composer-lab.html Fri Aug 07 16:05:29 2026 -0700 +++ b/mrjunejune/src/public/composer-lab.html Sat Aug 08 02:08:08 2026 -0700 @@ -7,9 +7,21 @@ <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> + <script type="module"> + const version = Date.now(); + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = `/public/mjj-composer.css?sandbox=${version}`; + await new Promise((resolve, reject) => { + link.addEventListener("load", resolve, { once: true }); + link.addEventListener("error", reject, { once: true }); + document.head.append(link); + }); + await import(`/public/mjj-composer.js?sandbox=${version}`); + void navigator.serviceWorker?.getRegistration() + .then(registration => registration?.update()); + </script> <style> body { box-sizing: border-box;
--- a/mrjunejune/src/public/mjj-composer.js Fri Aug 07 16:05:29 2026 -0700 +++ b/mrjunejune/src/public/mjj-composer.js Sat Aug 08 02:08:08 2026 -0700 @@ -1,3 +1,10 @@ +/** + * Layer 2 composer composite. + * + * Dependencies flow downward only: Zenbu Layer 1 primitives and Layer 0 + * themes/i18n/API mapping. Application routes and state remain outside. + */ + /** * Coordinates a native composer form built from Zenbu controls. *
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/public/mjj-modal.css Sat Aug 08 02:08:08 2026 -0700 @@ -0,0 +1,360 @@ +:where(mjj-content-modal, mjj-window-modal) { + display: contents; +} + +:where(mjj-content-modal, mjj-window-modal) dialog { + --mjj-modal-border: var(--zenbu-sys-color-border-strong); + --mjj-modal-shadow: + inset 0 0 0 var(--zenbu-sys-stroke-width) + var(--zenbu-sys-color-action-primary-border), + var(--zenbu-sys-space-control) + var(--zenbu-sys-space-control) + 0 + var(--zenbu-sys-color-danger-border), + var(--zenbu-sys-shadow-dialog); + box-sizing: border-box; + width: min( + calc(100vw - var(--zenbu-sys-space-container)), + var(--mjj-modal-width) + ); + max-width: none; + max-height: calc(100dvh - var(--zenbu-sys-space-container)); + margin: auto; + padding: 0; + overflow: hidden; + border: var(--zenbu-sys-stroke-width) solid var(--mjj-modal-border); + border-radius: 0; + background: var(--zenbu-sys-color-surface-raised); + color: var(--zenbu-sys-color-text-primary); + box-shadow: var(--mjj-modal-shadow); +} + +:where(mjj-content-modal, mjj-window-modal) dialog::backdrop { + background: var(--zenbu-sys-color-scrim); + backdrop-filter: blur(var(--zenbu-sys-stroke-width-emphasis)); +} + +:where(mjj-content-modal, mjj-window-modal) dialog[open] { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + animation: + mjj-modal-enter + var(--zenbu-sys-motion-duration-layout) + var(--zenbu-sys-motion-ease-enter); +} + +:where(mjj-content-modal, mjj-window-modal) [data-modal-header] { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: var(--zenbu-sys-space-group); +} + +:where(mjj-content-modal, mjj-window-modal) [data-modal-heading] { + display: grid; + gap: var(--zenbu-sys-space-icon-label); + min-width: 0; +} + +:where(mjj-content-modal, mjj-window-modal) [data-modal-kicker] { + margin: 0; + color: var(--zenbu-sys-color-warning-foreground); + font-size: var(--zenbu-sys-font-size-xs); + font-weight: var(--zenbu-sys-font-weight-strong); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +:where(mjj-content-modal, mjj-window-modal) [data-modal-title] { + margin: 0; + color: var(--zenbu-sys-color-action-primary-background); + font-family: var(--zenbu-sys-type-title-family); + font-size: var(--zenbu-sys-type-title-size); + font-weight: var(--zenbu-sys-type-title-weight); + line-height: var(--zenbu-sys-type-title-line-height); +} + +:where(mjj-content-modal, mjj-window-modal) [data-modal-header-actions], +:where(mjj-content-modal, mjj-window-modal) [data-modal-footer-actions] { + display: flex; + align-items: center; + gap: var(--zenbu-sys-space-control); +} + +:where(mjj-content-modal, mjj-window-modal) [data-modal-header-actions] { + justify-content: flex-end; +} + +:where(mjj-content-modal, mjj-window-modal) [data-modal-footer] { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--zenbu-sys-space-group); +} + +:where(mjj-content-modal, mjj-window-modal) [data-modal-footer] > p { + margin: 0; + color: var(--zenbu-sys-color-text-secondary); + font-size: var(--zenbu-sys-font-size-sm); +} + +:where(mjj-content-modal, mjj-window-modal) zen-scroll-area[data-cyber-scroll] { + box-sizing: border-box; + display: block; + width: 100%; + height: 100%; + max-height: none; + min-width: 0; + min-height: 0; + overflow-x: auto; + overflow-y: scroll; + border: 0; + border-radius: 0; + background: var(--zenbu-sys-color-surface-default); + scrollbar-color: + var(--zenbu-sys-color-action-primary-background) + var(--zenbu-sys-color-surface-sunken); + scrollbar-width: thin; + scrollbar-gutter: stable; +} + +:where(mjj-content-modal, mjj-window-modal) + zen-scroll-area[data-cyber-scroll]::-webkit-scrollbar { + width: var(--zenbu-sys-space-control); + height: var(--zenbu-sys-space-control); +} + +:where(mjj-content-modal, mjj-window-modal) + zen-scroll-area[data-cyber-scroll]::-webkit-scrollbar-track { + border-inline-start: var(--zenbu-sys-stroke-width) solid + var(--zenbu-sys-color-info-border); + background: var(--zenbu-sys-color-surface-sunken); +} + +:where(mjj-content-modal, mjj-window-modal) + zen-scroll-area[data-cyber-scroll]::-webkit-scrollbar-thumb { + border: var(--zenbu-sys-stroke-width) solid + var(--zenbu-sys-color-surface-sunken); + border-radius: 0; + background: var(--zenbu-sys-color-action-primary-background); +} + +:where(mjj-content-modal, mjj-window-modal) + zen-scroll-area[data-cyber-scroll]::-webkit-scrollbar-thumb:hover { + background: var(--zenbu-sys-color-danger-foreground); +} + +mjj-content-modal { + --mjj-modal-width: 54rem; +} + +mjj-content-modal dialog { + --mjj-modal-border: var(--zenbu-sys-color-action-primary-border); +} + +mjj-content-modal [data-modal-header] { + padding: + var(--zenbu-sys-padding-xl) + var(--zenbu-sys-padding-xl) + var(--zenbu-sys-padding-lg); + border-bottom: var(--zenbu-sys-stroke-width) solid + var(--zenbu-sys-color-danger-border); + background: var(--zenbu-sys-color-surface-raised); +} + +mjj-content-modal [data-modal-body] { + min-height: 0; + padding: 0; + overflow: auto; + background: var(--zenbu-sys-color-surface-default); + color: var(--zenbu-sys-color-text-primary); + font-family: var(--zenbu-sys-type-reading-family); + font-size: var(--zenbu-sys-type-reading-size); + line-height: var(--zenbu-sys-type-reading-line-height); +} + +mjj-content-modal [data-modal-article] { + display: grid; + gap: var(--zenbu-sys-space-content); + padding: + var(--zenbu-sys-padding-xl) + max( + var(--zenbu-sys-padding-xl), + calc((100% - 42rem) / 2) + ); +} + +mjj-content-modal [data-modal-article] > :where( + h2, + h3, + p, + blockquote, + pre, + ul, + ol +) { + margin-block: 0; +} + +mjj-content-modal [data-modal-body] :where(h2, h3) { + color: var(--zenbu-sys-color-action-primary-background); + font-family: var(--zenbu-sys-font-family-ui); +} + +mjj-content-modal [data-modal-body] [data-modal-description] { + color: var(--zenbu-sys-color-text-secondary); + font-size: var(--zenbu-sys-type-body-large-size); + line-height: var(--zenbu-sys-type-body-large-line-height); +} + +mjj-content-modal [data-modal-body] blockquote { + padding: + var(--zenbu-sys-padding-md) + var(--zenbu-sys-padding-lg); + border-inline-start: var(--zenbu-sys-stroke-width-emphasis) solid + var(--zenbu-sys-color-danger-border); + background: var(--zenbu-sys-color-surface-sunken); + color: var(--zenbu-sys-color-text-secondary); +} + +mjj-content-modal [data-modal-body] pre { + padding: var(--zenbu-sys-padding-lg); + overflow: auto; + border: var(--zenbu-sys-stroke-width) solid + var(--zenbu-sys-color-info-border); + border-radius: 0; + background: var(--zenbu-sys-color-surface-sunken); + font-family: var(--zenbu-sys-type-code-family); + font-size: var(--zenbu-sys-type-code-size); + line-height: var(--zenbu-sys-type-code-line-height); +} + +mjj-content-modal [data-modal-footer] { + padding: + var(--zenbu-sys-padding-md) + var(--zenbu-sys-padding-xl); + border-top: var(--zenbu-sys-stroke-width) solid + var(--zenbu-sys-color-danger-border); + background: var(--zenbu-sys-color-surface-raised); +} + +mjj-window-modal { + --mjj-modal-width: 72rem; +} + +mjj-window-modal dialog { + --mjj-modal-border: var(--zenbu-sys-color-info-border); + height: min(82dvh, 48rem); + border-radius: 0; +} + +mjj-window-modal [data-modal-header] { + padding: + var(--zenbu-sys-padding-md) + var(--zenbu-sys-padding-lg); + border-bottom: var(--zenbu-sys-stroke-width) solid + var(--zenbu-sys-color-action-primary-border); + background: var(--zenbu-sys-color-surface-sunken); + box-shadow: var(--zenbu-sys-shadow-subtle); +} + +mjj-window-modal [data-modal-body] { + min-width: 0; + min-height: 0; + padding: var(--zenbu-sys-padding-lg); + overflow: auto; + background: var(--zenbu-sys-color-surface-default); +} + +mjj-window-modal [data-modal-body][data-modal-layout="split"] { + display: grid; + grid-template-columns: minmax(12rem, 0.32fr) minmax(0, 1fr); + gap: var(--zenbu-sys-space-group); +} + +mjj-window-modal [data-modal-pane] { + min-width: 0; + min-height: 0; + padding: var(--zenbu-sys-padding-lg); + overflow: auto; + border: var(--zenbu-sys-stroke-width) solid + var(--zenbu-sys-color-info-border); + border-radius: 0; + background: var(--zenbu-sys-color-surface-sunken); +} + +mjj-window-modal [data-modal-pane="primary"] { + border-color: var(--zenbu-sys-color-action-primary-border); + background: var(--zenbu-sys-color-surface-raised); + box-shadow: + var(--zenbu-sys-space-icon-label) + var(--zenbu-sys-space-icon-label) + 0 + var(--zenbu-sys-color-danger-border); +} + +mjj-window-modal [data-modal-footer] { + padding: + var(--zenbu-sys-padding-sm) + var(--zenbu-sys-padding-lg); + border-top: var(--zenbu-sys-stroke-width) solid + var(--zenbu-sys-color-action-primary-border); + background: var(--zenbu-sys-color-surface-sunken); +} + +@keyframes mjj-modal-enter { + 0% { + clip-path: inset(49% 45%); + opacity: 0.72; + transform: scaleX(0.12); + } + 45% { + clip-path: inset(49% 0); + opacity: 1; + transform: scaleX(1); + } + 100% { + clip-path: inset(0); + opacity: 1; + transform: scaleX(1); + } +} + +@media (max-width: 42rem) { + :where(mjj-content-modal, mjj-window-modal) dialog { + width: calc(100vw - var(--zenbu-sys-space-group)); + max-height: calc(100dvh - var(--zenbu-sys-space-group)); + } + + mjj-content-modal [data-modal-header], + mjj-content-modal [data-modal-footer] { + padding-inline: var(--zenbu-sys-padding-lg); + } + + mjj-content-modal [data-modal-body] { + padding: 0; + } + + mjj-content-modal [data-modal-article] { + padding: var(--zenbu-sys-padding-lg); + } + + mjj-window-modal dialog { + height: calc(100dvh - var(--zenbu-sys-space-group)); + } + + mjj-window-modal [data-modal-body][data-modal-layout="split"] { + grid-template-columns: minmax(0, 1fr); + } + + :where(mjj-content-modal, mjj-window-modal) [data-modal-footer] { + align-items: stretch; + flex-direction: column; + } +} + +@media (prefers-reduced-motion: reduce) { + :where(mjj-content-modal, mjj-window-modal) dialog[open] { + animation: none; + } +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/public/mjj-modal.js Sat Aug 08 02:08:08 2026 -0700 @@ -0,0 +1,130 @@ +/** + * Layer 2 modal composites. + * + * Dependencies flow downward only: Zenbu Layer 1 primitives and Layer 0 + * themes/i18n/API mapping. Application routes and state remain outside. + */ + +let nextModalId = 1; + +/** + * Validates and labels a site modal composed with zen-dialog. + * + * @extends {HTMLElement} + */ +class MjjModal extends HTMLElement { + connectedCallback() { + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { childList: true, subtree: true }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + } + + /** + * Applies the strict modal region contract. + */ + sync() { + const owner = this.querySelector(":scope > zen-dialog"); + const dialog = owner?.querySelector(":scope > dialog"); + const header = dialog?.querySelector(":scope > [data-modal-header]"); + const body = dialog?.querySelector(":scope > [data-modal-body]"); + const footer = dialog?.querySelector(":scope > [data-modal-footer]"); + const title = header?.querySelector("[data-modal-title]"); + const regions = dialog + ? [...dialog.children].filter(element => + element.matches( + "[data-modal-header], [data-modal-body], [data-modal-footer]", + )) + : []; + const regionOrder = regions.map(element => { + if (element.hasAttribute("data-modal-header")) return "header"; + if (element.hasAttribute("data-modal-body")) return "body"; + return "footer"; + }); + const expectedOrder = footer + ? ["header", "body", "footer"] + : ["header", "body"]; + const errors = []; + + if (!owner) errors.push("requires one direct zen-dialog"); + if (!dialog) errors.push("requires one direct native dialog"); + if (!header) errors.push("requires a direct data-modal-header region"); + if (!body) errors.push("requires a direct data-modal-body region"); + if (!title) errors.push("requires data-modal-title inside the header"); + if (regionOrder.join(",") !== expectedOrder.join(",")) { + errors.push(`regions must be ordered ${expectedOrder.join(", ")}`); + } + + if (errors.length) { + this.dataset.invalid = ""; + this.removeAttribute("data-ready"); + const message = `${this.localName}: ${errors.join("; ")}`; + if (this._lastError !== message) { + this._lastError = message; + console.error(message); + this.dispatchEvent(new CustomEvent("mjj-modal-error", { + bubbles: true, + detail: { errors: [...errors] }, + })); + } + return; + } + + delete this.dataset.invalid; + this.dataset.ready = ""; + this.dataset.componentLayer = "2"; + this._lastError = null; + if (!title.id) title.id = `mjj-modal-title-${nextModalId++}`; + dialog.setAttribute("aria-labelledby", title.id); + dialog.setAttribute("aria-modal", "true"); + const description = body.querySelector("[data-modal-description]"); + if (description) { + if (!description.id) { + description.id = `mjj-modal-description-${nextModalId++}`; + } + dialog.setAttribute("aria-describedby", description.id); + } else { + dialog.removeAttribute("aria-describedby"); + } + } + + /** + * Opens the composed Zenbu dialog. + */ + open() { + this.querySelector(":scope > zen-dialog")?.open(); + } + + /** + * Closes the composed Zenbu dialog. + * + * @param {string=} returnValue + */ + close(returnValue) { + this.querySelector(":scope > zen-dialog")?.close(returnValue); + } +} + +/** + * Presents long-form editorial content with a reading-focused body. + * + * @extends {MjjModal} + */ +export class MjjContentModal extends MjjModal {} + +/** + * Presents application content in a strict window-style frame. + * + * @extends {MjjModal} + */ +export class MjjWindowModal extends MjjModal {} + +if (!customElements.get("mjj-content-modal")) { + customElements.define("mjj-content-modal", MjjContentModal); +} +if (!customElements.get("mjj-window-modal")) { + customElements.define("mjj-window-modal", MjjWindowModal); +}
--- a/mrjunejune/src/public/pwa-register.js Fri Aug 07 16:05:29 2026 -0700 +++ b/mrjunejune/src/public/pwa-register.js Sat Aug 08 02:08:08 2026 -0700 @@ -5,6 +5,7 @@ .register('/sw.js', { scope: '/' }) .then((registration) => { // console.log('[PWA] Service Worker registered:', registration.scope); + void registration.update(); // Check for updates periodically setInterval(() => { @@ -16,11 +17,7 @@ const newWorker = registration.installing; newWorker.addEventListener('statechange', () => { if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { - // New content available, show update notification - if (confirm('New version available! Reload to update?')) { - newWorker.postMessage({ type: 'SKIP_WAITING' }); - window.location.reload(); - } + newWorker.postMessage({ type: 'SKIP_WAITING' }); } }); });
--- a/mrjunejune/src/sw.js Fri Aug 07 16:05:29 2026 -0700 +++ b/mrjunejune/src/sw.js Sat Aug 08 02:08:08 2026 -0700 @@ -1,6 +1,12 @@ // Root-scoped Service Worker for MrJuneJune PWA -const CACHE_VERSION = 'v35-role-greeting'; +const CACHE_VERSION = 'v37-network-first'; const CACHE_NAME = `mrjunejune-${CACHE_VERSION}`; +const DEVELOPMENT_SANDBOX_PATHS = [ + '/public/component-sandbox', + '/public/composer-lab', + '/public/mjj-composer.', + '/public/mjj-modal.', +]; // Files to cache immediately on install const STATIC_CACHE = [ @@ -73,7 +79,46 @@ ); }); -// Fetch event - serve from cache, fallback to network +function isCacheable(response) { + return response && + response.status === 200 && + response.type !== 'error'; +} + +async function updateCache(request, response) { + if (!isCacheable(response)) return; + const cache = await caches.open(CACHE_NAME); + await cache.put(request, response.clone()); +} + +async function networkFirst(request, offlineFallback = false) { + try { + const response = await fetch(request, { cache: 'no-cache' }); + await updateCache(request, response); + return response; + } catch (error) { + const cached = await caches.match(request); + if (cached) return cached; + if (offlineFallback) { + const offline = await caches.match('/offline.html'); + if (offline) return offline; + } + throw error; + } +} + +async function staleWhileRevalidate(event, request) { + const cached = await caches.match(request); + const update = fetch(request, { cache: 'no-cache' }).then(async response => { + await updateCache(request, response); + return response; + }); + if (!cached) return update; + event.waitUntil(update.catch(() => undefined)); + return cached; +} + +// Fetch event - current code first, cached media first, offline fallback. self.addEventListener('fetch', (event) => { const { request } = event; const url = new URL(request.url); @@ -88,6 +133,10 @@ return; } + if (url.origin !== self.location.origin) { + return; + } + // Skip API calls and media uploads (always go to network) if (url.pathname.startsWith('/api/')) { return; @@ -98,60 +147,32 @@ return; } + // Component workbenches must reflect every ibazel rebuild on any hostname. + if (DEVELOPMENT_SANDBOX_PATHS.some(path => + url.pathname.startsWith(path))) { + return; + } + // The HLS tool and media must always reflect the current player version. if (url.pathname.startsWith('/tools/hls_player') || url.pathname.startsWith('/public/hls-sample/')) { return; } - event.respondWith( - caches.match(request).then((cachedResponse) => { - if (cachedResponse) { - console.log('[SW] Serving from cache:', url.pathname); - return cachedResponse; - } - - // Not in cache, fetch from network - return fetch(request).then((networkResponse) => { - // Only cache successful responses - if (!networkResponse || networkResponse.status !== 200 || networkResponse.type === 'error') { - return networkResponse; - } + const acceptsHtml = request.mode === 'navigate' || + request.headers.get('Accept')?.includes('text/html'); + const currentCode = /\.(?:css|js|json|wasm)$/i.test(url.pathname); + const revalidatedMedia = + /\.(?:svg|jpe?g|webp|woff2?|ttf|otf|pdf)$/i.test(url.pathname); - // Cache specific file types - const shouldCache = - url.pathname.endsWith('.css') || - url.pathname.endsWith('.js') || - url.pathname.endsWith('.svg') || - url.pathname.endsWith('.jpg') || - url.pathname.endsWith('.webp') || - url.pathname.endsWith('.woff') || - url.pathname.endsWith('.woff2') || - url.pathname.endsWith('.ttf') || - url.pathname.endsWith('.otf') || - url.pathname.startsWith('/blog/') || - url.pathname.startsWith('/notes/') || - url.pathname === '/'; + if (acceptsHtml || currentCode) { + event.respondWith(networkFirst(request, acceptsHtml)); + return; + } - if (shouldCache) { - const responseToCache = networkResponse.clone(); - caches.open(CACHE_NAME).then((cache) => { - console.log('[SW] Caching new resource:', url.pathname); - cache.put(request, responseToCache); - }); - } - - return networkResponse; - }).catch((error) => { - console.log('[SW] Fetch failed:', error); - - // Return offline page for HTML requests - if (request.headers.get('Accept').includes('text/html')) { - return caches.match('/offline.html'); - } - }); - }) - ); + if (revalidatedMedia) { + event.respondWith(staleWhileRevalidate(event, request)); + } }); // Handle messages from the client
--- a/mrjunejune/test/snapshots/jrpg.snapshot Fri Aug 07 16:05:29 2026 -0700 +++ b/mrjunejune/test/snapshots/jrpg.snapshot Sat Aug 08 02:08:08 2026 -0700 @@ -36,13 +36,18 @@ <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="/public/mjj-modal.css"> <link rel="stylesheet" href="/jrpg/jrpg.css"> <script type="module" src="/public/mjj-composer.js"></script> + <script type="module" src="/public/mjj-modal.js"></script> <script type="module" src="/jrpg/jrpg.js"></script> </head> <body class="jrpg-page"> <main class="jrpg-main"> - <mjj-jrpg-shell class="jrpg-shell"> + <mjj-jrpg-shell + class="jrpg-shell" + data-initial-panel="resume" + > <div class="jrpg-workspace"> <div class="jrpg-brand"> <header> @@ -78,61 +83,67 @@ </a> </zen-button> </nav> - <zen-dialog + <mjj-window-modal 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" - > - <header class="jrpg-mobile-destination-header"> - <zen-button - appearance="plain" - size="sm" - data-mobile-destination-back-owner - hidden + <zen-dialog> + <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" > - <button - type="button" - data-mobile-destination-back - aria-label="Back to destination menu" + <zen-icon name="menu"></zen-icon> + </button> + </zen-button> + <dialog + class="jrpg-mobile-destination-dialog" + data-mobile-destination-dialog + > + <header class="jrpg-mobile-destination-header" data-modal-header> + <zen-button + appearance="plain" + size="sm" + data-mobile-destination-back-owner + hidden > - <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> + <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 + data-modal-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> + <zen-scroll-area + class="jrpg-mobile-destination-content" + data-mobile-destination-content + data-modal-body + data-cyber-scroll + tabindex="0" + aria-label="Destination content" + ></zen-scroll-area> + </dialog> + </zen-dialog> + </mjj-window-modal> <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> @@ -178,50 +189,60 @@ <section class="jrpg-utility" aria-label="Selected destination"> <mjj-jrpg-preview data-selection="resume"> <article class="jrpg-preview-panel" aria-live="polite"> - <div class="jrpg-preview-content" data-preview-content> + <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. + Member of Technical Staff and engineering leader with 10+ years building AI agent platforms and production systems across Microsoft, Meta, Google, and growth-stage companies. </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> + <ul class="jrpg-work-showcase" data-work-showcase><li><a href="/resume" data-resume-modal><span>Full resume</span><small>Career dossier</small></a></li><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></ul> + <mjj-content-modal data-detail-modal-owner> + <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"> + <header class="jrpg-dialog-heading" data-modal-header> + <div data-modal-heading> + <p data-modal-kicker>DESTINATION DATA</p> + <h2 + id="jrpg-dialog-title" + data-dialog-title + data-modal-title + >Resume</h2> + </div> <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> + <zen-button + appearance="plain" + size="sm" + 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"> + <zen-button appearance="plain" size="sm"> <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> + </div> + </header> + <zen-scroll-area + class="jrpg-detail-body" + data-modal-body + data-cyber-scroll + tabindex="0" + aria-label="Destination details" + > + <div class="jrpg-detail-content" data-modal-article> + <p data-dialog-copy>Experience, projects, and the systems I have helped build.</p> <section class="jrpg-resume-dossier" data-resume-dossier @@ -258,18 +279,16 @@ <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> + </div> + </zen-scroll-area> + </dialog> + </zen-dialog> + </mjj-content-modal> </div> - <mjj-conversation-archive hidden aria-label="Conversations"> + <mjj-conversation-archive + hidden + aria-label="Conversations" + > <div class="jrpg-archive-heading"> <h2>Archive</h2> <zen-button appearance="plain" size="xs"> @@ -292,52 +311,83 @@ <button type="button" data-archive-load-more>Load more</button> </zen-button> <p class="jrpg-archive-status" data-archive-status aria-live="polite">Loading conversations...</p> - <dialog - class="jrpg-archive-dialog" - data-archive-delete-dialog - aria-labelledby="archive-delete-title" - aria-modal="true" - > - <p id="archive-delete-title" class="jrpg-archive-dialog-title">Delete this conversation?</p> - <p data-archive-delete-name class="jrpg-archive-dialog-subtitle"></p> - <div class="jrpg-archive-dialog-actions"> - <zen-button appearance="plain" size="md"> - <button type="button" data-archive-delete-cancel>Cancel</button> - </zen-button> - <zen-button appearance="plain" size="md"> - <button type="button" data-archive-delete-confirm>Delete</button> + <mjj-content-modal data-archive-delete-owner> + <zen-dialog> + <zen-button appearance="plain" size="xs" hidden> + <button type="button" data-zen-trigger tabindex="-1" aria-hidden="true"></button> </zen-button> - </div> - </dialog> - <dialog - class="jrpg-archive-dialog" - data-archive-rename-dialog - aria-labelledby="archive-rename-title" - aria-modal="true" - > - <p id="archive-rename-title" class="jrpg-archive-dialog-title">Rename conversation</p> - <form data-archive-rename-form novalidate> - <zen-field appearance="plain" size="md"> - <label for="archive-rename-input">New title</label> - <input - id="archive-rename-input" - data-archive-rename-input - type="text" - maxlength="200" - required - autocomplete="off" + <dialog + class="jrpg-archive-dialog" + data-archive-delete-dialog + > + <header data-modal-header> + <h2 + id="archive-delete-title" + class="jrpg-archive-dialog-title" + data-modal-title + >Delete this conversation?</h2> + </header> + <div data-modal-body> + <div data-modal-article> + <p data-archive-delete-name class="jrpg-archive-dialog-subtitle"></p> + </div> + </div> + <footer class="jrpg-archive-dialog-actions" data-modal-footer> + <zen-button appearance="plain" size="md"> + <button type="button" data-archive-delete-cancel>Cancel</button> + </zen-button> + <zen-button appearance="plain" size="md"> + <button type="button" data-archive-delete-confirm>Delete</button> + </zen-button> + </footer> + </dialog> + </zen-dialog> + </mjj-content-modal> + <mjj-window-modal data-archive-rename-owner> + <zen-dialog> + <zen-button appearance="plain" size="xs" hidden> + <button type="button" data-zen-trigger tabindex="-1" aria-hidden="true"></button> + </zen-button> + <dialog + class="jrpg-archive-dialog" + data-archive-rename-dialog + > + <header data-modal-header> + <h2 + id="archive-rename-title" + class="jrpg-archive-dialog-title" + data-modal-title + >Rename conversation</h2> + </header> + <form + id="archive-rename-form" + data-modal-body + data-archive-rename-form + novalidate > - </zen-field> - <div class="jrpg-archive-dialog-actions"> + <zen-field appearance="plain" size="md"> + <label for="archive-rename-input">New title</label> + <input + id="archive-rename-input" + data-archive-rename-input + type="text" + maxlength="200" + required + autocomplete="off" + > + </zen-field> + </form> + <footer class="jrpg-archive-dialog-actions" data-modal-footer> <zen-button appearance="plain" size="md"> <button type="button" data-archive-rename-cancel>Cancel</button> </zen-button> <zen-button appearance="plain" size="md"> - <button type="submit">Save</button> + <button type="submit" form="archive-rename-form">Save</button> </zen-button> - </div> - </form> - </dialog> + </footer> + </dialog> + </zen-dialog> + </mjj-window-modal> </mjj-conversation-archive> </article> </mjj-jrpg-preview> @@ -435,26 +485,30 @@ </nav> </mjj-jrpg-menu> - <zen-dialog class="jrpg-login-dialog-owner" data-login-dialog-owner> - <zen-button appearance="plain" size="md" hidden> - <button type="button" data-zen-trigger tabindex="-1" aria-hidden="true"></button> - </zen-button> - <dialog - class="jrpg-login-dialog" - data-login-dialog - aria-labelledby="jrpg-login-title" - aria-modal="true" - > - <div class="jrpg-login-dialog-header"> - <h2 id="jrpg-login-title">SIGN IN</h2> - <zen-button appearance="plain" size="md"> - <button type="button" data-zen-close aria-label="Close sign-in dialog"> - <zen-icon name="close"></zen-icon> - </button> - </zen-button> - </div> - <div role="alert" data-login-error aria-live="assertive" hidden></div> - <form data-login-form novalidate> + <mjj-window-modal class="jrpg-login-dialog-owner" data-login-dialog-owner> + <zen-dialog> + <zen-button appearance="plain" size="md" hidden> + <button type="button" data-zen-trigger tabindex="-1" aria-hidden="true"></button> + </zen-button> + <dialog + class="jrpg-login-dialog" + data-login-dialog + > + <header class="jrpg-login-dialog-header" data-modal-header> + <h2 id="jrpg-login-title" data-modal-title>SIGN IN</h2> + <zen-button appearance="plain" size="md"> + <button type="button" data-zen-close aria-label="Close sign-in dialog"> + <zen-icon name="close"></zen-icon> + </button> + </zen-button> + </header> + <form + id="jrpg-login-form" + data-login-form + data-modal-body + novalidate + > + <div role="alert" data-login-error aria-live="assertive" hidden></div> <zen-field appearance="plain" size="md"> <label for="jrpg-login-username">Username</label> <input @@ -480,17 +534,22 @@ maxlength="1024" > </zen-field> - <div class="jrpg-login-dialog-actions"> + </form> + <footer class="jrpg-login-dialog-actions" data-modal-footer> <zen-button appearance="plain" size="md"> <button type="button" data-login-cancel data-zen-close>Cancel</button> </zen-button> <zen-button appearance="plain" size="md"> - <button type="submit" data-login-submit>Sign in</button> + <button + type="submit" + form="jrpg-login-form" + data-login-submit + >Sign in</button> </zen-button> - </div> - </form> - </dialog> - </zen-dialog> + </footer> + </dialog> + </zen-dialog> + </mjj-window-modal> </div> </mjj-jrpg-shell> </main>
--- a/mrjunejune/test/theme_and_webp_test.js Fri Aug 07 16:05:29 2026 -0700 +++ b/mrjunejune/test/theme_and_webp_test.js Sat Aug 08 02:08:08 2026 -0700 @@ -491,6 +491,173 @@ await context.close(); } +async function testModalSandbox(browser) { + const context = await browser.newContext({ + serviceWorkers: 'block', + viewport: { width: 1280, height: 900 }, + }); + const page = await context.newPage(); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + page.on('console', message => { + if (message.type() === 'error') errors.push(message.text()); + }); + await page.goto(`${baseUrl}/public/component-sandbox.html`, { + waitUntil: 'networkidle', + }); + await page.waitForFunction(() => + customElements.get('mjj-content-modal') && + customElements.get('mjj-window-modal') && + [...document.querySelectorAll('mjj-content-modal, mjj-window-modal')] + .every(modal => modal.hasAttribute('data-ready')) + ); + await page.evaluate(() => document.fonts.ready); + const modalCss = await ( + await fetch(`${baseUrl}/public/mjj-modal.css`, { cache: 'no-store' }) + ).text(); + assert.match(modalCss, /clip-path:\s*inset\(49% 45%\)/); + assert.match(modalCss, /clip-path:\s*inset\(49% 0\)/); + assert.match(modalCss, /scrollbar-gutter:\s*stable/); + + assert.equal( + await page.locator('mjj-content-modal[data-invalid], mjj-window-modal[data-invalid]') + .count(), + 0, + ); + + await page.getByRole('button', { name: 'Open article' }).click(); + const contentDialog = page.locator('mjj-content-modal dialog'); + await contentDialog.waitFor({ state: 'visible' }); + await contentDialog.evaluate(dialog => + Promise.all(dialog.getAnimations().map(animation => animation.finished)) + ); + const contentMetrics = await contentDialog.evaluate(dialog => { + const body = dialog.querySelector(':scope > [data-modal-body]'); + const children = [...dialog.children].map(child => { + if (child.hasAttribute('data-modal-header')) return 'header'; + if (child.hasAttribute('data-modal-body')) return 'body'; + if (child.hasAttribute('data-modal-footer')) return 'footer'; + return child.localName; + }); + const style = getComputedStyle(dialog); + const bodyStyle = getComputedStyle(body); + return { + bodyScrollable: ['auto', 'scroll'].includes(bodyStyle.overflowY), + borderRadius: style.borderRadius, + children, + fontFamily: style.fontFamily, + labelled: dialog.getAttribute('aria-labelledby') === + dialog.querySelector('[data-modal-title]').id, + overflowing: body.scrollHeight > body.clientHeight, + shadow: style.boxShadow, + scrollPrimitive: body.localName, + scrollbarColor: bodyStyle.scrollbarColor, + scrollbarGutter: bodyStyle.scrollbarGutter, + width: dialog.getBoundingClientRect().width, + }; + }); + assert.deepEqual(contentMetrics.children, ['header', 'body', 'footer']); + assert.equal(contentMetrics.bodyScrollable, true); + assert.equal(contentMetrics.borderRadius, '0px'); + assert.equal(contentMetrics.labelled, true); + assert.equal(contentMetrics.fontFamily, '"Pixel Mplus"'); + assert.equal(contentMetrics.overflowing, true); + assert.equal(contentMetrics.scrollPrimitive, 'zen-scroll-area'); + assert.notEqual(contentMetrics.scrollbarColor, 'auto'); + assert.match(contentMetrics.scrollbarGutter, /stable/); + assert.notEqual(contentMetrics.shadow, 'none'); + assert.ok(contentMetrics.width <= 864); + await page.getByRole('button', { name: 'Close article' }).click(); + await contentDialog.waitFor({ state: 'hidden' }); + + await page.getByRole('button', { name: 'Open workspace' }).click(); + const windowDialog = page.locator('mjj-window-modal dialog'); + await windowDialog.waitFor({ state: 'visible' }); + await windowDialog.evaluate(dialog => + Promise.all(dialog.getAnimations().map(animation => animation.finished)) + ); + const windowMetrics = await windowDialog.evaluate(dialog => { + const body = dialog.querySelector(':scope > [data-modal-body]'); + const bodyStyle = getComputedStyle(body); + const primary = dialog.querySelector('[data-modal-pane="primary"]'); + return { + bodyColumns: bodyStyle.gridTemplateColumns, + bodyScrollable: ['auto', 'scroll'].includes(bodyStyle.overflowY), + borderRadius: getComputedStyle(dialog).borderRadius, + hasStrictRegions: + Boolean(dialog.querySelector(':scope > [data-modal-header]')) && + Boolean(body) && + Boolean(dialog.querySelector(':scope > [data-modal-footer]')), + height: dialog.getBoundingClientRect().height, + primaryShadow: getComputedStyle(primary).boxShadow, + primaryOverflowing: primary.scrollHeight > primary.clientHeight, + primaryScrollColor: getComputedStyle(primary).scrollbarColor, + scrollPrimitive: body.localName, + scrollPrimitives: + dialog.querySelectorAll('zen-scroll-area[data-cyber-scroll]').length, + scrollbarColor: bodyStyle.scrollbarColor, + scrollbarGutter: bodyStyle.scrollbarGutter, + shadow: getComputedStyle(dialog).boxShadow, + }; + }); + assert.equal(windowMetrics.bodyScrollable, true); + assert.equal(windowMetrics.borderRadius, '0px'); + assert.equal(windowMetrics.hasStrictRegions, true); + assert.equal(windowMetrics.scrollPrimitive, 'zen-scroll-area'); + assert.notEqual(windowMetrics.scrollbarColor, 'auto'); + assert.match(windowMetrics.scrollbarGutter, /stable/); + assert.match(windowMetrics.bodyColumns, /\d/); + assert.ok(windowMetrics.height <= 768); + assert.notEqual(windowMetrics.primaryShadow, 'none'); + assert.equal(windowMetrics.primaryOverflowing, true); + assert.notEqual(windowMetrics.primaryScrollColor, 'auto'); + assert.equal(windowMetrics.scrollPrimitives, 3); + assert.notEqual(windowMetrics.shadow, 'none'); + await page.getByRole('button', { name: 'Close workspace' }).click(); + await windowDialog.waitFor({ state: 'hidden' }); + assert.deepEqual(errors, []); + await context.close(); + + const mobileContext = await browser.newContext({ + serviceWorkers: 'block', + viewport: { width: 390, height: 844 }, + }); + const mobilePage = await mobileContext.newPage(); + await mobilePage.goto(`${baseUrl}/public/component-sandbox.html`, { + waitUntil: 'networkidle', + }); + await mobilePage.evaluate(() => document.fonts.ready); + await mobilePage.getByRole('button', { name: 'Open workspace' }).click(); + const mobileDialog = mobilePage.locator('mjj-window-modal dialog'); + await mobileDialog.waitFor({ state: 'visible' }); + await mobileDialog.evaluate(dialog => + Promise.all(dialog.getAnimations().map(animation => animation.finished)) + ); + const mobileMetrics = await mobileDialog.evaluate(dialog => { + const body = dialog.querySelector('[data-modal-body]'); + const dialogBox = dialog.getBoundingClientRect(); + return { + bodyColumns: getComputedStyle(body).gridTemplateColumns, + fits: + dialogBox.left >= 0 && + dialogBox.top >= 0 && + dialogBox.right <= innerWidth && + dialogBox.bottom <= innerHeight, + pageFits: document.documentElement.scrollWidth <= innerWidth, + }; + }); + assert.equal(mobileMetrics.fits, true); + assert.equal(mobileMetrics.pageFits, true); + assert.doesNotMatch(mobileMetrics.bodyColumns, /\s/); + assert.equal( + await mobilePage.locator('mjj-window-modal').getAttribute( + 'data-component-layer', + ), + '2', + ); + await mobileContext.close(); +} + async function testJrpgPage(browser) { const context = await browser.newContext({ colorScheme: 'light', @@ -547,6 +714,30 @@ } }); + for (const expected of [ + { panel: 'resume', title: 'Resume' }, + { panel: 'tools', title: 'Tools' }, + { panel: 'blog', title: 'Blogs' }, + ]) { + const html = await ( + await fetch(`${baseUrl}/jrpg?panel=${expected.panel}`) + ).text(); + assert.match( + html, + new RegExp(`data-initial-panel="${expected.panel}"`), + ); + assert.match(html, new RegExp(`data-preview-title>${expected.title}<`)); + assert.doesNotMatch(html, /__MJJ_[A-Z_]+__/); + } + const conversationHtml = await ( + await fetch(`${baseUrl}/jrpg?panel=conversations`) + ).text(); + assert.match(conversationHtml, /data-initial-panel="conversations"/); + assert.match( + conversationHtml, + /<mjj-conversation-archive\s+aria-label="Conversations"/, + ); + await page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' }); await page.waitForFunction(() => customElements.get('mjj-composer') && @@ -554,6 +745,26 @@ customElements.get('mjj-jrpg-chat') && customElements.get('mjj-jrpg-preview') ); + await page.waitForFunction(() => + [...document.querySelectorAll('.jrpg-workspace dialog')].every(dialog => { + const owner = dialog.closest('mjj-content-modal, mjj-window-modal'); + return owner?.hasAttribute('data-ready'); + }) + ); + assert.deepEqual( + await page.evaluate(() => + [...document.querySelectorAll('.jrpg-workspace dialog')] + .map(dialog => + dialog.closest('mjj-content-modal, mjj-window-modal')?.localName)), + [ + 'mjj-window-modal', + 'mjj-content-modal', + 'mjj-content-modal', + 'mjj-window-modal', + 'mjj-window-modal', + ], + 'every JRPG dialog has a Layer 2 owner', + ); await page.evaluate(() => document.fonts.ready); const desktop = await page.evaluate(() => { const scene = document.querySelector('.jrpg-scene').getBoundingClientRect(); @@ -1067,6 +1278,36 @@ await page.locator('button[data-preview="conversations"]').getAttribute('aria-pressed'), 'true', ); + await page.evaluate(() => { + document.querySelector('mjj-conversation-archive')?.addConversation({ + id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', + title: 'Modal migration fixture', + turn_count: 2, + }); + }); + await page.getByRole('button', { + name: 'Rename "Modal migration fixture"', + }).click(); + await page.locator('[data-archive-rename-dialog]').waitFor({ state: 'visible' }); + assert.equal( + await page.locator('[data-archive-rename-dialog]').evaluate(dialog => + dialog.closest('mjj-window-modal')?.localName), + 'mjj-window-modal', + ); + await page.locator('[data-archive-rename-cancel]').click(); + await page.locator('[data-archive-rename-dialog]').waitFor({ state: 'hidden' }); + + await page.getByRole('button', { + name: 'Delete "Modal migration fixture"', + }).click(); + await page.locator('[data-archive-delete-dialog]').waitFor({ state: 'visible' }); + assert.equal( + await page.locator('[data-archive-delete-dialog]').evaluate(dialog => + dialog.closest('mjj-content-modal')?.localName), + 'mjj-content-modal', + ); + await page.locator('[data-archive-delete-cancel]').click(); + await page.locator('[data-archive-delete-dialog]').waitFor({ state: 'hidden' }); // Archive: close via close button, navigates back to resume panel await page.locator('[data-archive-close]').click(); @@ -1128,10 +1369,7 @@ await page.locator('[data-preview-title]').textContent(), 'Tools', ); - assert.equal( - await page.locator('[data-preview-link]').getAttribute('href'), - '/tools', - ); + assert.equal(await page.locator('[data-preview-link]').count(), 0); assert.equal( await page.getByRole('button', { name: 'Tools', exact: true }) .getAttribute('aria-pressed'), @@ -1151,13 +1389,15 @@ await page.locator('[data-latest-tool]').first().click(); await page.locator('[data-functional-tool="markdown"]').waitFor(); assert.equal( + await page.locator('.jrpg-detail-dialog').evaluate(dialog => + dialog.closest('mjj-content-modal, mjj-window-modal')?.localName), + 'mjj-window-modal', + 'tool details use the window modal', + ); + assert.equal( await page.locator('[data-dialog-title]').textContent(), 'MarkDown to HTML', ); - assert.equal( - await page.locator('[data-preview-link]').getAttribute('href'), - '/tools/markdown_to_html', - ); await page.locator('[data-markdown-source]').fill('# Modal Markdown'); await page.waitForFunction(() => document.querySelector('[data-markdown-output] h1')?.textContent === @@ -1173,27 +1413,18 @@ .map(window => window.getBoundingClientRect()); const windowShadows = [...document.querySelectorAll('.jrpg-tool-window')] .map(window => getComputedStyle(window).boxShadow); - const rail = getComputedStyle( - document.querySelector('.jrpg-tool-panes'), - '::before', - ); return { dialogHeight: dialogElement.offsetHeight, dialogWidth: dialogElement.offsetWidth, - fillsViewport: dialogElement.offsetWidth > innerWidth * 0.9 && - dialogElement.offsetHeight > innerHeight * 0.9, + compact: dialogElement.offsetWidth <= innerWidth * 0.9 && + dialogElement.offsetHeight <= innerHeight * 0.8, viewportHeight: innerHeight, viewportWidth: innerWidth, - sideBySide: windows.length === 2 && - windows[0].right < windows[1].left && - Math.abs(windows[0].top - windows[1].top) < 1, - fullPageInHeader: Boolean( - document.querySelector('[data-preview-link]') - .closest('.jrpg-dialog-heading-actions'), - ), - footerHidden: document.querySelector('[data-dialog-actions]').hidden, - metalRail: rail.content !== 'none' && - rail.backgroundImage.includes('bar-ink.webp'), + stacked: windows.length === 2 && + windows[0].bottom < windows[1].top && + Math.abs(windows[0].left - windows[1].left) < 1, + noOpenPageAction: !document.querySelector('[data-preview-link]'), + noFooter: !document.querySelector('[data-dialog-actions]'), pixelIcons: document.querySelectorAll('.jrpg-tool-window zen-icon').length >= 2 && [...document.querySelectorAll('.jrpg-tool-window zen-icon svg')] .every(icon => @@ -1201,21 +1432,19 @@ ), separateGlows: windowShadows.length === 2 && windowShadows.every(shadow => shadow !== 'none'), - wideWindows: windows.every(window => window.width > dialog.width * 0.35), + wideWindows: windows.every(window => window.width > dialog.width * 0.8), }; }); - assert.equal( - toolWorkspace.fillsViewport, - true, - JSON.stringify(toolWorkspace), - ); - assert.equal(toolWorkspace.sideBySide, true); - assert.equal(toolWorkspace.fullPageInHeader, true); - assert.equal(toolWorkspace.footerHidden, true); - assert.equal(toolWorkspace.metalRail, true); + assert.equal(toolWorkspace.compact, true, JSON.stringify(toolWorkspace)); + assert.equal(toolWorkspace.stacked, true); + assert.equal(toolWorkspace.noOpenPageAction, true); + assert.equal(toolWorkspace.noFooter, true); assert.equal(toolWorkspace.pixelIcons, true); assert.equal(toolWorkspace.separateGlows, true); assert.equal(toolWorkspace.wideWindows, true); + await page.locator('.jrpg-detail-dialog').evaluate(dialog => + Promise.all(dialog.getAnimations().map(animation => animation.finished)) + ); await page.getByRole('button', { name: 'Close destination details', }).click(); @@ -1283,10 +1512,6 @@ await page.locator('[data-latest-tool]').nth(2).click(); await page.locator('[data-functional-tool="hls"]').waitFor(); - assert.equal( - await page.locator('[data-preview-link]').getAttribute('href'), - '/tools/hls_player', - ); await page.locator('[data-hls-sample]').click(); await page.waitForFunction(() => ['ready', 'error'].includes( @@ -1336,10 +1561,6 @@ await page.locator('[data-tool-content]').textContent(), /private browser-based writing workspace/, ); - assert.equal( - await page.locator('[data-preview-link]').getAttribute('href'), - '/notes', - ); await page.getByRole('button', { name: 'Close destination details', }).click(); @@ -1347,6 +1568,12 @@ await page.getByRole('button', { name: 'Resume', exact: true }).click(); await page.locator('[data-resume-modal]').click(); + assert.equal( + await page.locator('.jrpg-detail-dialog').evaluate(dialog => + dialog.closest('mjj-content-modal, mjj-window-modal')?.localName), + 'mjj-content-modal', + 'resume details use the content modal', + ); await page.locator('[data-resume-content] .sub-header').first().waitFor(); const resumeDossier = await page.locator('[data-resume-dossier]').evaluate( dossier => ({ @@ -1358,6 +1585,46 @@ assert.match(resumeDossier.text, /Copilot Tasks/); assert.match(resumeDossier.text, /Microsoft/); assert.match(resumeDossier.text, /Meta/); + const resumeModalGeometry = await page.locator('.jrpg-detail-dialog').evaluate( + dialog => { + const body = dialog.querySelector('[data-modal-body]').getBoundingClientRect(); + const dossier = dialog.querySelector('[data-resume-dossier]') + .getBoundingClientRect(); + const buttons = [...dialog.querySelectorAll( + '[data-modal-header] zen-button > :is(button, a)', + )].map(control => control.getBoundingClientRect()); + return { + bodyFilled: + dossier.width / body.width > 0.9 && + Math.abs(dossier.left - body.left) < 32, + equalHeaderControls: + buttons.length === 2 && + Math.abs(buttons[0].height - buttons[1].height) < 1, + headerControlHeights: buttons.map(button => button.height), + footerAbsent: !dialog.querySelector('[data-modal-footer]'), + headerPdfLinks: dialog.querySelectorAll( + '[data-modal-header] a[href="/public/resume.pdf"]', + ).length, + visibleBodyPdfLinks: [...dialog.querySelectorAll( + '[data-modal-body] a[href="/public/resume.pdf"]', + )].filter(link => getComputedStyle(link).display !== 'none').length, + pdfLinks: dialog.querySelectorAll('a[href="/public/resume.pdf"]').length, + }; + }, + ); + assert.equal(resumeModalGeometry.bodyFilled, true); + assert.equal( + resumeModalGeometry.equalHeaderControls, + true, + JSON.stringify(resumeModalGeometry), + ); + assert.equal(resumeModalGeometry.footerAbsent, true); + assert.equal(resumeModalGeometry.headerPdfLinks, 1); + assert.equal(resumeModalGeometry.visibleBodyPdfLinks, 0); + assert.equal(resumeModalGeometry.pdfLinks, 1); + await page.locator('.jrpg-detail-dialog').evaluate(dialog => + Promise.all(dialog.getAnimations().map(animation => animation.finished)) + ); assert.equal( await page.locator('[data-resume-content] a[target="_blank"]').count() > 0, true, @@ -1386,15 +1653,21 @@ 'WebSocket Demystified', ); assert.equal( - await page.locator('[data-preview-link]').getAttribute('href'), - '/blog/websocket-demystified', - ); - assert.equal( await page.locator('[data-blog-content]').evaluate( article => getComputedStyle(article).fontFamily, ), '"Pixel Mplus"', ); + const blogWidth = await page.locator('.jrpg-detail-dialog').evaluate(dialog => { + const body = dialog.querySelector('[data-modal-body]').getBoundingClientRect(); + const browser = dialog.querySelector('[data-blog-browser]') + .getBoundingClientRect(); + return browser.width / body.width; + }); + assert.ok(blogWidth > 0.9); + await page.locator('.jrpg-detail-dialog').evaluate(dialog => + Promise.all(dialog.getAnimations().map(animation => animation.finished)) + ); await page.getByRole('button', { name: 'Close destination details', }).click(); @@ -1410,10 +1683,6 @@ document.querySelector('[data-blog-content] h1')?.textContent === 'Creating Network Library in C' ); - assert.equal( - await page.locator('[data-preview-link]').getAttribute('href'), - '/blog/my-seobeo-journey', - ); await page.getByRole('button', { name: 'Close destination details', }).click(); @@ -1689,6 +1958,7 @@ ).matches, menuInModal: menuElement.parentElement === destinationContent, menuHidden: menu.width === 0 && menu.height === 0, + modalOwner: destinationContent.closest('mjj-window-modal')?.localName, mobileArtIsCorrect, noOverflow: document.documentElement.scrollWidth <= innerWidth, sendButtonCompact: @@ -1720,6 +1990,10 @@ utilityHidden: utility.width === 0 && utility.height === 0, utilityInModal: document.querySelector('.jrpg-utility').parentElement === destinationContent, + usesCyberScroll: + destinationContent.localName === 'zen-scroll-area' && + destinationContent.hasAttribute('data-cyber-scroll') && + getComputedStyle(destinationContent).scrollbarColor !== 'auto', usesDesignSystemControls: !composerField.hasAttribute('appearance') && !sendOwner.hasAttribute('appearance'), @@ -1762,6 +2036,8 @@ ); 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.modalOwner, 'mjj-window-modal'); + assert.equal(mobile.usesCyberScroll, true, 'JRPG modal uses the shared cyber scroll area'); 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'); @@ -1771,6 +2047,9 @@ await mobilePage.locator('[data-mobile-destination-dialog]').waitFor({ state: 'visible', }); + await mobilePage.locator('[data-mobile-destination-dialog]').evaluate(dialog => + Promise.all(dialog.getAnimations().map(animation => animation.finished)) + ); assert.equal( await mobilePage.locator('[data-mobile-menu-toggle]') .getAttribute('aria-expanded'), @@ -2810,7 +3089,7 @@ for (const source of [home, dogGame, manifest]) { assert.doesNotMatch(source, /\.png(?:["')]|$)/i); } - assert.match(serviceWorker, /v35-role-greeting/); + assert.match(serviceWorker, /v37-network-first/); assert.match( await ( await fetch(`${baseUrl}/public/pwa-register.js`) @@ -2820,6 +3099,18 @@ assert.ok(serviceWorker.includes("startsWith('/tools/hls_player')")); assert.ok(serviceWorker.includes("url.hostname === 'localhost'")); assert.ok(serviceWorker.includes("url.hostname === '127.0.0.1'")); + assert.ok(serviceWorker.includes("DEVELOPMENT_SANDBOX_PATHS")); + assert.ok(serviceWorker.includes("'/public/component-sandbox'")); + assert.ok(serviceWorker.includes("'/public/mjj-modal.'")); + assert.ok(serviceWorker.includes('networkFirst(request, acceptsHtml)')); + assert.ok(serviceWorker.includes('staleWhileRevalidate(event, request)')); + assert.ok(serviceWorker.includes("fetch(request, { cache: 'no-cache' })")); + assert.doesNotMatch( + await ( + await fetch(`${baseUrl}/public/pwa-register.js`) + ).text(), + /confirm\(/, + ); assert.ok(!serviceWorker.includes("startsWith('/jrpg')")); for (const asset of [ @@ -2877,6 +3168,7 @@ await testDynamicButtonOwnership(browser); await testResumePrint(browser); await testComposerLab(browser); + await testModalSandbox(browser); } if (runsSuite('jrpg')) await testJrpgPage(browser); if (runsSuite('routing')) await testConversationRouting(browser);