diff mrjunejune/src/jrpg/jrpg.js @ 261:b401627fc49e

Add JRPG mock flows and interactive previews Add scripted mock SSE commands, custom event forwarding, animated chat turns, full-height message navigation, and a cyberpunk resume dossier. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <mrjunejune@users.noreply.github.com>
date Wed, 05 Aug 2026 20:38:32 -0700
parents 1f9877b637e9
children 0f45474c1b1a
line wrap: on
line diff
--- a/mrjunejune/src/jrpg/jrpg.js	Wed Aug 05 09:19:41 2026 -0700
+++ b/mrjunejune/src/jrpg/jrpg.js	Wed Aug 05 20:38:32 2026 -0700
@@ -20,6 +20,38 @@
     title: "Resume",
     type: "Profile",
     url: "/resume",
+    works: [
+      {
+        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.",
@@ -27,6 +59,11 @@
     title: "Tools",
     type: "Utilities",
     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.",
@@ -34,10 +71,102 @@
     title: "Blogs",
     type: "Writing",
     url: "/blog",
+    works: [
+      { detail: "Archive", label: "All posts", url: "/blog" },
+    ],
   },
 });
 
 const CONVERSATION_STORAGE_KEY = "mjj-jrpg-conversation-id";
+const TYPING_INTERVAL_MS = 18;
+
+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() {
@@ -66,33 +195,89 @@
   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.scrollEnd();
+    this._refreshTurnGroups(select ? group : this._currentGroup);
     return item;
   }
 
   replaceMessages(turns) {
     if (!this._messages) return;
     this._messages.replaceChildren();
-    for (const turn of turns) {
-      if (!["user", "assistant"].includes(turn.role)) continue;
-      this.appendMessage(
+    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 === "failed" ? "The quest failed." : ""),
+        turn.content ||
+          (turn.status === "aborted"
+            ? "Quest cancelled."
+            : turn.status === "failed"
+              ? "The quest failed."
+              : ""),
+        group,
+        false,
       );
     }
+    this._refreshTurnGroups(group);
   }
 
   startAssistantMessage() {
@@ -105,6 +290,64 @@
     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) {
@@ -161,6 +404,10 @@
     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;
@@ -190,9 +437,22 @@
 
 class MjjJrpgPreview extends HTMLElement {
   connectedCallback() {
+    this._onClick = event => {
+      if (
+        event.target.closest("[data-zen-trigger]") &&
+        this.dataset.selection === "resume"
+      ) {
+        void this.loadResume();
+      }
+    };
+    this.addEventListener("click", this._onClick);
     this.show(this.dataset.selection || "resume");
   }
 
+  disconnectedCallback() {
+    this.removeEventListener("click", this._onClick);
+  }
+
   show(selection) {
     const preview = PREVIEWS[selection] || PREVIEWS.resume;
     this.dataset.selection = selection;
@@ -202,9 +462,79 @@
     this.querySelector("[data-preview-type]").textContent = preview.type;
     this.querySelector("[data-dialog-title]").textContent = preview.title;
     this.querySelector("[data-dialog-copy]").textContent = preview.copy;
+    const showcase = this.querySelector("[data-work-showcase]");
+    showcase.replaceChildren(...preview.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 (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;
+    }));
     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 isResume = selection === "resume";
+    resumeDossier.hidden = !isResume;
+    resumeDownload.hidden = !isResume;
+    this.querySelector("[data-dialog-copy]").hidden = isResume;
+  }
+
+  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");
+      for (const unsafe of resume.querySelectorAll(
+        "embed, iframe, object, script, style, svg",
+      )) {
+        unsafe.remove();
+      }
+      for (const element of resume.querySelectorAll("*")) {
+        for (const attribute of [...element.attributes]) {
+          if (attribute.name.toLowerCase().startsWith("on")) {
+            element.removeAttribute(attribute.name);
+          }
+        }
+      }
+      for (const anchor of resume.querySelectorAll("a")) {
+        const target = new URL(anchor.href, window.location.href);
+        if (target.origin !== window.location.origin) {
+          anchor.target = "_blank";
+          anchor.rel = "noreferrer";
+        }
+      }
+      content.replaceChildren(...resume.childNodes);
+      status.hidden = true;
+      this._resumeLoaded = true;
+    } catch (error) {
+      status.textContent = `Unable to load resume: ${error.message}`;
+    } finally {
+      this._resumeLoading = false;
+    }
   }
 }
 
@@ -327,9 +657,15 @@
     character?.setAttribute("state", "thinking");
     window.clearTimeout(characterTimer);
     const assistantItem = chat?.startAssistantMessage();
-    let assistantText = "";
+    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 () => {
@@ -370,23 +706,42 @@
         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 += data.delta || "";
-          chat?.setMessageText(assistantItem, assistantText);
+          assistantText.append(data.delta || "");
         } else if (eventName === "assistant.completed") {
-          assistantText = data.content || assistantText;
-          chat?.setMessageText(assistantItem, assistantText);
+          assistantText.complete(data.content);
         } else if (eventName === "turn.error") {
-          throw new Error(data.message || "Inference turn failed");
+          throw new Error(
+            data.error?.message || data.message || "Inference turn failed",
+          );
         } else if (eventName === "turn.done") {
           turnFinished = true;
-          if (data.failed) throw new Error("Inference turn failed");
+          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");
     } 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}`,