comparison 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
comparison
equal deleted inserted replaced
260:1f9877b637e9 261:b401627fc49e
18 copy: "Experience, projects, and the systems I have helped build.", 18 copy: "Experience, projects, and the systems I have helped build.",
19 kicker: "CHARACTER RECORD", 19 kicker: "CHARACTER RECORD",
20 title: "Resume", 20 title: "Resume",
21 type: "Profile", 21 type: "Profile",
22 url: "/resume", 22 url: "/resume",
23 works: [
24 {
25 detail: "Agentic execution",
26 label: "Copilot Tasks",
27 url: "https://www.microsoft.com/en-us/microsoft-copilot/blog/2026/02/26/copilot-tasks-from-answers-to-actions/",
28 },
29 {
30 detail: "AI platform",
31 label: "Copilot SuperApp",
32 url: "https://www.cio.com/article/3977098/microsoft-doubles-down-on-multi-model-ai-as-it-builds-a-copilot-super-app.html",
33 },
34 {
35 detail: "Build 2026",
36 label: "Code",
37 url: "https://news.microsoft.com/build-2026/",
38 },
39 {
40 detail: "Personal agent",
41 label: "Autopilot",
42 url: "https://www.microsoft.com/en-us/microsoft-365/blog/2026/06/02/introducing-microsoft-scout-your-always-on-personal-agent/",
43 },
44 {
45 detail: "Ads systems",
46 label: "Meta",
47 url: "https://www.meta.com/",
48 },
49 {
50 detail: "Workspace platform",
51 label: "Google",
52 url: "https://www.google.com/",
53 },
54 ],
23 }, 55 },
24 tools: { 56 tools: {
25 copy: "Small, focused utilities for writing, media, and experimentation.", 57 copy: "Small, focused utilities for writing, media, and experimentation.",
26 kicker: "ITEM INVENTORY", 58 kicker: "ITEM INVENTORY",
27 title: "Tools", 59 title: "Tools",
28 type: "Utilities", 60 type: "Utilities",
29 url: "/tools", 61 url: "/tools",
62 works: [
63 { detail: "Writing", label: "Markdown", url: "/tools/markdown_to_html" },
64 { detail: "Media", label: "Converter", url: "/tools/file_converter" },
65 { detail: "Streaming", label: "HLS Player", url: "/tools/hls_player" },
66 ],
30 }, 67 },
31 blog: { 68 blog: {
32 copy: "Notes from building systems, games, web tools, and curious prototypes.", 69 copy: "Notes from building systems, games, web tools, and curious prototypes.",
33 kicker: "QUEST ARCHIVE", 70 kicker: "QUEST ARCHIVE",
34 title: "Blogs", 71 title: "Blogs",
35 type: "Writing", 72 type: "Writing",
36 url: "/blog", 73 url: "/blog",
74 works: [
75 { detail: "Archive", label: "All posts", url: "/blog" },
76 ],
37 }, 77 },
38 }); 78 });
39 79
40 const CONVERSATION_STORAGE_KEY = "mjj-jrpg-conversation-id"; 80 const CONVERSATION_STORAGE_KEY = "mjj-jrpg-conversation-id";
81 const TYPING_INTERVAL_MS = 18;
82
83 class StreamingTextAnimator {
84 constructor(render) {
85 this._render = render;
86 this._displayed = "";
87 this._target = "";
88 this._timer = 0;
89 this._waiters = [];
90 this._reduceMotion = window.matchMedia(
91 "(prefers-reduced-motion: reduce)",
92 ).matches;
93 }
94
95 append(text) {
96 if (typeof text !== "string" || !text) return;
97 this._target += text;
98 this._schedule();
99 }
100
101 complete(text) {
102 if (typeof text === "string") {
103 this._target = text;
104 if (!this._target.startsWith(this._displayed)) {
105 let shared = 0;
106 while (
107 shared < this._target.length &&
108 shared < this._displayed.length &&
109 this._target[shared] === this._displayed[shared]
110 ) {
111 shared++;
112 }
113 this._displayed = this._displayed.slice(0, shared);
114 this._render(this._displayed);
115 }
116 }
117 this._schedule();
118 }
119
120 waitForIdle() {
121 if (!this._timer && this._displayed === this._target) {
122 return Promise.resolve();
123 }
124 return new Promise(resolve => {
125 this._waiters.push(resolve);
126 });
127 }
128
129 cancel() {
130 window.clearTimeout(this._timer);
131 this._timer = 0;
132 this._target = this._displayed;
133 this._settle();
134 }
135
136 _schedule() {
137 if (this._reduceMotion) {
138 this._displayed = this._target;
139 this._render(this._displayed);
140 this._settle();
141 return;
142 }
143 if (this._timer || this._displayed === this._target) return;
144 this._timer = window.setTimeout(() => this._tick(), TYPING_INTERVAL_MS);
145 }
146
147 _tick() {
148 this._timer = 0;
149 const remaining = this._target.length - this._displayed.length;
150 if (remaining <= 0) {
151 this._settle();
152 return;
153 }
154 const amount = Math.max(1, Math.ceil(remaining / 80));
155 this._displayed = this._target.slice(
156 0,
157 this._displayed.length + amount,
158 );
159 this._render(this._displayed);
160 if (this._displayed === this._target) this._settle();
161 else this._schedule();
162 }
163
164 _settle() {
165 if (this._timer || this._displayed !== this._target) return;
166 const waiters = this._waiters.splice(0);
167 for (const resolve of waiters) resolve();
168 }
169 }
41 170
42 class MjjJrpgCharacter extends HTMLElement { 171 class MjjJrpgCharacter extends HTMLElement {
43 static get observedAttributes() { 172 static get observedAttributes() {
44 return ["state"]; 173 return ["state"];
45 } 174 }
64 193
65 class MjjJrpgChat extends HTMLElement { 194 class MjjJrpgChat extends HTMLElement {
66 connectedCallback() { 195 connectedCallback() {
67 this._messages = this.querySelector("[data-messages]"); 196 this._messages = this.querySelector("[data-messages]");
68 this._viewport = this.querySelector("[data-zen-viewport]"); 197 this._viewport = this.querySelector("[data-zen-viewport]");
198 this._previousButton = this.querySelector("[data-turn-previous]");
199 this._nextButton = this.querySelector("[data-turn-next]");
200 this._position = this.querySelector("[data-turn-position]");
201 this._initialMessages = [...(this._messages?.children || [])].map(
202 item => item.cloneNode(true),
203 );
204 this._turnSequence = 0;
205 this._activeGroup = null;
206 this._onNavigationClick = event => {
207 const button = event.target.closest("button");
208 if (!button || !this.contains(button) || button.disabled) return;
209 if (button === this._previousButton) this._moveTurn(-1);
210 else if (button === this._nextButton) this._moveTurn(1);
211 };
212 this.addEventListener("click", this._onNavigationClick);
213 this._refreshTurnGroups("start");
214 }
215
216 disconnectedCallback() {
217 this.removeEventListener("click", this._onNavigationClick);
69 } 218 }
70 219
71 appendMessage(speaker, text) { 220 appendMessage(speaker, text) {
221 let group = this._activeGroup || "start";
222 if (speaker === "June") {
223 this._turnSequence++;
224 group = `turn-${this._turnSequence}`;
225 this._activeGroup = group;
226 }
227 return this._createMessage(speaker, text, group, true);
228 }
229
230 _createMessage(speaker, text, group, select) {
72 if (!this._messages) return; 231 if (!this._messages) return;
73 const item = document.createElement("li"); 232 const item = document.createElement("li");
74 const name = document.createElement("strong"); 233 const name = document.createElement("strong");
75 const copy = document.createElement("p"); 234 const copy = document.createElement("p");
76 item.className = "jrpg-message"; 235 item.className = "jrpg-message";
77 item.dataset.speaker = speaker; 236 item.dataset.speaker = speaker;
237 item.dataset.turnGroup = group;
78 name.textContent = speaker; 238 name.textContent = speaker;
79 copy.textContent = text; 239 copy.textContent = text;
80 item.append(name, copy); 240 item.append(name, copy);
81 this._messages.append(item); 241 this._messages.append(item);
82 this.scrollEnd(); 242 this._refreshTurnGroups(select ? group : this._currentGroup);
83 return item; 243 return item;
84 } 244 }
85 245
86 replaceMessages(turns) { 246 replaceMessages(turns) {
87 if (!this._messages) return; 247 if (!this._messages) return;
88 this._messages.replaceChildren(); 248 this._messages.replaceChildren();
89 for (const turn of turns) { 249 this._turnSequence = 0;
90 if (!["user", "assistant"].includes(turn.role)) continue; 250 this._activeGroup = null;
91 this.appendMessage( 251 const visibleTurns = turns.filter(turn =>
252 ["user", "assistant"].includes(turn.role)
253 );
254 if (!visibleTurns.length) {
255 this._messages.append(
256 ...this._initialMessages.map(item => item.cloneNode(true)),
257 );
258 this._refreshTurnGroups("start");
259 return;
260 }
261 let group = null;
262 for (const turn of visibleTurns) {
263 if (turn.role === "user" || !group) {
264 this._turnSequence++;
265 group = `turn-${this._turnSequence}`;
266 this._activeGroup = group;
267 }
268 this._createMessage(
92 turn.role === "user" ? "June" : "Epi", 269 turn.role === "user" ? "June" : "Epi",
93 turn.content || (turn.status === "failed" ? "The quest failed." : ""), 270 turn.content ||
271 (turn.status === "aborted"
272 ? "Quest cancelled."
273 : turn.status === "failed"
274 ? "The quest failed."
275 : ""),
276 group,
277 false,
94 ); 278 );
95 } 279 }
280 this._refreshTurnGroups(group);
96 } 281 }
97 282
98 startAssistantMessage() { 283 startAssistantMessage() {
99 return this.appendMessage("Epi", ""); 284 return this.appendMessage("Epi", "");
100 } 285 }
101 286
102 setMessageText(item, text) { 287 setMessageText(item, text) {
103 const copy = item?.querySelector("p"); 288 const copy = item?.querySelector("p");
104 if (copy) copy.textContent = text; 289 if (copy) copy.textContent = text;
290 this.scrollEnd();
291 }
292
293 setMessageStreaming(item, streaming) {
294 if (!item) return;
295 if (streaming) item.dataset.streaming = "true";
296 else delete item.dataset.streaming;
297 this._viewport?.setAttribute("aria-busy", String(streaming));
298 }
299
300 _refreshTurnGroups(preferredGroup) {
301 const groups = [];
302 for (const item of this._messages?.children || []) {
303 const group = item.dataset.turnGroup || "start";
304 item.dataset.turnGroup = group;
305 if (!groups.includes(group)) groups.push(group);
306 }
307 this._turnGroups = groups;
308 const preferredIndex = groups.indexOf(preferredGroup);
309 const currentIndex = groups.indexOf(this._currentGroup);
310 this._turnIndex = preferredIndex >= 0
311 ? preferredIndex
312 : currentIndex >= 0
313 ? currentIndex
314 : Math.max(0, groups.length - 1);
315 this._showTurn();
316 }
317
318 _moveTurn(offset) {
319 const nextIndex = Math.max(
320 0,
321 Math.min(this._turnGroups.length - 1, this._turnIndex + offset),
322 );
323 if (nextIndex === this._turnIndex) return;
324 this._turnIndex = nextIndex;
325 this._showTurn();
326 }
327
328 _showTurn() {
329 const group = this._turnGroups?.[this._turnIndex];
330 this._currentGroup = group;
331 for (const item of this._messages?.children || []) {
332 item.hidden = item.dataset.turnGroup !== group;
333 }
334 if (this._previousButton) this._previousButton.disabled = this._turnIndex <= 0;
335 if (this._nextButton) {
336 this._nextButton.disabled =
337 this._turnIndex >= (this._turnGroups?.length || 0) - 1;
338 }
339 if (this._position) {
340 if (group === "start") {
341 this._position.textContent = "START";
342 } else {
343 const turns = this._turnGroups.filter(item => item !== "start");
344 this._position.textContent =
345 `TURN ${turns.indexOf(group) + 1} / ${turns.length}`;
346 }
347 }
105 this.scrollEnd(); 348 this.scrollEnd();
106 } 349 }
107 350
108 scrollEnd() { 351 scrollEnd() {
109 requestAnimationFrame(() => { 352 requestAnimationFrame(() => {
159 this._button.setAttribute("aria-busy", String(busy)); 402 this._button.setAttribute("aria-busy", String(busy));
160 } 403 }
161 if (this._cancelControl) this._cancelControl.hidden = !busy; 404 if (this._cancelControl) this._cancelControl.hidden = !busy;
162 } 405 }
163 406
407 setCancellable(cancellable) {
408 if (this._cancelControl) this._cancelControl.hidden = !cancellable;
409 }
410
164 setDisabled(disabled) { 411 setDisabled(disabled) {
165 if (this._textarea) this._textarea.disabled = disabled; 412 if (this._textarea) this._textarea.disabled = disabled;
166 if (this._button) this._button.disabled = disabled; 413 if (this._button) this._button.disabled = disabled;
167 } 414 }
168 } 415 }
188 } 435 }
189 } 436 }
190 437
191 class MjjJrpgPreview extends HTMLElement { 438 class MjjJrpgPreview extends HTMLElement {
192 connectedCallback() { 439 connectedCallback() {
440 this._onClick = event => {
441 if (
442 event.target.closest("[data-zen-trigger]") &&
443 this.dataset.selection === "resume"
444 ) {
445 void this.loadResume();
446 }
447 };
448 this.addEventListener("click", this._onClick);
193 this.show(this.dataset.selection || "resume"); 449 this.show(this.dataset.selection || "resume");
450 }
451
452 disconnectedCallback() {
453 this.removeEventListener("click", this._onClick);
194 } 454 }
195 455
196 show(selection) { 456 show(selection) {
197 const preview = PREVIEWS[selection] || PREVIEWS.resume; 457 const preview = PREVIEWS[selection] || PREVIEWS.resume;
198 this.dataset.selection = selection; 458 this.dataset.selection = selection;
200 this.querySelector("[data-preview-title]").textContent = preview.title; 460 this.querySelector("[data-preview-title]").textContent = preview.title;
201 this.querySelector("[data-preview-copy]").textContent = preview.copy; 461 this.querySelector("[data-preview-copy]").textContent = preview.copy;
202 this.querySelector("[data-preview-type]").textContent = preview.type; 462 this.querySelector("[data-preview-type]").textContent = preview.type;
203 this.querySelector("[data-dialog-title]").textContent = preview.title; 463 this.querySelector("[data-dialog-title]").textContent = preview.title;
204 this.querySelector("[data-dialog-copy]").textContent = preview.copy; 464 this.querySelector("[data-dialog-copy]").textContent = preview.copy;
465 const showcase = this.querySelector("[data-work-showcase]");
466 showcase.replaceChildren(...preview.works.map(work => {
467 const item = document.createElement("li");
468 const link = document.createElement("a");
469 const label = document.createElement("span");
470 const detail = document.createElement("small");
471 link.href = work.url;
472 if (new URL(work.url, window.location.href).origin !== window.location.origin) {
473 link.target = "_blank";
474 link.rel = "noreferrer";
475 }
476 label.textContent = work.label;
477 detail.textContent = work.detail;
478 link.append(label, detail);
479 item.append(link);
480 return item;
481 }));
205 const link = this.querySelector("[data-preview-link]"); 482 const link = this.querySelector("[data-preview-link]");
206 link.href = preview.url; 483 link.href = preview.url;
207 link.setAttribute("aria-label", `Open ${preview.title}`); 484 link.setAttribute("aria-label", `Open ${preview.title}`);
485 const resumeDossier = this.querySelector("[data-resume-dossier]");
486 const resumeDownload = this.querySelector("[data-resume-download]");
487 const isResume = selection === "resume";
488 resumeDossier.hidden = !isResume;
489 resumeDownload.hidden = !isResume;
490 this.querySelector("[data-dialog-copy]").hidden = isResume;
491 }
492
493 async loadResume() {
494 if (this._resumeLoaded || this._resumeLoading) return;
495 this._resumeLoading = true;
496 const status = this.querySelector("[data-resume-status]");
497 const content = this.querySelector("[data-resume-content]");
498 try {
499 const response = await fetch("/resume", {
500 headers: { Accept: "text/html" },
501 });
502 if (!response.ok) {
503 throw new Error(`Resume request failed (${response.status})`);
504 }
505 const documentCopy = new DOMParser().parseFromString(
506 await response.text(),
507 "text/html",
508 );
509 const resume = documentCopy.querySelector("main");
510 if (!resume) throw new Error("Resume content is unavailable");
511 for (const unsafe of resume.querySelectorAll(
512 "embed, iframe, object, script, style, svg",
513 )) {
514 unsafe.remove();
515 }
516 for (const element of resume.querySelectorAll("*")) {
517 for (const attribute of [...element.attributes]) {
518 if (attribute.name.toLowerCase().startsWith("on")) {
519 element.removeAttribute(attribute.name);
520 }
521 }
522 }
523 for (const anchor of resume.querySelectorAll("a")) {
524 const target = new URL(anchor.href, window.location.href);
525 if (target.origin !== window.location.origin) {
526 anchor.target = "_blank";
527 anchor.rel = "noreferrer";
528 }
529 }
530 content.replaceChildren(...resume.childNodes);
531 status.hidden = true;
532 this._resumeLoaded = true;
533 } catch (error) {
534 status.textContent = `Unable to load resume: ${error.message}`;
535 } finally {
536 this._resumeLoading = false;
537 }
208 } 538 }
209 } 539 }
210 540
211 class MjjJrpgShell extends HTMLElement {} 541 class MjjJrpgShell extends HTMLElement {}
212 542
325 chat?.appendMessage("June", event.detail.message); 655 chat?.appendMessage("June", event.detail.message);
326 composer?.setBusy(true); 656 composer?.setBusy(true);
327 character?.setAttribute("state", "thinking"); 657 character?.setAttribute("state", "thinking");
328 window.clearTimeout(characterTimer); 658 window.clearTimeout(characterTimer);
329 const assistantItem = chat?.startAssistantMessage(); 659 const assistantItem = chat?.startAssistantMessage();
330 let assistantText = ""; 660 chat?.setMessageStreaming(assistantItem, true);
661 const assistantText = new StreamingTextAnimator(text => {
662 chat?.setMessageText(assistantItem, text);
663 });
331 let turnFinished = false; 664 let turnFinished = false;
332 activeController = new AbortController(); 665 activeController = new AbortController();
666 activeController.signal.addEventListener("abort", () => {
667 assistantText.cancel();
668 }, { once: true });
333 669
334 try { 670 try {
335 const createConversation = async () => { 671 const createConversation = async () => {
336 const conversation = await requestJson("/api/conversations", { 672 const conversation = await requestJson("/api/conversations", {
337 method: "POST", 673 method: "POST",
368 // Preserve the status-derived message. 704 // Preserve the status-derived message.
369 } 705 }
370 throw new Error(message); 706 throw new Error(message);
371 } 707 }
372 await consumeSse(response, (eventName, data) => { 708 await consumeSse(response, (eventName, data) => {
709 shell?.dispatchEvent(new CustomEvent("mjj-jrpg-stream-event", {
710 bubbles: true,
711 detail: {
712 data,
713 type: eventName,
714 },
715 }));
373 if (eventName === "assistant.delta") { 716 if (eventName === "assistant.delta") {
374 assistantText += data.delta || ""; 717 assistantText.append(data.delta || "");
375 chat?.setMessageText(assistantItem, assistantText);
376 } else if (eventName === "assistant.completed") { 718 } else if (eventName === "assistant.completed") {
377 assistantText = data.content || assistantText; 719 assistantText.complete(data.content);
378 chat?.setMessageText(assistantItem, assistantText);
379 } else if (eventName === "turn.error") { 720 } else if (eventName === "turn.error") {
380 throw new Error(data.message || "Inference turn failed"); 721 throw new Error(
722 data.error?.message || data.message || "Inference turn failed",
723 );
381 } else if (eventName === "turn.done") { 724 } else if (eventName === "turn.done") {
382 turnFinished = true; 725 turnFinished = true;
383 if (data.failed) throw new Error("Inference turn failed"); 726 composer?.setCancellable(false);
727 if (data.failed || data.aborted) {
728 throw new Error(
729 data.aborted ? "Inference turn aborted" : "Inference turn failed",
730 );
731 }
384 } 732 }
385 }); 733 });
386 if (!turnFinished) throw new Error("Inference stream ended unexpectedly"); 734 if (!turnFinished) throw new Error("Inference stream ended unexpectedly");
735 await assistantText.waitForIdle();
736 if (activeController.signal.aborted) {
737 throw new DOMException("Quest cancelled", "AbortError");
738 }
739 chat?.setMessageStreaming(assistantItem, false);
387 character?.setAttribute("state", "default"); 740 character?.setAttribute("state", "default");
388 } catch (error) { 741 } catch (error) {
389 const aborted = error instanceof DOMException && error.name === "AbortError"; 742 const aborted = error instanceof DOMException && error.name === "AbortError";
743 assistantText.cancel();
744 chat?.setMessageStreaming(assistantItem, false);
390 chat?.setMessageText( 745 chat?.setMessageText(
391 assistantItem, 746 assistantItem,
392 aborted ? "Quest cancelled." : `System error: ${error.message}`, 747 aborted ? "Quest cancelled." : `System error: ${error.message}`,
393 ); 748 );
394 character?.setAttribute("state", "sad"); 749 character?.setAttribute("state", "sad");