changeset 259:667156fcd3e3

Add dev-only cyberpunk JRPG page
author MrJuneJune <me@mrjunejune.com>
date Wed, 05 Aug 2026 09:19:41 -0700
parents 60a876c4587a
children 1f9877b637e9
files .claude/skills/zenbu-personal-site/SKILL.md design_system/THEMES.md design_system/src/styles/reference.css design_system/src/styles/semantic.css design_system/src/styles/themes.css design_system/test/catalog_test.js design_system/wiki/TOKENS_AND_SIZING.md mrjunejune/BUILD mrjunejune/README.md mrjunejune/main.c mrjunejune/src/jrpg/index.html mrjunejune/src/jrpg/jrpg.css mrjunejune/src/jrpg/jrpg.js mrjunejune/src/public/fonts/pixel-mplus-12-regular.ttf mrjunejune/src/public/jrpg/bar-ink.webp mrjunejune/src/public/jrpg/bar-paper.webp mrjunejune/src/public/jrpg/shiba-default.webp mrjunejune/src/public/jrpg/shiba-sad.webp mrjunejune/src/public/jrpg/shiba-thinking.webp mrjunejune/src/sw.js mrjunejune/test/BUILD mrjunejune/test/integration_test.c mrjunejune/test/production_bundle_exclusion_test.sh mrjunejune/test/theme_and_webp_test.js
diffstat 24 files changed, 1585 insertions(+), 14 deletions(-) [+]
line wrap: on
line diff
--- a/.claude/skills/zenbu-personal-site/SKILL.md	Wed Aug 05 05:25:40 2026 -0700
+++ b/.claude/skills/zenbu-personal-site/SKILL.md	Wed Aug 05 09:19:41 2026 -0700
@@ -29,6 +29,10 @@
 - `mrjunejune/test/`: integration tests and snapshots.
 - `mrjunejune/BUILD`: Bazel build, bundle, asset movement, and test-visible filegroups.
 - `mrjunejune/.config` and `.config.development`: server configuration. Do not commit secrets.
+- Experimental pages use the `MRJUNEJUNE_ENABLE_DEV_PAGES` compile-time gate.
+  Their static files belong in `src_files_development`; production
+  `src_files`, `mrjunejune_server`, and `mrjunejune_server_bundle` must exclude
+  them.
 
 ## How rendering works
 
@@ -70,10 +74,10 @@
 bazel test //mrjunejune/test:integration_test
 ```
 
-Use the debug server when investigating routing or networking:
+Use the development server for experimental pages and debug networking:
 
 ```bash
