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