changeset 262:0f45474c1b1a

Add cyberpunk JRPG blog browser Show the latest posts in the JRPG preview and render the full blog archive and sanitized post details in the Inspect modal. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <mrjunejune@users.noreply.github.com>
date Thu, 06 Aug 2026 04:08:45 -0700
parents b401627fc49e
children ee04e4e69fed
files mrjunejune/src/jrpg/index.html mrjunejune/src/jrpg/jrpg.css mrjunejune/src/jrpg/jrpg.js mrjunejune/src/sw.js mrjunejune/test/snapshots/jrpg.snapshot mrjunejune/test/theme_and_webp_test.js
diffstat 6 files changed, 406 insertions(+), 40 deletions(-) [+]
line wrap: on
line diff
--- a/mrjunejune/src/jrpg/index.html	Wed Aug 05 20:38:32 2026 -0700
+++ b/mrjunejune/src/jrpg/index.html	Thu Aug 06 04:08:45 2026 -0700
@@ -107,7 +107,7 @@
                       <zen-icon name="search"></zen-icon>
                     </button>
                   </zen-button>
-                  <dialog class="jrpg-resume-dialog" aria-labelledby="jrpg-dialog-title">
+                  <dialog class="jrpg-detail-dialog" aria-labelledby="jrpg-dialog-title">
                     <div class="jrpg-dialog-heading">
                       <p>DESTINATION DATA</p>
                       <zen-button appearance="plain" size="md">
@@ -126,6 +126,23 @@
                       <p data-resume-status>Loading resume dossier...</p>
                       <div data-resume-content></div>
                     </section>
+                    <section
+                      class="jrpg-blog-browser"
+                      data-blog-browser
+                      aria-label="Blog archive"
+                      hidden
+                    >
+                      <aside>
+                        <h3>All blogs</h3>
+                        <ol data-blog-list>
+                          <li>Loading blog archive...</li>
+                        </ol>
+                      </aside>
+                      <article>
+                        <p data-blog-status>Select a blog to inspect.</p>
+                        <div data-blog-content></div>
+                      </article>
+                    </section>
                     <div class="jrpg-dialog-actions">
                       <zen-button appearance="plain" size="lg">
                         <a href="/resume" data-preview-link>
--- a/mrjunejune/src/jrpg/jrpg.css	Wed Aug 05 20:38:32 2026 -0700
+++ b/mrjunejune/src/jrpg/jrpg.css	Thu Aug 06 04:08:45 2026 -0700
@@ -534,13 +534,13 @@
   margin-bottom: var(--zenbu-sys-space-content);
 }
 
-.jrpg-preview-dialog dialog.jrpg-resume-dialog {
+.jrpg-preview-dialog dialog.jrpg-detail-dialog {
   width: min(94vw, 72rem);
   max-height: calc(100dvh - var(--zenbu-sys-space-container));
   overflow: hidden;
 }
 
-.jrpg-preview-dialog dialog.jrpg-resume-dialog[open] {
+.jrpg-preview-dialog dialog.jrpg-detail-dialog[open] {
   display: grid;
   grid-template-rows: auto auto minmax(0, 1fr) auto;
   gap: var(--zenbu-sys-space-group);
@@ -556,7 +556,9 @@
 }
 
 .jrpg-resume-dossier[hidden],
+.jrpg-blog-browser[hidden],
 [data-dialog-copy][hidden],
+[data-blog-status][hidden],
 [data-resume-status][hidden],
 [data-resume-download][hidden] {
   display: none;
@@ -657,6 +659,102 @@
   gap: var(--zenbu-sys-space-control);
 }
 
+.jrpg-blog-browser {
+  display: grid;
+  grid-template-columns: minmax(12rem, 0.35fr) minmax(0, 1fr);
+  gap: var(--zenbu-sys-space-group);
+  min-height: 0;
+  overflow: hidden;
+}
+
+.jrpg-blog-browser > aside,
+.jrpg-blog-browser > article {
+  min-height: 0;
+  padding: var(--zenbu-sys-padding-md);
+  overflow: auto;
+  border: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft);
+  background: var(--mjj-jrpg-surface-sunken);
+  scrollbar-color: var(--mjj-jrpg-frame) transparent;
+}
+
+.jrpg-blog-browser h3,
+.jrpg-blog-browser [data-blog-status] {
+  margin-top: 0;
+  color: var(--mjj-jrpg-highlight);
+}
+
+.jrpg-blog-browser [data-blog-list] {
+  display: grid;
+  gap: var(--zenbu-sys-space-control);
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+
+.jrpg-blog-browser [data-blog-list] zen-button,
+.jrpg-blog-browser [data-blog-list] button {
+  width: 100%;
+}
+
+.jrpg-blog-browser [data-blog-list] button {
+  display: grid;
+  justify-items: start;
+  gap: var(--zenbu-sys-space-icon-label);
+  border: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft);
+  border-radius: 0;
+  background: var(--mjj-jrpg-panel);
+  color: var(--mjj-jrpg-primary);
+  text-align: left;
+  cursor: pointer;
+}
+
+.jrpg-blog-browser [data-blog-list] button:hover,
+.jrpg-blog-browser [data-blog-list] button[aria-pressed="true"] {
+  border-color: var(--mjj-jrpg-accent);
+  background: var(--mjj-jrpg-surface-raised);
+}
+
+.jrpg-blog-browser [data-blog-list] small {
+  color: var(--mjj-jrpg-text-muted);
+  font-size: var(--zenbu-sys-font-size-xs);
+}
+
+.jrpg-blog-browser [data-blog-content] {
+  color: var(--mjj-jrpg-text);
+  line-height: var(--zenbu-sys-line-height-lg);
+}
+
+.jrpg-blog-browser [data-blog-content] :where(h1, h2, h3, h4) {
+  color: var(--mjj-jrpg-primary);
+}
+
+.jrpg-blog-browser [data-blog-content] a {
+  color: var(--mjj-jrpg-primary);
+  text-decoration-color: var(--mjj-jrpg-accent);
+  text-underline-offset: var(--zenbu-sys-space-icon-label);
+}
+
+.jrpg-blog-browser [data-blog-content] :where(img, video) {
+  display: block;
+  max-width: 100%;
+  height: auto;
+  margin-inline: auto;
+}
+
+.jrpg-blog-browser [data-blog-content] :where(pre, blockquote) {
+  max-width: 100%;
+  padding: var(--zenbu-sys-padding-md);
+  overflow: auto;
+  border-left: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-accent);
+  background: var(--mjj-jrpg-panel);
+}
+
+.jrpg-blog-browser [data-blog-content] code {
+  color: var(--mjj-jrpg-success);
+  white-space: pre-wrap;
+  overflow-wrap: anywhere;
+}
+
 mjj-jrpg-composer {
   grid-area: composer;
   display: block;
@@ -948,6 +1046,15 @@
     width: 100%;
   }
 
