view 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 source

const CHARACTER_STATES = Object.freeze({
  default: {
    alt: "Epi the Shiba Inu standing happily",
    src: "/public/jrpg/shiba-default.webp",
  },
  sad: {
    alt: "Epi the Shiba Inu sitting sadly",
    src: "/public/jrpg/shiba-sad.webp",
  },
  thinking: {
    alt: "Epi the Shiba Inu tilting her head in thought",
    src: "/public/jrpg/shiba-thinking.webp",
  },
});

const PREVIEWS = Object.freeze({
  resume: {
    copy: "Experience, projects, and the systems I have helped build.",
    kicker: "CHARACTER RECORD",
    title: "Resume",
    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/",
      },
      {
        detail: "AI platform",
        label: "Copilot SuperApp",
        url: "https://www.cio.com/article/3977098/microsoft-doubles-down-on-multi-model-ai-as-it-builds-a-copilot-super-app.html",
      },
      {
        detail: "Build 2026",
        label: "Code",
        url: "https://news.microsoft.com/build-2026/",
      },
      {
        detail: "Personal agent",
        label: "Autopilot",
        url: "https://www.microsoft.com/en-us/microsoft-365/blog/2026/06/02/introducing-microsoft-scout-your-always-on-personal-agent/",
      },
      {
        detail: "Ads systems",
        label: "Meta",
        url: "https://www.meta.com/",
      },
      {
        detail: "Workspace platform",
        label: "Google",
        url: "https://www.google.com/",
      },
    ],
  },
  tools: {
    copy: "Small, focused utilities for writing, media, and experimentation.",
    kicker: "ITEM INVENTORY",
    title: "Tools",
    url: "/tools",
    works: [
      { detail: "Writing", label: "Markdown", url: "/tools/markdown_to_html" },
      { detail: "Media", label: "Converter", url: "/tools/file_converter" },
      { detail: "Streaming", label: "HLS Player", url: "/tools/hls_player" },
    ],
  },
  blog: {
    copy: "Notes from building systems, games, web tools, and curious prototypes.",
    kicker: "QUEST ARCHIVE",
    title: "Blogs",
    url: "/blog",
    works: [
      { detail: "Archive", label: "All posts", url: "/blog" },
    ],
  },
});

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) {
    this._render = render;
    this._displayed = "";
    this._target = "";
    this._timer = 0;
    this._waiters = [];
    this._reduceMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    ).matches;
  }

  append(text) {
    if (typeof text !== "string" || !text) return;
    this._target += text;
    this._schedule();
  }

  complete(text) {
    if (typeof text === "string") {
      this._target = text;
      if (!this._target.startsWith(this._displayed)) {
        let shared = 0;
        while (
          shared < this._target.length &&
          shared < this._displayed.length &&
          this._target[shared] === this._displayed[shared]
        ) {
          shared++;
        }
        this._displayed = this._displayed.slice(0, shared);
        this._render(this._displayed);
      }
    }
    this._schedule();
  }

  waitForIdle() {
    if (!this._timer && this._displayed === this._target) {
      return Promise.resolve();
    }
    return new Promise(resolve => {
      this._waiters.push(resolve);
    });
  }

  cancel() {
    window.clearTimeout(this._timer);
    this._timer = 0;
    this._target = this._displayed;
    this._settle();
  }

  _schedule() {
    if (this._reduceMotion) {
      this._displayed = this._target;
      this._render(this._displayed);
      this._settle();
      return;
    }
    if (this._timer || this._displayed === this._target) return;
    this._timer = window.setTimeout(() => this._tick(), TYPING_INTERVAL_MS);
  }

  _tick() {
    this._timer = 0;
    const remaining = this._target.length - this._displayed.length;
    if (remaining <= 0) {
      this._settle();
      return;
    }
    const amount = Math.max(1, Math.ceil(remaining / 80));
    this._displayed = this._target.slice(
      0,
      this._displayed.length + amount,
    );
    this._render(this._displayed);
    if (this._displayed === this._target) this._settle();
    else this._schedule();
  }

  _settle() {
    if (this._timer || this._displayed !== this._target) return;
    const waiters = this._waiters.splice(0);
    for (const resolve of waiters) resolve();
  }
}

class MjjJrpgCharacter extends HTMLElement {
  static get observedAttributes() {
    return ["state"];
  }

  connectedCallback() {
    this.render();
  }

  attributeChangedCallback() {
    if (this.isConnected) this.render();
  }

