diff mrjunejune/src/jrpg/jrpg.js @ 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
line wrap: on
line diff
--- 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();