+  .jrpg-blog-browser {
+    grid-template-columns: 1fr;
+    overflow: auto;
+  }
+
+  .jrpg-blog-browser > aside {
+    max-height: 16rem;
+  }
+
   mjj-jrpg-menu ul {
     grid-template-columns: 1fr;
   }
--- a/mrjunejune/src/jrpg/jrpg.js	Wed Aug 05 20:38:32 2026 -0700
+++ b/mrjunejune/src/jrpg/jrpg.js	Thu Aug 06 04:08:45 2026 -0700
@@ -435,14 +435,62 @@
   }
 }
 
+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]) {
+      if (attribute.name.toLowerCase().startsWith("on")) {
+        element.removeAttribute(attribute.name);
+      }
+    }
+  }
+  for (const anchor of main.querySelectorAll("a")) {
+    const target = new URL(
+      anchor.getAttribute("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;
+}
+
 class MjjJrpgPreview extends HTMLElement {
   connectedCallback() {
     this._onClick = event => {
-      if (
-        event.target.closest("[data-zen-trigger]") &&
-        this.dataset.selection === "resume"
-      ) {
-        void this.loadResume();
+      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();
+        }
+        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);
       }
     };
     this.addEventListener("click", this._onClick);
@@ -462,13 +510,44 @@
     this.querySelector("[data-preview-type]").textContent = preview.type;
     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 isResume = selection === "resume";
+    const isBlog = selection === "blog";
+    resumeDossier.hidden = !isResume;
+    resumeDownload.hidden = !isResume;
+    blogBrowser.hidden = !isBlog;
+    this.querySelector("[data-dialog-copy]").hidden = isResume || isBlog;
+    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();
+    }
+  }
+
+  renderShowcase(works, latestBlogs = false) {
     const showcase = this.querySelector("[data-work-showcase]");
-    showcase.replaceChildren(...preview.works.map(work => {
+    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) {
+        link.dataset.latestBlog = "";
+        link.dataset.blogUrl = work.url;
+      }
       if (new URL(work.url, window.location.href).origin !== window.location.origin) {
         link.target = "_blank";
         link.rel = "noreferrer";
@@ -479,15 +558,6 @@
       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() {
@@ -508,25 +578,7 @@
       );
       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";
-        }
-      }
+      sanitizeFetchedMain(resume);
       content.replaceChildren(...resume.childNodes);
       status.hidden = true;
       this._resumeLoaded = true;
@@ -536,6 +588,127 @@
       this._resumeLoading = false;
     }
   }