  render() {
    const image = this.querySelector("[data-character]");
    if (!image) return;
    const state = CHARACTER_STATES[this.getAttribute("state")] ||
      CHARACTER_STATES.default;
    image.src = state.src;
    image.alt = state.alt;
  }
}

class MjjJrpgChat extends HTMLElement {
  connectedCallback() {
    this._messages = this.querySelector("[data-messages]");
    this._viewport = this.querySelector("[data-zen-viewport]");
    this._previousButton = this.querySelector("[data-turn-previous]");
    this._nextButton = this.querySelector("[data-turn-next]");
    this._position = this.querySelector("[data-turn-position]");
    this._initialMessages = [...(this._messages?.children || [])].map(
      item => item.cloneNode(true),
    );
    this._turnSequence = 0;
    this._activeGroup = null;
    this._onNavigationClick = event => {
      const button = event.target.closest("button");
      if (!button || !this.contains(button) || button.disabled) return;
      if (button === this._previousButton) this._moveTurn(-1);
      else if (button === this._nextButton) this._moveTurn(1);
    };
    this.addEventListener("click", this._onNavigationClick);
    this._refreshTurnGroups("start");
  }

  disconnectedCallback() {
    this.removeEventListener("click", this._onNavigationClick);
  }

  appendMessage(speaker, text) {
    let group = this._activeGroup || "start";
    if (speaker === "June") {
      this._turnSequence++;
      group = `turn-${this._turnSequence}`;
      this._activeGroup = group;
    }
    return this._createMessage(speaker, text, group, true);
  }

  _createMessage(speaker, text, group, select) {
    if (!this._messages) return;
    const item = document.createElement("li");
    const name = document.createElement("strong");
    const copy = document.createElement("p");
    item.className = "jrpg-message";
    item.dataset.speaker = speaker;
    item.dataset.turnGroup = group;
    name.textContent = speaker;
    copy.textContent = text;
    item.append(name, copy);
    this._messages.append(item);
    this._refreshTurnGroups(select ? group : this._currentGroup);
    return item;
  }

  replaceMessages(turns) {
    if (!this._messages) return;
    this._messages.replaceChildren();
    this._turnSequence = 0;
    this._activeGroup = null;
    const visibleTurns = turns.filter(turn =>
      ["user", "assistant"].includes(turn.role)
    );
    if (!visibleTurns.length) {
      this._messages.append(
        ...this._initialMessages.map(item => item.cloneNode(true)),
      );
      this._refreshTurnGroups("start");
      return;
    }
    let group = null;
    for (const turn of visibleTurns) {
      if (turn.role === "user" || !group) {
        this._turnSequence++;
        group = `turn-${this._turnSequence}`;
        this._activeGroup = group;
      }
      this._createMessage(
        turn.role === "user" ? "June" : "Epi",
        turn.content ||
          (turn.status === "aborted"
            ? "Quest cancelled."
            : turn.status === "failed"
              ? "The quest failed."
              : ""),
        group,
        false,
      );
    }
    this._refreshTurnGroups(group);
  }

  startAssistantMessage() {
    return this.appendMessage("Epi", "");
  }

  setMessageText(item, text) {
    const copy = item?.querySelector("p");
    if (copy) copy.textContent = text;
    this.scrollEnd();
  }

  setMessageStreaming(item, streaming) {
    if (!item) return;
    if (streaming) item.dataset.streaming = "true";
    else delete item.dataset.streaming;
    this._viewport?.setAttribute("aria-busy", String(streaming));
  }

  _refreshTurnGroups(preferredGroup) {
    const groups = [];
    for (const item of this._messages?.children || []) {
      const group = item.dataset.turnGroup || "start";
      item.dataset.turnGroup = group;
      if (!groups.includes(group)) groups.push(group);
    }
    this._turnGroups = groups;
    const preferredIndex = groups.indexOf(preferredGroup);
    const currentIndex = groups.indexOf(this._currentGroup);
    this._turnIndex = preferredIndex >= 0
      ? preferredIndex
      : currentIndex >= 0
        ? currentIndex
        : Math.max(0, groups.length - 1);
    this._showTurn();
  }

  _moveTurn(offset) {
    const nextIndex = Math.max(
      0,
      Math.min(this._turnGroups.length - 1, this._turnIndex + offset),
    );
    if (nextIndex === this._turnIndex) return;
    this._turnIndex = nextIndex;
    this._showTurn();
  }

