Mercurial
changeset 263:ee04e4e69fed
Add functional JRPG frame and tools
Add full-screen background_2 apertures, functional frame chrome, card-driven details, dual-window tools, live conversion workflows, and bounded cleanup for generated downloads.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <mrjunejune@users.noreply.github.com> |
|---|---|
| date | Thu, 06 Aug 2026 11:31:30 -0700 |
| parents | 0f45474c1b1a |
| children | 04fee26ecce0 |
| files | mrjunejune/main.c mrjunejune/src/jrpg/index.html mrjunejune/src/jrpg/jrpg.css mrjunejune/src/jrpg/jrpg.js mrjunejune/src/public/jrpg/background-frame-2.webp mrjunejune/src/sw.js mrjunejune/test/BUILD mrjunejune/test/snapshots/jrpg.snapshot mrjunejune/test/theme_and_webp_test.js |
| diffstat | 9 files changed, 2233 insertions(+), 203 deletions(-) [+] |
line wrap: on
line diff
--- a/mrjunejune/main.c Thu Aug 06 04:08:45 2026 -0700 +++ b/mrjunejune/main.c Thu Aug 06 11:31:30 2026 -0700 @@ -691,6 +691,67 @@ return resp; } +static boolean Converted_Filename_Is_Valid(const char *filename) +{ + if (!filename) + return FALSE; + const char *extension = strrchr(filename, '.'); + if (!extension || (strcmp(extension, ".webp") != 0 && + strcmp(extension, ".mp4") != 0)) + return FALSE; + if ((size_t)(extension - filename) != 36) + return FALSE; + for (size_t i = 0; i < 36; i++) + { + char value = filename[i]; + if (i == 8 || i == 13 || i == 18 || i == 23) + { + if (value != '-') + return FALSE; + continue; + } + if (!((value >= '0' && value <= '9') || + (value >= 'a' && value <= 'f') || + (value >= 'A' && value <= 'F'))) + return FALSE; + } + return TRUE; +} + +static Seobeo_Request_Entry *Converted_File_Error( + Dowa_Arena *arena, + char *status, + char *message) +{ + Seobeo_Request_Entry *resp = NULL; + Dowa_HashMap_Push_Arena(resp, "status", status, arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); + Dowa_HashMap_Push_Arena(resp, "body", message, arena); + return resp; +} + +Seobeo_Request_Entry *DeleteConvertedFile( + Seobeo_Request_Entry *req, + Dowa_Arena *arena) +{ + void *filename_kv = Dowa_HashMap_Get_Ptr(req, ":filename"); + const char *filename = filename_kv + ? ((Seobeo_Request_Entry *)filename_kv)->value + : NULL; + if (!Converted_Filename_Is_Valid(filename)) + return Converted_File_Error(arena, "400", "Invalid converted filename"); + + char filepath[512]; + snprintf(filepath, sizeof(filepath), "/tmp/%s", filename); + if (unlink(filepath) != 0 && errno != ENOENT) + return Converted_File_Error(arena, "500", "Unable to delete converted file"); + + Seobeo_Request_Entry *resp = NULL; + Dowa_HashMap_Push_Arena(resp, "status", "204", arena); + Dowa_HashMap_Push_Arena(resp, "body", "", arena); + return resp; +} + Seobeo_Request_Entry *DownloadConvertedFile(Seobeo_Request_Entry *req, Dowa_Arena *arena) { Seobeo_Request_Entry *resp = NULL; @@ -706,26 +767,8 @@ } const char *filename = ((Seobeo_Request_Entry*)filename_kv)->value; - - // TODO: Maybe check if the uuid is allowed or not? - // if (strlen(filename) != TMP_FILE_LENGTH) - // { - // char *error_msg = "Not Allowed Filename"; - // Dowa_HashMap_Push_Arena(resp, "status", "404", arena); - // Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); - // Dowa_HashMap_Push_Arena(resp, "body", error_msg, arena); - // return resp; - // } - // boolean allowed = FALSE; - // for (int i = 0; i < Dowa_Array_Length(g_uuid4_array); i++) - // { - // if strcmp(g_uuid4_array, filename) - // { - // allowed = TRUE; - // break; - // } - // g_uuid4_array++; - // } + if (!Converted_Filename_Is_Valid(filename)) + return Converted_File_Error(arena, "400", "Invalid converted filename"); char filepath[512]; snprintf(filepath, sizeof(filepath), "/tmp/%s", filename); @@ -759,8 +802,8 @@ file_data[file_size] = '\0'; fclose(file); - const char *content_type = "application/octet-stream"; - if (strstr(filename, ".webp")) + char *content_type = "application/octet-stream"; + if (strcmp(strrchr(filename, '.'), ".webp") == 0) content_type = "image/webp"; else if (strstr(filename, ".mp4")) content_type = "video/mp4"; @@ -2067,6 +2110,7 @@ Seobeo_Router_Register("POST", "/api/convert/image-to-webp", ConvertImageToWebP); Seobeo_Router_Register("POST", "/api/convert/video-to-mp4", ConvertVideoToMP4); Seobeo_Router_Register("GET", "/api/download/:filename", DownloadConvertedFile); + Seobeo_Router_Register("DELETE", "/api/download/:filename", DeleteConvertedFile); Seobeo_Router_Register("POST", "/api/latex/render", RenderLatexPdf); // -- S3 Upload --/
--- a/mrjunejune/src/jrpg/index.html Thu Aug 06 04:08:45 2026 -0700 +++ b/mrjunejune/src/jrpg/index.html Thu Aug 06 11:31:30 2026 -0700 @@ -3,6 +3,7 @@ <head> <title>Shiba Quest | MrJuneJune</title> {{/parts/base_head.html}} + <link rel="preload" href="/public/jrpg/background-frame-2.webp" as="image"> <link rel="preload" href="/public/jrpg/bar-ink.webp" as="image"> <link rel="stylesheet" href="/jrpg/jrpg.css"> <script type="module" src="/jrpg/jrpg.js"></script> @@ -11,6 +12,47 @@ <main class="jrpg-main"> <mjj-jrpg-shell class="jrpg-shell"> <div class="jrpg-workspace"> + <div class="jrpg-brand"> + <header> + <h1> + <a href="/" data-zen-link-effect="none">MrJuneJune</a> + </h1> + </header> + </div> + <nav class="jrpg-frame-controls" aria-label="Window controls"> + <zen-button appearance="plain" size="xs"> + <button + type="button" + data-frame-minimize + aria-label="Minimize interface" + aria-pressed="false" + > + <zen-icon name="arrow-down"></zen-icon> + </button> + </zen-button> + <zen-button appearance="plain" size="xs"> + <button + type="button" + data-frame-fullscreen + aria-label="Toggle fullscreen" + aria-pressed="false" + > + <zen-icon name="external"></zen-icon> + </button> + </zen-button> + <zen-button appearance="plain" size="xs"> + <a href="/" aria-label="Close interface and return home"> + <zen-icon name="close"></zen-icon> + </a> + </zen-button> + </nav> + <footer class="jrpg-frame-telemetry" aria-label="System telemetry"> + <span aria-label="Temperature unavailable">N/A</span> + <span aria-label="User June">JUNE</span> + <span data-frame-network aria-label="Status online">ONLINE</span> + <time data-frame-uptime aria-label="Uptime">00:00:00</time> + </footer> + <section class="jrpg-scene" aria-label="Shiba Quest conversation"> <mjj-jrpg-character state="default"> <img data-character alt=""> @@ -59,15 +101,6 @@ </section> <section class="jrpg-utility" aria-label="Selected destination"> - <div class="jrpg-brand"> - <header> - <h1> - <a href="/" data-zen-link-effect="none">MrJuneJune</a> - </h1> - </header> - <p>SHIBA QUEST TERMINAL</p> - </div> - <mjj-jrpg-preview data-selection="resume"> <article class="jrpg-preview-panel" aria-live="polite"> <p class="jrpg-preview-kicker" data-preview-kicker>CHARACTER RECORD</p> @@ -89,32 +122,26 @@ </a> </li> </ul> - <dl class="jrpg-preview-stats"> - <div> - <dt>TYPE</dt> - <dd data-preview-type>Profile</dd> - </div> - <div> - <dt>STATUS</dt> - <dd>Available</dd> - </div> - </dl> - <zen-dialog class="jrpg-preview-dialog"> - <zen-button appearance="plain" size="md"> - <button type="button" data-zen-trigger> - Inspect - <zen-icon name="search"></zen-icon> - </button> + <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> - <zen-button appearance="plain" size="md"> - <button type="button" data-zen-close aria-label="Close destination details"> - <zen-icon name="close"></zen-icon> - </button> - </zen-button> + <div class="jrpg-dialog-heading-actions"> + <zen-button appearance="plain" size="md"> + <a href="/resume" data-preview-link> + Open page + <zen-icon name="external"></zen-icon> + </a> + </zen-button> + <zen-button appearance="plain" size="md"> + <button type="button" data-zen-close aria-label="Close destination details"> + <zen-icon name="close"></zen-icon> + </button> + </zen-button> + </div> </div> <h2 id="jrpg-dialog-title" data-dialog-title>Resume</h2> <p data-dialog-copy>Experience, projects, and the systems I have helped build.</p> @@ -143,13 +170,18 @@ <div data-blog-content></div> </article> </section> - <div class="jrpg-dialog-actions"> - <zen-button appearance="plain" size="lg"> - <a href="/resume" data-preview-link> - Open full page - <zen-icon name="arrow-right"></zen-icon> - </a> - </zen-button> + <section + class="jrpg-tool-browser" + data-tool-browser + aria-label="Tool workspace" + hidden + > + <article> + <p data-tool-status>Select a tool to inspect.</p> + <div data-tool-content></div> + </article> + </section> + <div class="jrpg-dialog-actions" data-dialog-actions> <zen-button appearance="plain" size="lg" data-resume-download> <a href="/public/resume.pdf"> Download PDF @@ -177,13 +209,13 @@ ></textarea> </zen-field> <div class="jrpg-composer-actions"> - <zen-button appearance="plain" size="lg"> + <zen-button appearance="plain" size="sm"> <button type="submit"> Send <zen-icon name="arrow-right"></zen-icon> </button> </zen-button> - <zen-button appearance="plain" size="lg" data-cancel-control hidden> + <zen-button appearance="plain" size="sm" data-cancel-control hidden> <button type="button" data-cancel> Cancel <zen-icon name="close"></zen-icon> @@ -199,7 +231,7 @@ <p class="jrpg-menu-label">Menu</p> <ul> <li> - <zen-button appearance="plain" size="lg"> + <zen-button appearance="plain" size="sm"> <button type="button" data-preview="resume" aria-pressed="true"> <zen-icon name="chevron-right"></zen-icon> Resume @@ -207,7 +239,7 @@ </zen-button> </li> <li> - <zen-button appearance="plain" size="lg"> + <zen-button appearance="plain" size="sm"> <button type="button" data-preview="tools" aria-pressed="false"> <zen-icon name="chevron-right"></zen-icon> Tools @@ -215,7 +247,7 @@ </zen-button> </li> <li> - <zen-button appearance="plain" size="lg"> + <zen-button appearance="plain" size="sm"> <button type="button" data-preview="blog" aria-pressed="false"> <zen-icon name="chevron-right"></zen-icon> Blogs
--- a/mrjunejune/src/jrpg/jrpg.css Thu Aug 06 04:08:45 2026 -0700 +++ b/mrjunejune/src/jrpg/jrpg.css Thu Aug 06 11:31:30 2026 -0700 @@ -6,6 +6,7 @@ :root { --mjj-jrpg-art: url("/public/jrpg/bar-ink.webp"); + --mjj-jrpg-frame-art: url("/public/jrpg/background-frame-2.webp"); --mjj-jrpg-canvas: var(--zenbu-sys-color-surface-page); --mjj-jrpg-surface: var(--zenbu-sys-color-surface-default); --mjj-jrpg-surface-raised: var(--zenbu-sys-color-surface-raised); @@ -55,6 +56,12 @@ font-family: inherit; } +.jrpg-page zen-icon svg { + shape-rendering: crispEdges; + stroke-linecap: square; + stroke-linejoin: miter; +} + .jrpg-main { box-sizing: border-box; width: 100%; @@ -65,29 +72,243 @@ } .jrpg-shell { + --mjj-frame-scene-inline: 3.2%; + --mjj-frame-scene-block: 9.4%; + --mjj-frame-scene-width: 54.8%; + --mjj-frame-scene-height: 57%; + --mjj-frame-utility-inline: 60.4%; + --mjj-frame-utility-block: 9.4%; + --mjj-frame-utility-width: 36.5%; + --mjj-frame-utility-height: 57%; + --mjj-frame-composer-inline: 3.2%; + --mjj-frame-composer-block: 72%; + --mjj-frame-composer-width: 68.2%; + --mjj-frame-composer-height: 17.2%; + --mjj-frame-menu-inline: 73.4%; + --mjj-frame-menu-block: 72%; + --mjj-frame-menu-width: 23.4%; + --mjj-frame-menu-height: 17.2%; + --mjj-frame-brand-inline: 5.4%; + --mjj-frame-brand-block: 2.2%; + --mjj-frame-brand-width: 7.8%; + --mjj-frame-brand-height: 5.2%; + --mjj-frame-controls-inline: 86.6%; + --mjj-frame-controls-block: 3.4%; + --mjj-frame-controls-width: 11.5%; + --mjj-frame-controls-height: 10%; + --mjj-frame-controls-gap: 7%; + --mjj-frame-telemetry-inline: 8.5%; + --mjj-frame-telemetry-block: 92.8%; + --mjj-frame-telemetry-width: 75%; + --mjj-frame-telemetry-height: 4.8%; + --mjj-frame-temp-value-inline: 12%; + --mjj-frame-user-value-inline: 32.5%; + --mjj-frame-status-value-inline: 60.5%; + --mjj-frame-uptime-value-inline: 88%; + --mjj-frame-dog-bar-block-end: 4%; + --mjj-frame-dog-bar-height: 31%; box-sizing: border-box; - display: block; + display: grid; + place-items: center; width: 100%; max-width: none; height: 100%; margin: 0; - padding: var(--zenbu-sys-padding-sm); - border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-frame); + padding: 0; + border: 0; background: var(--mjj-jrpg-canvas); - box-shadow: - inset 0 0 0 var(--zenbu-sys-stroke-width) - var(--mjj-jrpg-frame-soft); + box-shadow: none; } .jrpg-workspace { + position: relative; + display: block; + width: 100%; + height: 100%; + max-height: none; + aspect-ratio: auto; + min-height: 0; + background-image: var(--mjj-jrpg-frame-art); + background-position: center; + background-repeat: no-repeat; + background-size: 100% 100%; + image-rendering: pixelated; +} + +.jrpg-workspace > .jrpg-brand { + position: absolute; + z-index: 4; + inset: + var(--mjj-frame-brand-block) + auto + auto + var(--mjj-frame-brand-inline); display: grid; - grid-template: - "scene utility" minmax(0, 1fr) - "composer menu" minmax(9rem, auto) / - minmax(0, 1.22fr) minmax(19rem, 0.95fr); - gap: var(--zenbu-sys-space-group); + place-items: center; + width: var(--mjj-frame-brand-width); + height: var(--mjj-frame-brand-height); + margin: 0; + padding: 0; + border: 0; + background: var(--mjj-jrpg-canvas); + box-shadow: none; + text-align: center; +} + +.jrpg-workspace > .jrpg-brand header { + position: relative; + z-index: 1; +} + +.jrpg-frame-controls { + position: absolute; + z-index: 5; + inset: + var(--mjj-frame-controls-block) + auto + auto + var(--mjj-frame-controls-inline); + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--mjj-frame-controls-gap); + width: var(--mjj-frame-controls-width); + height: var(--mjj-frame-controls-height); + margin: 0; +} + +.jrpg-frame-controls zen-button, +.jrpg-frame-controls :where(button, a) { + width: 100%; height: 100%; - min-height: 0; +} + +.jrpg-frame-controls :where(button, a) { + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + color: transparent; + cursor: pointer; +} + +.jrpg-frame-controls zen-icon { + opacity: 0; +} + +.jrpg-frame-telemetry { + position: absolute; + z-index: 4; + inset: + var(--mjj-frame-telemetry-block) + auto + auto + var(--mjj-frame-telemetry-inline); + display: block; + width: var(--mjj-frame-telemetry-width); + height: var(--mjj-frame-telemetry-height); + margin: 0; + padding: 0; + color: var(--mjj-jrpg-primary); + font-size: clamp( + var(--zenbu-sys-font-size-xs), + 1vw, + var(--zenbu-sys-font-size-sm) + ); +} + +.jrpg-frame-telemetry > * { + position: absolute; + top: 50%; + padding-inline: var(--zenbu-sys-padding-md); + background: var(--mjj-jrpg-canvas); + transform: translate(-50%, -50%); + white-space: nowrap; +} + +.jrpg-frame-telemetry > :nth-child(1) { + left: var(--mjj-frame-temp-value-inline); +} + +.jrpg-frame-telemetry > :nth-child(2) { + left: var(--mjj-frame-user-value-inline); + padding-inline: var(--zenbu-sys-padding-xl); +} + +.jrpg-frame-telemetry > :nth-child(3) { + left: var(--mjj-frame-status-value-inline); + padding-inline: var(--zenbu-sys-padding-xl); +} + +.jrpg-frame-telemetry > :nth-child(4) { + left: var(--mjj-frame-uptime-value-inline); +} + +.jrpg-frame-telemetry [data-frame-network][data-state="offline"] { + color: var(--mjj-jrpg-accent); +} + +.jrpg-frame-controls :where(button, a):focus-visible { + outline-color: var(--mjj-jrpg-focus); +} + +.jrpg-shell[data-minimized="true"] .jrpg-workspace > :is( + .jrpg-scene, + .jrpg-utility, + mjj-jrpg-composer, + mjj-jrpg-menu +) { + visibility: hidden; +} + +.jrpg-workspace > .jrpg-scene, +.jrpg-workspace > .jrpg-utility, +.jrpg-workspace > mjj-jrpg-composer, +.jrpg-workspace > mjj-jrpg-menu { + position: absolute; + margin: 0; + border: 0; + border-radius: 0; + box-shadow: none; +} + +.jrpg-workspace > .jrpg-scene { + inset: + var(--mjj-frame-scene-block) + auto + auto + var(--mjj-frame-scene-inline); + width: var(--mjj-frame-scene-width); + height: var(--mjj-frame-scene-height); +} + +.jrpg-workspace > .jrpg-utility { + inset: + var(--mjj-frame-utility-block) + auto + auto + var(--mjj-frame-utility-inline); + width: var(--mjj-frame-utility-width); + height: var(--mjj-frame-utility-height); +} + +.jrpg-workspace > mjj-jrpg-composer { + inset: + var(--mjj-frame-composer-block) + auto + auto + var(--mjj-frame-composer-inline); + width: var(--mjj-frame-composer-width); + height: var(--mjj-frame-composer-height); +} + +.jrpg-workspace > mjj-jrpg-menu { + inset: + var(--mjj-frame-menu-block) + auto + auto + var(--mjj-frame-menu-inline); + width: var(--mjj-frame-menu-width); + height: var(--mjj-frame-menu-height); } .jrpg-scene, @@ -102,12 +323,13 @@ .jrpg-scene { grid-area: scene; + position: relative; display: grid; - grid-template-columns: minmax(8rem, 0.38fr) minmax(15rem, 0.92fr); + grid-template-columns: minmax(5rem, 0.38fr) minmax(0, 0.92fr); align-items: end; - gap: var(--zenbu-sys-space-group); + gap: var(--zenbu-sys-space-control); min-height: 0; - padding: var(--zenbu-sys-padding-lg); + padding: var(--zenbu-sys-padding-md); overflow: hidden; background-image: linear-gradient( @@ -124,7 +346,23 @@ background-size: cover; } +.jrpg-scene::before { + position: absolute; + z-index: 0; + inset: auto 0 var(--mjj-frame-dog-bar-block-end); + height: var(--mjj-frame-dog-bar-height); + background-image: var(--mjj-jrpg-art); + background-position: center; + background-repeat: no-repeat; + background-size: 100% 100%; + content: ""; + image-rendering: pixelated; + pointer-events: none; +} + mjj-jrpg-character { + position: relative; + z-index: 2; display: block; align-self: end; min-width: 0; @@ -156,6 +394,7 @@ mjj-jrpg-chat { position: relative; + z-index: 1; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; align-self: stretch; @@ -180,8 +419,7 @@ .jrpg-preview-kicker, .jrpg-menu-label, .jrpg-brand > p, -.jrpg-dialog-heading p, -.jrpg-preview-stats dt { +.jrpg-dialog-heading p { color: var(--mjj-jrpg-text-muted); font-size: var(--zenbu-sys-font-size-xs); letter-spacing: 0.08em; @@ -317,8 +555,8 @@ .jrpg-utility { grid-area: utility; display: grid; - grid-template-rows: auto minmax(0, 1fr); - gap: var(--zenbu-sys-space-group); + grid-template-rows: minmax(0, 1fr); + gap: 0; padding: var(--zenbu-sys-padding-md); background: var(--mjj-jrpg-surface); } @@ -363,9 +601,19 @@ min-height: 0; } +.jrpg-workspace > .jrpg-brand header h1 { + font-size: clamp( + var(--zenbu-sys-font-size-xs), + 0.95vw, + var(--zenbu-sys-font-size-sm) + ); + line-height: 1; + white-space: nowrap; +} + .jrpg-preview-panel { display: grid; - grid-template-rows: auto auto auto minmax(0, 1fr) auto auto; + grid-template-rows: auto auto auto minmax(0, 1fr) auto; gap: var(--zenbu-sys-space-group); height: 100%; padding: var(--zenbu-sys-padding-lg); @@ -427,22 +675,14 @@ font-size: var(--zenbu-sys-font-size-xs); } -.jrpg-preview-stats { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: var(--zenbu-sys-space-control); - margin: 0; +.jrpg-work-showcase a:has(> zen-icon) { + grid-template-columns: auto minmax(0, 1fr); } -.jrpg-preview-stats div { - padding: var(--zenbu-sys-padding-sm); - border: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft); - background: var(--mjj-jrpg-panel); -} - -.jrpg-preview-stats dd { - color: var(--mjj-jrpg-success); - margin: var(--zenbu-sys-space-icon-label) 0 0; +.jrpg-work-showcase a > zen-icon { + grid-row: 1 / 3; + align-self: center; + color: var(--mjj-jrpg-highlight); } .jrpg-preview-dialog > zen-button > button, @@ -517,15 +757,25 @@ width: auto; } -.jrpg-dialog-heading zen-button > button { - width: var(--zenbu-control-height); +.jrpg-dialog-heading-actions { + display: flex; + align-items: center; + gap: var(--zenbu-sys-space-control); +} + +.jrpg-dialog-heading zen-button > :where(button, a) { + width: auto; min-width: var(--zenbu-control-height); - padding: 0; border-color: var(--mjj-jrpg-accent); background: var(--mjj-jrpg-surface); color: var(--mjj-jrpg-text); } +.jrpg-dialog-heading zen-button > button { + width: var(--zenbu-control-height); + padding: 0; +} + .jrpg-preview-dialog dialog h2 { margin-top: var(--zenbu-sys-space-content); } @@ -546,6 +796,35 @@ gap: var(--zenbu-sys-space-group); } +.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); +} + +.jrpg-detail-dialog[data-detail-mode="tools"] > h2 { + display: none; +} + +.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); @@ -557,9 +836,13 @@ .jrpg-resume-dossier[hidden], .jrpg-blog-browser[hidden], +.jrpg-tool-browser[hidden], +[data-dialog-trigger-owner][hidden], [data-dialog-copy][hidden], [data-blog-status][hidden], +[data-tool-status][hidden], [data-resume-status][hidden], +[data-dialog-actions][hidden], [data-resume-download][hidden] { display: none; } @@ -659,7 +942,7 @@ gap: var(--zenbu-sys-space-control); } -.jrpg-blog-browser { +:is(.jrpg-blog-browser, .jrpg-tool-browser) { display: grid; grid-template-columns: minmax(12rem, 0.35fr) minmax(0, 1fr); gap: var(--zenbu-sys-space-group); @@ -667,8 +950,8 @@ overflow: hidden; } -.jrpg-blog-browser > aside, -.jrpg-blog-browser > article { +:is(.jrpg-blog-browser, .jrpg-tool-browser) > aside, +:is(.jrpg-blog-browser, .jrpg-tool-browser) > article { min-height: 0; padding: var(--zenbu-sys-padding-md); overflow: auto; @@ -677,13 +960,35 @@ scrollbar-color: var(--mjj-jrpg-frame) transparent; } -.jrpg-blog-browser h3, -.jrpg-blog-browser [data-blog-status] { +.jrpg-tool-browser { + display: block; + height: 100%; +} + +.jrpg-tool-browser > article { + height: 100%; + padding: 0; + overflow: hidden; + border: 0; + background: transparent; +} + +.jrpg-tool-browser [data-tool-content], +.jrpg-functional-tool, +.jrpg-tool-panes { + height: 100%; + min-height: 0; +} + +:is(.jrpg-blog-browser, .jrpg-tool-browser) h3, +.jrpg-blog-browser [data-blog-status], +.jrpg-tool-browser [data-tool-status] { margin-top: 0; color: var(--mjj-jrpg-highlight); } -.jrpg-blog-browser [data-blog-list] { +:is(.jrpg-blog-browser, .jrpg-tool-browser) + :is([data-blog-list], [data-tool-list]) { display: grid; gap: var(--zenbu-sys-space-control); margin: 0; @@ -691,12 +996,15 @@ list-style: none; } -.jrpg-blog-browser [data-blog-list] zen-button, -.jrpg-blog-browser [data-blog-list] button { +:is(.jrpg-blog-browser, .jrpg-tool-browser) + :is([data-blog-list], [data-tool-list]) zen-button, +:is(.jrpg-blog-browser, .jrpg-tool-browser) + :is([data-blog-list], [data-tool-list]) button { width: 100%; } -.jrpg-blog-browser [data-blog-list] button { +:is(.jrpg-blog-browser, .jrpg-tool-browser) + :is([data-blog-list], [data-tool-list]) button { display: grid; justify-items: start; gap: var(--zenbu-sys-space-icon-label); @@ -708,40 +1016,51 @@ cursor: pointer; } -.jrpg-blog-browser [data-blog-list] button:hover, -.jrpg-blog-browser [data-blog-list] button[aria-pressed="true"] { +:is(.jrpg-blog-browser, .jrpg-tool-browser) + :is([data-blog-list], [data-tool-list]) button:hover, +:is(.jrpg-blog-browser, .jrpg-tool-browser) + :is([data-blog-list], [data-tool-list]) button[aria-pressed="true"] { border-color: var(--mjj-jrpg-accent); background: var(--mjj-jrpg-surface-raised); } -.jrpg-blog-browser [data-blog-list] small { +:is(.jrpg-blog-browser, .jrpg-tool-browser) + :is([data-blog-list], [data-tool-list]) small { color: var(--mjj-jrpg-text-muted); font-size: var(--zenbu-sys-font-size-xs); } -.jrpg-blog-browser [data-blog-content] { +:is(.jrpg-blog-browser, .jrpg-tool-browser) + :is([data-blog-content], [data-tool-content]) { color: var(--mjj-jrpg-text); line-height: var(--zenbu-sys-line-height-lg); } -.jrpg-blog-browser [data-blog-content] :where(h1, h2, h3, h4) { +:is(.jrpg-blog-browser, .jrpg-tool-browser) + :is([data-blog-content], [data-tool-content]) + :where(h1, h2, h3, h4) { color: var(--mjj-jrpg-primary); } -.jrpg-blog-browser [data-blog-content] a { +:is(.jrpg-blog-browser, .jrpg-tool-browser) + :is([data-blog-content], [data-tool-content]) a { color: var(--mjj-jrpg-primary); text-decoration-color: var(--mjj-jrpg-accent); text-underline-offset: var(--zenbu-sys-space-icon-label); } -.jrpg-blog-browser [data-blog-content] :where(img, video) { +:is(.jrpg-blog-browser, .jrpg-tool-browser) + :is([data-blog-content], [data-tool-content]) + :where(img, video) { display: block; max-width: 100%; height: auto; margin-inline: auto; } -.jrpg-blog-browser [data-blog-content] :where(pre, blockquote) { +:is(.jrpg-blog-browser, .jrpg-tool-browser) + :is([data-blog-content], [data-tool-content]) + :where(pre, blockquote) { max-width: 100%; padding: var(--zenbu-sys-padding-md); overflow: auto; @@ -749,12 +1068,265 @@ background: var(--mjj-jrpg-panel); } -.jrpg-blog-browser [data-blog-content] code { +:is(.jrpg-blog-browser, .jrpg-tool-browser) + :is([data-blog-content], [data-tool-content]) code { color: var(--mjj-jrpg-success); white-space: pre-wrap; overflow-wrap: anywhere; } +.jrpg-tool-readonly-note { + padding: var(--zenbu-sys-padding-sm); + border: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-highlight); + background: var(--mjj-jrpg-panel); + color: var(--mjj-jrpg-highlight); +} + +.jrpg-functional-tool { + min-height: 100%; +} + +.jrpg-functional-tool [hidden] { + display: none; +} + +.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); + gap: var(--zenbu-sys-space-control); + height: 100%; + 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; +} + +.jrpg-tool-window:first-of-type { + grid-column: 1; + grid-row: 1; +} + +.jrpg-tool-window:last-of-type { + grid-column: 3; + grid-row: 1; +} + +.jrpg-tool-pane { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + align-content: start; + gap: var(--zenbu-sys-space-control); + min-width: 0; + min-height: 0; + padding: var(--zenbu-sys-padding-md); + overflow: auto; + border: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft); + background: var(--mjj-jrpg-panel); +} + +.jrpg-tool-window { + border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-primary); + box-shadow: + inset 0 0 0 var(--zenbu-sys-stroke-width) + var(--mjj-jrpg-info), + 0 0 0 var(--zenbu-sys-stroke-width-emphasis) + var(--mjj-jrpg-accent), + 0 0 var(--zenbu-sys-space-control) + color-mix(in srgb, var(--mjj-jrpg-info) 68%, transparent); +} + +.jrpg-tool-pane h2, +.jrpg-tool-pane-heading { + margin: 0; + color: var(--mjj-jrpg-primary); +} + +.jrpg-tool-pane h2 { + display: flex; + align-items: center; + gap: var(--zenbu-sys-space-icon-label); +} + +.jrpg-tool-pane h2 zen-icon { + color: var(--mjj-jrpg-highlight); +} + +.jrpg-tool-pane-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--zenbu-sys-space-control); +} + +.jrpg-tool-pane-heading h2 { + margin: 0; +} + +.jrpg-functional-tool zen-field[appearance="plain"] { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: var(--zenbu-sys-space-icon-label); + min-height: 0; +} + +.jrpg-functional-tool :where(input, textarea) { + box-sizing: border-box; + width: 100%; + min-height: var(--zenbu-control-height); + padding: + var(--zenbu-control-padding-block) + var(--zenbu-control-padding-inline); + border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-frame); + border-radius: 0; + background: var(--mjj-jrpg-surface-sunken); + color: var(--mjj-jrpg-text); + font: inherit; +} + +.jrpg-functional-tool textarea { + height: 100%; + min-height: 22rem; + resize: vertical; +} + +.jrpg-functional-tool zen-button > :where(button, a) { + width: 100%; + border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-frame); + border-radius: 0; + background: var(--mjj-jrpg-primary); + color: var(--mjj-jrpg-primary-foreground); + cursor: pointer; +} + +.jrpg-functional-tool zen-button > :where(button, a):hover { + border-color: var(--mjj-jrpg-primary-hover); + background: var(--mjj-jrpg-primary-hover); +} + +.jrpg-functional-tool button:disabled { + opacity: var(--zenbu-sys-opacity-disabled); + cursor: not-allowed; +} + +.jrpg-tool-actions { + display: flex; + gap: var(--zenbu-sys-space-control); +} + +.jrpg-tool-actions zen-button { + flex: 1; +} + +.jrpg-tool-status { + margin-top: 0; + padding: var(--zenbu-sys-padding-sm); + border-left: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-info); + background: var(--mjj-jrpg-panel); + color: var(--mjj-jrpg-text); +} + +.jrpg-tool-status[data-state="ready"] { + border-left-color: var(--mjj-jrpg-success); + color: var(--mjj-jrpg-success); +} + +.jrpg-tool-status[data-state="working"] { + border-left-color: var(--mjj-jrpg-highlight); + color: var(--mjj-jrpg-highlight); +} + +.jrpg-tool-status[data-state="error"] { + border-left-color: var(--mjj-jrpg-accent); + color: var(--mjj-jrpg-accent); +} + +.jrpg-markdown-output { + min-height: 22rem; + padding: var(--zenbu-sys-padding-md); + overflow: auto; + border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-frame); + background: var(--mjj-jrpg-surface-sunken); +} + +.jrpg-markdown-output :where(h1, h2, h3) { + color: var(--mjj-jrpg-primary); +} + +.jrpg-markdown-output code { + color: var(--mjj-jrpg-success); +} + +.jrpg-functional-tool :where(video, iframe) { + width: 100%; + min-height: 22rem; + border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-frame); + background: var(--mjj-jrpg-canvas); +} + +.jrpg-tool-details { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--zenbu-sys-space-control); + margin: 0; +} + +.jrpg-tool-details div { + padding: var(--zenbu-sys-padding-sm); + border: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft); +} + +.jrpg-tool-details dd { + margin: var(--zenbu-sys-space-icon-label) 0 0; + color: var(--mjj-jrpg-success); +} + +.jrpg-functional-tool [data-converter-download] { + color: var(--mjj-jrpg-primary); + text-decoration-color: var(--mjj-jrpg-accent); +} + mjj-jrpg-composer { grid-area: composer; display: block; @@ -928,7 +1500,48 @@ opacity: 1; } -@media (max-width: 52rem) { +.jrpg-workspace > :is( + .jrpg-scene, + .jrpg-utility, + mjj-jrpg-composer, + mjj-jrpg-menu +), +.jrpg-scene > mjj-jrpg-chat, +.jrpg-utility > mjj-jrpg-preview > .jrpg-preview-panel { + border: 0; + background-color: transparent; + box-shadow: none; +} + +.jrpg-workspace > .jrpg-scene { + background-image: none; +} + +.jrpg-workspace > mjj-jrpg-composer, +.jrpg-workspace > mjj-jrpg-menu { + padding: var(--zenbu-sys-padding-sm); +} + +.jrpg-workspace > mjj-jrpg-composer textarea { + min-height: 0; +} + +.jrpg-scene .jrpg-panel-heading, +.jrpg-scene .jrpg-message, +.jrpg-scene .jrpg-turn-navigation, +.jrpg-brand, +.jrpg-work-showcase a, +.jrpg-preview-panel > .jrpg-preview-dialog > zen-button > button, +mjj-jrpg-composer > .jrpg-composer textarea, +mjj-jrpg-composer > .jrpg-composer zen-button > button, +mjj-jrpg-menu nav button { + border: 0; +} + +@media + (max-width: 52rem), + (max-height: 40rem), + (min-aspect-ratio: 2 / 1) { .jrpg-page { overflow-y: auto; } @@ -945,14 +1558,82 @@ } .jrpg-workspace { + display: grid; grid-template: + "brand" auto "scene" auto "utility" auto "menu" auto - "composer" auto / + "composer" auto + "telemetry" auto / minmax(0, 1fr); + gap: var(--zenbu-sys-space-group); + width: 100%; + max-height: none; height: auto; + aspect-ratio: auto; min-height: 0; + background-size: cover; + } + + .jrpg-workspace > .jrpg-scene, + .jrpg-workspace > .jrpg-utility, + .jrpg-workspace > mjj-jrpg-composer, + .jrpg-workspace > mjj-jrpg-menu, + .jrpg-workspace > .jrpg-brand, + .jrpg-workspace > .jrpg-frame-controls, + .jrpg-workspace > .jrpg-frame-telemetry { + position: relative; + inset: auto; + width: auto; + height: auto; + } + + .jrpg-workspace > .jrpg-brand { + grid-area: brand; + justify-self: start; + padding: var(--zenbu-sys-padding-sm); + background: transparent; + box-shadow: none; + } + + .jrpg-workspace > .jrpg-frame-controls { + grid-area: brand; + justify-self: end; + align-self: center; + display: flex; + gap: var(--zenbu-sys-space-icon-label); + } + + .jrpg-frame-controls zen-button, + .jrpg-frame-controls :where(button, a) { + width: var(--zenbu-control-height); + height: var(--zenbu-control-height); + } + + .jrpg-frame-controls :where(button, a) { + background: var(--mjj-jrpg-surface-raised); + color: var(--mjj-jrpg-text); + } + + .jrpg-frame-controls zen-icon { + opacity: 1; + } + + .jrpg-workspace > .jrpg-frame-telemetry { + grid-area: telemetry; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--zenbu-sys-space-control); + padding: var(--zenbu-sys-padding-sm); + background: var(--mjj-jrpg-surface-sunken); + } + + .jrpg-frame-telemetry > * { + position: static; + justify-self: start; + background: transparent; + transform: none; } .jrpg-scene { @@ -1001,10 +1682,6 @@ align-items: center; } - .jrpg-brand > p { - max-width: 9rem; - } - .jrpg-composer { grid-template-columns: 1fr; grid-template-rows: @@ -1046,15 +1723,66 @@ width: 100%; } - .jrpg-blog-browser { + :is(.jrpg-blog-browser, .jrpg-tool-browser) { grid-template-columns: 1fr; overflow: auto; } - .jrpg-blog-browser > aside { + :is(.jrpg-blog-browser, .jrpg-tool-browser) > aside { max-height: 16rem; } + .jrpg-tool-panes { + grid-template-columns: 1fr; + grid-template-rows: + minmax(24rem, auto) + calc(var(--zenbu-sys-space-container) * 2) + minmax(24rem, auto); + overflow: auto; + } + + .jrpg-tool-panes::before { + grid-column: 1; + grid-row: 2; + background-image: + radial-gradient( + circle at var(--zenbu-sys-space-content) 50%, + 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 calc(100% - var(--zenbu-sys-space-content)) 50%, + 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( + 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); + } + + .jrpg-tool-window:first-of-type { + grid-column: 1; + grid-row: 1; + } + + .jrpg-tool-window:last-of-type { + grid-column: 1; + grid-row: 3; + } + + .jrpg-tool-pane { + min-height: 24rem; + } + mjj-jrpg-menu ul { grid-template-columns: 1fr; } @@ -1073,8 +1801,39 @@ } } +.jrpg-page, +.jrpg-page * { + scrollbar-color: var(--mjj-jrpg-primary) transparent; + scrollbar-width: thin; +} + +.jrpg-page::-webkit-scrollbar, +.jrpg-page *::-webkit-scrollbar { + width: var(--zenbu-sys-space-related); + height: var(--zenbu-sys-space-related); +} + +.jrpg-page::-webkit-scrollbar-track, +.jrpg-page *::-webkit-scrollbar-track { + background: transparent; +} + +.jrpg-page::-webkit-scrollbar-thumb, +.jrpg-page *::-webkit-scrollbar-thumb { + border: 0; + border-radius: 0; + background: var(--mjj-jrpg-primary); +} + +.jrpg-page::-webkit-scrollbar-button, +.jrpg-page *::-webkit-scrollbar-button { + display: none; +} + @media (forced-colors: active) { - .jrpg-scene { + .jrpg-workspace, + .jrpg-scene, + .jrpg-scene::before { background-image: none; } }
--- a/mrjunejune/src/jrpg/jrpg.js Thu Aug 06 04:08:45 2026 -0700 +++ b/mrjunejune/src/jrpg/jrpg.js Thu Aug 06 11:31:30 2026 -0700 @@ -18,10 +18,15 @@ copy: "Experience, projects, and the systems I have helped build.", kicker: "CHARACTER RECORD", title: "Resume", - type: "Profile", url: "/resume", works: [ { + detail: "Career dossier", + label: "Full resume", + modal: "resume", + url: "/resume", + }, + { detail: "Agentic execution", label: "Copilot Tasks", url: "https://www.microsoft.com/en-us/microsoft-copilot/blog/2026/02/26/copilot-tasks-from-answers-to-actions/", @@ -57,7 +62,6 @@ copy: "Small, focused utilities for writing, media, and experimentation.", kicker: "ITEM INVENTORY", title: "Tools", - type: "Utilities", url: "/tools", works: [ { detail: "Writing", label: "Markdown", url: "/tools/markdown_to_html" }, @@ -69,7 +73,6 @@ copy: "Notes from building systems, games, web tools, and curious prototypes.", kicker: "QUEST ARCHIVE", title: "Blogs", - type: "Writing", url: "/blog", works: [ { detail: "Archive", label: "All posts", url: "/blog" }, @@ -79,6 +82,31 @@ const CONVERSATION_STORAGE_KEY = "mjj-jrpg-conversation-id"; const TYPING_INTERVAL_MS = 18; +const SCRIPT_LOADS = new Map(); +const TOOL_ICON_NAMES = Object.freeze({ + "/notes": "repository", + "/tools/file_converter": "retry", + "/tools/hls_player": "play", + "/tools/latex_editor": "file", + "/tools/markdown_to_html": "code", +}); + +function loadScript(source) { + if (SCRIPT_LOADS.has(source)) return SCRIPT_LOADS.get(source); + const loading = new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = source; + script.addEventListener("load", resolve, { once: true }); + script.addEventListener("error", () => { + SCRIPT_LOADS.delete(source); + script.remove(); + reject(new Error(`Unable to load ${source}`)); + }, { once: true }); + document.head.append(script); + }); + SCRIPT_LOADS.set(source, loading); + return loading; +} class StreamingTextAnimator { constructor(render) { @@ -443,14 +471,20 @@ } for (const element of main.querySelectorAll("*")) { for (const attribute of [...element.attributes]) { - if (attribute.name.toLowerCase().startsWith("on")) { + const name = attribute.name.toLowerCase(); + if (name.startsWith("on") || name === "id" || name === "style") { element.removeAttribute(attribute.name); } } } for (const anchor of main.querySelectorAll("a")) { + const href = anchor.getAttribute("href"); + if (!href || anchor.getAttribute("aria-disabled") === "true") { + anchor.remove(); + continue; + } const target = new URL( - anchor.getAttribute("href") || "", + href, window.location.href, ); anchor.href = target.href; @@ -469,8 +503,37 @@ return main; } +function sanitizeToolDetail(main) { + sanitizeFetchedMain(main); + for (const textarea of main.querySelectorAll("textarea")) { + const sample = document.createElement("pre"); + sample.className = "jrpg-tool-source-sample"; + sample.textContent = textarea.value || textarea.textContent; + textarea.replaceWith(sample); + } + for (const inactive of main.querySelectorAll( + "button, canvas, dialog, input, select, video, [hidden]", + )) { + inactive.remove(); + } + for (const owner of main.querySelectorAll( + "zen-button, zen-checkbox, zen-field, zen-input, zen-textarea", + )) { + owner.replaceWith(...owner.childNodes); + } + for (const label of main.querySelectorAll("label[for]")) { + label.removeAttribute("for"); + } + return main; +} + class MjjJrpgPreview extends HTMLElement { connectedCallback() { + this._dialog = this.querySelector("dialog"); + this._onDialogClose = () => { + this._toolRequest = (this._toolRequest || 0) + 1; + this.cleanupTool(); + }; this._onClick = event => { const trigger = event.target.closest("[data-zen-trigger]"); if (trigger) { @@ -479,6 +542,10 @@ this.show("blog"); void this.loadBlogs(); } + if (this.dataset.selection === "tools") { + this.show("tools"); + void this.openToolWorkspace(); + } return; } const blogEntry = event.target.closest("[data-blog-entry]"); @@ -491,23 +558,49 @@ event.preventDefault(); this.querySelector("zen-dialog").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"); + void this.loadBlogs(); + return; + } + const resumeModal = event.target.closest("[data-resume-modal]"); + if (resumeModal) { + event.preventDefault(); + this.querySelector("zen-dialog").open(); + this.show("resume"); + 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(); + void this.loadToolDetail(latestTool.dataset.toolUrl); } }; this.addEventListener("click", this._onClick); + this._dialog.addEventListener("close", this._onDialogClose); this.show(this.dataset.selection || "resume"); } disconnectedCallback() { this.removeEventListener("click", this._onClick); + this._dialog?.removeEventListener("close", this._onDialogClose); + this.cleanupTool(); } show(selection) { + this.cleanupTool(); const preview = PREVIEWS[selection] || PREVIEWS.resume; this.dataset.selection = selection; this.querySelector("[data-preview-kicker]").textContent = preview.kicker; this.querySelector("[data-preview-title]").textContent = preview.title; this.querySelector("[data-preview-copy]").textContent = preview.copy; - this.querySelector("[data-preview-type]").textContent = preview.type; this.querySelector("[data-dialog-title]").textContent = preview.title; this.querySelector("[data-dialog-copy]").textContent = preview.copy; this.renderShowcase(preview.works); @@ -517,12 +610,18 @@ const resumeDossier = this.querySelector("[data-resume-dossier]"); const resumeDownload = this.querySelector("[data-resume-download]"); const blogBrowser = this.querySelector("[data-blog-browser]"); + const toolBrowser = this.querySelector("[data-tool-browser]"); const isResume = selection === "resume"; const isBlog = selection === "blog"; + const isTools = selection === "tools"; + this._dialog.dataset.detailMode = isTools ? "tools" : "content"; resumeDossier.hidden = !isResume; resumeDownload.hidden = !isResume; + this.querySelector("[data-dialog-actions]").hidden = !isResume; blogBrowser.hidden = !isBlog; - this.querySelector("[data-dialog-copy]").hidden = isResume || isBlog; + toolBrowser.hidden = !isTools; + this.querySelector("[data-dialog-copy]").hidden = + isResume || isBlog || isTools; if (!isBlog) this._blogRequest = (this._blogRequest || 0) + 1; if (isBlog) { const status = this.querySelector("[data-blog-status]"); @@ -534,9 +633,17 @@ } void this.loadBlogs(); } + if (!isTools) this._toolRequest = (this._toolRequest || 0) + 1; + if (isTools) { + const status = this.querySelector("[data-tool-status]"); + status.hidden = false; + status.textContent = "Select a tool to inspect."; + this.querySelector("[data-tool-content]").replaceChildren(); + void this.loadTools(); + } } - renderShowcase(works, latestBlogs = false) { + renderShowcase(works, latestBlogs = false, latestKind = null) { const showcase = this.querySelector("[data-work-showcase]"); showcase.replaceChildren(...works.map(work => { const item = document.createElement("li"); @@ -545,8 +652,20 @@ const detail = document.createElement("small"); link.href = work.url; if (latestBlogs) { - link.dataset.latestBlog = ""; - link.dataset.blogUrl = work.url; + if (work.modal === "blog") { + link.dataset.blogArchive = ""; + } else { + link.dataset.latestBlog = ""; + link.dataset.blogUrl = work.url; + } + } + if (work.modal === "resume") link.dataset.resumeModal = ""; + if (latestKind === "tool") { + link.dataset.latestTool = ""; + link.dataset.toolUrl = work.url; + const icon = document.createElement("zen-icon"); + icon.setAttribute("name", TOOL_ICON_NAMES[work.url] || "settings"); + link.prepend(icon); } if (new URL(work.url, window.location.href).origin !== window.location.origin) { link.target = "_blank"; @@ -592,7 +711,15 @@ async loadBlogs() { if (this._blogs) { if (this.dataset.selection === "blog") { - this.renderShowcase(this._blogs.slice(0, 4), true); + this.renderShowcase([ + ...this._blogs.slice(0, 4), + { + detail: "Full archive", + label: "All blogs", + modal: "blog", + url: "/blog", + }, + ], true); } return true; } @@ -622,7 +749,15 @@ if (!this._blogs.length) throw new Error("No blog entries were found"); this.renderBlogList(); if (this.dataset.selection === "blog") { - this.renderShowcase(this._blogs.slice(0, 4), true); + this.renderShowcase([ + ...this._blogs.slice(0, 4), + { + detail: "Full archive", + label: "All blogs", + modal: "blog", + url: "/blog", + }, + ], true); } return true; })().catch(error => { @@ -709,6 +844,561 @@ status.textContent = `Unable to load blog: ${error.message}`; } } + + async loadTools() { + if (this._tools) { + if (this.dataset.selection === "tools") { + this.renderShowcase(this._tools, false, "tool"); + } + return true; + } + if (this._toolsLoading) return this._toolsLoading; + this._toolsLoading = (async () => { + const response = await fetch("/tools", { + headers: { Accept: "text/html" }, + }); + if (!response.ok) { + throw new Error(`Tools request failed (${response.status})`); + } + const documentCopy = new DOMParser().parseFromString( + await response.text(), + "text/html", + ); + this._tools = [...documentCopy.querySelectorAll("main li a[href]")].map( + anchor => ({ + detail: anchor.getAttribute("href").startsWith("/notes") + ? "Writing" + : "Utility", + label: anchor.textContent.trim(), + url: anchor.getAttribute("href"), + }), + ); + if (!this._tools.length) throw new Error("No tools were found"); + if (this.dataset.selection === "tools") { + this.renderShowcase(this._tools, false, "tool"); + } + return true; + })().catch(error => { + const status = this.querySelector("[data-tool-status]"); + status.hidden = false; + status.textContent = `Unable to load tools: ${error.message}`; + return false; + }).finally(() => { + this._toolsLoading = null; + }); + return this._toolsLoading; + } + + async openToolWorkspace() { + if (!await this.loadTools() || this.dataset.selection !== "tools") return; + await this.loadToolDetail(this._activeToolUrl || this._tools[0].url); + } + + async loadToolDetail(url) { + const request = (this._toolRequest || 0) + 1; + this._toolRequest = request; + if (!await this.loadTools()) return; + if (request !== this._toolRequest || this.dataset.selection !== "tools") { + return; + } + const tool = this._tools.find(item => item.url === url); + const status = this.querySelector("[data-tool-status]"); + const content = this.querySelector("[data-tool-content]"); + if (!tool) { + status.hidden = false; + status.textContent = "Unable to load tool: unknown entry."; + return; + } + this._activeToolUrl = tool.url; + status.hidden = false; + status.textContent = `Loading ${tool.label}...`; + 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; + } + if (tool.url === "/tools/file_converter") { + this.renderFileConverterTool(content, status); + return; + } + if (tool.url === "/tools/hls_player") { + await this.renderHlsTool(content, status, request); + return; + } + if (tool.url === "/tools/latex_editor") { + this.renderLatexTool(content, status); + return; + } + if (tool.url === "/notes") { + const heading = document.createElement("h1"); + const description = document.createElement("p"); + const privacy = document.createElement("p"); + heading.textContent = "Personal Notes"; + description.textContent = + "A private browser-based writing workspace with rich-text editing and server-backed persistence."; + privacy.textContent = + "The interactive editor and authentication flow are available on the full Notes page."; + content.replaceChildren( + this.createReadonlyToolNote(), + heading, + description, + privacy, + ); + status.hidden = true; + return; + } + try { + const response = await fetch(tool.url, { + headers: { Accept: "text/html" }, + }); + if (!response.ok) { + throw new Error(`Tool request failed (${response.status})`); + } + const documentCopy = new DOMParser().parseFromString( + await response.text(), + "text/html", + ); + const detail = documentCopy.querySelector("main, .container") || + documentCopy.body; + if (!detail) throw new Error("Tool content is unavailable"); + sanitizeToolDetail(detail); + if (request !== this._toolRequest || this.dataset.selection !== "tools") { + return; + } + content.replaceChildren( + this.createReadonlyToolNote(), + ...detail.childNodes, + ); + status.hidden = true; + } catch (error) { + if (request !== this._toolRequest) return; + status.hidden = false; + status.textContent = `Unable to load tool: ${error.message}`; + } + } + + async renderMarkdownTool(content, status, request) { + content.innerHTML = ` + <section class="jrpg-functional-tool" data-functional-tool="markdown"> + <div class="jrpg-tool-panes"> + <section class="jrpg-tool-pane jrpg-tool-window"> + <h2><zen-icon name="code"></zen-icon> Markdown source</h2> + <zen-field appearance="plain" size="md"> + <label for="jrpgMarkdownSource">Markdown</label> + <textarea id="jrpgMarkdownSource" data-markdown-source></textarea> + </zen-field> + </section> + <section class="jrpg-tool-pane jrpg-tool-window"> + <h2><zen-icon name="check"></zen-icon> Converted HTML</h2> + <div class="jrpg-markdown-output" data-markdown-output></div> + </section> + </div> + </section> + `; + const source = content.querySelector("[data-markdown-source]"); + const output = content.querySelector("[data-markdown-output]"); + source.value = [ + "# JRPG Markdown", + "", + "Edit this **Markdown** to update the converted panel.", + "", + "- Bazel-built WASM", + "- Sanitized HTML output", + ].join("\n"); + status.hidden = false; + status.textContent = "Loading the Markdown converter..."; + try { + this._markdownModule ||= import("/markdown_to_html_bin.js") + .then(module => module.default()); + const module = await this._markdownModule; + if (request !== this._toolRequest || !source.isConnected) return; + const convertMarkdown = module.cwrap( + "markdown_to_html", + "number", + ["string"], + ); + const freeMarkdown = module.cwrap("markdown_free", null, ["number"]); + const convert = () => { + const pointer = convertMarkdown(source.value); + const html = module.UTF8ToString(pointer); + freeMarkdown(pointer); + const documentCopy = new DOMParser().parseFromString(html, "text/html"); + sanitizeFetchedMain(documentCopy.body); + output.replaceChildren(...documentCopy.body.childNodes); + }; + source.addEventListener("input", convert); + convert(); + status.hidden = true; + } catch (error) { + this._markdownModule = null; + if (request !== this._toolRequest) return; + status.hidden = false; + status.textContent = `Unable to start Markdown: ${error.message}`; + } + } + + renderFileConverterTool(content, status) { + content.innerHTML = ` + <section class="jrpg-functional-tool" data-functional-tool="converter"> + <p class="jrpg-tool-status" data-converter-status> + Choose a file in either panel. + </p> + <div class="jrpg-tool-panes"> + <section class="jrpg-tool-pane jrpg-tool-window"> + <h2><zen-icon name="file"></zen-icon> Image to WebP</h2> + <zen-field appearance="plain" size="md"> + <label for="jrpgImageInput">Image file</label> + <input id="jrpgImageInput" data-converter-input="image" type="file" accept="image/*"> + </zen-field> + <zen-button appearance="plain" size="md"> + <button type="button" data-convert-kind="image"> + Convert image <zen-icon name="arrow-right"></zen-icon> + </button> + </zen-button> + <a data-converter-download="image" hidden>Download WebP</a> + </section> + <section class="jrpg-tool-pane jrpg-tool-window"> + <h2><zen-icon name="play"></zen-icon> Video to MP4</h2> + <zen-field appearance="plain" size="md"> + <label for="jrpgVideoInput">Video file</label> + <input id="jrpgVideoInput" data-converter-input="video" type="file" accept="video/*"> + </zen-field> + <zen-button appearance="plain" size="md"> + <button type="button" data-convert-kind="video"> + Convert video <zen-icon name="arrow-right"></zen-icon> + </button> + </zen-button> + <a data-converter-download="video" hidden>Download MP4</a> + </section> + </div> + </section> + `; + const toolStatus = content.querySelector("[data-converter-status]"); + let disposed = false; + const downloads = new Set(); + const discardDownload = url => { + if (!url) return; + downloads.delete(url); + void fetch(url, { + method: "DELETE", + keepalive: true, + }).then(response => { + if (!response.ok && response.status !== 404) { + console.warn(`Unable to discard converted file (${response.status})`); + } + }).catch(error => { + console.warn(`Unable to discard converted file: ${error.message}`); + }); + }; + const convert = async kind => { + const input = content.querySelector(`[data-converter-input="${kind}"]`); + const button = content.querySelector(`[data-convert-kind="${kind}"]`); + const download = content.querySelector( + `[data-converter-download="${kind}"]`, + ); + const file = input.files?.[0]; + if (!file) { + toolStatus.textContent = `Choose a ${kind} file first.`; + toolStatus.dataset.state = "error"; + return; + } + button.disabled = true; + discardDownload(download.dataset.cleanupUrl); + delete download.dataset.cleanupUrl; + download.removeAttribute("href"); + download.hidden = true; + toolStatus.textContent = `Converting ${file.name}...`; + toolStatus.dataset.state = "working"; + try { + const endpoint = kind === "image" + ? "/api/convert/image-to-webp" + : "/api/convert/video-to-mp4"; + const response = await fetch(endpoint, { + method: "POST", + body: file, + headers: { "Content-Type": file.type || "application/octet-stream" }, + }); + if (!response.ok) { + throw new Error(await response.text() || `Conversion failed (${response.status})`); + } + const result = await response.json(); + if (disposed || !download.isConnected) { + discardDownload(result.download_url); + return; + } + download.href = result.download_url; + download.dataset.cleanupUrl = result.download_url; + downloads.add(result.download_url); + download.download = file.name.replace(/\.[^/.]+$/, "") + + (kind === "image" ? ".webp" : ".mp4"); + download.hidden = false; + toolStatus.textContent = "Conversion complete."; + toolStatus.dataset.state = "ready"; + } catch (error) { + if (!disposed) { + toolStatus.textContent = `Conversion failed: ${error.message}`; + toolStatus.dataset.state = "error"; + } + } finally { + if (button.isConnected) button.disabled = false; + } + }; + for (const button of content.querySelectorAll("[data-convert-kind]")) { + button.addEventListener("click", () => { + void convert(button.dataset.convertKind); + }); + } + for (const download of content.querySelectorAll("[data-converter-download]")) { + download.addEventListener("click", () => { + downloads.delete(download.dataset.cleanupUrl); + delete download.dataset.cleanupUrl; + }); + } + this._toolCleanup = () => { + disposed = true; + for (const download of [...downloads]) discardDownload(download); + }; + status.hidden = true; + } + + async renderHlsTool(content, status, request) { + content.innerHTML = ` + <section class="jrpg-functional-tool" data-functional-tool="hls"> + <div class="jrpg-tool-panes"> + <section class="jrpg-tool-pane jrpg-tool-window"> + <h2><zen-icon name="file"></zen-icon> HLS source</h2> + <zen-field appearance="plain" size="md"> + <label for="jrpgHlsUrl">Playlist URL</label> + <input + id="jrpgHlsUrl" + data-hls-url + type="url" + value="/public/hls-sample/h264-ts-stream.m3u8" + > + </zen-field> + <div class="jrpg-tool-actions"> + <zen-button appearance="plain" size="md"> + <button type="button" data-hls-load> + Load URL <zen-icon name="arrow-right"></zen-icon> + </button> + </zen-button> + <zen-button appearance="plain" size="md"> + <button type="button" data-hls-sample> + Load sample <zen-icon name="play"></zen-icon> + </button> + </zen-button> + </div> + <p class="jrpg-tool-status" data-hls-status>Loading player engine...</p> + </section> + <section class="jrpg-tool-pane jrpg-tool-window"> + <h2><zen-icon name="play"></zen-icon> Playback</h2> + <video data-hls-video controls playsinline preload="metadata"></video> + <dl class="jrpg-tool-details" data-hls-details hidden> + <div><dt>Playback</dt><dd data-detail="mode">-</dd></div> + <div><dt>Segments</dt><dd data-detail="segments">-</dd></div> + <div><dt>Duration</dt><dd data-detail="duration">-</dd></div> + <div><dt>Codec</dt><dd data-detail="codecs">-</dd></div> + </dl> + </section> + </div> + </section> + `; + try { + await loadScript("/public/hls.min.js"); + await loadScript("/tools/hls_player/hls-player.js"); + if (request !== this._toolRequest || !content.isConnected) return; + const video = content.querySelector("[data-hls-video]"); + const playerStatus = content.querySelector("[data-hls-status]"); + const details = content.querySelector("[data-hls-details]"); + const url = content.querySelector("[data-hls-url]"); + const player = new window.HlsPlayerModule.HlsPlayer(video, { + statusElement: playerStatus, + detailsElement: details, + }); + const load = () => { + void player.load(url.value.trim()).catch(() => {}); + }; + content.querySelector("[data-hls-load]").addEventListener("click", load); + content.querySelector("[data-hls-sample]").addEventListener("click", () => { + url.value = "/public/hls-sample/h264-ts-stream.m3u8"; + load(); + }); + playerStatus.textContent = "Ready to load a playlist."; + playerStatus.dataset.state = "ready"; + this._toolCleanup = () => player.destroy(); + status.hidden = true; + } catch (error) { + if (request !== this._toolRequest) return; + status.hidden = false; + status.textContent = `Unable to start HLS: ${error.message}`; + } + } + + renderLatexTool(content, status) { + content.innerHTML = ` + <section class="jrpg-functional-tool" data-functional-tool="latex"> + <p class="jrpg-tool-status" data-latex-status> + Edit the source, then compile the PDF. + </p> + <div class="jrpg-tool-panes"> + <section class="jrpg-tool-pane jrpg-tool-window"> + <div class="jrpg-tool-pane-heading"> + <h2><zen-icon name="code"></zen-icon> LaTeX source</h2> + <span data-latex-size></span> + </div> + <zen-field appearance="plain" size="md"> + <label for="jrpgLatexSource">document.tex</label> + <textarea id="jrpgLatexSource" data-latex-source></textarea> + </zen-field> + <div class="jrpg-tool-actions"> + <zen-button appearance="plain" size="md"> + <button type="button" data-latex-compile> + Compile PDF <zen-icon name="file"></zen-icon> + </button> + </zen-button> + <zen-button appearance="plain" size="md" data-latex-download hidden> + <a download="document.pdf"> + Download PDF <zen-icon name="download"></zen-icon> + </a> + </zen-button> + </div> + </section> + <section class="jrpg-tool-pane jrpg-tool-window"> + <h2><zen-icon name="file"></zen-icon> PDF preview</h2> + <iframe data-latex-preview title="Compiled LaTeX PDF preview"></iframe> + <pre data-latex-diagnostics hidden></pre> + </section> + </div> + </section> + `; + const source = content.querySelector("[data-latex-source]"); + const compileButton = content.querySelector("[data-latex-compile]"); + const toolStatus = content.querySelector("[data-latex-status]"); + const size = content.querySelector("[data-latex-size]"); + const preview = content.querySelector("[data-latex-preview]"); + const diagnostics = content.querySelector("[data-latex-diagnostics]"); + const downloadOwner = content.querySelector("[data-latex-download]"); + const download = downloadOwner.querySelector("a"); + source.value = String.raw`\documentclass[11pt]{article} +\usepackage[margin=1in]{geometry} +\title{JRPG LaTeX} +\author{MrJuneJune} +\begin{document} +\maketitle +This PDF was compiled from the cyberpunk tool modal. +\end{document}`; + let controller = null; + let pdfUrl = null; + let compileGeneration = 0; + const clearPdf = () => { + if (pdfUrl) URL.revokeObjectURL(pdfUrl); + pdfUrl = null; + preview.src = "about:blank"; + preview.hidden = false; + diagnostics.hidden = true; + download.removeAttribute("href"); + downloadOwner.hidden = true; + }; + const updateSize = () => { + const bytes = new TextEncoder().encode(source.value).byteLength; + size.textContent = `${bytes.toLocaleString()} / 65,536 bytes`; + return bytes; + }; + const compile = async () => { + const generation = ++compileGeneration; + clearPdf(); + const bytes = updateSize(); + if (!source.value.trim() || bytes > 64 * 1024) { + toolStatus.textContent = bytes > 64 * 1024 + ? "Source exceeds 64 KiB." + : "Write LaTeX before compiling."; + toolStatus.dataset.state = "error"; + return; + } + controller?.abort(); + const requestController = new AbortController(); + controller = requestController; + compileButton.disabled = true; + toolStatus.textContent = "Compiling on the server..."; + toolStatus.dataset.state = "working"; + try { + const response = await fetch("/api/latex/render", { + method: "POST", + headers: { "Content-Type": "text/plain; charset=utf-8" }, + body: source.value, + cache: "no-store", + signal: requestController.signal, + }); + if (generation !== compileGeneration) return; + if (!response.ok) { + throw new Error(await response.text() || `Compilation failed (${response.status})`); + } + const blob = await response.blob(); + if (generation !== compileGeneration) return; + if (blob.type !== "application/pdf" || blob.size < 5) { + throw new Error("The server returned an invalid PDF."); + } + if (pdfUrl) URL.revokeObjectURL(pdfUrl); + pdfUrl = URL.createObjectURL(blob); + preview.src = pdfUrl; + preview.hidden = false; + diagnostics.hidden = true; + download.href = pdfUrl; + downloadOwner.hidden = false; + toolStatus.textContent = `PDF ready (${Math.ceil(blob.size / 1024)} KiB).`; + toolStatus.dataset.state = "ready"; + } catch (error) { + if (error.name !== "AbortError" && generation === compileGeneration) { + diagnostics.textContent = error.message; + diagnostics.hidden = false; + preview.hidden = true; + toolStatus.textContent = "Compilation failed."; + toolStatus.dataset.state = "error"; + } + } finally { + if (controller === requestController) { + controller = null; + compileButton.disabled = false; + } + } + }; + source.addEventListener("input", () => { + compileGeneration++; + controller?.abort(); + controller = null; + compileButton.disabled = false; + clearPdf(); + updateSize(); + toolStatus.textContent = "Changes ready to compile."; + toolStatus.dataset.state = "working"; + }); + compileButton.addEventListener("click", () => void compile()); + updateSize(); + this._toolCleanup = () => { + compileGeneration++; + controller?.abort(); + clearPdf(); + }; + status.hidden = true; + } + + cleanupTool() { + this._toolCleanup?.(); + this._toolCleanup = null; + } + + createReadonlyToolNote() { + const introduction = document.createElement("p"); + introduction.className = "jrpg-tool-readonly-note"; + introduction.textContent = + "Read-only preview. Open the full page to use this tool."; + return introduction; + } } class MjjJrpgShell extends HTMLElement {} @@ -792,10 +1482,59 @@ const chat = shell?.querySelector("mjj-jrpg-chat"); const composer = shell?.querySelector("mjj-jrpg-composer"); const preview = shell?.querySelector("mjj-jrpg-preview"); + const frameNetwork = shell?.querySelector("[data-frame-network]"); + const frameUptime = shell?.querySelector("[data-frame-uptime]"); + const minimizeButton = shell?.querySelector("[data-frame-minimize]"); + const fullscreenButton = shell?.querySelector("[data-frame-fullscreen]"); let characterTimer = 0; let conversationId = sessionStorage.getItem(CONVERSATION_STORAGE_KEY); let activeController = null; composer?.setDisabled(true); + const setFrameStatus = (label, state, title = "") => { + if (frameNetwork) { + frameNetwork.textContent = label; + frameNetwork.setAttribute("aria-label", `Status ${label.toLowerCase()}`); + frameNetwork.dataset.state = state; + frameNetwork.title = title; + } + }; + setFrameStatus("ONLINE", "online"); + const startedAt = Date.now(); + const updateUptime = () => { + const elapsed = Math.floor((Date.now() - startedAt) / 1000); + const hours = String(Math.floor(elapsed / 3600)).padStart(2, "0"); + const minutes = String(Math.floor((elapsed % 3600) / 60)).padStart(2, "0"); + const seconds = String(elapsed % 60).padStart(2, "0"); + if (frameUptime) { + frameUptime.textContent = `${hours}:${minutes}:${seconds}`; + } + }; + updateUptime(); + window.setInterval(updateUptime, 1000); + + minimizeButton?.addEventListener("click", () => { + const minimized = shell.dataset.minimized !== "true"; + shell.dataset.minimized = String(minimized); + minimizeButton.setAttribute("aria-pressed", String(minimized)); + minimizeButton.setAttribute( + "aria-label", + minimized ? "Restore interface" : "Minimize interface", + ); + }); + fullscreenButton?.addEventListener("click", async () => { + try { + if (document.fullscreenElement) await document.exitFullscreen(); + else await shell.requestFullscreen(); + } catch (error) { + setFrameStatus("DENIED", "offline", error.message); + } + }); + document.addEventListener("fullscreenchange", () => { + fullscreenButton?.setAttribute( + "aria-pressed", + String(Boolean(document.fullscreenElement)), + ); + }); if (conversationId) { try { @@ -911,6 +1650,7 @@ } chat?.setMessageStreaming(assistantItem, false); character?.setAttribute("state", "default"); + setFrameStatus("ONLINE", "online"); } catch (error) { const aborted = error instanceof DOMException && error.name === "AbortError"; assistantText.cancel(); @@ -920,6 +1660,7 @@ aborted ? "Quest cancelled." : `System error: ${error.message}`, ); character?.setAttribute("state", "sad"); + if (!aborted) setFrameStatus("OFFLINE", "offline"); } finally { composer?.setBusy(false); composer?.querySelector("textarea")?.focus();
--- a/mrjunejune/src/sw.js Thu Aug 06 04:08:45 2026 -0700 +++ b/mrjunejune/src/sw.js Thu Aug 06 11:31:30 2026 -0700 @@ -1,5 +1,5 @@ // Root-scoped Service Worker for MrJuneJune PWA -const CACHE_VERSION = 'v14-blog-browser'; +const CACHE_VERSION = 'v30-card-driven-details'; const CACHE_NAME = `mrjunejune-${CACHE_VERSION}`; // Files to cache immediately on install
--- a/mrjunejune/test/BUILD Thu Aug 06 04:08:45 2026 -0700 +++ b/mrjunejune/test/BUILD Thu Aug 06 11:31:30 2026 -0700 @@ -124,6 +124,7 @@ "//hg-web/e2e:node_modules/playwright-core", "//mrjunejune:mrjunejune_server_debug", ":inference_bridge_fake_sidecar", + "shiba.webp", "@playwright_chromium_linux//:chrome", "@playwright_chromium_linux//:chromium", ],
--- a/mrjunejune/test/snapshots/jrpg.snapshot Thu Aug 06 04:08:45 2026 -0700 +++ b/mrjunejune/test/snapshots/jrpg.snapshot Thu Aug 06 11:31:30 2026 -0700 @@ -32,6 +32,7 @@ <script src="/public/pwa-register.js" defer></script> + <link rel="preload" href="/public/jrpg/background-frame-2.webp" as="image"> <link rel="preload" href="/public/jrpg/bar-ink.webp" as="image"> <link rel="stylesheet" href="/jrpg/jrpg.css"> <script type="module" src="/jrpg/jrpg.js"></script> @@ -40,6 +41,47 @@ <main class="jrpg-main"> <mjj-jrpg-shell class="jrpg-shell"> <div class="jrpg-workspace"> + <div class="jrpg-brand"> + <header> + <h1> + <a href="/" data-zen-link-effect="none">MrJuneJune</a> + </h1> + </header> + </div> + <nav class="jrpg-frame-controls" aria-label="Window controls"> + <zen-button appearance="plain" size="xs"> + <button + type="button" + data-frame-minimize + aria-label="Minimize interface" + aria-pressed="false" + > + <zen-icon name="arrow-down"></zen-icon> + </button> + </zen-button> + <zen-button appearance="plain" size="xs"> + <button + type="button" + data-frame-fullscreen + aria-label="Toggle fullscreen" + aria-pressed="false" + > + <zen-icon name="external"></zen-icon> + </button> + </zen-button> + <zen-button appearance="plain" size="xs"> + <a href="/" aria-label="Close interface and return home"> + <zen-icon name="close"></zen-icon> + </a> + </zen-button> + </nav> + <footer class="jrpg-frame-telemetry" aria-label="System telemetry"> + <span aria-label="Temperature unavailable">N/A</span> + <span aria-label="User June">JUNE</span> + <span data-frame-network aria-label="Status online">ONLINE</span> + <time data-frame-uptime aria-label="Uptime">00:00:00</time> + </footer> + <section class="jrpg-scene" aria-label="Shiba Quest conversation"> <mjj-jrpg-character state="default"> <img data-character alt=""> @@ -88,15 +130,6 @@ </section> <section class="jrpg-utility" aria-label="Selected destination"> - <div class="jrpg-brand"> - <header> - <h1> - <a href="/" data-zen-link-effect="none">MrJuneJune</a> - </h1> - </header> - <p>SHIBA QUEST TERMINAL</p> - </div> - <mjj-jrpg-preview data-selection="resume"> <article class="jrpg-preview-panel" aria-live="polite"> <p class="jrpg-preview-kicker" data-preview-kicker>CHARACTER RECORD</p> @@ -118,32 +151,26 @@ </a> </li> </ul> - <dl class="jrpg-preview-stats"> - <div> - <dt>TYPE</dt> - <dd data-preview-type>Profile</dd> - </div> - <div> - <dt>STATUS</dt> - <dd>Available</dd> - </div> - </dl> - <zen-dialog class="jrpg-preview-dialog"> - <zen-button appearance="plain" size="md"> - <button type="button" data-zen-trigger> - Inspect - <zen-icon name="search"></zen-icon> - </button> + <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> - <zen-button appearance="plain" size="md"> - <button type="button" data-zen-close aria-label="Close destination details"> - <zen-icon name="close"></zen-icon> - </button> - </zen-button> + <div class="jrpg-dialog-heading-actions"> + <zen-button appearance="plain" size="md"> + <a href="/resume" data-preview-link> + Open page + <zen-icon name="external"></zen-icon> + </a> + </zen-button> + <zen-button appearance="plain" size="md"> + <button type="button" data-zen-close aria-label="Close destination details"> + <zen-icon name="close"></zen-icon> + </button> + </zen-button> + </div> </div> <h2 id="jrpg-dialog-title" data-dialog-title>Resume</h2> <p data-dialog-copy>Experience, projects, and the systems I have helped build.</p> @@ -172,13 +199,18 @@ <div data-blog-content></div> </article> </section> - <div class="jrpg-dialog-actions"> - <zen-button appearance="plain" size="lg"> - <a href="/resume" data-preview-link> - Open full page - <zen-icon name="arrow-right"></zen-icon> - </a> - </zen-button> + <section + class="jrpg-tool-browser" + data-tool-browser + aria-label="Tool workspace" + hidden + > + <article> + <p data-tool-status>Select a tool to inspect.</p> + <div data-tool-content></div> + </article> + </section> + <div class="jrpg-dialog-actions" data-dialog-actions> <zen-button appearance="plain" size="lg" data-resume-download> <a href="/public/resume.pdf"> Download PDF @@ -206,13 +238,13 @@ ></textarea> </zen-field> <div class="jrpg-composer-actions"> - <zen-button appearance="plain" size="lg"> + <zen-button appearance="plain" size="sm"> <button type="submit"> Send <zen-icon name="arrow-right"></zen-icon> </button> </zen-button> - <zen-button appearance="plain" size="lg" data-cancel-control hidden> + <zen-button appearance="plain" size="sm" data-cancel-control hidden> <button type="button" data-cancel> Cancel <zen-icon name="close"></zen-icon> @@ -228,7 +260,7 @@ <p class="jrpg-menu-label">Menu</p> <ul> <li> - <zen-button appearance="plain" size="lg"> + <zen-button appearance="plain" size="sm"> <button type="button" data-preview="resume" aria-pressed="true"> <zen-icon name="chevron-right"></zen-icon> Resume @@ -236,7 +268,7 @@ </zen-button> </li> <li> - <zen-button appearance="plain" size="lg"> + <zen-button appearance="plain" size="sm"> <button type="button" data-preview="tools" aria-pressed="false"> <zen-icon name="chevron-right"></zen-icon> Tools @@ -244,7 +276,7 @@ </zen-button> </li> <li> - <zen-button appearance="plain" size="lg"> + <zen-button appearance="plain" size="sm"> <button type="button" data-preview="blog" aria-pressed="false"> <zen-icon name="chevron-right"></zen-icon> Blogs
--- a/mrjunejune/test/theme_and_webp_test.js Thu Aug 06 04:08:45 2026 -0700 +++ b/mrjunejune/test/theme_and_webp_test.js Thu Aug 06 11:31:30 2026 -0700 @@ -183,6 +183,20 @@ } }); page.on('requestfailed', request => { + if (request.url().startsWith('blob:') && + request.failure()?.errorText === 'net::ERR_ABORTED') { + return; + } + if (request.method() === 'DELETE' && + request.url().includes('/api/download/') && + request.failure()?.errorText === 'net::ERR_ABORTED') { + return; + } + if (request.method() === 'POST' && + request.url().endsWith('/api/latex/render') && + request.failure()?.errorText === 'net::ERR_ABORTED') { + return; + } errors.push(`requestfailed: ${request.url()} ${request.failure()?.errorText || ''}`); }); page.on('response', response => { @@ -449,6 +463,20 @@ } }); page.on('requestfailed', request => { + if (request.url().startsWith('blob:') && + request.failure()?.errorText === 'net::ERR_ABORTED') { + return; + } + if (request.method() === 'DELETE' && + request.url().includes('/api/download/') && + request.failure()?.errorText === 'net::ERR_ABORTED') { + return; + } + if (request.method() === 'POST' && + request.url().endsWith('/api/latex/render') && + request.failure()?.errorText === 'net::ERR_ABORTED') { + return; + } errors.push(`requestfailed: ${request.url()} ${request.failure()?.errorText || ''}`); }); page.on('response', response => { @@ -487,10 +515,25 @@ .getBoundingClientRect(); const menu = document.querySelector('mjj-jrpg-menu').getBoundingClientRect(); const character = document.querySelector('[data-character]'); + const characterBox = character.getBoundingClientRect(); + const sceneBarStyle = getComputedStyle( + document.querySelector('.jrpg-scene'), + '::before', + ); const messageScroller = document.querySelector('zen-message-scroller') .getBoundingClientRect(); const messageViewport = document.querySelector('.jrpg-message-viewport') .getBoundingClientRect(); + const workspace = document.querySelector('.jrpg-workspace') + .getBoundingClientRect(); + const brand = document.querySelector('.jrpg-brand').getBoundingClientRect(); + const frameControls = document.querySelector('.jrpg-frame-controls') + .getBoundingClientRect(); + const frameTelemetry = document.querySelector('.jrpg-frame-telemetry') + .getBoundingClientRect(); + const telemetryValues = [ + ...document.querySelectorAll('.jrpg-frame-telemetry > *'), + ].map(value => value.getBoundingClientRect()); const shell = document.querySelector('.jrpg-shell').getBoundingClientRect(); const main = document.querySelector('.jrpg-main').getBoundingClientRect(); const colorProbe = document.createElement('span'); @@ -566,8 +609,53 @@ surfaceProbe.remove(); sunkenProbe.remove(); panelProbe.remove(); + const closeTo = (value, expected) => Math.abs(value - expected) < 0.01; + const frameAlignment = [ + closeTo((scene.left - workspace.left) / workspace.width, 0.032), + closeTo((scene.top - workspace.top) / workspace.height, 0.094), + closeTo(scene.width / workspace.width, 0.548), + closeTo(scene.height / workspace.height, 0.57), + closeTo((utility.left - workspace.left) / workspace.width, 0.604), + closeTo(utility.width / workspace.width, 0.365), + closeTo((composer.top - workspace.top) / workspace.height, 0.72), + closeTo(composer.width / workspace.width, 0.682), + closeTo((menu.left - workspace.left) / workspace.width, 0.734), + closeTo(menu.width / workspace.width, 0.234), + ].every(Boolean); + const borderless = [ + '.jrpg-shell', + '.jrpg-scene', + '.jrpg-utility', + 'mjj-jrpg-chat', + 'mjj-jrpg-composer', + 'mjj-jrpg-menu', + '.jrpg-preview-panel', + '.jrpg-message', + '.jrpg-work-showcase a', + '.jrpg-composer textarea', + '.jrpg-composer button[type="submit"]', + 'mjj-jrpg-menu button', + ].every(selector => { + const style = getComputedStyle(document.querySelector(selector)); + return style.borderTopWidth === '0px' && + style.borderRightWidth === '0px' && + style.borderBottomWidth === '0px' && + style.borderLeftWidth === '0px'; + }); return { - art: getComputedStyle(document.querySelector('.jrpg-scene')).backgroundImage, + art: getComputedStyle(document.querySelector('.jrpg-workspace')) + .backgroundImage, + borderless, + brandInSystemSlot: + closeTo((brand.left - workspace.left) / workspace.width, 0.054) && + closeTo((brand.top - workspace.top) / workspace.height, 0.022) && + document.querySelector('.jrpg-brand').parentElement.classList + .contains('jrpg-workspace'), + brandCoversSystemLabel: + brand.left <= workspace.left + workspace.width * 0.062 && + brand.right >= workspace.left + workspace.width * 0.102 && + brand.top <= workspace.top + workspace.height * 0.03 && + brand.bottom >= workspace.top + workspace.height * 0.06, characterLoaded: character.complete && character.naturalWidth > 0, colors, composerBelowScene: composer.top >= scene.bottom, @@ -600,6 +688,11 @@ main.top === 0 && Math.abs(main.right - innerWidth) < 1 && Math.abs(main.bottom - innerHeight) < 1, + frameAlignment, + frameChromeAligned: + closeTo((frameControls.left - workspace.left) / workspace.width, 0.866) && + closeTo((frameTelemetry.left - workspace.left) / workspace.width, 0.085) && + closeTo((frameTelemetry.top - workspace.top) / workspace.height, 0.928), fonts: computedFonts, fontLoaded: [...document.fonts].some(font => font.family.replaceAll('"', '') === 'Pixel Mplus' && @@ -614,23 +707,72 @@ noOverflow: document.documentElement.scrollWidth <= innerWidth, nonPixelText, questLabelAbsent: !document.body.textContent.includes('QUEST 01'), + statsRemoved: + !document.querySelector('.jrpg-preview-stats') && + !document.querySelector('[data-preview-type]'), + telemetryCovered: + document.querySelectorAll('.jrpg-frame-telemetry > *').length === 4 && + document.querySelector('[data-frame-network]').textContent + .includes('ONLINE') && + document.querySelector('[data-frame-uptime]').textContent + .match(/^\d{2}:\d{2}:\d{2}$/) && + [...document.querySelectorAll('.jrpg-frame-telemetry > *')] + .every(item => !/TEMP:|USER:|STATUS:|UPTIME:/.test(item.textContent)), + telemetryValuesAligned: + closeTo( + (telemetryValues[0].left + telemetryValues[0].width / 2 - + frameTelemetry.left) / frameTelemetry.width, + 0.12, + ) && + closeTo( + (telemetryValues[1].left + telemetryValues[1].width / 2 - + frameTelemetry.left) / frameTelemetry.width, + 0.325, + ) && + closeTo( + (telemetryValues[2].left + telemetryValues[2].width / 2 - + frameTelemetry.left) / frameTelemetry.width, + 0.605, + ) && + closeTo( + (telemetryValues[3].left + telemetryValues[3].width / 2 - + frameTelemetry.left) / frameTelemetry.width, + 0.88, + ), + shibaInFrontOfBar: + sceneBarStyle.backgroundImage.includes('bar-ink.webp') && + Number(getComputedStyle(character.parentElement).zIndex) > + Number(sceneBarStyle.zIndex) && + characterBox.bottom > scene.top + scene.height * 0.65, + terminalScrollbar: ( + getComputedStyle( + document.querySelector('.jrpg-message-viewport'), + ).scrollbarWidth === 'thin' && + getComputedStyle( + document.querySelector('.jrpg-message-viewport'), + ).scrollbarColor !== 'auto' + ), theme: document.documentElement.dataset.zenTheme, utilityRightOfScene: utility.left >= scene.right, + workspaceFillsViewport: + Math.abs(workspace.left) < 1 && + Math.abs(workspace.top) < 1 && + Math.abs(workspace.right - innerWidth) < 1 && + Math.abs(workspace.bottom - innerHeight) < 1, }; }); - assert.match(desktop.art, /bar-ink\.webp/); + assert.match(desktop.art, /background-frame-2\.webp/); + assert.equal(desktop.borderless, true); + assert.equal(desktop.brandInSystemSlot, true); + assert.equal(desktop.brandCoversSystemLabel, true); assert.equal(desktop.characterLoaded, true); assert.equal(desktop.colors.page, desktop.colors.canvas); - assert.equal( - desktop.colors.conversation, - desktop.colors.conversationPanel, - ); - assert.equal(desktop.colors.conversationBorder, desktop.colors.frame); + assert.equal(desktop.colors.conversation, 'rgba(0, 0, 0, 0)'); assert.equal(desktop.colors.conversationText, desktop.colors.text); - assert.equal(desktop.colors.menu, desktop.colors.sunken); + assert.equal(desktop.colors.menu, 'rgba(0, 0, 0, 0)'); assert.equal(desktop.colors.primaryText, desktop.colors.primary); assert.equal(desktop.colors.submit, desktop.colors.primary); - assert.equal(desktop.colors.utility, desktop.colors.surface); + assert.equal(desktop.colors.utility, 'rgba(0, 0, 0, 0)'); assert.equal(desktop.composerBelowScene, true); assert.equal( desktop.composerTextareaFills, @@ -638,6 +780,8 @@ JSON.stringify(desktop.composerTextareaGeometry), ); assert.equal(desktop.fillsViewport, true); + assert.equal(desktop.frameAlignment, true); + assert.equal(desktop.frameChromeAligned, true); assert.ok(desktop.fonts.every(font => font === '"Pixel Mplus"')); assert.equal(desktop.fontLoaded, true); assert.equal(desktop.menuRightOfComposer, true); @@ -645,8 +789,14 @@ assert.equal(desktop.noOverflow, true); assert.deepEqual(desktop.nonPixelText, []); assert.equal(desktop.questLabelAbsent, true); + assert.equal(desktop.statsRemoved, true); + assert.equal(desktop.telemetryCovered, true); + assert.equal(desktop.telemetryValuesAligned, true); + assert.equal(desktop.shibaInFrontOfBar, true); + assert.equal(desktop.terminalScrollbar, true); assert.equal(desktop.theme, 'cyberpunk'); assert.equal(desktop.utilityRightOfScene, true); + assert.equal(desktop.workspaceFillsViewport, true); await page.locator('.jrpg-brand a').focus(); assert.equal( await page.locator('.jrpg-brand a').evaluate(element => @@ -665,7 +815,33 @@ await page.locator('meta[name="theme-color"]').first().getAttribute('content'), desktop.colors.canvas, ); - assert.equal(await page.locator('[data-work-showcase] a').count(), 6); + assert.equal( + await page.locator('.jrpg-frame-controls :is(button, a)').count(), + 3, + ); + assert.equal( + await page.locator('.jrpg-frame-controls a').getAttribute('href'), + '/', + ); + await page.getByRole('button', { name: 'Minimize interface' }).click(); + assert.equal( + await page.locator('.jrpg-scene').evaluate( + scene => getComputedStyle(scene).visibility, + ), + 'hidden', + ); + await page.getByRole('button', { name: 'Restore interface' }).click(); + assert.equal( + await page.locator('.jrpg-scene').evaluate( + scene => getComputedStyle(scene).visibility, + ), + 'visible', + ); + assert.equal(await page.locator('[data-work-showcase] a').count(), 7); + assert.equal( + await page.getByRole('button', { name: /Inspect/ }).count(), + 0, + ); assert.match( await page.locator('[data-work-showcase]').textContent(), /Copilot Tasks/, @@ -701,13 +877,208 @@ .getAttribute('aria-pressed'), 'true', ); - assert.equal(await page.locator('[data-work-showcase] a').count(), 3); - - await page.getByRole('button', { name: /Inspect/ }).click(); - await page.locator('.jrpg-preview-dialog dialog[open]').waitFor(); + await page.waitForFunction(() => + document.querySelectorAll('[data-latest-tool]').length === 5 + ); + assert.match( + await page.locator('[data-work-showcase]').textContent(), + /Online LaTeX Editor/, + ); + assert.match( + await page.locator('[data-work-showcase]').textContent(), + /Personal Notes/, + ); + await page.locator('[data-latest-tool]').first().click(); + await page.locator('[data-functional-tool="markdown"]').waitFor(); assert.equal( await page.locator('[data-dialog-title]').textContent(), - 'Tools', + '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 === + 'Modal Markdown' + ); + assert.equal(await page.locator('.jrpg-tool-pane').count(), 2); + const toolWorkspace = await page.evaluate(() => { + const dialogElement = document.querySelector( + '.jrpg-detail-dialog[data-detail-mode="tools"]', + ); + const dialog = dialogElement.getBoundingClientRect(); + const windows = [...document.querySelectorAll('.jrpg-tool-window')] + .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, + 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'), + pixelIcons: document.querySelectorAll('.jrpg-tool-window zen-icon').length >= 2 && + [...document.querySelectorAll('.jrpg-tool-window zen-icon svg')] + .every(icon => + getComputedStyle(icon).shapeRendering.toLowerCase() === 'crispedges' + ), + separateGlows: windowShadows.length === 2 && + windowShadows.every(shadow => shadow !== 'none'), + wideWindows: windows.every(window => window.width > dialog.width * 0.35), + }; + }); + 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.pixelIcons, true); + assert.equal(toolWorkspace.separateGlows, true); + assert.equal(toolWorkspace.wideWindows, true); + await page.getByRole('button', { + name: 'Close destination details', + }).click(); + await page.locator('.jrpg-preview-dialog dialog').waitFor({ state: 'hidden' }); + + await page.locator('[data-latest-tool]').first().click(); + await page.locator('[data-functional-tool="markdown"]').waitFor(); + await page.getByRole('button', { + name: 'Close destination details', + }).click(); + await page.locator('.jrpg-preview-dialog dialog').waitFor({ state: 'hidden' }); + + await page.locator('[data-latest-tool]').nth(1).click(); + await page.locator('[data-functional-tool="converter"]').waitFor(); + assert.equal( + await page.locator('[data-converter-input]').count(), + 2, + ); + const converterFixture = path.join( + RUNFILES, + WORKSPACE, + 'mrjunejune/test/shiba.webp', + ); + assert.equal(fs.existsSync(converterFixture), true); + await page.locator('[data-converter-input="image"]').setInputFiles( + converterFixture, + ); + const conversionResponse = page.waitForResponse(response => + response.url().endsWith('/api/convert/image-to-webp') && + response.request().method() === 'POST' + ); + await page.locator('[data-convert-kind="image"]').click(); + assert.equal((await conversionResponse).status(), 200); + await page.locator('[data-converter-download="image"]').waitFor(); + const cleanupResponse = page.waitForResponse(response => + response.url().includes('/api/download/') && + response.request().method() === 'DELETE' + ); + await page.getByRole('button', { + name: 'Close destination details', + }).click(); + assert.equal((await cleanupResponse).status(), 204); + await page.locator('.jrpg-preview-dialog dialog').waitFor({ state: 'hidden' }); + + await page.locator('[data-latest-tool]').nth(1).click(); + await page.locator('[data-functional-tool="converter"]').waitFor(); + await page.locator('[data-converter-input="image"]').setInputFiles( + converterFixture, + ); + const lateConversionResponse = page.waitForResponse(response => + response.url().endsWith('/api/convert/image-to-webp') && + response.request().method() === 'POST' + ); + const lateCleanupResponse = page.waitForResponse(response => + response.url().includes('/api/download/') && + response.request().method() === 'DELETE' + ); + await page.locator('[data-convert-kind="image"]').click(); + await page.getByRole('button', { + name: 'Close destination details', + }).click(); + assert.equal((await lateConversionResponse).status(), 200); + assert.equal((await lateCleanupResponse).status(), 204); + await page.locator('.jrpg-preview-dialog dialog').waitFor({ state: 'hidden' }); + + 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( + document.querySelector('[data-hls-status]')?.dataset.state, + ) + ); + assert.ok( + ['ready', 'error'].includes( + await page.locator('[data-hls-status]').getAttribute('data-state'), + ), + ); + assert.match( + await page.locator('[data-hls-status]').textContent(), + /ready|unsupported|failed/i, + ); + await page.getByRole('button', { + name: 'Close destination details', + }).click(); + await page.locator('.jrpg-preview-dialog dialog').waitFor({ state: 'hidden' }); + + await page.locator('[data-latest-tool]').nth(3).click(); + await page.locator('[data-functional-tool="latex"]').waitFor(); + await page.locator('[data-latex-compile]').click(); + await page.locator('[data-latex-source]').fill( + '\\documentclass{article}\\begin{document}Updated modal PDF\\end{document}', + ); + assert.equal(await page.locator('[data-latex-download]').isHidden(), true); + await page.locator('[data-latex-compile]').click(); + await page.waitForFunction(() => + document.querySelector('[data-latex-status]')?.dataset.state === 'ready' + ); + assert.match( + await page.locator('[data-latex-preview]').getAttribute('src'), + /^blob:/, + ); + await page.getByRole('button', { + name: 'Close destination details', + }).click(); + await page.locator('.jrpg-preview-dialog dialog').waitFor({ state: 'hidden' }); + + await page.locator('[data-latest-tool]').nth(4).click(); + await page.waitForFunction(() => + document.querySelector('[data-tool-content] h1')?.textContent === + 'Personal Notes' + ); + assert.match( + 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', @@ -715,7 +1086,7 @@ await page.locator('.jrpg-preview-dialog dialog').waitFor({ state: 'hidden' }); await page.getByRole('button', { name: 'Resume', exact: true }).click(); - await page.getByRole('button', { name: /Inspect/ }).click(); + await page.locator('[data-resume-modal]').click(); await page.locator('[data-resume-content] .sub-header').first().waitFor(); const resumeDossier = await page.locator('[data-resume-dossier]').evaluate( dossier => ({ @@ -769,7 +1140,7 @@ }).click(); await page.locator('.jrpg-preview-dialog dialog').waitFor({ state: 'hidden' }); - await page.getByRole('button', { name: /Inspect/ }).click(); + await page.locator('[data-blog-archive]').click(); await page.waitForFunction(() => document.querySelectorAll('[data-blog-entry]').length === 9 ); @@ -886,7 +1257,7 @@ const controlledReload = await page.evaluate(() => { const shell = document.querySelector('.jrpg-shell').getBoundingClientRect(); return { - art: getComputedStyle(document.querySelector('.jrpg-scene')) + art: getComputedStyle(document.querySelector('.jrpg-workspace')) .backgroundImage, fillsViewport: shell.left === 0 && shell.top === 0 && @@ -899,7 +1270,7 @@ theme: document.documentElement.dataset.zenTheme, }; }); - assert.match(controlledReload.art, /bar-ink\.webp/); + assert.match(controlledReload.art, /background-frame-2\.webp/); assert.equal(controlledReload.fillsViewport, true); assert.equal(controlledReload.fontLoaded, true); assert.equal(controlledReload.fontFamily, '"Pixel Mplus"'); @@ -939,6 +1310,56 @@ utilityBelowScene: true, }); await mobileContext.close(); + + const shortContext = await browser.newContext({ + colorScheme: 'dark', + viewport: { width: 900, height: 500 }, + }); + const shortPage = await shortContext.newPage(); + await shortPage.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' }); + const shortLayout = await shortPage.evaluate(() => { + const scene = document.querySelector('.jrpg-scene').getBoundingClientRect(); + const character = document.querySelector('mjj-jrpg-character') + .getBoundingClientRect(); + const chat = document.querySelector('mjj-jrpg-chat') + .getBoundingClientRect(); + return { + characterInside: character.left >= scene.left && + character.right <= scene.right, + chatInside: chat.left >= scene.left && chat.right <= scene.right, + noSceneOverflow: document.querySelector('.jrpg-scene').scrollWidth <= + document.querySelector('.jrpg-scene').clientWidth, + }; + }); + assert.deepEqual(shortLayout, { + characterInside: true, + chatInside: true, + noSceneOverflow: true, + }); + await shortContext.close(); + + const forcedColorsContext = await browser.newContext({ + forcedColors: 'active', + viewport: { width: 1280, height: 900 }, + }); + const forcedColorsPage = await forcedColorsContext.newPage(); + await forcedColorsPage.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' }); + assert.deepEqual( + await forcedColorsPage.evaluate(() => ({ + sceneBar: getComputedStyle( + document.querySelector('.jrpg-scene'), + '::before', + ).backgroundImage, + workspace: getComputedStyle( + document.querySelector('.jrpg-workspace'), + ).backgroundImage, + })), + { + sceneBar: 'none', + workspace: 'none', + }, + ); + await forcedColorsContext.close(); } async function testHlsPlayer(browser, siteRoot) { @@ -1261,7 +1682,7 @@ for (const source of [home, dogGame, manifest]) { assert.doesNotMatch(source, /\.png(?:["')]|$)/i); } - assert.match(serviceWorker, /v14-blog-browser/); + assert.match(serviceWorker, /v30-card-driven-details/); assert.match( await ( await fetch(`${baseUrl}/public/pwa-register.js`)