Mercurial
changeset 241:9c2eec61a152
[assets] Generate site images as WebP with Bazel
line wrap: on
line diff
--- a/MODULE.bazel Mon Aug 03 10:48:59 2026 -0700 +++ b/MODULE.bazel Mon Aug 03 11:26:30 2026 -0700 @@ -33,6 +33,16 @@ """, ) +http_archive( + name = "libwebp_linux_x86_64", + urls = ["https://storage.googleapis.com/downloads.webmproject.org/releases/webp/libwebp-1.6.0-linux-x86-64.tar.gz"], + sha256 = "1c5ffab71efecefa0e3c23516c3a3a1dccb45cc310ae1095c6f14ae268e38067", + strip_prefix = "libwebp-1.6.0-linux-x86-64", + build_file_content = """ +exports_files(["bin/cwebp"]) +""", +) + node = use_extension("@rules_nodejs//nodejs:extensions.bzl", "node") node.toolchain(node_version = "20.18.0") use_repo(node, "nodejs")
--- a/assets/BUILD Mon Aug 03 10:48:59 2026 -0700 +++ b/assets/BUILD Mon Aug 03 11:26:30 2026 -0700 @@ -1,5 +1,33 @@ +load("//gui_ze:gui_ze.bzl", "webp_image") + +_ICON_ASSETS = [ + "binder", + "bold", + "book", + "cabinet", + "clipy", + "h1", + "h2", + "h3", + "link", + "open-book", +] + +[ + webp_image( + name = "webp_{}".format(asset.replace("-", "_")), + src = "png/{}.png".format(asset), + out = "generated_icons/{}.webp".format(asset), + lossless = True, + ) + for asset in _ICON_ASSETS +] + filegroup( - name = "icons", - srcs = glob(["icons/*.png"]), - visibility = ["//visibility:public"], + name = "icons", + srcs = [ + ":webp_{}".format(asset.replace("-", "_")) + for asset in _ICON_ASSETS + ], + visibility = ["//visibility:public"], )
--- a/gui_ze/gui_ze.bzl Mon Aug 03 10:48:59 2026 -0700 +++ b/gui_ze/gui_ze.bzl Mon Aug 03 11:26:30 2026 -0700 @@ -307,6 +307,20 @@ }, ) +def webp_image(name, src, out, quality = 90, lossless = False): + """Convert one image to WebP as a Bazel build action.""" + native.genrule( + name = name, + srcs = [src], + outs = [out], + tools = ["@libwebp_linux_x86_64//:bin/cwebp"], + cmd = "$(location @libwebp_linux_x86_64//:bin/cwebp) -quiet -m 6 {} -q {} $(location {}) -o $@".format( + "-lossless" if lossless else "", + quality, + src, + ), + ) + def _move_to_directory_impl(ctx): srcs = ctx.files.data res = []
--- a/mrjunejune/BUILD Mon Aug 03 10:48:59 2026 -0700 +++ b/mrjunejune/BUILD Mon Aug 03 11:26:30 2026 -0700 @@ -1,7 +1,76 @@ load("@rules_cc//cc:cc_binary.bzl", "cc_binary") load("@rules_cc//cc:cc_library.bzl", "cc_library") load("@rules_shell//shell:sh_binary.bzl", "sh_binary") -load("//gui_ze:gui_ze.bzl", "move_files_into_dir", "bundle") +load("//gui_ze:gui_ze.bzl", "move_files_into_dir", "bundle", "webp_image") + +_PNG_ASSETS = [ + "23k", + "blue-noise-random", + "blue-noises-optimal", + "dog-treat", + "epi_all_colors", + "icon-192", + "icon-512", + "icon-maskable-512", + "logo_black", + "logo_white", + "logo_white_og", + "sprite_shiba0", + "sprite_shiba1", + "sprite_shiba2", + "sprite_shiba3", + "start-large", + "start-small-1", + "start-small-2", + "start-small-3", + "vscode", + "wabbit_alpha", + "white-noise-grass", +] + +_LOSSLESS_WEBP_ASSETS = [ + "dog-treat", + "epi_all_colors", + "icon-192", + "icon-512", + "icon-maskable-512", + "logo_black", + "logo_white", + "logo_white_og", + "sprite_shiba0", + "sprite_shiba1", + "sprite_shiba2", + "sprite_shiba3", + "start-large", + "start-small-1", + "start-small-2", + "start-small-3", + "wabbit_alpha", +] + +[ + webp_image( + name = "webp_{}".format(asset.replace("-", "_")), + src = "assets/png/{}.png".format(asset), + out = "generated_webp/{}.webp".format(asset), + lossless = asset in _LOSSLESS_WEBP_ASSETS, + ) + for asset in _PNG_ASSETS +] + +filegroup( + name = "generated_webp_assets", + srcs = [ + ":webp_{}".format(asset.replace("-", "_")) + for asset in _PNG_ASSETS + ], +) + +move_files_into_dir( + name = "generated_public_webp", + srcs = [":generated_webp_assets"], + dest = "src/public", +) # Files move_files_into_dir( @@ -47,7 +116,10 @@ filegroup( name = "public_files", - srcs = glob(["src/public/*"]), + srcs = glob( + ["src/public/*"], + exclude = ["src/public/*.png"], + ) + [":generated_public_webp"], visibility = ["//visibility:public"], ) @@ -59,7 +131,10 @@ filegroup( name = "src_files", - srcs = glob(["src/**"]) + [":react_pages", ":shared_js_non_public", ":shared_js_file", ":rich_editor_js", ":icons"], + srcs = glob( + ["src/**"], + exclude = ["src/**/*.png"], + ) + [":react_pages", ":shared_js_non_public", ":shared_js_file", ":rich_editor_js", ":icons", ":generated_public_webp"], visibility = ["//mrjunejune/test:__pkg__"], )
--- a/mrjunejune/PWA_SETUP.md Mon Aug 03 10:48:59 2026 -0700 +++ b/mrjunejune/PWA_SETUP.md Mon Aug 03 11:26:30 2026 -0700 @@ -10,50 +10,35 @@ 4. **offline.html** - Offline fallback page 5. **Updated base_head.html** - Links to manifest and PWA script -## Missing: App Icons +## App Icons and WebP Assets -You need to create PNG icons from your SVG. Run these commands: - -### Using ImageMagick (recommended): +All shipped raster images are generated as WebP by Bazel. Source PNGs live +under `mrjunejune/assets/png/` and are never copied into the release bundle. ```bash -cd src/public - -# 192x192 icon -convert epi_all_colors.svg -resize 192x192 -background none icon-192.png - -# 512x512 icon -convert epi_all_colors.svg -resize 512x512 -background none icon-512.png - -# Maskable icon (512x512 with safe zone) -convert epi_all_colors.svg -resize 409x409 -background none -gravity center -extent 512x512 icon-maskable-512.png +bazel build //mrjunejune:generated_webp_assets ``` -### Or using Inkscape: +The complete site bundle also runs the conversion pipeline: ```bash -inkscape epi_all_colors.svg --export-type=png --export-filename=icon-192.png -w 192 -h 192 -inkscape epi_all_colors.svg --export-type=png --export-filename=icon-512.png -w 512 -h 512 -inkscape epi_all_colors.svg --export-type=png --export-filename=icon-maskable-512.png -w 512 -h 512 +bazel build //mrjunejune:mrjunejune_server_bundle ``` -### Or use an online tool: -- https://realfavicongenerator.net/ -- https://www.pwabuilder.com/ - ## Optional: Screenshots (for better app install experience) Create screenshots of your site: ```bash # Mobile screenshot (540x720) -screenshot-mobile.png +screenshot-mobile.webp # Desktop screenshot (1280x720) -screenshot-desktop.png +screenshot-desktop.webp ``` -You can use browser DevTools to capture these, or remove the `screenshots` section from manifest.json if you don't want them. +You can use browser DevTools to capture these. Add their PNG sources to the +WebP asset list before referencing the generated `.webp` files in the manifest. ## Testing Your PWA
--- a/mrjunejune/README.md Mon Aug 03 10:48:59 2026 -0700 +++ b/mrjunejune/README.md Mon Aug 03 11:26:30 2026 -0700 @@ -2,6 +2,21 @@ My personal website where I post my blogs / about me and resume. +## WebP assets + +Raster source images live under `mrjunejune/assets/png/` and `assets/png/`. +Bazel converts them to WebP before they enter the server runfiles or release +bundle: + +```bash +bazel build //mrjunejune:generated_webp_assets +bazel build //mrjunejune:mrjunejune_server_bundle +``` + +The deployed site contains no PNG files. Add new PNG source assets to the +corresponding list in `mrjunejune/BUILD` or `assets/BUILD`, then reference the +generated `.webp` URL from the site. + ## TODO - Add caching layer
--- a/mrjunejune/generate-icons.sh Mon Aug 03 10:48:59 2026 -0700 +++ b/mrjunejune/generate-icons.sh Mon Aug 03 11:26:30 2026 -0700 @@ -1,42 +1,8 @@ -#!/bin/bash - -# Generate PWA icons from SVG -# Requires ImageMagick: apt-get install imagemagick - -cd "$(dirname "$0")/src/public" - -if ! command -v convert &> /dev/null; then - echo "❌ ImageMagick not found. Please install it:" - echo " Ubuntu/Debian: sudo apt-get install imagemagick" - echo " macOS: brew install imagemagick" - echo "" - echo "Or use an online tool: https://realfavicongenerator.net/" - exit 1 -fi - -echo "🎨 Generating PWA icons from epi_all_colors.svg..." +#!/usr/bin/env bash +set -euo pipefail -# 192x192 icon -echo " → Creating icon-192.png..." -convert epi_all_colors.svg -resize 192x192 -background none icon-192.png - -# 512x512 icon -echo " → Creating icon-512.png..." -convert epi_all_colors.svg -resize 512x512 -background none icon-512.png - -# Maskable icon (512x512 with safe zone - 80% of size) -echo " → Creating icon-maskable-512.png..." -convert epi_all_colors.svg -resize 409x409 -background none -gravity center -extent 512x512 icon-maskable-512.png +workspace="$(hg root)" +cd "$workspace" -echo "" -echo "✅ Icons generated successfully!" -echo "" -echo "Generated files:" -echo " - icon-192.png" -echo " - icon-512.png" -echo " - icon-maskable-512.png" -echo "" -echo "Next steps:" -echo " 1. Verify icons look good: ls -lh src/public/icon-*.png" -echo " 2. Test PWA: Open site in Chrome → DevTools → Application → Manifest" -echo " 3. Run Lighthouse audit to check PWA score" +bazel build //mrjunejune:generated_webp_assets +echo "Generated WebP assets are under bazel-bin/mrjunejune/generated_webp/."
--- a/mrjunejune/main.c Mon Aug 03 10:48:59 2026 -0700 +++ b/mrjunejune/main.c Mon Aug 03 11:26:30 2026 -0700 @@ -1894,5 +1894,8 @@ Seobeo_WebSocket_Server_Register("/chat", Chat_Handler, NULL); Seobeo_Log(SEOBEO_INFO, "WTF is going on\n"); - Seobeo_Web_Server_Start("mrjunejune/src", "6969", SEOBEO_MODE_EDGE, 1); + const char *server_port = getenv("MRJUNEJUNE_PORT"); + if (!server_port || server_port[0] == '\0') + server_port = "6969"; + Seobeo_Web_Server_Start("mrjunejune/src", server_port, SEOBEO_MODE_EDGE, 1); }
--- a/mrjunejune/src/blog/my-seobeo-journey/index.md Mon Aug 03 10:48:59 2026 -0700 +++ b/mrjunejune/src/blog/my-seobeo-journey/index.md Mon Aug 03 11:26:30 2026 -0700 @@ -7,7 +7,7 @@ I'm so tired of modern web development. You want to spin up a simple web server, and suddenly you’re wrestling with a mountain of dependencies. Express, Fastify, whatever the flavor of the week is. They all promise simplicity, but dump a black hole of `node_modules` onto your machine. Have you ever actually looked inside? It’s insane. And all of them use React, Svelte, or whatever the current trend is for modern development. One time, I created a single React file using Bun and it had over 18k lines of JavaScript code lmao. And for what? To handle a simple HTTP request for a text file? -<img src="/public/23k.png" /> +<img src="/public/23k.webp" /> ** It was actually 23K lines long lol.. you can test it [here](https://zenbu.babocoder.com/file/tip/playground). Just clone the repo and run `bazel build playground:hello` **
--- a/mrjunejune/src/blog/optimizing-grass-rendering/index.md Mon Aug 03 10:48:59 2026 -0700 +++ b/mrjunejune/src/blog/optimizing-grass-rendering/index.md Mon Aug 03 11:26:30 2026 -0700 @@ -42,7 +42,7 @@ This is computationally the most efficient as there is no constraints on where it can be placed which mean it will often lead to clumping together. -<div class="center"> <img src="/public/white-noise-grass.png" /> </div> +<div class="center"> <img src="/public/white-noise-grass.webp" /> </div> Above image is okay, but lacks realism. We can do better. @@ -84,7 +84,7 @@ As you can see from below image, this approach improves visual quality... -<div class="center"> <img src="/public/blue-noise-random.png" /> </div> +<div class="center"> <img src="/public/blue-noise-random.webp" /> </div> ...but at the cost of computational complexity. As the number of points increases, available space becomes limited, requiring more attempts to place a point. This can take several seconds, which isn’t ideal. @@ -156,7 +156,7 @@ ** It only took 2 seconds or so. It slows down at the end since it probably needed to look for one or two points using random number ** -<div class="center"> <img src="/public/blue-noises-optimal.png" /> </div> +<div class="center"> <img src="/public/blue-noises-optimal.webp" /> </div> ### Conclusion
--- a/mrjunejune/src/blog/thoughts-on-ide/index.md Mon Aug 03 10:48:59 2026 -0700 +++ b/mrjunejune/src/blog/thoughts-on-ide/index.md Mon Aug 03 11:26:30 2026 -0700 @@ -13,7 +13,7 @@ I fully support tools that help developers write better code and become better engineers. But if someone doesn’t understand what’s happening under the hood and asks questions like, “Why can’t I run Python in my VS Code?”, then we’ve overcomplicated the process. This makes things harder for beginners to actually learn what is going on and frustrating for those who already know what they’re doing since it takes forever to figure out where to set simple configurations. Losing context about what’s happening behind the scenes leads to a noticeable degradation in the quality of engineers, who can no longer perform simple tasks in the terminal. -<div class="center"> <img src="/public/vscode.png" /> </div> +<div class="center"> <img src="/public/vscode.webp" /> </div> *Why is VS Code taking up 1GB of RAM for opening a text file? The folder doesn't have 1GB worth of the files.*
--- a/mrjunejune/src/public/dog-game.js Mon Aug 03 10:48:59 2026 -0700 +++ b/mrjunejune/src/public/dog-game.js Mon Aug 03 11:26:30 2026 -0700 @@ -1,9 +1,9 @@ // Dog frames const imagesSrc = [ - "/public/sprite_shiba0.png", - "/public/sprite_shiba1.png", - "/public/sprite_shiba2.png", - "/public/sprite_shiba3.png", + "/public/sprite_shiba0.webp", + "/public/sprite_shiba1.webp", + "/public/sprite_shiba2.webp", + "/public/sprite_shiba3.webp", ]; const images =imagesSrc.map((src, index) => { const img = new Image(); @@ -17,15 +17,15 @@ // Load treat image const treatImage = new Image(); -treatImage.src = "/public/dog-treat.png"; +treatImage.src = "/public/dog-treat.webp"; // Load star images for background const lightStarColors = ["#315a91", "#8f3f63", "#34705f", "#76519b"]; let starImages = [ - { src: "/public/start-large.png", img: new Image(), lightImg: null }, - { src: "/public/start-small-1.png", img: new Image(), lightImg: null }, - { src: "/public/start-small-2.png", img: new Image(), lightImg: null }, - { src: "/public/start-small-3.png", img: new Image(), lightImg: null } + { src: "/public/start-large.webp", img: new Image(), lightImg: null }, + { src: "/public/start-small-1.webp", img: new Image(), lightImg: null }, + { src: "/public/start-small-2.webp", img: new Image(), lightImg: null }, + { src: "/public/start-small-3.webp", img: new Image(), lightImg: null } ]; starImages.forEach((star, index) => { star.img.onload = () => {
--- a/mrjunejune/src/public/manifest.json Mon Aug 03 10:48:59 2026 -0700 +++ b/mrjunejune/src/public/manifest.json Mon Aug 03 11:26:30 2026 -0700 @@ -15,36 +15,22 @@ "purpose": "any maskable" }, { - "src": "/public/icon-192.png", + "src": "/public/icon-192.webp", "sizes": "192x192", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/public/icon-512.png", - "sizes": "512x512", - "type": "image/png", + "type": "image/webp", "purpose": "any" }, { - "src": "/public/icon-maskable-512.png", + "src": "/public/icon-512.webp", "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ], - "screenshots": [ - { - "src": "/public/screenshot-mobile.png", - "sizes": "540x720", - "type": "image/png", - "form_factor": "narrow" + "type": "image/webp", + "purpose": "any" }, { - "src": "/public/screenshot-desktop.png", - "sizes": "1280x720", - "type": "image/png", - "form_factor": "wide" + "src": "/public/icon-maskable-512.webp", + "sizes": "512x512", + "type": "image/webp", + "purpose": "maskable" } ], "categories": ["lifestyle", "productivity"],
--- a/mrjunejune/src/public/sw.js Mon Aug 03 10:48:59 2026 -0700 +++ b/mrjunejune/src/public/sw.js Mon Aug 03 11:26:30 2026 -0700 @@ -1,5 +1,5 @@ // Service Worker for MrJuneJune PWA -const CACHE_VERSION = 'v1'; +const CACHE_VERSION = 'v2-webp'; const CACHE_NAME = `mrjunejune-${CACHE_VERSION}`; // Files to cache immediately on install @@ -88,7 +88,6 @@ url.pathname.endsWith('.css') || url.pathname.endsWith('.js') || url.pathname.endsWith('.svg') || - url.pathname.endsWith('.png') || url.pathname.endsWith('.jpg') || url.pathname.endsWith('.webp') || url.pathname.endsWith('.woff') ||
--- a/mrjunejune/test/BUILD Mon Aug 03 10:48:59 2026 -0700 +++ b/mrjunejune/test/BUILD Mon Aug 03 11:26:30 2026 -0700 @@ -1,5 +1,6 @@ load("@rules_cc//cc:cc_binary.bzl", "cc_binary") 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") # Files needed for test @@ -46,3 +47,24 @@ timeout = "long", args = ["$(location //mrjunejune:mrjunejune_server)"], ) + +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", + "@playwright_chromium_linux//:chrome", + "@playwright_chromium_linux//:chromium", + ], + env = { + "CHROMIUM_PATH": "$(rootpath @playwright_chromium_linux//:chrome)", + }, + no_copy_to_bin = ["@playwright_chromium_linux//:chromium"], + size = "large", + target_compatible_with = [ + "@platforms//cpu:x86_64", + "@platforms//os:linux", + ], + timeout = "long", +)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/test/theme_and_webp_test.js Mon Aug 03 11:26:30 2026 -0700 @@ -0,0 +1,209 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const net = require('node:net'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); + +const RUNFILES = process.env.JS_BINARY__RUNFILES; +const WORKSPACE = process.env.JS_BINARY__WORKSPACE; +let port; +let baseUrl; +const playwrightPath = path.join( + RUNFILES, + WORKSPACE, + 'hg-web/e2e/node_modules/playwright-core', +); +const { chromium } = require(playwrightPath); + +function stopProcess(child) { + if (!child || child.exitCode !== null) return Promise.resolve(); + child.kill('SIGTERM'); + return new Promise(resolve => { + const timer = setTimeout(() => { + if (child.exitCode === null) child.kill('SIGKILL'); + }, 3000); + child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + }); +} + +async function waitForServer(child, logs) { + const deadline = Date.now() + 15000; + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error(`Server exited with ${child.exitCode}\n${logs.join('')}`); + } + try { + const response = await fetch(baseUrl); + if (response.ok) return; + } catch { + // Keep waiting for startup. + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error(`Server startup timed out\n${logs.join('')}`); +} + +function listFiles(root) { + const files = []; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + const absolute = path.join(root, entry.name); + if (entry.isDirectory()) files.push(...listFiles(absolute)); + else files.push(absolute); + } + return files; +} + +async function sampleTheme(browser, theme) { + const context = await browser.newContext({ colorScheme: 'dark' }); + await context.addInitScript(value => { + localStorage.setItem('theme-preference', value); + }, theme); + 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, { waitUntil: 'networkidle' }); + await page.waitForFunction(() => { + const canvas = document.querySelector('#background'); + if (!canvas) return false; + const data = canvas.getContext('2d').getImageData( + 0, + 0, + canvas.width, + canvas.height, + ).data; + for (let index = 3; index < data.length; index += 4) { + if (data[index] >= 20) return true; + } + return false; + }); + + const sample = await page.evaluate(() => { + const canvas = document.querySelector('#background'); + const data = canvas.getContext('2d').getImageData( + 0, + 0, + canvas.width, + canvas.height, + ).data; + let count = 0; + let luminance = 0; + for (let index = 0; index < data.length; index += 4) { + if (data[index + 3] < 20) continue; + count++; + luminance += + data[index] * 0.2126 + + data[index + 1] * 0.7152 + + data[index + 2] * 0.0722; + } + return { + count, + luminance: count ? luminance / count : 0, + rootClass: document.documentElement.className, + }; + }); + + if (errors.length) throw new Error(`${theme}\n${errors.join('\n')}`); + await context.close(); + return sample; +} + +function findFreePort() { + return new Promise((resolve, reject) => { + const socket = net.createServer(); + socket.once('error', reject); + socket.listen(0, '127.0.0.1', () => { + const address = socket.address(); + socket.close(error => { + if (error) reject(error); + else resolve(String(address.port)); + }); + }); + }); +} + +(async () => { + assert.ok(RUNFILES); + assert.ok(WORKSPACE); + const runfilesWorkspace = path.join(RUNFILES, WORKSPACE); + const serverBinary = path.join( + runfilesWorkspace, + 'mrjunejune/mrjunejune_server', + ); + const siteRoot = path.join(runfilesWorkspace, 'mrjunejune/src'); + const chromiumPath = path.resolve(process.env.CHROMIUM_PATH); + const serverLogs = []; + let server; + let browser; + + try { + port = await findFreePort(); + baseUrl = `http://127.0.0.1:${port}`; + const pngFiles = listFiles(siteRoot).filter(file => file.endsWith('.png')); + assert.deepEqual(pngFiles, [], `PNG files leaked into runfiles: ${pngFiles}`); + + server = spawn(serverBinary, [], { + cwd: runfilesWorkspace, + env: { ...process.env, MRJUNEJUNE_PORT: port }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + server.stdout.on('data', chunk => serverLogs.push(chunk.toString())); + server.stderr.on('data', chunk => serverLogs.push(chunk.toString())); + await waitForServer(server, serverLogs); + + const home = await (await fetch(baseUrl)).text(); + const dogGame = await ( + await fetch(`${baseUrl}/public/dog-game.js`) + ).text(); + const manifest = await ( + await fetch(`${baseUrl}/public/manifest.json`) + ).text(); + for (const source of [home, dogGame, manifest]) { + assert.doesNotMatch(source, /\.png(?:["')]|$)/i); + } + + for (const asset of [ + 'sprite_shiba0.webp', + 'dog-treat.webp', + 'start-large.webp', + 'icon-192.webp', + 'vscode.webp', + ]) { + const response = await fetch(`${baseUrl}/public/${asset}`); + assert.equal(response.status, 200, asset); + assert.ok((await response.arrayBuffer()).byteLength > 0, asset); + } + + browser = await chromium.launch({ + executablePath: chromiumPath, + headless: true, + args: ['--no-sandbox'], + }); + const light = await sampleTheme(browser, 'light'); + const dark = await sampleTheme(browser, 'dark'); + assert.ok(light.count > 0); + assert.ok(dark.count > 0); + assert.ok(light.luminance < 175, JSON.stringify(light)); + assert.ok(dark.luminance > 200, JSON.stringify(dark)); + } finally { + if (browser) await browser.close(); + await stopProcess(server); + } +})().catch(error => { + console.error(error.stack || error); + process.exitCode = 1; +});
--- a/rich_editor/rich_editor.js Mon Aug 03 10:48:59 2026 -0700 +++ b/rich_editor/rich_editor.js Mon Aug 03 11:26:30 2026 -0700 @@ -44,19 +44,19 @@ toolbar.className = 'rich-editor-toolbar'; const buttons = [ - { cmd: 'h1', label: '', title: 'Heading 1', icons: "/public/icons/h1.png" }, - { cmd: 'h2', label: '', title: 'Heading 2', icons: "/public/icons/h2.png" }, - { cmd: 'h3', label: '', title: 'Heading 3', icons: "/public/icons/h3.png" }, + { cmd: 'h1', label: '', title: 'Heading 1', icons: "/public/icons/h1.webp" }, + { cmd: 'h2', label: '', title: 'Heading 2', icons: "/public/icons/h2.webp" }, + { cmd: 'h3', label: '', title: 'Heading 3', icons: "/public/icons/h3.webp" }, { cmd: 'p', label: '¶', title: 'Paragraph' }, { cmd: 'ul', label: '•', title: 'Bullet List' }, { cmd: 'ol', label: '1.', title: 'Numbered List' }, - { cmd: 'bold', label: '', title: 'Bold', icons: "/public/icons/bold.png" }, + { cmd: 'bold', label: '', title: 'Bold', icons: "/public/icons/bold.webp" }, { cmd: 'separator' }, - { cmd: 'link', label: '', title: 'Insert Link', icons: "/public/icons/link.png" }, - { cmd: 'notelink', label: '', title: 'Link to Note', icons: "/public/icons/binder.png" }, - { cmd: 'upload', label: '', title: 'Upload File', icons: "/public/icons/clipy.png" }, + { cmd: 'link', label: '', title: 'Insert Link', icons: "/public/icons/link.webp" }, + { cmd: 'notelink', label: '', title: 'Link to Note', icons: "/public/icons/binder.webp" }, + { cmd: 'upload', label: '', title: 'Upload File', icons: "/public/icons/clipy.webp" }, { cmd: 'separator' }, - { cmd: 'readonly', label: '', title: 'readonly', icons: "/public/icons/book.png" }, + { cmd: 'readonly', label: '', title: 'readonly', icons: "/public/icons/book.webp" }, ]; buttons.forEach(btn => {