# HG changeset patch # User MrJuneJune # Date 1785946781 25200 # Node ID 667156fcd3e3ea8747eaca5b416a64f4e8406007 # Parent 60a876c4587a4e191ee5e9c984d705f8c3514dd0 Add dev-only cyberpunk JRPG page diff -r 60a876c4587a -r 667156fcd3e3 .claude/skills/zenbu-personal-site/SKILL.md --- 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. diff -r 60a876c4587a -r 667156fcd3e3 design_system/THEMES.md --- 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: diff -r 60a876c4587a -r 667156fcd3e3 design_system/src/styles/reference.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; diff -r 60a876c4587a -r 667156fcd3e3 design_system/src/styles/semantic.css --- 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) { diff -r 60a876c4587a -r 667156fcd3e3 design_system/src/styles/themes.css --- 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); diff -r 60a876c4587a -r 667156fcd3e3 design_system/test/catalog_test.js --- 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, diff -r 60a876c4587a -r 667156fcd3e3 design_system/wiki/TOKENS_AND_SIZING.md --- 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: diff -r 60a876c4587a -r 667156fcd3e3 mrjunejune/BUILD --- 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"], diff -r 60a876c4587a -r 667156fcd3e3 mrjunejune/README.md --- 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/`. diff -r 60a876c4587a -r 667156fcd3e3 mrjunejune/main.c --- 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); diff -r 60a876c4587a -r 667156fcd3e3 mrjunejune/src/jrpg/index.html --- /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 @@ + + + + Shiba Quest | MrJuneJune + {{/parts/base_head.html}} + + + + + +
+ +
+
+ + + + + +
+ +

Conversations

+
+ +
+
    +
  1. + Epi +

    Welcome, traveler. Pick a destination or tell me what you are looking for.

    +
  2. +
  3. + System +

    The Shiba terminal is ready.

    +
  4. +
+
+
+ +
+
+ +
+
+
+

+ MrJuneJune +

+
+

SHIBA QUEST TERMINAL

+
+ + +
+

CHARACTER RECORD

+ +

Resume

+
+

Experience, projects, and the systems I have helped build.

+
+
+
TYPE
+
Profile
+
+
+
STATUS
+
Available
+
+
+ + + + + + +
+

DESTINATION DATA

+ + + +
+

Resume

+

Experience, projects, and the systems I have helped build.

+ + + Open destination + + + +
+
+
+
+
+ + +
+ + + + + + + +

Enter to send / Shift + Enter for a new line

+
+
+ + + + +
+
+
+ + diff -r 60a876c4587a -r 667156fcd3e3 mrjunejune/src/jrpg/jrpg.css --- /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; + } +} diff -r 60a876c4587a -r 667156fcd3e3 mrjunejune/src/jrpg/jrpg.js --- /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(); +} diff -r 60a876c4587a -r 667156fcd3e3 mrjunejune/src/public/fonts/pixel-mplus-12-regular.ttf Binary file mrjunejune/src/public/fonts/pixel-mplus-12-regular.ttf has changed diff -r 60a876c4587a -r 667156fcd3e3 mrjunejune/src/public/jrpg/bar-ink.webp Binary file mrjunejune/src/public/jrpg/bar-ink.webp has changed diff -r 60a876c4587a -r 667156fcd3e3 mrjunejune/src/public/jrpg/bar-paper.webp Binary file mrjunejune/src/public/jrpg/bar-paper.webp has changed diff -r 60a876c4587a -r 667156fcd3e3 mrjunejune/src/public/jrpg/shiba-default.webp Binary file mrjunejune/src/public/jrpg/shiba-default.webp has changed diff -r 60a876c4587a -r 667156fcd3e3 mrjunejune/src/public/jrpg/shiba-sad.webp Binary file mrjunejune/src/public/jrpg/shiba-sad.webp has changed diff -r 60a876c4587a -r 667156fcd3e3 mrjunejune/src/public/jrpg/shiba-thinking.webp Binary file mrjunejune/src/public/jrpg/shiba-thinking.webp has changed diff -r 60a876c4587a -r 667156fcd3e3 mrjunejune/src/sw.js --- 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/')) { diff -r 60a876c4587a -r 667156fcd3e3 mrjunejune/test/BUILD --- 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", ], diff -r 60a876c4587a -r 667156fcd3e3 mrjunejune/test/integration_test.c --- 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]); diff -r 60a876c4587a -r 667156fcd3e3 mrjunejune/test/production_bundle_exclusion_test.sh --- /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 diff -r 60a876c4587a -r 667156fcd3e3 mrjunejune/test/theme_and_webp_test.js --- 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();