view 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 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",
    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.",
    kicker: "ITEM INVENTORY",
    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.",
    kicker: "QUEST ARCHIVE",
    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() {
    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);
  }
}

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;
    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;
    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;
    }
  }
}

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");
  let characterTimer = 0;
  let conversationId = sessionStorage.getItem(CONVERSATION_STORAGE_KEY);
  let activeController = null;
  composer?.setDisabled(true);

  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");
    } 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");
    } 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();
}