+
+  async loadBlogs() {
+    if (this._blogs) {
+      if (this.dataset.selection === "blog") {
+        this.renderShowcase(this._blogs.slice(0, 4), 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), 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}`;
+    }
+  }
 }
 
 class MjjJrpgShell extends HTMLElement {}
--- a/mrjunejune/src/sw.js	Wed Aug 05 20:38:32 2026 -0700
+++ b/mrjunejune/src/sw.js	Thu Aug 06 04:08:45 2026 -0700
@@ -1,5 +1,5 @@
 // Root-scoped Service Worker for MrJuneJune PWA
-const CACHE_VERSION = 'v13-turn-resume';
+const CACHE_VERSION = 'v14-blog-browser';
 const CACHE_NAME = `mrjunejune-${CACHE_VERSION}`;
 
 // Files to cache immediately on install
--- a/mrjunejune/test/snapshots/jrpg.snapshot	Wed Aug 05 20:38:32 2026 -0700
+++ b/mrjunejune/test/snapshots/jrpg.snapshot	Thu Aug 06 04:08:45 2026 -0700
@@ -136,7 +136,7 @@
                       <zen-icon name="search"></zen-icon>
                     </button>
                   </zen-button>
-                  <dialog class="jrpg-resume-dialog" aria-labelledby="jrpg-dialog-title">
+                  <dialog class="jrpg-detail-dialog" aria-labelledby="jrpg-dialog-title">
                     <div class="jrpg-dialog-heading">
                       <p>DESTINATION DATA</p>
                       <zen-button appearance="plain" size="md">
@@ -155,6 +155,23 @@
                       <p data-resume-status>Loading resume dossier...</p>
                       <div data-resume-content></div>
                     </section>
+                    <section
+                      class="jrpg-blog-browser"
+                      data-blog-browser
+                      aria-label="Blog archive"
+                      hidden
+                    >
+                      <aside>
+                        <h3>All blogs</h3>
+                        <ol data-blog-list>
+                          <li>Loading blog archive...</li>
+                        </ol>
+                      </aside>
+                      <article>
+                        <p data-blog-status>Select a blog to inspect.</p>
+                        <div data-blog-content></div>
+                      </article>
+                    </section>
                     <div class="jrpg-dialog-actions">
                       <zen-button appearance="plain" size="lg">
                         <a href="/resume" data-preview-link>
--- a/mrjunejune/test/theme_and_webp_test.js	Wed Aug 05 20:38:32 2026 -0700
+++ b/mrjunejune/test/theme_and_webp_test.js	Thu Aug 06 04:08:45 2026 -0700
@@ -734,6 +734,58 @@
   await page.getByRole('button', {
     name: 'Close destination details',
   }).click();
+  await page.locator('.jrpg-preview-dialog dialog').waitFor({ state: 'hidden' });
+
+  await page.getByRole('button', { name: 'Blogs', exact: true }).click();
+  await page.waitForFunction(() =>
+    document.querySelectorAll('[data-latest-blog]').length === 4
+  );
+  assert.match(
+    await page.locator('[data-work-showcase]').textContent(),
+    /Websocket Demystified/,
+  );
+  assert.match(
+    await page.locator('[data-work-showcase]').textContent(),
+    /Creating Network Library in C/,
+  );
+  await page.locator('[data-latest-blog]').first().click();
+  await page.locator('[data-blog-content] h1').waitFor();
+  assert.equal(
+    await page.locator('[data-blog-content] h1').textContent(),
+    'WebSocket Demystified',
+  );
+  assert.equal(
+    await page.locator('[data-preview-link]').getAttribute('href'),
+    '/blog/websocket-demystified',
+  );
+  assert.equal(
+    await page.locator('[data-blog-content]').evaluate(
+      article => getComputedStyle(article).fontFamily,
+    ),
+    '"Pixel Mplus"',
+  );
+  await page.getByRole('button', {
+    name: 'Close destination details',
+  }).click();
+  await page.locator('.jrpg-preview-dialog dialog').waitFor({ state: 'hidden' });
+
+  await page.getByRole('button', { name: /Inspect/ }).click();
+  await page.waitForFunction(() =>
+    document.querySelectorAll('[data-blog-entry]').length === 9
+  );
+  assert.equal(await page.locator('[data-blog-entry]').count(), 9);
+  await page.locator('[data-blog-entry]').nth(1).click();
+  await page.waitForFunction(() =>
+    document.querySelector('[data-blog-content] h1')?.textContent ===
+      'Creating Network Library in C'
+  );
+  assert.equal(
+    await page.locator('[data-preview-link]').getAttribute('href'),
+    '/blog/my-seobeo-journey',
+  );
+  await page.getByRole('button', {
+    name: 'Close destination details',
+  }).click();
 
   await page.evaluate(() => {
     window.__jrpgStreamEvents = [];
@@ -1209,7 +1261,7 @@
     for (const source of [home, dogGame, manifest]) {
       assert.doesNotMatch(source, /\.png(?:["')]|$)/i);
     }
-    assert.match(serviceWorker, /v13-turn-resume/);
+    assert.match(serviceWorker, /v14-blog-browser/);
     assert.match(
       await (
         await fetch(`${baseUrl}/public/pwa-register.js`)