  _showTurn() {
    const group = this._turnGroups?.[this._turnIndex];
    this._currentGroup = group;
    for (const item of this._messages?.children || []) {
      item.hidden = item.dataset.turnGroup !== group;
    }
    if (this._previousButton) this._previousButton.disabled = this._turnIndex <= 0;
    if (this._nextButton) {
      this._nextButton.disabled =
        this._turnIndex >= (this._turnGroups?.length || 0) - 1;
    }
    if (this._position) {
      if (group === "start") {
        this._position.textContent = "START";
      } else {
        const turns = this._turnGroups.filter(item => item !== "start");
        this._position.textContent =
          `TURN ${turns.indexOf(group) + 1} / ${turns.length}`;
      }
    }
    this.scrollEnd();
  }

  scrollEnd() {
    requestAnimationFrame(() => {
      if (this._viewport) {
        this._viewport.scrollTop = this._viewport.scrollHeight;
      }
    });
  }
}

class MjjJrpgComposer extends HTMLElement {
  connectedCallback() {
    this._form = this.querySelector("[data-composer]");
    this._textarea = this.querySelector("textarea");
    this._button = this.querySelector('button[type="submit"]');
    this._cancel = this.querySelector("[data-cancel]");
    this._cancelControl = this.querySelector("[data-cancel-control]");
    this._onSubmit = event => {
      event.preventDefault();
      const message = this._textarea?.value.trim();
      if (!message || this._button?.disabled) return;
      this.dispatchEvent(new CustomEvent("mjj-jrpg-submit", {
        bubbles: true,
        detail: { message },
      }));
      this._form.reset();
    };
    this._onKeydown = event => {
      if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
      event.preventDefault();
      this._form?.requestSubmit();
    };
    this._form?.addEventListener("submit", this._onSubmit);
    this._textarea?.addEventListener("keydown", this._onKeydown);
    this._onCancel = () => {
      this.dispatchEvent(new CustomEvent("mjj-jrpg-cancel", {
        bubbles: true,
      }));
    };
    this._cancel?.addEventListener("click", this._onCancel);
  }

  disconnectedCallback() {
    this._form?.removeEventListener("submit", this._onSubmit);
    this._textarea?.removeEventListener("keydown", this._onKeydown);
    this._cancel?.removeEventListener("click", this._onCancel);
  }

  setBusy(busy) {
    if (this._textarea) this._textarea.disabled = busy;
    if (this._button) {
      this._button.disabled = busy;
      this._button.setAttribute("aria-busy", String(busy));
    }
    if (this._cancelControl) this._cancelControl.hidden = !busy;
  }

  setCancellable(cancellable) {
    if (this._cancelControl) this._cancelControl.hidden = !cancellable;
  }

  setDisabled(disabled) {
    if (this._textarea) this._textarea.disabled = disabled;
    if (this._button) this._button.disabled = disabled;
  }
}

class MjjJrpgMenu extends HTMLElement {
  connectedCallback() {
    this._onClick = event => {
      const button = event.target.closest("button[data-preview]");
      if (!button || !this.contains(button)) return;
      for (const item of this.querySelectorAll("button[data-preview]")) {
        item.setAttribute("aria-pressed", String(item === button));
      }
      this.dispatchEvent(new CustomEvent("mjj-jrpg-preview-change", {
        bubbles: true,
        detail: { selection: button.dataset.preview },
      }));
    };
    this.addEventListener("click", this._onClick);
  }

  disconnectedCallback() {
    this.removeEventListener("click", this._onClick);
  }
}