-bazel build //mrjunejune:mrjunejune_server_debug -c dbg
+bazel run //mrjunejune:mrjunejune_server_dev -c dbg
 ```
 
 ## Production-minded checklist
@@ -86,7 +90,8 @@
 - For binary responses, set `content-length` and avoid string-only operations on body bytes.
 - If a route is part of the public website, add or update snapshot coverage in `mrjunejune/test`.
 - Keep PWA changes consistent across `manifest.json`, `sw.js`, icons, and `PWA_SETUP.md`.
-- Site themes use `data-zen-theme` with Auto, Paper, Ink, and Playful states.
+- Site themes use `data-zen-theme` with Auto, Paper, Ink, Playful, and
+  Cyberpunk states.
   Preserve the More Sugar font aliases and keep theme-specific values in shared
   design-system tokens rather than branching inside components.
 - Reuse `seobeo`, `dowa`, `deita`, `s3`, and `markdown_converter` instead of adding parallel implementations.
--- a/design_system/THEMES.md	Wed Aug 05 05:25:40 2026 -0700
+++ b/design_system/THEMES.md	Wed Aug 05 09:19:41 2026 -0700
@@ -12,6 +12,8 @@
 - `paper`: warm neutral surfaces, restrained natural accents, editorial type;
 - `ink`: dark charcoal surfaces with paper-like text and softer status colors;
 - `playful`: brighter clay, persimmon, violet, and rounded shapes;
+- `cyberpunk`: midnight surfaces with neon cyan, magenta, violet, yellow, and
+  mint roles;
 - no attribute: follow the operating-system light/dark preference.
 
 Applications can provide their own fonts without changing component CSS:
--- a/design_system/src/styles/reference.css	Wed Aug 05 05:25:40 2026 -0700
+++ b/design_system/src/styles/reference.css	Wed Aug 05 09:19:41 2026 -0700
@@ -58,6 +58,17 @@
   --zenbu-ref-color-blue-600: #48647f;
   --zenbu-ref-color-blue-900: #303c49;
   --zenbu-ref-color-blue-950: #1c252e;
+  --zenbu-ref-color-midnight-700: #211049;
+  --zenbu-ref-color-midnight-800: #140b2d;
+  --zenbu-ref-color-midnight-900: #0b0718;
+  --zenbu-ref-color-midnight-950: #05040d;
+  --zenbu-ref-color-electric-cyan: #35e8ff;
+  --zenbu-ref-color-electric-magenta: #ff3cac;
+  --zenbu-ref-color-electric-violet: #8c52ff;
+  --zenbu-ref-color-electric-yellow: #f7f63d;
+  --zenbu-ref-color-electric-mint: #4dffc3;
+  --zenbu-ref-color-electric-ice: #f5f6ff;
+  --zenbu-ref-color-electric-slate: #a9afd0;
   --zenbu-ref-color-accent: #ffb000;
   --zenbu-ref-color-accent-hover: #ffca4a;
   --zenbu-ref-color-accent-pressed: #9e8124;
--- a/design_system/src/styles/semantic.css	Wed Aug 05 05:25:40 2026 -0700
+++ b/design_system/src/styles/semantic.css	Wed Aug 05 09:19:41 2026 -0700
@@ -207,6 +207,7 @@
   --zenbu-sys-color-data-3: var(--zenbu-ref-color-success-600);
   --zenbu-sys-color-data-4: var(--zenbu-ref-color-violet-500);
   --zenbu-sys-color-data-5: var(--zenbu-ref-color-amber-600);
+
 }
 
 @media (forced-colors: active) {
--- a/design_system/src/styles/themes.css	Wed Aug 05 05:25:40 2026 -0700
+++ b/design_system/src/styles/themes.css	Wed Aug 05 09:19:41 2026 -0700
@@ -76,6 +76,30 @@
   --zen-radius-lg: 1.5rem;
 }
 
+:root[data-zen-theme="cyberpunk"] {
+  color-scheme: dark;
+  --zen-color-canvas: var(--zenbu-ref-color-midnight-950);
+  --zen-color-surface: var(--zenbu-ref-color-midnight-900);
+  --zen-color-surface-raised: var(--zenbu-ref-color-midnight-800);
+  --zen-color-surface-muted: var(--zenbu-ref-color-midnight-700);
+  --zen-color-surface-sunken: var(--zenbu-ref-color-midnight-950);
+  --zen-color-text: var(--zenbu-ref-color-electric-ice);
+  --zen-color-text-muted: var(--zenbu-ref-color-electric-slate);
+  --zen-color-text-subtle: var(--zenbu-ref-color-electric-slate);
+  --zen-color-border: var(--zenbu-ref-color-electric-cyan);
+  --zen-color-border-muted: var(--zenbu-ref-color-midnight-700);
+  --zen-color-accent: var(--zenbu-ref-color-electric-cyan);
+  --zen-color-accent-hover: var(--zenbu-ref-color-electric-magenta);
+  --zen-color-accent-muted: var(--zenbu-ref-color-midnight-700);
+  --zen-color-info: var(--zenbu-ref-color-electric-violet);
+  --zen-color-success: var(--zenbu-ref-color-electric-mint);
+  --zen-color-warning: var(--zenbu-ref-color-electric-yellow);
+  --zen-color-danger: var(--zenbu-ref-color-electric-magenta);
+  --zen-color-focus: var(--zenbu-ref-color-electric-yellow);
+  --zen-color-on-accent: var(--zenbu-ref-color-midnight-950);
+  --zen-color-text-inverse: var(--zenbu-ref-color-midnight-950);
+}
+
 :root[data-zen-theme="paper"] {
   --zenbu-sys-font-family-ui: var(
     --zenbu-font-editorial,
@@ -172,6 +196,63 @@
   --zenbu-sys-radius-container: var(--zenbu-ref-radius-5);
 }
 
+:root[data-zen-theme="cyberpunk"] {
+  --zenbu-sys-color-surface-page: var(--zenbu-ref-color-midnight-950);
+  --zenbu-sys-color-surface-canvas: var(--zenbu-ref-color-midnight-950);
+  --zenbu-sys-color-surface-default: var(--zenbu-ref-color-midnight-900);
+  --zenbu-sys-color-surface-subtle: var(--zenbu-ref-color-midnight-700);
+  --zenbu-sys-color-surface-raised: var(--zenbu-ref-color-midnight-800);
+  --zenbu-sys-color-surface-overlay: var(--zenbu-ref-color-midnight-800);
+  --zenbu-sys-color-surface-sunken: var(--zenbu-ref-color-midnight-950);
+  --zenbu-sys-color-text-primary: var(--zenbu-ref-color-electric-ice);
+  --zenbu-sys-color-text-secondary: var(--zenbu-ref-color-electric-slate);
+  --zenbu-sys-color-text-tertiary: var(--zenbu-ref-color-electric-violet);
+  --zenbu-sys-color-text-placeholder: var(--zenbu-ref-color-electric-slate);
+  --zenbu-sys-color-text-disabled: var(--zenbu-ref-color-midnight-700);
+  --zenbu-sys-color-text-inverse: var(--zenbu-ref-color-midnight-950);
+  --zenbu-sys-color-text-link: var(--zenbu-ref-color-electric-cyan);
+  --zenbu-sys-color-link-decoration: var(--zenbu-ref-color-electric-magenta);
+  --zenbu-sys-color-icon-primary: var(--zenbu-ref-color-electric-ice);
+  --zenbu-sys-color-icon-secondary: var(--zenbu-ref-color-electric-cyan);
+  --zenbu-sys-color-border-subtle: var(--zenbu-ref-color-midnight-700);
+  --zenbu-sys-color-border-default: var(--zenbu-ref-color-electric-violet);
+  --zenbu-sys-color-border-strong: var(--zenbu-ref-color-electric-cyan);
+  --zenbu-sys-color-action-primary-background: var(--zenbu-ref-color-electric-cyan);
+  --zenbu-sys-color-action-primary-background-hover: var(--zenbu-ref-color-electric-magenta);
+  --zenbu-sys-color-action-primary-background-pressed: var(--zenbu-ref-color-electric-violet);
+  --zenbu-sys-color-action-primary-foreground: var(--zenbu-ref-color-midnight-950);
+  --zenbu-sys-color-action-primary-border: var(--zenbu-ref-color-electric-cyan);
+  --zenbu-sys-color-action-neutral-background: var(--zenbu-ref-color-midnight-900);
+  --zenbu-sys-color-action-neutral-background-hover: var(--zenbu-ref-color-midnight-800);
+  --zenbu-sys-color-action-neutral-background-pressed: var(--zenbu-ref-color-midnight-950);
+  --zenbu-sys-color-action-neutral-foreground: var(--zenbu-ref-color-electric-ice);
+  --zenbu-sys-color-action-neutral-border: var(--zenbu-ref-color-electric-violet);
+  --zenbu-sys-color-action-danger-background: var(--zenbu-ref-color-electric-magenta);
+  --zenbu-sys-color-action-danger-background-hover: var(--zenbu-ref-color-electric-violet);
+  --zenbu-sys-color-action-danger-background-pressed: var(--zenbu-ref-color-midnight-700);
+  --zenbu-sys-color-action-danger-foreground: var(--zenbu-ref-color-midnight-950);
+  --zenbu-sys-color-action-danger-border: var(--zenbu-ref-color-electric-magenta);
+  --zenbu-sys-color-focus-inner: var(--zenbu-ref-color-midnight-950);
+  --zenbu-sys-color-focus-outer: var(--zenbu-ref-color-electric-yellow);
+  --zenbu-sys-color-selection-background: var(--zenbu-ref-color-electric-magenta);
+  --zenbu-sys-color-selection-foreground: var(--zenbu-ref-color-midnight-950);
+  --zenbu-sys-color-info-background: var(--zenbu-ref-color-midnight-900);
+  --zenbu-sys-color-info-foreground: var(--zenbu-ref-color-electric-violet);
+  --zenbu-sys-color-info-border: var(--zenbu-ref-color-electric-violet);
+  --zenbu-sys-color-success-background: var(--zenbu-ref-color-midnight-900);
+  --zenbu-sys-color-success-foreground: var(--zenbu-ref-color-electric-mint);
+  --zenbu-sys-color-success-border: var(--zenbu-ref-color-electric-mint);
+  --zenbu-sys-color-warning-background: var(--zenbu-ref-color-midnight-900);
+  --zenbu-sys-color-warning-foreground: var(--zenbu-ref-color-electric-yellow);
+  --zenbu-sys-color-warning-border: var(--zenbu-ref-color-electric-yellow);
+  --zenbu-sys-color-danger-background: var(--zenbu-ref-color-midnight-900);
+  --zenbu-sys-color-danger-foreground: var(--zenbu-ref-color-electric-magenta);
+  --zenbu-sys-color-danger-border: var(--zenbu-ref-color-electric-magenta);
+  --zenbu-sys-color-code-surface: var(--zenbu-ref-color-midnight-950);
+  --zenbu-sys-color-code-text: var(--zenbu-ref-color-electric-mint);
+  --zenbu-sys-color-gridline: var(--zenbu-ref-color-midnight-700);
+}
+
 @media (prefers-color-scheme: dark) {
   :root:not([data-zen-theme]) {
     --zenbu-sys-color-surface-page: var(--zenbu-ref-color-neutral-950);
--- a/design_system/test/catalog_test.js	Wed Aug 05 05:25:40 2026 -0700
+++ b/design_system/test/catalog_test.js	Wed Aug 05 09:19:41 2026 -0700
@@ -805,7 +805,7 @@
         { length: 10 },
         (_, index) => value(`data-${index + 1}`),
       );
-      const themes = ['paper', 'ink', 'playful'].map(theme => {
+      const themes = ['paper', 'ink', 'playful', 'cyberpunk'].map(theme => {
         root.dataset.zenTheme = theme;
         const channels = value => value.match(/\d+(?:\.\d+)?/g)
           .slice(0, 3)
@@ -871,7 +871,7 @@
     assert.notDeepEqual(colorTokenState.data, colorTokenState.darkData);
     assert.equal(
       new Set(colorTokenState.themes.map(theme => theme.canvas)).size,
-      3,
+      4,
     );
     assert.notEqual(
       colorTokenState.themes.find(theme => theme.name === 'paper').radius,
--- a/design_system/wiki/TOKENS_AND_SIZING.md	Wed Aug 05 05:25:40 2026 -0700
+++ b/design_system/wiki/TOKENS_AND_SIZING.md	Wed Aug 05 09:19:41 2026 -0700
@@ -12,6 +12,11 @@
 that fall back to semantic roles. Reference colors are restricted to token and
 theme definitions.
 
+Themes remap the standard semantic roles rather than creating parallel
+application palettes. Paper, Ink, Playful, and Cyberpunk all provide the same
+surface, text, border, action, status, and focus contracts, so components remain
+theme-agnostic.
+
 ## Five-step scale
 
 Controls, text, icons, gaps, and padding use:
--- a/mrjunejune/BUILD	Wed Aug 05 05:25:40 2026 -0700
+++ b/mrjunejune/BUILD	Wed Aug 05 09:19:41 2026 -0700
@@ -164,19 +164,39 @@
 
 filegroup(
   name = "public_fonts_files",
-  srcs = glob(["src/public/fonts/*"]),
+  srcs = glob(
+    ["src/public/fonts/*"],
+    exclude = ["src/public/fonts/pixel-mplus-12-regular.ttf"],
+  ),
   visibility = ["//visibility:public"],
 )
-    
+
 filegroup(
   name = "src_files",
   srcs = glob(
     ["src/**"],
-    exclude = ["src/**/*.png"],
+    exclude = [
+      "src/**/*.png",
+      "src/jrpg/**",
+      "src/public/fonts/pixel-mplus-12-regular.ttf",
+      "src/public/jrpg/**",
+    ],
   ) + [":react_pages", ":shared_js_non_public", ":shared_js_file", ":rich_editor_js", ":design_system_components", ":design_system_styles", ":icons", ":generated_public_webp", ":hlsjs_runtime"],
   visibility = ["//mrjunejune/test:__pkg__"],
 )
 
+filegroup(
+  name = "src_files_development",
+  srcs = [
+    ":src_files",
+    "src/public/fonts/pixel-mplus-12-regular.ttf",
+  ] + glob([
+    "src/jrpg/**",
+    "src/public/jrpg/**",
+  ]),
+  visibility = ["//mrjunejune/test:__pkg__"],
+)
+
 js_library(
   name = "hls_player_js",
   srcs = ["src/tools/hls_player/hls-player.js"],
@@ -237,14 +257,23 @@
     "//deita:deita",
     ":latex_renderer",
   ],
-  copts = ["-D_GNU_SOURCE"],
+  copts = [
+    "-D_GNU_SOURCE",
+    "-DMRJUNEJUNE_ENABLE_DEV_PAGES",
+  ],
   linkopts = ["-lpthread"],
   data = [
-    ":src_files",
+    ":src_files_development",
     ":config_file",
     ":tectonic_runtime_data",
     "//:env_file",
   ],
+  visibility = ["//mrjunejune/test:__pkg__"],
+)
+
+alias(
+  name = "mrjunejune_server_dev",
+  actual = ":mrjunejune_server_debug",
 )
 
 filegroup(
@@ -274,6 +303,7 @@
 bundle(
   name = "mrjunejune_server_bundle",
   binary = ":mrjunejune_server",
+  visibility = ["//mrjunejune/test:__pkg__"],
 )
 
 bundle(
@@ -281,6 +311,11 @@
   binary = ":mrjunejune_server_debug",
 )
 
+alias(
+  name = "mrjunejune_server_dev_bundle",
+  actual = ":mrjunejune_server_debug_bundle",
+)
+
 sh_binary(
   name = "generate_resume_pdf",
   srcs = ["generate_resume_pdf.sh"],
--- a/mrjunejune/README.md	Wed Aug 05 05:25:40 2026 -0700
+++ b/mrjunejune/README.md	Wed Aug 05 09:19:41 2026 -0700
@@ -2,6 +2,23 @@
 
 My personal website where I post my blogs / about me and resume.
 
+## Development-only pages
+
+Experimental pages are compiled and packaged only by the development target:
+
+```bash
+bazel run //mrjunejune:mrjunejune_server_dev -c dbg
+```
+
+The production server and the bundle used by `deploy.sh` omit their route
+registrations and static assets:
+
+```bash
+bazel build -c opt //mrjunejune:mrjunejune_server_bundle
+```
+
+`/jrpg` is currently development-only.
+
 ## WebP assets
 
 Raster source images live under `mrjunejune/assets/png/` and `assets/png/`.
--- a/mrjunejune/main.c	Wed Aug 05 05:25:40 2026 -0700
+++ b/mrjunejune/main.c	Wed Aug 05 09:19:41 2026 -0700
@@ -831,6 +831,17 @@
   return resp;
 }
 
+#if defined(MRJUNEJUNE_ENABLE_DEV_PAGES)
+Seobeo_Request_Entry *GetJrpg(Seobeo_Request_Entry *req, Dowa_Arena *arena)
+{
+  Seobeo_Request_Entry *resp = NULL;
+  char *final_body = Dowa_Arena_Allocate(arena, 50 * 1024);
+  Seobeo_Render_Html_FilePath(final_body, "/jrpg/index.html", arena);
+  Dowa_HashMap_Push_Arena(resp, "body", final_body, arena);
+  return resp;
+}
+#endif
+
 Seobeo_Request_Entry *GetNotesLogin(Seobeo_Request_Entry *req, Dowa_Arena *arena)
 {
   Seobeo_Request_Entry *resp = NULL;
@@ -867,6 +878,9 @@
 CREATE_REDIRECT_HANDLER(HlsPlayer, "/tools/hls_player")
 CREATE_REDIRECT_HANDLER(LatexEditor, "/tools/latex_editor")
 CREATE_REDIRECT_HANDLER(Talk, "/talk")
+#if defined(MRJUNEJUNE_ENABLE_DEV_PAGES)
+CREATE_REDIRECT_HANDLER(Jrpg, "/jrpg")
+#endif
 CREATE_REDIRECT_HANDLER(Editor, "/editor")
 
 // S3 Upload URL API
@@ -2053,6 +2067,12 @@
   Seobeo_Router_Register("GET", "/talk", GetTalk);
   Seobeo_Router_Register("GET", "/talk/index.html", GetRedirectTalk);
 
+#if defined(MRJUNEJUNE_ENABLE_DEV_PAGES)
+  // -- Development pages --/
+  Seobeo_Router_Register("GET", "/jrpg", GetJrpg);
+  Seobeo_Router_Register("GET", "/jrpg/index.html", GetRedirectJrpg);
+#endif
+
   // -- Notes --/
   Seobeo_Router_Register("GET", "/notes", GetNotes);
   Seobeo_Router_Register("GET", "/notes/", GetNotes);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/src/jrpg/index.html	Wed Aug 05 09:19:41 2026 -0700
@@ -0,0 +1,166 @@
+<!doctype html>
+<html lang="en" data-zen-theme="cyberpunk">
+  <head>
+    <title>Shiba Quest | MrJuneJune</title>
+    {{/parts/base_head.html}}
+    <link rel="preload" href="/public/jrpg/bar-ink.webp" as="image">
+    <link rel="stylesheet" href="/jrpg/jrpg.css">
+    <script type="module" src="/jrpg/jrpg.js"></script>
+  </head>
+  <body class="jrpg-page">
+    <main class="jrpg-main">
+      <mjj-jrpg-shell class="jrpg-shell">
+        <div class="jrpg-workspace">
+          <section class="jrpg-scene" aria-label="Shiba Quest conversation">
+            <mjj-jrpg-character state="default">
+              <img data-character alt="">
+            </mjj-jrpg-character>
+
+            <mjj-jrpg-chat aria-labelledby="conversation-title">
+              <div class="jrpg-panel-heading">
+                <span aria-hidden="true">QUEST 01</span>
+                <h1 id="conversation-title">Conversations</h1>
+              </div>
+              <zen-message-scroller>
+                <div
+                  class="jrpg-message-viewport"
+                  data-zen-viewport
+                  role="log"
+                  aria-live="polite"
+                  aria-relevant="additions"
+                >
+                  <ol class="jrpg-messages" data-messages>
+                    <li class="jrpg-message" data-speaker="Epi">
+                      <strong>Epi</strong>
+                      <p>Welcome, traveler. Pick a destination or tell me what you are looking for.</p>
+                    </li>
+                    <li class="jrpg-message" data-speaker="System">
+                      <strong>System</strong>
+                      <p>The Shiba terminal is ready.</p>
+                    </li>
+                  </ol>
+                </div>
+              </zen-message-scroller>
+              <p class="jrpg-next" aria-hidden="true">
+                NEXT <zen-icon name="chevron-right" size="0.9em"></zen-icon>
+              </p>
+            </mjj-jrpg-chat>
+          </section>
+
+          <section class="jrpg-utility" aria-label="Selected destination">
+            <div class="jrpg-brand">
+              <header>
+                <h1>
+                  <a href="/" data-zen-link-effect="none">MrJuneJune</a>
+                </h1>
+              </header>
+              <p>SHIBA QUEST TERMINAL</p>
+            </div>
+
+            <mjj-jrpg-preview data-selection="resume">
+              <article class="jrpg-preview-panel" aria-live="polite">
+                <p class="jrpg-preview-kicker" data-preview-kicker>CHARACTER RECORD</p>
+                <zen-heading size="xl">
+                  <h2 data-preview-title>Resume</h2>
+                </zen-heading>
+                <p data-preview-copy>Experience, projects, and the systems I have helped build.</p>
+                <dl class="jrpg-preview-stats">
+                  <div>
+                    <dt>TYPE</dt>
+                    <dd data-preview-type>Profile</dd>
+                  </div>
+                  <div>
+                    <dt>STATUS</dt>
+                    <dd>Available</dd>
+                  </div>
+                </dl>
+
+                <zen-dialog class="jrpg-preview-dialog">
+                  <zen-button appearance="plain" size="md">
+                    <button type="button" data-zen-trigger>
+                      Inspect
+                      <zen-icon name="search"></zen-icon>
+                    </button>
+                  </zen-button>
+                  <dialog aria-labelledby="jrpg-dialog-title">
+                    <div class="jrpg-dialog-heading">
+                      <p>DESTINATION DATA</p>
+                      <zen-button appearance="plain" size="md">
+                        <button type="button" data-zen-close aria-label="Close destination details">
+                          <zen-icon name="close"></zen-icon>
+                        </button>
+                      </zen-button>
+                    </div>
+                    <h2 id="jrpg-dialog-title" data-dialog-title>Resume</h2>
+                    <p data-dialog-copy>Experience, projects, and the systems I have helped build.</p>
+                    <zen-button appearance="plain" size="lg">
+                      <a href="/resume" data-preview-link>
+                        Open destination
+                        <zen-icon name="arrow-right"></zen-icon>
+                      </a>
+                    </zen-button>
+                  </dialog>
+                </zen-dialog>
+              </article>
+            </mjj-jrpg-preview>
+          </section>
+
+          <mjj-jrpg-composer>
+            <form class="jrpg-composer" data-composer>
+              <zen-field appearance="plain" size="lg">
+                <label for="jrpg-message">Message</label>
+                <textarea
+                  id="jrpg-message"
+                  name="message"
+                  rows="3"
+                  maxlength="500"
+                  placeholder="Ask Epi about this site..."
+                  required
+                ></textarea>
+              </zen-field>
+              <zen-button appearance="plain" size="lg">
+                <button type="submit">
+                  Send
+                  <zen-icon name="arrow-right"></zen-icon>
+                </button>
+              </zen-button>
+              <p class="jrpg-composer-hint">Enter to send / Shift + Enter for a new line</p>
+            </form>
+          </mjj-jrpg-composer>
+
+          <mjj-jrpg-menu>
+            <nav aria-label="Shiba Quest destinations">
+              <p class="jrpg-menu-label">Menu</p>
+              <ul>
+                <li>
+                  <zen-button appearance="plain" size="lg">
+                    <button type="button" data-preview="resume" aria-pressed="true">
+                      <zen-icon name="chevron-right"></zen-icon>
+                      Resume
+                    </button>
+                  </zen-button>
+                </li>
+                <li>
+                  <zen-button appearance="plain" size="lg">
+                    <button type="button" data-preview="tools" aria-pressed="false">
+                      <zen-icon name="chevron-right"></zen-icon>
+                      Tools
+                    </button>
+                  </zen-button>
+                </li>
+                <li>
+                  <zen-button appearance="plain" size="lg">
+                    <button type="button" data-preview="blog" aria-pressed="false">
+                      <zen-icon name="chevron-right"></zen-icon>
+                      Blogs
+                    </button>
+                  </zen-button>
+                </li>
+              </ul>
+            </nav>
+          </mjj-jrpg-menu>
+        </div>
+      </mjj-jrpg-shell>
+    </main>
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/src/jrpg/jrpg.css	Wed Aug 05 09:19:41 2026 -0700
@@ -0,0 +1,685 @@
+@font-face {
+  font-family: "Pixel Mplus";
+  src: url("/public/fonts/pixel-mplus-12-regular.ttf") format("truetype");
+  font-display: block;
+}
+
+:root {
+  --mjj-jrpg-art: url("/public/jrpg/bar-ink.webp");
+  --mjj-jrpg-canvas: var(--zenbu-sys-color-surface-page);
+  --mjj-jrpg-surface: var(--zenbu-sys-color-surface-default);
+  --mjj-jrpg-surface-raised: var(--zenbu-sys-color-surface-raised);
+  --mjj-jrpg-surface-sunken: var(--zenbu-sys-color-surface-sunken);
+  --mjj-jrpg-text: var(--zenbu-sys-color-text-primary);
+  --mjj-jrpg-text-muted: var(--zenbu-sys-color-text-secondary);
+  --mjj-jrpg-frame: var(--zenbu-sys-color-border-strong);
+  --mjj-jrpg-frame-soft: var(--zenbu-sys-color-border-subtle);
+  --mjj-jrpg-primary: var(--zenbu-sys-color-action-primary-background);
+  --mjj-jrpg-primary-hover:
+    var(--zenbu-sys-color-action-primary-background-hover);
+  --mjj-jrpg-primary-foreground:
+    var(--zenbu-sys-color-action-primary-foreground);
+  --mjj-jrpg-accent: var(--zenbu-sys-color-danger-foreground);
+  --mjj-jrpg-highlight: var(--zenbu-sys-color-warning-foreground);
+  --mjj-jrpg-info: var(--zenbu-sys-color-info-foreground);
+  --mjj-jrpg-success: var(--zenbu-sys-color-success-foreground);
+  --mjj-jrpg-focus: var(--zenbu-sys-color-focus-outer);
+  --mjj-jrpg-panel: color-mix(
+    in srgb,
+    var(--mjj-jrpg-surface) 92%,
+    transparent
+  );
+  --mjj-jrpg-panel-strong: color-mix(
+    in srgb,
+    var(--mjj-jrpg-surface-sunken) 92%,
+    transparent
+  );
+}
+
+.jrpg-page {
+  --zenbu-sys-font-family-ui: "Pixel Mplus";
+  --zenbu-sys-font-family-content: "Pixel Mplus";
+  --zenbu-sys-font-family-code: "Pixel Mplus";
+  min-width: 20rem;
+  min-height: 100dvh;
+  overflow: hidden;
+  overflow-anchor: none;
+  background: var(--mjj-jrpg-canvas);
+  color: var(--mjj-jrpg-text);
+  font-family: "Pixel Mplus";
+  font-size: var(--zenbu-sys-font-size-md);
+  line-height: var(--zenbu-sys-line-height-md);
+}
+
+.jrpg-page * {
+  font-family: inherit;
+}
+
+.jrpg-main {
+  box-sizing: border-box;
+  width: 100%;
+  max-width: none;
+  height: 100dvh;
+  min-height: 0;
+  padding: 0;
+}
+
+.jrpg-shell {
+  box-sizing: border-box;
+  display: block;
+  width: 100%;
+  max-width: none;
+  height: 100%;
+  margin: 0;
+  padding: var(--zenbu-sys-padding-sm);
+  border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-frame);
+  background: var(--mjj-jrpg-canvas);
+  box-shadow:
+    inset 0 0 0 var(--zenbu-sys-stroke-width)
+    var(--mjj-jrpg-frame-soft);
+}
+
+.jrpg-workspace {
+  display: grid;
+  grid-template:
+    "scene utility" minmax(0, 1fr)
+    "composer menu" minmax(9rem, auto) /
+    minmax(0, 1.22fr) minmax(19rem, 0.95fr);
+  gap: var(--zenbu-sys-space-group);
+  height: 100%;
+  min-height: 0;
+}
+
+.jrpg-scene,
+.jrpg-utility,
+mjj-jrpg-composer,
+mjj-jrpg-menu,
+.jrpg-preview-panel {
+  box-sizing: border-box;
+  border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-frame);
+  border-radius: 0;
+}
+
+.jrpg-scene {
+  grid-area: scene;
+  display: grid;
+  grid-template-columns: minmax(8rem, 0.38fr) minmax(15rem, 0.92fr);
+  align-items: end;
+  gap: var(--zenbu-sys-space-group);
+  min-height: 0;
+  padding: var(--zenbu-sys-padding-lg);
+  overflow: hidden;
+  background-image:
+    linear-gradient(
+      to top,
+      color-mix(
+        in srgb,
+        var(--mjj-jrpg-canvas) 56%,
+        transparent
+      ),
+      transparent 55%
+    ),
+    var(--mjj-jrpg-art);
+  background-position: center;
+  background-size: cover;
+}
+
+mjj-jrpg-character {
+  display: block;
+  align-self: end;
+  min-width: 0;
+  filter: drop-shadow(
+    var(--zenbu-sys-space-related)
+    var(--zenbu-sys-space-related)
+    0
+    color-mix(in srgb, var(--mjj-jrpg-frame) 35%, transparent)
+  );
+}
+
+mjj-jrpg-character img {
+  display: block;
+  width: 100%;
+  max-height: 25rem;
+  object-fit: contain;
+  object-position: center bottom;
+  border-radius: 0;
+  animation: mjj-jrpg-idle 2.4s steps(2, end) infinite;
+}
+
+mjj-jrpg-character[state="thinking"] img {
+  animation-duration: 0.8s;
+}
+
+@keyframes mjj-jrpg-idle {
+  50% { transform: translateY(calc(var(--zenbu-sys-space-related) * -1)); }
+}
+
+mjj-jrpg-chat {
+  position: relative;
+  display: grid;
+  grid-template-rows: auto minmax(0, 1fr) auto;
+  align-self: stretch;
+  min-height: 0;
+  padding: var(--zenbu-sys-padding-md);
+  border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-frame);
+  background: var(--mjj-jrpg-panel-strong);
+  box-shadow:
+    inset 0 0 0 var(--zenbu-sys-stroke-width)
+    var(--mjj-jrpg-info);
+}
+
+.jrpg-panel-heading {
+  display: flex;
+  align-items: baseline;
+  justify-content: space-between;
+  gap: var(--zenbu-sys-space-group);
+  padding-bottom: var(--zenbu-sys-padding-sm);
+  border-bottom: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft);
+}
+
+.jrpg-panel-heading span,
+.jrpg-preview-kicker,
+.jrpg-menu-label,
+.jrpg-brand > p,
+.jrpg-dialog-heading p,
+.jrpg-preview-stats dt {
+  color: var(--mjj-jrpg-text-muted);
+  font-size: var(--zenbu-sys-font-size-xs);
+  letter-spacing: 0.08em;
+}
+
+.jrpg-panel-heading h1 {
+  margin: 0;
+  color: var(--mjj-jrpg-text);
+  font-size: var(--zenbu-sys-font-size-lg);
+}
+
+zen-message-scroller,
+.jrpg-message-viewport {
+  display: block;
+  min-height: 0;
+  height: 100%;
+}
+
+.jrpg-message-viewport {
+  overflow-y: auto;
+  overflow-anchor: none;
+  overscroll-behavior: contain;
+  scrollbar-color: var(--mjj-jrpg-frame) transparent;
+}
+
+.jrpg-messages {
+  display: grid;
+  gap: var(--zenbu-sys-space-group);
+  margin: 0;
+  padding: var(--zenbu-sys-padding-md) 0;
+  list-style: none;
+}
+
+.jrpg-message {
+  display: grid;
+  grid-template-columns: minmax(4rem, auto) 1fr;
+  gap: var(--zenbu-sys-space-control);
+  padding-left: var(--zenbu-sys-padding-sm);
+  border-left: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-accent);
+}
+
+.jrpg-message[data-speaker="June"] {
+  border-left-color: var(--mjj-jrpg-info);
+}
+
+.jrpg-message strong {
+  color: var(--mjj-jrpg-highlight);
+  font-weight: var(--zenbu-sys-font-weight-bold);
+}
+
+.jrpg-message p {
+  margin: 0;
+  color: var(--mjj-jrpg-text);
+}
+
+.jrpg-next {
+  display: flex;
+  align-items: center;
+  justify-content: end;
+  gap: var(--zenbu-sys-space-icon-label);
+  margin: 0;
+  color: var(--mjj-jrpg-primary);
+  font-size: var(--zenbu-sys-font-size-xs);
+}
+
+.jrpg-utility {
+  grid-area: utility;
+  display: grid;
+  grid-template-rows: auto minmax(0, 1fr);
+  gap: var(--zenbu-sys-space-group);
+  padding: var(--zenbu-sys-padding-md);
+  background: var(--mjj-jrpg-surface);
+}
+
+.jrpg-brand {
+  display: flex;
+  align-items: end;
+  justify-content: space-between;
+  gap: var(--zenbu-sys-space-control);
+  padding: 0 var(--zenbu-sys-padding-sm) var(--zenbu-sys-padding-sm);
+  border-bottom: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-frame);
+}
+
+body.jrpg-page .jrpg-brand header {
+  width: auto;
+  max-width: none;
+  margin: 0;
+  padding: 0;
+  text-align: left;
+}
+
+body.jrpg-page .jrpg-brand header h1 {
+  margin: 0;
+  color: var(--mjj-jrpg-primary);
+  font-size: var(--zenbu-sys-font-size-2xl);
+  letter-spacing: 0;
+  text-shadow:
+    var(--zenbu-sys-space-icon-label)
+    var(--zenbu-sys-space-icon-label)
+    0
+    var(--mjj-jrpg-accent);
+}
+
+.jrpg-brand > p {
+  margin: 0;
+  color: var(--mjj-jrpg-highlight);
+  text-align: right;
+}
+
+mjj-jrpg-preview {
+  display: block;
+  min-height: 0;
+}
+
+.jrpg-preview-panel {
+  display: grid;
+  grid-template-rows: auto auto minmax(0, 1fr) auto auto;
+  gap: var(--zenbu-sys-space-group);
+  height: 100%;
+  padding: var(--zenbu-sys-padding-lg);
+  background:
+    linear-gradient(
+      135deg,
+      var(--mjj-jrpg-surface-raised),
+      var(--mjj-jrpg-surface)
+    );
+  box-shadow:
+    inset 0 0 0 var(--zenbu-sys-stroke-width)
+    var(--mjj-jrpg-accent);
+}
+
+.jrpg-preview-panel h2,
+.jrpg-preview-panel p {
+  margin: 0;
+}
+
+.jrpg-preview-panel h2 {
+  color: var(--mjj-jrpg-primary);
+}
+
+.jrpg-preview-panel > p:not(.jrpg-preview-kicker) {
+  color: var(--mjj-jrpg-text);
+}
+
+.jrpg-preview-stats {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: var(--zenbu-sys-space-control);
+  margin: 0;
+}
+
+.jrpg-preview-stats div {
+  padding: var(--zenbu-sys-padding-sm);
+  border: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft);
+  background: var(--mjj-jrpg-panel);
+}
+
+.jrpg-preview-stats dd {
+  color: var(--mjj-jrpg-success);
+  margin: var(--zenbu-sys-space-icon-label) 0 0;
+}
+
+.jrpg-preview-dialog > zen-button > button,
+.jrpg-preview-dialog dialog zen-button > :where(button, a) {
+  width: 100%;
+  border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-frame);
+  border-radius: 0;
+  background: var(--mjj-jrpg-primary);
+  color: var(--mjj-jrpg-primary-foreground);
+  cursor: pointer;
+}
+
+.jrpg-preview-dialog > zen-button > button:hover,
+.jrpg-preview-dialog dialog zen-button > :where(button, a):hover {
+  border-color: var(--mjj-jrpg-primary-hover);
+  background: var(--mjj-jrpg-primary-hover);
+}
+
+.jrpg-preview-dialog dialog {
+  box-sizing: border-box;
+  width: min(82vw, 46rem);
+  max-width: calc(100% - var(--zenbu-sys-space-content));
+  padding: var(--zenbu-sys-padding-xl);
+  border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-frame);
+  border-radius: 0;
+  background: var(--mjj-jrpg-surface-raised);
+  color: var(--mjj-jrpg-text);
+  box-shadow:
+    inset 0 0 0 var(--zenbu-sys-stroke-width)
+    var(--mjj-jrpg-accent),
+    var(--zenbu-sys-space-control)
+    var(--zenbu-sys-space-control)
+    0
+    var(--mjj-jrpg-accent);
+}
+
+.jrpg-preview-dialog dialog::backdrop {
+  background: color-mix(in srgb, var(--mjj-jrpg-canvas) 84%, transparent);
+}
+
+.jrpg-preview-dialog dialog[open] {
+  animation: mjj-jrpg-dialog-open var(--zenbu-sys-motion-duration-layout)
+    var(--zenbu-sys-motion-ease-enter);
+}
+
+@keyframes mjj-jrpg-dialog-open {
+  from {
+    clip-path: inset(48% 25%);
+    transform: scaleX(0.35);
+  }
+  to {
+    clip-path: inset(0);
+    transform: scaleX(1);
+  }
+}
+
+.jrpg-dialog-heading {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: var(--zenbu-sys-space-group);
+  padding-bottom: var(--zenbu-sys-padding-md);
+  border-bottom: var(--zenbu-sys-stroke-width) solid var(--mjj-jrpg-frame-soft);
+}
+
+.jrpg-dialog-heading p,
+.jrpg-preview-dialog dialog > p {
+  margin: 0;
+}
+
+.jrpg-dialog-heading zen-button {
+  width: auto;
+}
+
+.jrpg-dialog-heading zen-button > button {
+  width: var(--zenbu-control-height);
+  min-width: var(--zenbu-control-height);
+  padding: 0;
+  border-color: var(--mjj-jrpg-accent);
+  background: var(--mjj-jrpg-surface);
+  color: var(--mjj-jrpg-text);
+}
+
+.jrpg-preview-dialog dialog h2 {
+  margin-top: var(--zenbu-sys-space-content);
+}
+
+.jrpg-preview-dialog dialog > p {
+  margin-bottom: var(--zenbu-sys-space-content);
+}
+
+mjj-jrpg-composer {
+  grid-area: composer;
+  display: block;
+  padding: var(--zenbu-sys-padding-md);
+  background: var(--mjj-jrpg-surface);
+}
+
+.jrpg-composer {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) auto;
+  gap: var(--zenbu-sys-space-control);
+}
+
+.jrpg-composer zen-field {
+  display: grid;
+  gap: var(--zenbu-sys-space-icon-label);
+}
+
+.jrpg-composer label,
+.jrpg-composer-hint {
+  grid-column: 1 / -1;
+  color: var(--mjj-jrpg-text-muted);
+  font-size: var(--zenbu-sys-font-size-xs);
+}
+
+.jrpg-composer textarea {
+  box-sizing: border-box;
+  width: 100%;
+  min-height: calc(var(--zenbu-control-height) * 2);
+  resize: vertical;
+  padding:
+    var(--zenbu-control-padding-block)
+    var(--zenbu-control-padding-inline);
+  border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-frame);
+  border-radius: 0;
+  background: var(--mjj-jrpg-surface-sunken);
+  color: var(--mjj-jrpg-text);
+  font: inherit;
+}
+
+.jrpg-composer textarea::placeholder {
+  color: var(--mjj-jrpg-text-muted);
+}
+
+.jrpg-page :where(a, button, textarea):focus-visible {
+  outline: var(--zenbu-sys-focus-width) solid var(--mjj-jrpg-focus);
+  outline-offset: var(--zenbu-sys-focus-offset);
+}
+
+.jrpg-composer zen-button {
+  align-self: end;
+}
+
+.jrpg-composer zen-button > button {
+  border: var(--zenbu-sys-stroke-width-emphasis) solid var(--mjj-jrpg-frame);
+  border-radius: 0;
+  background: var(--mjj-jrpg-primary);
+  color: var(--mjj-jrpg-primary-foreground);
+  cursor: pointer;
+}
+
+.jrpg-composer zen-button > button:hover {
+  border-color: var(--mjj-jrpg-primary-hover);
+  background: var(--mjj-jrpg-primary-hover);
+}
+
+.jrpg-composer button:disabled {
+  opacity: var(--zenbu-sys-opacity-disabled);
+  cursor: not-allowed;
+}
+
+.jrpg-composer-hint {
+  margin: 0;
+}
+
+mjj-jrpg-menu {
+  grid-area: menu;
+  display: block;
+  padding: var(--zenbu-sys-padding-md);
+  background: var(--mjj-jrpg-surface-sunken);
+}
+
+mjj-jrpg-menu nav {
+  display: grid;
+  grid-template-columns: auto 1fr;
+  align-items: center;
+  gap: var(--zenbu-sys-space-group);
+  height: 100%;
+}
+
+.jrpg-menu-label {
+  margin: 0;
+  writing-mode: vertical-rl;
+}
+
+mjj-jrpg-menu ul {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: var(--zenbu-sys-space-control);
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+
+mjj-jrpg-menu zen-button,
+mjj-jrpg-menu button {
+  width: 100%;
+}
+
+mjj-jrpg-menu button {
+  justify-content: start;
+  border: var(--zenbu-sys-stroke-width) solid transparent;
+  border-radius: 0;
+  background: transparent;
+  color: var(--mjj-jrpg-text);
+  cursor: pointer;
+}
+
+mjj-jrpg-menu button:hover {
+  border-color: var(--mjj-jrpg-accent);
+  color: var(--mjj-jrpg-primary-hover);
+}
+
+mjj-jrpg-menu button[aria-pressed="true"] {
+  border-color: var(--mjj-jrpg-frame);
+  background: var(--mjj-jrpg-surface-raised);
+  color: var(--mjj-jrpg-primary);
+}
+
+mjj-jrpg-menu button zen-icon {
+  opacity: 0;
+}
+
+mjj-jrpg-menu button[aria-pressed="true"] zen-icon {
+  opacity: 1;
+}
+
+@media (max-width: 52rem) {
+  .jrpg-page {
+    overflow-y: auto;
+  }
+
+  .jrpg-main {
+    height: auto;
+    min-height: 100dvh;
+    padding: 0;
+  }
+
+  .jrpg-shell {
+    height: auto;
+    min-height: 100dvh;
+  }
+
+  .jrpg-workspace {
+    grid-template:
+      "scene" auto
+      "utility" auto
+      "menu" auto
+      "composer" auto /
+      minmax(0, 1fr);
+    height: auto;
+    min-height: 0;
+  }
+
+  .jrpg-scene {
+    min-height: 34rem;
+  }
+
+  .jrpg-utility {
+    min-height: 27rem;
+  }
+
+  mjj-jrpg-menu nav {
+    grid-template-columns: 1fr;
+  }
+
+  .jrpg-menu-label {
+    writing-mode: initial;
+  }
+
+  mjj-jrpg-menu ul {
+    grid-template-columns: repeat(3, minmax(0, 1fr));
+  }
+}
+
+@media (max-width: 34rem) {
+  .jrpg-main {
+    padding: 0;
+  }
+
+  .jrpg-scene {
+    grid-template-columns: 1fr;
+    grid-template-rows: minmax(12rem, 0.6fr) minmax(20rem, 1fr);
+    min-height: 42rem;
+    padding: var(--zenbu-sys-padding-md);
+  }
+
+  mjj-jrpg-character {
+    justify-self: center;
+    width: min(15rem, 65%);
+  }
+
+  mjj-jrpg-character img {
+    max-height: 15rem;
+  }
+
+  .jrpg-brand {
+    align-items: center;
+  }
+
+  .jrpg-brand > p {
+    max-width: 9rem;
+  }
+
+  .jrpg-composer {
+    grid-template-columns: 1fr;
+  }
+
+  .jrpg-composer zen-button {
+    width: 100%;
+  }
+
+  .jrpg-composer zen-button > button {
+    width: 100%;
+  }
+
+  mjj-jrpg-menu ul {
+    grid-template-columns: 1fr;
+  }
+
+  .jrpg-preview-dialog dialog {
+    width: calc(100% - var(--zenbu-sys-space-group));
+    max-width: none;
+    padding: var(--zenbu-sys-padding-lg);
+  }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  mjj-jrpg-character img,
+  .jrpg-preview-dialog dialog[open] {
+    animation: none;
+  }
+}
+
+@media (forced-colors: active) {
+  .jrpg-scene {
+    background-image: none;
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/src/jrpg/jrpg.js	Wed Aug 05 09:19:41 2026 -0700
@@ -0,0 +1,231 @@
+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",
+  },
+  tools: {
+    copy: "Small, focused utilities for writing, media, and experimentation.",
+    kicker: "ITEM INVENTORY",
+    title: "Tools",
+    type: "Utilities",
+    url: "/tools",
+  },
+  blog: {
+    copy: "Notes from building systems, games, web tools, and curious prototypes.",
+    kicker: "QUEST ARCHIVE",
+    title: "Blogs",
+    type: "Writing",
+    url: "/blog",
+  },
+});
+
+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]");
+  }
+
+  appendMessage(speaker, text) {
+    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;
+    name.textContent = speaker;
+    copy.textContent = text;
+    item.append(name, copy);
+    this._messages.append(item);
+    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._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);
+  }
+
+  disconnectedCallback() {
+    this._form?.removeEventListener("submit", this._onSubmit);
+    this._textarea?.removeEventListener("keydown", this._onKeydown);
+  }
+
+  setBusy(busy) {
+    if (this._textarea) this._textarea.disabled = busy;
+    if (this._button) {
+      this._button.disabled = busy;
+      this._button.setAttribute("aria-busy", String(busy));
+    }
+  }
+}
+
+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.show(this.dataset.selection || "resume");
+  }
+
+  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 link = this.querySelector("[data-preview-link]");
+    link.href = preview.url;
+    link.setAttribute("aria-label", `Open ${preview.title}`);
+  }
+}
+
+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);
+}
+
+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 replyTimer = 0;
+
+  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-submit", event => {
+    chat?.appendMessage("June", event.detail.message);
+    composer?.setBusy(true);
+    character?.setAttribute("state", "thinking");
+    window.clearTimeout(characterTimer);
+    window.clearTimeout(replyTimer);
+    replyTimer = window.setTimeout(() => {
+      chat?.appendMessage(
+        "Epi",
+        "A promising quest. The menu has the fastest paths through this site.",
+      );
+      character?.setAttribute("state", "default");
+      composer?.setBusy(false);
+      composer?.querySelector("textarea")?.focus();
+    }, 700);
+  });
+}
+
+if (document.readyState === "loading") {
+  document.addEventListener("DOMContentLoaded", initializeJrpgPage, {
+    once: true,
+  });
+} else {
+  initializeJrpgPage();
+}
Binary file mrjunejune/src/public/fonts/pixel-mplus-12-regular.ttf has changed
Binary file mrjunejune/src/public/jrpg/bar-ink.webp has changed
Binary file mrjunejune/src/public/jrpg/bar-paper.webp has changed
Binary file mrjunejune/src/public/jrpg/shiba-default.webp has changed
Binary file mrjunejune/src/public/jrpg/shiba-sad.webp has changed
Binary file mrjunejune/src/public/jrpg/shiba-thinking.webp has changed
--- a/mrjunejune/src/sw.js	Wed Aug 05 05:25:40 2026 -0700
+++ b/mrjunejune/src/sw.js	Wed Aug 05 09:19:41 2026 -0700
@@ -1,5 +1,5 @@
 // Root-scoped Service Worker for MrJuneJune PWA
-const CACHE_VERSION = 'v6-zenbu-headless';
+const CACHE_VERSION = 'v9-dev-page-split';
 const CACHE_NAME = `mrjunejune-${CACHE_VERSION}`;
 
 // Files to cache immediately on install
@@ -93,6 +93,11 @@
     return;
   }
 
+  // Development servers should always serve current source and assets.
+  if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') {
+    return;
+  }
+
   // The HLS tool and media must always reflect the current player version.
   if (url.pathname.startsWith('/tools/hls_player') ||
       url.pathname.startsWith('/public/hls-sample/')) {
--- a/mrjunejune/test/BUILD	Wed Aug 05 05:25:40 2026 -0700
+++ b/mrjunejune/test/BUILD	Wed Aug 05 09:19:41 2026 -0700
@@ -2,6 +2,7 @@
 load("@rules_cc//cc:cc_test.bzl", "cc_test")
 load("@aspect_rules_js//js:defs.bzl", "js_test")
 load("//gui_ze:gui_ze.bzl", "move_files_into_dir", "bundle")
+load("@rules_shell//shell:sh_test.bzl", "sh_test")
 
 # Files needed for test
 filegroup(
@@ -86,12 +87,20 @@
   timeout = "long",
 )
 
+sh_test(
+  name = "production_bundle_exclusion_test",
+  srcs = ["production_bundle_exclusion_test.sh"],
+  args = ["$(rootpath //mrjunejune:mrjunejune_server_bundle)"],
+  data = ["//mrjunejune:mrjunejune_server_bundle"],
+  size = "small",
+)
+
 js_test(
   name = "theme_and_webp_test",
   entry_point = "theme_and_webp_test.js",
   data = [
     "//hg-web/e2e:node_modules/playwright-core",
-    "//mrjunejune:mrjunejune_server",
+    "//mrjunejune:mrjunejune_server_debug",
     "@playwright_chromium_linux//:chrome",
     "@playwright_chromium_linux//:chromium",
   ],
--- a/mrjunejune/test/integration_test.c	Wed Aug 05 05:25:40 2026 -0700
+++ b/mrjunejune/test/integration_test.c	Wed Aug 05 09:19:41 2026 -0700
@@ -412,6 +412,8 @@
     {"/nonexistent", 404, NULL, NULL, NULL, 0},
     {"/does/not/exist", 404, NULL, NULL, NULL, 0},
     {"/missing.html", 404, NULL, NULL, NULL, 0},
+    {"/jrpg", 404, NULL, NULL, NULL, 0},
+    {"/jrpg/index.html", 404, NULL, NULL, NULL, 0},
   };
   int num_failure_tests = sizeof(failure_tests) / sizeof(failure_tests[0]);
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/test/production_bundle_exclusion_test.sh	Wed Aug 05 09:19:41 2026 -0700
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+bundle="$1"
+
+if find "$bundle" -type f | grep -E '/jrpg/|pixel-mplus-12-regular'; then
+  echo "Production bundle contains development-only JRPG files" >&2
+  exit 1
+fi
+
+if grep -R -a -l -E '"/jrpg"|/jrpg/index\.html' "$bundle"; then
+  echo "Production bundle contains a development-only JRPG route" >&2
+  exit 1
+fi
--- a/mrjunejune/test/theme_and_webp_test.js	Wed Aug 05 05:25:40 2026 -0700
+++ b/mrjunejune/test/theme_and_webp_test.js	Wed Aug 05 09:19:41 2026 -0700
@@ -373,6 +373,284 @@
   await page.close();
 }
 
+async function testJrpgPage(browser) {
+  const context = await browser.newContext({
+    colorScheme: 'light',
+    viewport: { width: 1280, height: 900 },
+  });
+  const page = await context.newPage();
+  const errors = [];
+  page.on('pageerror', error => errors.push(`pageerror: ${error.message}`));
+  page.on('console', message => {
+    if (message.type() === 'error') errors.push(`console: ${message.text()}`);
+  });
+  page.on('requestfailed', request => {
+    errors.push(`requestfailed: ${request.url()} ${request.failure()?.errorText || ''}`);
+  });
+  page.on('response', response => {
+    if (response.status() >= 400) {
+      errors.push(`response: ${response.status()} ${response.url()}`);
+    }
+  });
+
+  await page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' });
+  await page.waitForFunction(() =>
+    customElements.get('mjj-jrpg-composer') &&
+    customElements.get('mjj-jrpg-menu') &&
+    customElements.get('mjj-jrpg-chat') &&
+    customElements.get('mjj-jrpg-preview')
+  );
+  await page.evaluate(() => document.fonts.ready);
+  const desktop = await page.evaluate(() => {
+    const scene = document.querySelector('.jrpg-scene').getBoundingClientRect();
+    const utility = document.querySelector('.jrpg-utility').getBoundingClientRect();
+    const composer = document.querySelector('mjj-jrpg-composer')
+      .getBoundingClientRect();
+    const menu = document.querySelector('mjj-jrpg-menu').getBoundingClientRect();
+    const character = document.querySelector('[data-character]');
+    const shell = document.querySelector('.jrpg-shell').getBoundingClientRect();
+    const main = document.querySelector('.jrpg-main').getBoundingClientRect();
+    const colorProbe = document.createElement('span');
+    colorProbe.style.background = 'var(--zenbu-sys-color-surface-page)';
+    colorProbe.style.color =
+      'var(--zenbu-sys-color-action-primary-background)';
+    colorProbe.style.borderColor = 'var(--zenbu-sys-color-focus-outer)';
+    const surfaceProbe = document.createElement('span');
+    surfaceProbe.style.background =
+      'var(--zenbu-sys-color-surface-default)';
+    surfaceProbe.style.color = 'var(--zenbu-sys-color-text-primary)';
+    surfaceProbe.style.borderColor = 'var(--zenbu-sys-color-border-strong)';
+    const sunkenProbe = document.createElement('span');
+    sunkenProbe.style.background =
+      'var(--zenbu-sys-color-surface-sunken)';
+    const panelProbe = document.createElement('span');
+    panelProbe.style.background = 'var(--mjj-jrpg-panel-strong)';
+    document.body.append(colorProbe, surfaceProbe, sunkenProbe, panelProbe);
+    const probeStyle = getComputedStyle(colorProbe);
+    const surfaceProbeStyle = getComputedStyle(surfaceProbe);
+    const sunkenProbeStyle = getComputedStyle(sunkenProbe);
+    const panelProbeStyle = getComputedStyle(panelProbe);
+    const computedFonts = [
+      document.body,
+      document.querySelector('.jrpg-brand h1'),
+      document.querySelector('.jrpg-message p'),
+      document.querySelector('#jrpg-message'),
+      document.querySelector('.jrpg-composer button'),
+    ].map(element => getComputedStyle(element).fontFamily);
+    const nonPixelText = [document.body, ...document.body.querySelectorAll('*')]
+      .filter(element => [...element.childNodes].some(node =>
+        node.nodeType === Node.TEXT_NODE && node.textContent.trim()
+      ))
+      .filter(element =>
+        getComputedStyle(element).fontFamily !== '"Pixel Mplus"'
+      )
+      .map(element => element.localName);
+    const pageStyle = getComputedStyle(document.body);
+    const submitStyle = getComputedStyle(
+      document.querySelector('.jrpg-composer button'),
+    );
+    const colors = {
+      canvas: probeStyle.backgroundColor,
+      conversation: getComputedStyle(
+        document.querySelector('mjj-jrpg-chat'),
+      ).backgroundColor,
+      conversationBorder: getComputedStyle(
+        document.querySelector('mjj-jrpg-chat'),
+      ).borderTopColor,
+      conversationPanel: panelProbeStyle.backgroundColor,
+      conversationText: getComputedStyle(
+        document.querySelector('.jrpg-message p'),
+      ).color,
+      focus: probeStyle.borderColor,
+      frame: surfaceProbeStyle.borderColor,
+      menu: getComputedStyle(
+        document.querySelector('mjj-jrpg-menu'),
+      ).backgroundColor,
+      page: pageStyle.backgroundColor,
+      primary: probeStyle.color,
+      primaryText: getComputedStyle(
+        document.querySelector('.jrpg-preview-panel h2'),
+      ).color,
+      sunken: sunkenProbeStyle.backgroundColor,
+      surface: surfaceProbeStyle.backgroundColor,
+      submit: submitStyle.backgroundColor,
+      text: surfaceProbeStyle.color,
+      utility: getComputedStyle(
+        document.querySelector('.jrpg-utility'),
+      ).backgroundColor,
+    };
+    colorProbe.remove();
+    surfaceProbe.remove();
+    sunkenProbe.remove();
+    panelProbe.remove();
+    return {
+      art: getComputedStyle(document.querySelector('.jrpg-scene')).backgroundImage,
+      characterLoaded: character.complete && character.naturalWidth > 0,
+      colors,
+      composerBelowScene: composer.top >= scene.bottom,
+      fillsViewport: shell.left === 0 &&
+        shell.top === 0 &&
+        Math.abs(shell.right - innerWidth) < 1 &&
+        Math.abs(shell.bottom - innerHeight) < 1 &&
+        main.left === 0 &&
+        main.top === 0 &&
+        Math.abs(main.right - innerWidth) < 1 &&
+        Math.abs(main.bottom - innerHeight) < 1,
+      fonts: computedFonts,
+      fontLoaded: [...document.fonts].some(font =>
+        font.family.replaceAll('"', '') === 'Pixel Mplus' &&
+        font.status === 'loaded'
+      ),
+      menuRightOfComposer: menu.left >= composer.right,
+      noOverflow: document.documentElement.scrollWidth <= innerWidth,
+      nonPixelText,
+      theme: document.documentElement.dataset.zenTheme,
+      utilityRightOfScene: utility.left >= scene.right,
+    };
+  });
+  assert.match(desktop.art, /bar-ink\.webp/);
+  assert.equal(desktop.characterLoaded, true);
+  assert.equal(desktop.colors.page, desktop.colors.canvas);
+  assert.equal(
+    desktop.colors.conversation,
+    desktop.colors.conversationPanel,
+  );
+  assert.equal(desktop.colors.conversationBorder, desktop.colors.frame);
+  assert.equal(desktop.colors.conversationText, desktop.colors.text);
+  assert.equal(desktop.colors.menu, desktop.colors.sunken);
+  assert.equal(desktop.colors.primaryText, desktop.colors.primary);
+  assert.equal(desktop.colors.submit, desktop.colors.primary);
+  assert.equal(desktop.colors.utility, desktop.colors.surface);
+  assert.equal(desktop.composerBelowScene, true);
+  assert.equal(desktop.fillsViewport, true);
+  assert.ok(desktop.fonts.every(font => font === '"Pixel Mplus"'));
+  assert.equal(desktop.fontLoaded, true);
+  assert.equal(desktop.menuRightOfComposer, true);
+  assert.equal(desktop.noOverflow, true);
+  assert.deepEqual(desktop.nonPixelText, []);
+  assert.equal(desktop.theme, 'cyberpunk');
+  assert.equal(desktop.utilityRightOfScene, true);
+  await page.locator('.jrpg-brand a').focus();
+  assert.equal(
+    await page.locator('.jrpg-brand a').evaluate(element =>
+      getComputedStyle(element).outlineColor
+    ),
+    desktop.colors.focus,
+  );
+  await page.locator('.jrpg-composer button').focus();
+  assert.equal(
+    await page.locator('.jrpg-composer button').evaluate(element =>
+      getComputedStyle(element).outlineColor
+    ),
+    desktop.colors.focus,
+  );
+  assert.equal(
+    await page.locator('meta[name="theme-color"]').first().getAttribute('content'),
+    desktop.colors.canvas,
+  );
+
+  await page.getByRole('button', { name: 'Tools', exact: true }).click();
+  assert.equal(
+    await page.locator('[data-preview-title]').textContent(),
+    'Tools',
+  );
+  assert.equal(
+    await page.locator('[data-preview-link]').getAttribute('href'),
+    '/tools',
+  );
+  assert.equal(
+    await page.getByRole('button', { name: 'Tools', exact: true })
+      .getAttribute('aria-pressed'),
+    'true',
+  );
+
+  await page.getByRole('button', { name: /Inspect/ }).click();
+  await page.locator('.jrpg-preview-dialog dialog[open]').waitFor();
+  assert.equal(
+    await page.locator('[data-dialog-title]').textContent(),
+    'Tools',
+  );
+  await page.getByRole('button', {
+    name: 'Close destination details',
+  }).click();
+
+  await page.locator('#jrpg-message').fill('Where should I begin?');
+  await page.locator('#jrpg-message').press('Enter');
+  await page.locator('.jrpg-message[data-speaker="June"]').last().waitFor();
+  await page.getByRole('button', { name: 'Blogs', exact: true }).click();
+  assert.equal(
+    await page.locator('.jrpg-message[data-speaker="June"] p').last()
+      .textContent(),
+    'Where should I begin?',
+  );
+  await page.waitForFunction(() =>
+    document.querySelector('mjj-jrpg-character')?.getAttribute('state') ===
+      'default' &&
+    document.querySelectorAll('.jrpg-message[data-speaker="Epi"]').length > 1
+  );
+  assert.equal(await page.locator('#jrpg-message').isEnabled(), true);
+
+  await page.waitForFunction(() => navigator.serviceWorker?.controller);
+  await page.reload({ waitUntil: 'networkidle' });
+  await page.evaluate(() => document.fonts.ready);
+  const controlledReload = await page.evaluate(() => {
+    const shell = document.querySelector('.jrpg-shell').getBoundingClientRect();
+    return {
+      art: getComputedStyle(document.querySelector('.jrpg-scene'))
+        .backgroundImage,
+      fillsViewport: shell.left === 0 &&
+        shell.top === 0 &&
+        Math.abs(shell.right - innerWidth) < 1 &&
+        Math.abs(shell.bottom - innerHeight) < 1,
+      fontLoaded: document.fonts.check('16px "Pixel Mplus"'),
+      fontFamily: getComputedStyle(
+        document.querySelector('.jrpg-brand h1'),
+      ).fontFamily,
+      theme: document.documentElement.dataset.zenTheme,
+    };
+  });
+  assert.match(controlledReload.art, /bar-ink\.webp/);
+  assert.equal(controlledReload.fillsViewport, true);
+  assert.equal(controlledReload.fontLoaded, true);
+  assert.equal(controlledReload.fontFamily, '"Pixel Mplus"');
+  assert.equal(controlledReload.theme, 'cyberpunk');
+  assert.deepEqual(errors, []);
+  await context.close();
+
+  const mobileContext = await browser.newContext({
+    colorScheme: 'light',
+    viewport: { width: 360, height: 640 },
+  });
+  const mobilePage = await mobileContext.newPage();
+  await mobilePage.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' });
+  const mobile = await mobilePage.evaluate(() => {
+    const scene = document.querySelector('.jrpg-scene').getBoundingClientRect();
+    const utility = document.querySelector('.jrpg-utility').getBoundingClientRect();
+    const shell = document.querySelector('.jrpg-shell').getBoundingClientRect();
+    const menuButtons = [...document.querySelectorAll(
+      'mjj-jrpg-menu button[data-preview]',
+    )];
+    return {
+      buttonsFit: menuButtons.every(button =>
+        button.getBoundingClientRect().right <= innerWidth
+      ),
+      fillsWidth: shell.left === 0 &&
+        Math.abs(shell.right - innerWidth) < 1,
+      initialScroll: scrollY,
+      noOverflow: document.documentElement.scrollWidth <= innerWidth,
+      utilityBelowScene: utility.top >= scene.bottom,
+    };
+  });
+  assert.deepEqual(mobile, {
+    buttonsFit: true,
+    fillsWidth: true,
+    initialScroll: 0,
+    noOverflow: true,
+    utilityBelowScene: true,
+  });
+  await mobileContext.close();
+}
+
 async function testHlsPlayer(browser, siteRoot) {
   const page = await browser.newPage();
   const errors = [];
@@ -615,7 +893,7 @@
   const runfilesWorkspace = path.join(RUNFILES, WORKSPACE);
   const serverBinary = path.join(
     runfilesWorkspace,
-    'mrjunejune/mrjunejune_server',
+    'mrjunejune/mrjunejune_server_debug',
   );
   const siteRoot = path.join(runfilesWorkspace, 'mrjunejune/src');
   const chromiumPath = path.resolve(process.env.CHROMIUM_PATH);
@@ -679,7 +957,7 @@
     for (const source of [home, dogGame, manifest]) {
       assert.doesNotMatch(source, /\.png(?:["')]|$)/i);
     }
-    assert.match(serviceWorker, /v6-zenbu-headless/);
+    assert.match(serviceWorker, /v9-dev-page-split/);
     assert.match(
       await (
         await fetch(`${baseUrl}/public/pwa-register.js`)
@@ -687,6 +965,9 @@
       /register\('\/sw\.js', \{ scope: '\/' \}\)/,
     );
     assert.ok(serviceWorker.includes("startsWith('/tools/hls_player')"));
+    assert.ok(serviceWorker.includes("url.hostname === 'localhost'"));
+    assert.ok(serviceWorker.includes("url.hostname === '127.0.0.1'"));
+    assert.ok(!serviceWorker.includes("startsWith('/jrpg')"));
 
     for (const asset of [
       'sprite_shiba0.webp',
@@ -741,6 +1022,7 @@
     await testButtonScale(browser);
     await testDynamicButtonOwnership(browser);
     await testResumePrint(browser);
+    await testJrpgPage(browser);
     await testHlsPlayer(browser, siteRoot);
   } finally {
     if (browser) await browser.close();