function sanitizeFetchedMain(main) {
  for (const unsafe of main.querySelectorAll(
    "embed, form, iframe, object, script, style, svg",
  )) {
    unsafe.remove();
  }
  for (const element of main.querySelectorAll("*")) {
    for (const attribute of [...element.attributes]) {
      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(
      href,
      window.location.href,
    );
    anchor.href = target.href;
    if (target.origin !== window.location.origin) {
      anchor.target = "_blank";
      anchor.rel = "noreferrer";
    }
  }
  for (const image of main.querySelectorAll("img")) {
    const source = image.getAttribute("src") || "";
    if (source.startsWith("/public/") && source.endsWith(".png")) {
      image.src = source.replace(/\.png$/i, ".webp");
    }
    image.loading = "lazy";
  }
  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) {
        if (this.dataset.selection === "resume") void this.loadResume();
        if (this.dataset.selection === "blog") {
          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]");
      if (blogEntry) {
        void this.loadBlogDetail(blogEntry.dataset.blogUrl);
        return;
      }
      const latestBlog = event.target.closest("[data-latest-blog]");
      if (latestBlog && !event.metaKey && !event.ctrlKey && !event.shiftKey) {
        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-dialog-title]").textContent = preview.title;
    this.querySelector("[data-dialog-copy]").textContent = preview.copy;
    this.renderShowcase(preview.works);
    const link = this.querySelector("[data-preview-link]");
    link.href = preview.url;
    link.setAttribute("aria-label", `Open ${preview.title}`);
    const resumeDossier = this.querySelector("[data-resume-dossier]");
    const resumeDownload = this.querySelector("[data-resume-download]");
    const blogBrowser = this.querySelector("[data-blog-browser]");
    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;
    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]");
      status.hidden = false;
      status.textContent = "Select a blog to inspect.";
      this.querySelector("[data-blog-content]").replaceChildren();
      for (const button of this.querySelectorAll("[data-blog-entry]")) {
        button.setAttribute("aria-pressed", "false");
      }
      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, latestKind = null) {
    const showcase = this.querySelector("[data-work-showcase]");
    showcase.replaceChildren(...works.map(work => {
      const item = document.createElement("li");
      const link = document.createElement("a");
      const label = document.createElement("span");
      const detail = document.createElement("small");
      link.href = work.url;
      if (latestBlogs) {
        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";
        link.rel = "noreferrer";
      }
      label.textContent = work.label;
      detail.textContent = work.detail;
      link.append(label, detail);
      item.append(link);
      return item;
    }));
  }

  async loadResume() {
    if (this._resumeLoaded || this._resumeLoading) return;
    this._resumeLoading = true;
    const status = this.querySelector("[data-resume-status]");
    const content = this.querySelector("[data-resume-content]");
    try {
      const response = await fetch("/resume", {
        headers: { Accept: "text/html" },
      });
      if (!response.ok) {
        throw new Error(`Resume request failed (${response.status})`);
      }
      const documentCopy = new DOMParser().parseFromString(
        await response.text(),
        "text/html",
      );
      const resume = documentCopy.querySelector("main");
      if (!resume) throw new Error("Resume content is unavailable");
      sanitizeFetchedMain(resume);
      content.replaceChildren(...resume.childNodes);
      status.hidden = true;
      this._resumeLoaded = true;
    } catch (error) {
      status.textContent = `Unable to load resume: ${error.message}`;
    } finally {
      this._resumeLoading = false;
    }
  }

  async loadBlogs() {
    if (this._blogs) {
      if (this.dataset.selection === "blog") {
        this.renderShowcase([
          ...this._blogs.slice(0, 4),
          {
            detail: "Full archive",
            label: "All blogs",
            modal: "blog",
            url: "/blog",
          },
        ], true);
      }
      return true;
    }
    if (this._blogsLoading) return this._blogsLoading;
    const list = this.querySelector("[data-blog-list]");
    this._blogsLoading = (async () => {
      const response = await fetch("/blog", {
        headers: { Accept: "text/html" },
      });
      if (!response.ok) {
        throw new Error(`Blog archive request failed (${response.status})`);
      }
      const documentCopy = new DOMParser().parseFromString(
        await response.text(),
        "text/html",
      );
      this._blogs = [...documentCopy.querySelectorAll(
        'main li a[href^="/blog/"]',
      )].map(anchor => ({
        date: anchor.closest("li")?.querySelector("span")?.textContent.trim() ||
          "Archive",
        detail: anchor.closest("li")?.querySelector("span")?.textContent.trim() ||
          "Archive",
        label: anchor.textContent.trim(),
        url: anchor.getAttribute("href"),
      }));
      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),
          {
            detail: "Full archive",
            label: "All blogs",
            modal: "blog",
            url: "/blog",
          },
        ], true);
      }
      return true;
    })().catch(error => {
      list.replaceChildren();
      const item = document.createElement("li");
      item.textContent = `Unable to load blogs: ${error.message}`;
      list.append(item);
      return false;
    }).finally(() => {
      this._blogsLoading = null;
    });
    return this._blogsLoading;
  }

  renderBlogList() {
    const list = this.querySelector("[data-blog-list]");
    list.replaceChildren(...this._blogs.map(blog => {
      const item = document.createElement("li");
      const owner = document.createElement("zen-button");
      const button = document.createElement("button");
      const title = document.createElement("span");
      const date = document.createElement("small");
      owner.setAttribute("appearance", "plain");
      owner.setAttribute("size", "sm");
      button.type = "button";
      button.dataset.blogEntry = "";
      button.dataset.blogUrl = blog.url;
      title.textContent = blog.label;
      date.textContent = blog.date;
      button.append(title, date);
      owner.append(button);
      item.append(owner);
      return item;
    }));
  }

  async loadBlogDetail(url) {
    if (!await this.loadBlogs()) return;
    const blog = this._blogs.find(item => item.url === url);
    const status = this.querySelector("[data-blog-status]");
    const content = this.querySelector("[data-blog-content]");
    if (!blog) {
      status.hidden = false;
      status.textContent = "Unable to load blog: unknown entry.";
      return;
    }
    const request = (this._blogRequest || 0) + 1;
    this._blogRequest = request;
    status.hidden = false;
    status.textContent = `Loading ${blog.label}...`;
    content.replaceChildren();
    this.querySelector("[data-dialog-title]").textContent = blog.label;
    const fullPage = this.querySelector("[data-preview-link]");
    fullPage.href = blog.url;
    fullPage.setAttribute("aria-label", `Open ${blog.label}`);
    for (const button of this.querySelectorAll("[data-blog-entry]")) {
      button.setAttribute(
        "aria-pressed",
        String(button.dataset.blogUrl === blog.url),
      );
    }
    try {
      const response = await fetch(blog.url, {
        headers: { Accept: "text/html" },
      });
      if (!response.ok) {
        throw new Error(`Blog request failed (${response.status})`);
      }
      const documentCopy = new DOMParser().parseFromString(
        await response.text(),
        "text/html",
      );
      const article = documentCopy.querySelector("main");
      if (!article) throw new Error("Blog content is unavailable");
      sanitizeFetchedMain(article);
      if (request !== this._blogRequest || this.dataset.selection !== "blog") {
        return;
      }
      content.replaceChildren(...article.childNodes);
      status.hidden = true;
    } catch (error) {
      if (request !== this._blogRequest) return;
      status.hidden = false;
      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 {}

for (const [name, constructor] of Object.entries({
  "mjj-jrpg-character": MjjJrpgCharacter,
  "mjj-jrpg-chat": MjjJrpgChat,
  "mjj-jrpg-composer": MjjJrpgComposer,
  "mjj-jrpg-menu": MjjJrpgMenu,
  "mjj-jrpg-preview": MjjJrpgPreview,
  "mjj-jrpg-shell": MjjJrpgShell,
})) {
  if (!customElements.get(name)) customElements.define(name, constructor);
}

async function requestJson(url, options = {}) {
  const response = await fetch(url, {
    ...options,
    headers: {
      "Content-Type": "application/json",
      ...options.headers,
    },
  });
  if (!response.ok) {
    let message = `Request failed (${response.status})`;
    try {
      const body = await response.json();
      message = body.error?.message || message;
    } catch {
      // Preserve the status-derived message.
    }
    throw new Error(message);
  }
  return response.status === 204 ? null : response.json();
}

async function consumeSse(response, onEvent) {
  if (!response.body) throw new Error("Streaming response body is unavailable");
  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

  const dispatch = block => {
    let eventName = "message";
    const data = [];
    for (const line of block.split("\n")) {
      if (line.startsWith("event:")) eventName = line.slice(6).trim();
      else if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
    }
    if (!data.length) return;
    onEvent(eventName, JSON.parse(data.join("\n")));
  };

  while (true) {
    const { done, value } = await reader.read();
    buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
    buffer = buffer.replaceAll("\r\n", "\n");
    let boundary;
    while ((boundary = buffer.indexOf("\n\n")) >= 0) {
      const block = buffer.slice(0, boundary);
      buffer = buffer.slice(boundary + 2);
      if (block.trim()) dispatch(block);
    }
    if (done) break;
  }
  if (buffer.trim()) dispatch(buffer);
}

async function initializeJrpgPage() {
  const colorProbe = document.createElement("span");
  colorProbe.style.background = "var(--zenbu-sys-color-surface-page)";
  document.body.append(colorProbe);
  const themeColor = getComputedStyle(colorProbe).backgroundColor;
  colorProbe.remove();
  for (const meta of document.querySelectorAll('meta[name="theme-color"]')) {
    meta.setAttribute("content", themeColor);
  }

  const shell = document.querySelector("mjj-jrpg-shell");
  const character = shell?.querySelector("mjj-jrpg-character");
  const chat = shell?.querySelector("mjj-jrpg-chat");
  const composer = shell?.querySelector("mjj-jrpg-composer");
  const 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 {
      const conversation = await requestJson(
        `/api/conversations/${encodeURIComponent(conversationId)}`,
        { headers: {} },
      );
      chat?.replaceMessages(conversation.turns);
    } catch {
      sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
      conversationId = null;
    }
  }

  shell?.addEventListener("mjj-jrpg-preview-change", event => {
    preview?.show(event.detail.selection);
    character?.setAttribute("state", "thinking");
    window.clearTimeout(characterTimer);
    characterTimer = window.setTimeout(() => {
      character?.setAttribute("state", "default");
    }, 650);
  });

  shell?.addEventListener("mjj-jrpg-cancel", () => {
    activeController?.abort();
  });

  shell?.addEventListener("mjj-jrpg-submit", async event => {
    if (activeController) return;
    chat?.appendMessage("June", event.detail.message);
    composer?.setBusy(true);
    character?.setAttribute("state", "thinking");
    window.clearTimeout(characterTimer);
    const assistantItem = chat?.startAssistantMessage();
    chat?.setMessageStreaming(assistantItem, true);
    const assistantText = new StreamingTextAnimator(text => {
      chat?.setMessageText(assistantItem, text);
    });
    let turnFinished = false;
    activeController = new AbortController();
    activeController.signal.addEventListener("abort", () => {
      assistantText.cancel();
    }, { once: true });

    try {
      const createConversation = async () => {
        const conversation = await requestJson("/api/conversations", {
          method: "POST",
          body: JSON.stringify({ title: "Shiba Quest" }),
        });
        conversationId = conversation.id;
        sessionStorage.setItem(CONVERSATION_STORAGE_KEY, conversationId);
      };
      if (!conversationId) {
        await createConversation();
      }

      const postTurn = () => fetch(
        `/api/conversations/${encodeURIComponent(conversationId)}/turns`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ prompt: event.detail.message }),
          signal: activeController.signal,
        },
      );
      let response = await postTurn();
      if (response.status === 404) {
        sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
        conversationId = null;
        await createConversation();
        response = await postTurn();
      }
      if (!response.ok) {
        let message = `Turn failed (${response.status})`;
        try {
          const body = await response.json();
          message = body.error?.message || message;
        } catch {
          // Preserve the status-derived message.
        }
        throw new Error(message);
      }
      await consumeSse(response, (eventName, data) => {
        shell?.dispatchEvent(new CustomEvent("mjj-jrpg-stream-event", {
          bubbles: true,
          detail: {
            data,
            type: eventName,
          },
        }));
        if (eventName === "assistant.delta") {
          assistantText.append(data.delta || "");
        } else if (eventName === "assistant.completed") {
          assistantText.complete(data.content);
        } else if (eventName === "turn.error") {
          throw new Error(
            data.error?.message || data.message || "Inference turn failed",
          );
        } else if (eventName === "turn.done") {
          turnFinished = true;
          composer?.setCancellable(false);
          if (data.failed || data.aborted) {
            throw new Error(
              data.aborted ? "Inference turn aborted" : "Inference turn failed",
            );
          }
        }
      });
      if (!turnFinished) throw new Error("Inference stream ended unexpectedly");
      await assistantText.waitForIdle();
      if (activeController.signal.aborted) {
        throw new DOMException("Quest cancelled", "AbortError");
      }
      chat?.setMessageStreaming(assistantItem, false);
      character?.setAttribute("state", "default");
      setFrameStatus("ONLINE", "online");
    } catch (error) {
      const aborted = error instanceof DOMException && error.name === "AbortError";
      assistantText.cancel();
      chat?.setMessageStreaming(assistantItem, false);
      chat?.setMessageText(
        assistantItem,
        aborted ? "Quest cancelled." : `System error: ${error.message}`,
      );
      character?.setAttribute("state", "sad");
      if (!aborted) setFrameStatus("OFFLINE", "offline");
    } finally {
      composer?.setBusy(false);
      composer?.querySelector("textarea")?.focus();
      activeController = null;
    }
  });
  composer?.setDisabled(false);
}

if (document.readyState === "loading") {
  document.addEventListener("DOMContentLoaded", () => {
    void initializeJrpgPage();
  }, {
    once: true,
  });
} else {
  void initializeJrpgPage();
}