From 510668a2f897fd66815756ac32738fd1667cb077 Mon Sep 17 00:00:00 2001 From: James Ross Date: Mon, 7 Sep 2026 11:35:05 -0700 Subject: [PATCH 1/5] feat(startup): open on the Jim logo in colour Startup opened on a blank viewer. It now renders JimLogo.svg -- the restored original artwork, not the replacement -- as coloured Braille. The previous splash was removed because its generator could not draw this logo. That pipeline knocked out one specific background colour, extracted alpha, and flood-filled inward from the border; run against artwork with its own opaque geometry it produced a 392-byte mask with zero set bits. It also needed ImageMagick on every `npm run check`, which failed a fresh checkout before the first test. This generator uses rsvg-convert and runs manually, never as part of the gate. Its output is committed, so building and testing jedit needs no image tooling at all; only regenerating the artwork does. The frame is stored as a 15-entry palette plus one index per pixel at 64x64 -- index 0 transparent -- which is 155 committed lines rather than a raw RGBA dump. Colour is the visible difference. The old renderer passed `colorMode: 'none'` and painted every cell one flat theme token, so the artwork arrived as a silhouette. This passes `'fg'`, so the silver J, the blue diamond and the grid keep their own colours, and blank Braille cells stay transparent instead of painting an opaque rectangle over the workspace surface. Costs nothing the previous work reclaimed. No scene, no meshes, no frame pulse: `workspaceAnimationIsActive` is unchanged, so an idle editor still returns the same model and renders zero times. Measured after: createInitialModelSnapshot() 8.9 ms (was 8.3 ms before this change) renderJimLogoScreen 120x32 2.4 ms steady state, on input only Caveat worth knowing: the artwork carries black outlines, so on a dark terminal those merge into the background and the shapes read without their defining edge. The J, grid and wordmark remain legible. A light card behind the logo would fix it and was rejected as visually jarring in a terminal. This branch touches src/ui/title-screen.ts and therefore needs the `title-unfreeze` label, as #304 does. npm run check: 840 tests, 828 pass, 0 fail, 12 intentional skips; native suites green; quality gate reports no regressions. --- scripts/generate-jim-logo-frame.mjs | 202 +++++++++++++++++++++++++++ spec/workspace-fast-startup.spec.mjs | 44 ++++-- src/app/workspace/viewer-content.ts | 8 +- src/ui/jim-logo-frame-data.ts | 155 ++++++++++++++++++++ src/ui/jim-logo-screen.ts | 146 +++++++++++++++++++ src/ui/title-screen.ts | 2 + 6 files changed, 544 insertions(+), 13 deletions(-) create mode 100644 scripts/generate-jim-logo-frame.mjs create mode 100644 src/ui/jim-logo-frame-data.ts create mode 100644 src/ui/jim-logo-screen.ts diff --git a/scripts/generate-jim-logo-frame.mjs b/scripts/generate-jim-logo-frame.mjs new file mode 100644 index 00000000..644960f6 --- /dev/null +++ b/scripts/generate-jim-logo-frame.mjs @@ -0,0 +1,202 @@ +#!/usr/bin/env node + +// Renders JimLogo.svg into a small indexed-colour frame committed as source. +// +// This is a manual, documented step -- deliberately not part of `npm run check`. +// The previous generator ran on every gate and shelled out to ImageMagick, so a +// fresh checkout without it failed before a single test ran. The generated +// module below is committed, so building and testing jedit needs no image +// tooling at all; only regenerating the artwork does. +// +// rsvg-convert is used rather than ImageMagick because the logo carries its own +// opaque geometry. The old pipeline knocked out one specific background colour +// and flood-filled inward from the border, which was tuned to different artwork +// and produced an all-zero mask for this one. +// +// Usage: node scripts/generate-jim-logo-frame.mjs [--check] + +import { spawnSync } from "node:child_process"; +import zlib from "node:zlib"; +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +const SOURCE_PATH = path.resolve("JimLogo.svg"); +const OUTPUT_PATH = path.resolve("src", "ui", "jim-logo-frame-data.ts"); +const FRAME_SIZE = 64; +const RGBA_CHANNELS = 4; +const ALPHA_CHANNEL_OFFSET = 3; +const OPAQUE_ALPHA_THRESHOLD = 110; +const QUANTISE_STEP = 24; +const MAX_PALETTE_ENTRIES = 15; +const TRANSPARENT_INDEX = 0; +const CHECK_FLAG = "--check"; +const RSVG_COMMAND = "rsvg-convert"; +const MAX_RASTER_BYTES = FRAME_SIZE * FRAME_SIZE * RGBA_CHANNELS; +const INDICES_PER_LINE = 32; +const PROCESS_SUCCESS = 0; +const TEXT_ENCODING = "utf8"; + +const rgba = renderRgba(); +const { palette, indices } = quantise(rgba); +const generated = generatedModule(palette, indices); + +if (process.argv.includes(CHECK_FLAG)) { + const current = readFileSync(OUTPUT_PATH, TEXT_ENCODING); + if (current !== generated) { + throw new Error(`${path.relative(process.cwd(), OUTPUT_PATH)} is stale`); + } +} else { + writeFileSync(OUTPUT_PATH, generated); +} + +function renderRgba() { + const result = spawnSync( + RSVG_COMMAND, + [ + "-w", String(FRAME_SIZE), + "-h", String(FRAME_SIZE), + "-a", + "--background-color=none", + "-f", "png", + SOURCE_PATH, + ], + { encoding: null, maxBuffer: MAX_RASTER_BYTES * 4 }, + ); + if (result.error != null) { + throw result.error; + } + if (result.status !== PROCESS_SUCCESS) { + throw new Error(result.stderr.toString(TEXT_ENCODING)); + } + return decodePng(result.stdout); +} + +// Minimal PNG reader: rsvg-convert emits 8-bit RGBA, non-interlaced. +function decodePng(bytes) { + let offset = 8; + let width = 0; + let height = 0; + const idat = []; + while (offset < bytes.length) { + const length = bytes.readUInt32BE(offset); + const type = bytes.toString("ascii", offset + 4, offset + 8); + const body = bytes.subarray(offset + 8, offset + 8 + length); + if (type === "IHDR") { + width = body.readUInt32BE(0); + height = body.readUInt32BE(4); + } else if (type === "IDAT") { + idat.push(body); + } + offset += length + 12; + } + const raw = zlib.inflateSync(Buffer.concat(idat)); + const stride = width * RGBA_CHANNELS; + const out = Buffer.alloc(stride * height); + let previous = Buffer.alloc(stride); + for (let y = 0; y < height; y += 1) { + const filter = raw[y * (stride + 1)]; + const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)); + const current = Buffer.alloc(stride); + for (let x = 0; x < stride; x += 1) { + const a = x >= RGBA_CHANNELS ? current[x - RGBA_CHANNELS] : 0; + const b = previous[x]; + const c = x >= RGBA_CHANNELS ? previous[x - RGBA_CHANNELS] : 0; + current[x] = (line[x] + unfilter(filter, a, b, c)) & 0xff; + } + current.copy(out, y * stride); + previous = current; + } + return out; +} + +function unfilter(filter, a, b, c) { + if (filter === 1) return a; + if (filter === 2) return b; + if (filter === 3) return Math.floor((a + b) / 2); + if (filter === 4) return paeth(a, b, c); + return 0; +} + +function paeth(a, b, c) { + const p = a + b - c; + const pa = Math.abs(p - a); + const pb = Math.abs(p - b); + const pc = Math.abs(p - c); + if (pa <= pb && pa <= pc) return a; + return pb <= pc ? b : c; +} + +function quantise(rgba) { + const counts = new Map(); + for (let i = 0; i < rgba.length; i += RGBA_CHANNELS) { + if (rgba[i + ALPHA_CHANNEL_OFFSET] < OPAQUE_ALPHA_THRESHOLD) { + continue; + } + const key = bucketKey(rgba[i], rgba[i + 1], rgba[i + 2]); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + const ranked = [...counts.entries()] + .sort((left, right) => right[1] - left[1]) + .slice(0, MAX_PALETTE_ENTRIES) + .map(([key]) => key); + + const palette = ranked.map(unbucketKey); + const indices = new Uint8Array(FRAME_SIZE * FRAME_SIZE); + for (let pixel = 0; pixel < indices.length; pixel += 1) { + const i = pixel * RGBA_CHANNELS; + if (rgba[i + ALPHA_CHANNEL_OFFSET] < OPAQUE_ALPHA_THRESHOLD) { + indices[pixel] = TRANSPARENT_INDEX; + continue; + } + indices[pixel] = nearest(palette, rgba[i], rgba[i + 1], rgba[i + 2]) + 1; + } + return { palette, indices }; +} + +function bucketKey(red, green, blue) { + const q = (value) => Math.min(255, Math.round(value / QUANTISE_STEP) * QUANTISE_STEP); + return `${q(red)},${q(green)},${q(blue)}`; +} + +function unbucketKey(key) { + return key.split(",").map(Number); +} + +function nearest(palette, red, green, blue) { + let best = 0; + let bestDistance = Number.POSITIVE_INFINITY; + for (let i = 0; i < palette.length; i += 1) { + const [pr, pg, pb] = palette[i]; + const distance = ((pr - red) ** 2) + ((pg - green) ** 2) + ((pb - blue) ** 2); + if (distance < bestDistance) { + bestDistance = distance; + best = i; + } + } + return best; +} + +function generatedModule(palette, indices) { + const paletteText = palette + .map(([r, g, b]) => ` [${r}, ${g}, ${b}],`) + .join("\n"); + const indexLines = []; + for (let i = 0; i < indices.length; i += INDICES_PER_LINE) { + indexLines.push(` ${[...indices.subarray(i, i + INDICES_PER_LINE)].join(", ")},`); + } + return `// Generated by scripts/generate-jim-logo-frame.mjs from JimLogo.svg. +// Do not edit this file by hand. Regeneration is a manual step and requires +// rsvg-convert; building and testing jedit does not. + +export const JIM_LOGO_FRAME_SIZE = ${FRAME_SIZE}; + +// Index 0 is transparent. Every other index is palette entry (index - 1). +export const JIM_LOGO_PALETTE: readonly (readonly [number, number, number])[] = [ +${paletteText} +]; + +export const JIM_LOGO_INDICES = new Uint8Array([ +${indexLines.join("\n")} +]); +`; +} diff --git a/spec/workspace-fast-startup.spec.mjs b/spec/workspace-fast-startup.spec.mjs index 3f2be73b..5f0960b1 100644 --- a/spec/workspace-fast-startup.spec.mjs +++ b/spec/workspace-fast-startup.spec.mjs @@ -39,7 +39,7 @@ test("startup snapshot preloads no title scene", async () => { assert.equal(snapshot.sceneOverrideName == null, true); }); -test("a workspace with no open file renders no title backdrop", async () => { +test("a workspace with no open file renders the Jim logo", async () => { const [init, viewerContent] = await Promise.all([ importDist("app", "workspace", "init.js"), importDist("app", "workspace", "viewer-content.js"), @@ -52,22 +52,46 @@ test("a workspace with no open file renders no title backdrop", async () => { nowMs: STARTUP_NOW_MS, }); - const surface = viewerContent.renderViewer( - model, - VIEWER_WIDTH, - VIEWER_HEIGHT, - ); - let painted = 0; + const surface = viewerContent.renderViewer(model, VIEWER_WIDTH, VIEWER_HEIGHT); + let braille = 0; + for (let row = 0; row < VIEWER_HEIGHT; row += 1) { + for (let column = 0; column < VIEWER_WIDTH; column += 1) { + const char = surface.get(column, row)?.char ?? " "; + if (char >= "\u2801" && char <= "\u28ff") { + braille += 1; + } + } + } + + assert.ok(braille > 40, `expected Braille logo ink, saw ${braille} cells`); +}); + +test("the startup logo carries its own colours, not one flat token", async () => { + const [init, viewerContent] = await Promise.all([ + importDist("app", "workspace", "init.js"), + importDist("app", "workspace", "viewer-content.js"), + ]); + const model = init.createInitialModel(REPO_ROOT, VIEWER_WIDTH, VIEWER_HEIGHT, { + entries: [], + titleSceneSeed: FIXED_SEED, + jeditTheme: mockJeditTheme(), + i18n: mockI18n(), + nowMs: STARTUP_NOW_MS, + }); + + const surface = viewerContent.renderViewer(model, VIEWER_WIDTH, VIEWER_HEIGHT); + const inkColours = new Set(); for (let row = 0; row < VIEWER_HEIGHT; row += 1) { for (let column = 0; column < VIEWER_WIDTH; column += 1) { const cell = surface.get(column, row); - if (cell?.char != null && cell.char.trim() !== "") { - painted += 1; + const char = cell?.char ?? " "; + if (char >= "\u2801" && char <= "\u28ff" && cell?.fgRGB != null) { + inkColours.add(cell.fgRGB.join(",")); } } } - assert.equal(painted, 0); + assert.ok(inkColours.size > 3, `expected multiple ink colours, saw ${inkColours.size}`); }); test("no title scene stats are reported when no backdrop is drawn", async () => { diff --git a/src/app/workspace/viewer-content.ts b/src/app/workspace/viewer-content.ts index 576e90bd..de14974b 100644 --- a/src/app/workspace/viewer-content.ts +++ b/src/app/workspace/viewer-content.ts @@ -2,6 +2,7 @@ import { createSurface, type Surface } from "@flyingrobots/bijou"; import { paintMarkdownPreview } from "../../ui/markdown-preview.js"; import { renderSourceViewer } from "../../ui/source-viewer.js"; import { + renderJimLogoScreen, TITLE_BACKDROP_KIND, TITLE_RENDER_MODE, paintTitleScreenPresentation, @@ -176,14 +177,15 @@ function renderViewerWithState( ); } +// vi opens on nothing; jedit opens on its own mark. This is a single static +// render of committed artwork -- no scene, no meshes, and no frame pulse, so it +// costs one draw rather than an animation. function emptyViewerSurface( model: WorkspaceModel, width: number, height: number, ): Surface { - const surface = createSurface(width, height); - fillSurface(surface, model.jeditTheme.surface.workspace); - return surface; + return renderJimLogoScreen(width, height, model.jeditTheme); } function renderTitleViewer( diff --git a/src/ui/jim-logo-frame-data.ts b/src/ui/jim-logo-frame-data.ts new file mode 100644 index 00000000..dc30005b --- /dev/null +++ b/src/ui/jim-logo-frame-data.ts @@ -0,0 +1,155 @@ +// Generated by scripts/generate-jim-logo-frame.mjs from JimLogo.svg. +// Do not edit this file by hand. Regeneration is a manual step and requires +// rsvg-convert; building and testing jedit does not. + +export const JIM_LOGO_FRAME_SIZE = 64; + +// Index 0 is transparent. Every other index is palette entry (index - 1). +export const JIM_LOGO_PALETTE: readonly (readonly [number, number, number])[] = [ + [0, 0, 0], + [216, 216, 216], + [120, 120, 120], + [0, 0, 24], + [24, 24, 24], + [0, 48, 120], + [0, 24, 48], + [192, 192, 192], + [144, 144, 144], + [0, 48, 144], + [0, 24, 72], + [72, 72, 72], + [0, 72, 192], + [168, 168, 168], + [96, 96, 96], +]; + +export const JIM_LOGO_INDICES = new Uint8Array([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 4, + 4, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 11, + 11, 4, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 1, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 1, 1, 0, 0, + 0, 0, 0, 0, 0, 1, 5, 14, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, + 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 15, 1, 1, 0, + 0, 0, 0, 0, 0, 1, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 8, 3, 5, 1, 0, + 0, 0, 0, 0, 0, 1, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 8, 3, 5, 1, 0, + 0, 0, 0, 0, 0, 1, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 14, 3, 5, 1, 0, + 0, 0, 0, 0, 0, 1, 15, 8, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 9, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 1, 0, + 0, 0, 0, 0, 0, 1, 1, 12, 15, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 3, 9, + 9, 9, 14, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 12, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 5, 1, 0, 0, + 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, + 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 4, 13, 10, 4, 11, 13, 4, 4, 1, 3, 3, + 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 4, 13, 10, 6, 10, 10, 13, 10, 11, 1, 3, 3, + 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 4, 13, 10, 4, 1, 1, 7, 10, 1, 1, 1, 3, 3, + 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 4, 13, 10, 11, 1, 1, 1, 7, 10, 1, 1, 1, 3, 3, + 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 4, 13, 10, 7, 6, 1, 1, 1, 4, 10, 1, 1, 1, 3, 3, + 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 4, 13, 10, 7, 6, 10, 11, 11, 11, 6, 13, 11, 7, 1, 3, 3, + 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 4, 13, 10, 11, 6, 10, 13, 6, 6, 6, 6, 13, 6, 7, 1, 3, 3, + 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 4, 13, 10, 4, 1, 1, 11, 6, 1, 1, 1, 4, 10, 1, 1, 1, 3, 3, + 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 11, 4, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 4, 13, 10, 11, 1, 1, 1, 11, 6, 1, 1, 1, 7, 10, 1, 1, 1, 3, 3, + 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 4, 13, 4, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 4, 13, 10, 11, 11, 1, 1, 1, 11, 6, 1, 1, 1, 4, 10, 1, 1, 1, 3, 3, + 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 11, 13, 4, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 4, 13, 10, 11, 13, 10, 6, 6, 6, 10, 13, 6, 6, 6, 10, 13, 6, 11, 1, 3, 3, + 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 4, 6, 6, 13, 4, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 4, 13, 10, 7, 7, 10, 6, 7, 11, 7, 6, 10, 7, 11, 11, 11, 13, 7, 4, 1, 3, 3, + 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 4, 11, 7, 6, 13, 4, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 1, 4, 13, 10, 4, 1, 1, 6, 11, 1, 1, 1, 11, 6, 1, 1, 1, 4, 10, 1, 1, 1, 3, 3, + 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 1, 1, 7, 6, 13, 4, 1, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 1, 4, 13, 10, 7, 1, 1, 1, 6, 11, 1, 1, 1, 11, 6, 1, 1, 1, 7, 10, 1, 1, 1, 3, 3, + 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 1, 1, 7, 10, 11, 13, 4, 1, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 1, 4, 13, 10, 6, 7, 1, 4, 1, 6, 11, 1, 4, 1, 11, 10, 1, 4, 1, 7, 10, 1, 1, 1, 3, 3, + 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 4, 1, 11, 10, 1, 11, 13, 4, 1, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 1, 4, 13, 10, 6, 13, 10, 10, 10, 10, 13, 13, 10, 10, 10, 13, 13, 10, 10, 10, 10, 13, 10, 11, 1, 3, 3, + 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 7, 10, 10, 10, 13, 10, 6, 6, 13, 4, 1, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 1, 4, 13, 10, 4, 1, 10, 7, 4, 4, 1, 10, 6, 4, 4, 4, 11, 10, 1, 4, 4, 7, 13, 4, 1, 1, 3, 3, + 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 4, 4, 11, 10, 1, 4, 4, 11, 13, 4, 1, 0, 0, 0, 0, + 0, 0, 0, 1, 4, 13, 10, 4, 1, 1, 10, 7, 1, 1, 1, 6, 11, 1, 1, 1, 11, 6, 1, 1, 1, 4, 10, 1, 1, 1, 3, 3, + 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 1, 1, 7, 10, 1, 1, 1, 4, 6, 13, 4, 1, 0, 0, 0, + 0, 0, 1, 4, 13, 10, 4, 1, 1, 1, 10, 7, 1, 1, 1, 6, 11, 1, 1, 1, 11, 6, 1, 1, 1, 4, 10, 1, 1, 1, 3, 3, + 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 1, 1, 7, 10, 1, 1, 1, 1, 10, 11, 13, 4, 1, 0, 0, + 0, 1, 4, 13, 10, 6, 7, 7, 7, 7, 13, 11, 7, 7, 7, 10, 6, 7, 7, 7, 6, 10, 7, 7, 7, 11, 13, 7, 4, 1, 3, 3, + 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 4, 7, 7, 11, 10, 7, 7, 7, 11, 13, 7, 11, 13, 4, 1, 0, + 0, 1, 4, 10, 6, 10, 10, 6, 6, 6, 13, 10, 6, 6, 6, 13, 10, 6, 6, 6, 10, 13, 6, 6, 6, 10, 13, 6, 11, 1, 3, 3, + 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 7, 6, 6, 10, 13, 6, 6, 6, 10, 13, 6, 11, 10, 4, 4, 0, + 0, 0, 1, 7, 13, 10, 4, 1, 1, 1, 10, 7, 1, 1, 1, 6, 11, 1, 1, 1, 11, 6, 1, 1, 1, 7, 10, 1, 1, 1, 3, 3, + 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 1, 1, 7, 10, 1, 1, 1, 4, 10, 11, 13, 4, 4, 0, 0, + 0, 0, 0, 1, 7, 13, 6, 1, 1, 1, 10, 7, 1, 1, 1, 6, 11, 1, 1, 1, 11, 6, 1, 1, 1, 7, 10, 1, 1, 1, 3, 3, + 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 1, 1, 7, 10, 1, 1, 1, 4, 6, 13, 4, 4, 0, 0, 0, + 0, 0, 0, 0, 1, 7, 13, 6, 1, 1, 10, 4, 1, 1, 1, 6, 11, 1, 1, 1, 11, 6, 1, 1, 1, 4, 10, 1, 1, 1, 3, 3, + 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 1, 1, 7, 10, 1, 1, 1, 11, 13, 4, 4, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 1, 7, 13, 6, 11, 13, 6, 6, 6, 11, 10, 10, 11, 6, 11, 10, 13, 11, 6, 6, 6, 13, 11, 7, 1, 3, 3, + 3, 3, 9, 2, 15, 1, 1, 1, 12, 8, 2, 2, 3, 3, 3, 1, 4, 6, 6, 6, 13, 11, 11, 11, 13, 4, 4, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 1, 7, 13, 6, 10, 6, 11, 11, 11, 10, 10, 11, 11, 11, 10, 10, 11, 11, 11, 6, 13, 11, 7, 1, 3, 3, + 3, 3, 9, 9, 1, 3, 9, 3, 1, 14, 2, 2, 3, 3, 3, 1, 4, 11, 11, 6, 13, 11, 11, 13, 4, 4, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 1, 7, 13, 6, 7, 1, 1, 1, 6, 11, 1, 1, 1, 11, 6, 1, 1, 1, 4, 10, 1, 1, 1, 3, 3, + 3, 3, 9, 12, 12, 2, 2, 9, 1, 8, 2, 2, 3, 3, 3, 1, 1, 1, 1, 7, 10, 11, 13, 4, 4, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 1, 7, 13, 6, 1, 1, 1, 6, 11, 1, 1, 1, 11, 6, 1, 1, 1, 7, 10, 1, 1, 1, 3, 3, + 3, 3, 9, 1, 3, 2, 2, 12, 12, 2, 2, 2, 3, 3, 3, 1, 1, 1, 1, 7, 6, 13, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 7, 13, 6, 1, 1, 6, 11, 1, 1, 1, 11, 6, 1, 1, 1, 7, 10, 1, 1, 1, 3, 3, + 3, 3, 9, 5, 5, 5, 5, 1, 9, 2, 2, 2, 3, 3, 3, 1, 1, 1, 1, 11, 13, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 7, 13, 6, 6, 13, 13, 6, 10, 6, 10, 13, 6, 10, 10, 10, 13, 6, 11, 1, 3, 3, + 3, 3, 9, 8, 3, 3, 3, 9, 2, 2, 2, 2, 3, 3, 3, 1, 7, 6, 11, 13, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 7, 13, 6, 6, 6, 7, 7, 7, 6, 10, 4, 7, 7, 11, 13, 7, 4, 1, 3, 3, + 3, 15, 3, 14, 14, 14, 14, 2, 2, 8, 14, 14, 3, 3, 3, 1, 1, 11, 13, 4, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 7, 13, 6, 11, 1, 1, 1, 11, 6, 1, 1, 1, 4, 10, 1, 1, 1, 3, 15, + 1, 1, 1, 1, 1, 1, 1, 14, 9, 1, 1, 1, 1, 1, 5, 1, 4, 6, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 7, 13, 6, 1, 1, 1, 11, 6, 1, 1, 1, 4, 10, 1, 1, 1, 3, 12, + 1, 12, 3, 8, 8, 9, 1, 8, 12, 5, 12, 14, 14, 14, 5, 1, 4, 1, 3, 14, 14, 3, 1, 1, 1, 5, 14, 14, 14, 3, 1, 0, + 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 7, 13, 6, 4, 4, 6, 10, 4, 4, 4, 11, 13, 4, 1, 1, 3, 15, + 5, 1, 3, 2, 2, 12, 12, 2, 3, 5, 5, 2, 2, 2, 8, 15, 15, 3, 2, 2, 2, 2, 9, 3, 3, 14, 2, 2, 2, 8, 1, 0, + 0, 0, 0, 0, 1, 1, 12, 12, 12, 12, 12, 12, 12, 1, 1, 1, 7, 13, 6, 6, 13, 13, 10, 10, 10, 10, 13, 10, 1, 12, 3, 3, + 12, 1, 8, 2, 8, 5, 3, 2, 2, 5, 15, 2, 8, 14, 14, 14, 14, 14, 8, 2, 2, 14, 9, 9, 9, 9, 14, 2, 2, 9, 1, 0, + 0, 0, 0, 1, 1, 15, 3, 9, 9, 9, 9, 9, 9, 12, 1, 1, 1, 7, 13, 6, 11, 6, 1, 1, 1, 7, 10, 1, 1, 3, 3, 3, + 5, 12, 2, 2, 9, 1, 8, 2, 14, 1, 14, 2, 9, 1, 1, 1, 1, 1, 3, 2, 8, 1, 1, 1, 1, 1, 15, 2, 2, 12, 1, 0, + 0, 0, 0, 0, 1, 15, 3, 2, 2, 2, 2, 2, 2, 9, 12, 1, 0, 1, 7, 13, 6, 6, 1, 1, 1, 7, 6, 1, 12, 3, 3, 3, + 1, 9, 2, 2, 12, 12, 2, 2, 15, 5, 2, 2, 12, 5, 5, 1, 0, 1, 8, 2, 9, 1, 0, 0, 0, 1, 14, 2, 8, 1, 0, 0, + 0, 0, 0, 0, 1, 5, 3, 14, 2, 2, 2, 2, 2, 2, 15, 5, 1, 1, 1, 7, 13, 6, 1, 1, 1, 1, 1, 12, 3, 3, 3, 12, + 1, 8, 2, 8, 1, 3, 2, 8, 5, 15, 2, 8, 5, 12, 1, 1, 1, 12, 2, 2, 15, 1, 0, 0, 1, 5, 2, 2, 9, 1, 0, 0, + 0, 0, 0, 0, 1, 1, 15, 14, 2, 2, 2, 2, 2, 2, 2, 3, 12, 5, 1, 1, 1, 1, 1, 1, 1, 5, 3, 3, 3, 3, 3, 5, + 12, 2, 2, 9, 1, 8, 2, 14, 1, 14, 2, 9, 1, 5, 1, 0, 1, 3, 2, 8, 5, 1, 0, 0, 1, 15, 2, 2, 15, 1, 0, 0, + 0, 0, 0, 0, 0, 1, 5, 8, 2, 2, 2, 2, 2, 2, 2, 2, 14, 15, 15, 3, 15, 15, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, + 9, 2, 2, 12, 12, 2, 2, 15, 5, 2, 2, 15, 5, 1, 0, 0, 1, 14, 2, 14, 1, 0, 0, 0, 1, 9, 2, 2, 5, 1, 0, 0, + 0, 0, 0, 0, 0, 1, 1, 15, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 8, 9, 9, 3, 3, 3, 3, 3, 3, 9, 9, 8, 3, 5, + 8, 2, 8, 1, 3, 2, 14, 5, 3, 2, 2, 5, 1, 0, 0, 1, 5, 2, 2, 15, 1, 0, 0, 1, 1, 8, 2, 14, 1, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 1, 1, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 5, 15, + 2, 2, 3, 1, 3, 3, 15, 1, 14, 2, 14, 1, 0, 0, 0, 1, 15, 2, 2, 5, 1, 0, 0, 1, 12, 2, 2, 15, 1, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 1, 1, 15, 8, 2, 2, 8, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 9, 3, 1, 14, + 2, 2, 12, 1, 12, 3, 12, 5, 2, 2, 15, 1, 0, 0, 0, 1, 14, 2, 14, 1, 1, 0, 0, 1, 9, 2, 2, 5, 1, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 5, 15, 9, 14, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 12, 5, 14, + 14, 14, 12, 5, 5, 5, 1, 12, 9, 9, 12, 1, 1, 0, 1, 1, 9, 9, 15, 1, 1, 0, 0, 1, 9, 9, 9, 5, 1, 1, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 4, 11, 7, 1, 11, + 1, 1, 4, 10, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 7, 13, 6, 10, + 1, 7, 13, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 7, 13, 6, + 7, 13, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 7, 13, + 13, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 7, + 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, +]); diff --git a/src/ui/jim-logo-screen.ts b/src/ui/jim-logo-screen.ts new file mode 100644 index 00000000..17c4ca9d --- /dev/null +++ b/src/ui/jim-logo-screen.ts @@ -0,0 +1,146 @@ +import { createSurface, type Surface } from '@flyingrobots/bijou'; +import { rasterToGlyphSurface, type RgbaFrame } from '@flyingrobots/bijou-tui'; + +import type { JeditTheme } from './jedit-theme.js'; +import { + JIM_LOGO_FRAME_SIZE, + JIM_LOGO_INDICES, + JIM_LOGO_PALETTE, +} from './jim-logo-frame-data.js'; + +const MAX_LOGO_ROWS = 18; +const MIN_LOGO_ROWS = 4; +const LOGO_COLUMNS_PER_ROW = 2; +const LOGO_HORIZONTAL_MARGIN = 2; +const LOGO_VERTICAL_MARGIN = 2; +const BRAILLE_CELL_ASPECT_RATIO = 0.5; +const BRAILLE_DARKNESS_THRESHOLD = 0.5; +const BRAILLE_BLANK = '⠀'; +const SURFACE_BLANK = ' '; +const GLYPH_SURFACE_FIT = 'contain'; +// 'fg' keeps each cell's own colour. The previous renderer used 'none', which +// is why the logo was a single flat theme token rather than the artwork. +const GLYPH_SURFACE_COLOR_MODE = 'fg'; +const GLYPH_SURFACE_RENDERER_KIND = 'braille'; +const RGBA_CHANNEL_COUNT = 4; +const OPAQUE_ALPHA = 255; +const TRANSPARENT_INDEX = 0; + +// Built once at module load. The frame is static, so the startup screen renders +// without a frame pulse and without loading any mesh or scene. +const JIM_LOGO_FRAME = createJimLogoFrame(); + +interface JimLogoBounds { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +export function renderJimLogoScreen( + width: number, + height: number, + theme: JeditTheme, +): Surface { + const surface = fillWithWorkspace(width, height, theme); + const bounds = jimLogoBounds(width, height); + if (bounds == null) { + return surface; + } + const glyphs = rasterToGlyphSurface(JIM_LOGO_FRAME, { + columns: bounds.width, + rows: bounds.height, + fit: GLYPH_SURFACE_FIT, + cellAspectRatio: BRAILLE_CELL_ASPECT_RATIO, + colorMode: GLYPH_SURFACE_COLOR_MODE, + renderer: { + kind: GLYPH_SURFACE_RENDERER_KIND, + threshold: BRAILLE_DARKNESS_THRESHOLD, + }, + }); + blitInkOnly(surface, glyphs, bounds.x, bounds.y); + return surface; +} + +function fillWithWorkspace(width: number, height: number, theme: JeditTheme): Surface { + const token = theme.surface.workspace; + const surface = createSurface(width, height, { char: SURFACE_BLANK, empty: false }); + for (let y = 0; y < surface.height; y += 1) { + for (let x = 0; x < surface.width; x += 1) { + surface.set(x, y, { + ...surface.get(x, y), + fg: token.fg, + fgRGB: token.fgRGB, + bg: token.bg, + bgRGB: token.bgRGB, + empty: false, + }); + } + } + return surface; +} + +function jimLogoBounds(width: number, height: number): JimLogoBounds | undefined { + const availableRows = Math.min( + MAX_LOGO_ROWS, + height - (LOGO_VERTICAL_MARGIN * 2), + Math.floor((width - (LOGO_HORIZONTAL_MARGIN * 2)) / LOGO_COLUMNS_PER_ROW), + ); + if (availableRows < MIN_LOGO_ROWS) { + return undefined; + } + const logoWidth = availableRows * LOGO_COLUMNS_PER_ROW; + return { + x: Math.floor((width - logoWidth) / 2), + y: Math.floor((height - availableRows) / 2), + width: logoWidth, + height: availableRows, + }; +} + +// Blank Braille cells stay transparent so the workspace surface shows through +// rather than the logo painting an opaque rectangle. +function blitInkOnly( + target: Surface, + glyphs: Surface, + originX: number, + originY: number, +): void { + for (let y = 0; y < glyphs.height; y += 1) { + for (let x = 0; x < glyphs.width; x += 1) { + const glyph = glyphs.get(x, y); + if (glyph.char === BRAILLE_BLANK || glyph.char === SURFACE_BLANK) { + continue; + } + const cell = target.get(originX + x, originY + y); + target.set(originX + x, originY + y, { + ...cell, + char: glyph.char, + fg: glyph.fg, + fgRGB: glyph.fgRGB, + empty: false, + }); + } + } +} + +function createJimLogoFrame(): RgbaFrame { + const size = JIM_LOGO_FRAME_SIZE; + const data = new Uint8ClampedArray(size * size * RGBA_CHANNEL_COUNT); + for (let pixel = 0; pixel < JIM_LOGO_INDICES.length; pixel += 1) { + const index = JIM_LOGO_INDICES[pixel] ?? TRANSPARENT_INDEX; + if (index === TRANSPARENT_INDEX) { + continue; + } + const colour = JIM_LOGO_PALETTE[index - 1]; + if (colour == null) { + continue; + } + const offset = pixel * RGBA_CHANNEL_COUNT; + data[offset] = colour[0]; + data[offset + 1] = colour[1]; + data[offset + 2] = colour[2]; + data[offset + 3] = OPAQUE_ALPHA; + } + return { width: size, height: size, data }; +} diff --git a/src/ui/title-screen.ts b/src/ui/title-screen.ts index 6bb7e2b8..92f7f59f 100644 --- a/src/ui/title-screen.ts +++ b/src/ui/title-screen.ts @@ -465,3 +465,5 @@ function getRayDir( ), ); } + +export { renderJimLogoScreen } from './jim-logo-screen.js'; From 709ac1d433e26dcb662289ebda85586e8416a1bc Mon Sep 17 00:00:00 2001 From: James Ross Date: Mon, 7 Sep 2026 11:55:42 -0700 Subject: [PATCH 2/5] feat: scale the startup logo to fill the terminal The logo was capped at 18 rows, so on a full-screen terminal it sat as a small square in a large empty field. The cap is gone: the artwork now grows until it runs out of viewport on whichever axis is tighter, minus margins. Filling the screen means rasterising a lot more Braille, so two costs had to come down with it: - The glyph raster depends only on the cell grid, so it is cached per size. Only blitInkOnly reads it and it never writes, so the surface is safe to share; a resize pays for one re-raster and nothing else does. - fillWithWorkspace built each cell by spreading the cell it had just read back out of the surface. Every cell starts identical, so that was a whole extra pass over the grid for nothing. It writes a literal now. Together: 10.05 ms -> 2.62 ms per render at 190x50, back under the 2.36 ms the smaller logo cost. The screen renders only on input, not on a frame pulse, so this is per keystroke on the startup screen, not per frame. The source frame is regenerated at 192px (was 64px) so it has the detail to survive being blown up, and the generated indices are packed two per byte and base64'd to keep the committed asset small. Adds spec/jim-logo-screen.spec.mjs, which had no coverage at all: the logo scales with the viewport, is bounded by width or height whichever runs out first, never paints outside the viewport, steps aside when there is no room to be legible, and keeps its own colours instead of one flat theme token. --- scripts/generate-jim-logo-frame.mjs | 33 ++- spec/jim-logo-screen.spec.mjs | 109 +++++++++ src/ui/jim-logo-frame-data.ts | 352 +++++++++++++++++----------- src/ui/jim-logo-screen.ts | 47 +++- 4 files changed, 388 insertions(+), 153 deletions(-) create mode 100644 spec/jim-logo-screen.spec.mjs diff --git a/scripts/generate-jim-logo-frame.mjs b/scripts/generate-jim-logo-frame.mjs index 644960f6..2bcbd2d1 100644 --- a/scripts/generate-jim-logo-frame.mjs +++ b/scripts/generate-jim-logo-frame.mjs @@ -22,7 +22,7 @@ import path from "node:path"; const SOURCE_PATH = path.resolve("JimLogo.svg"); const OUTPUT_PATH = path.resolve("src", "ui", "jim-logo-frame-data.ts"); -const FRAME_SIZE = 64; +const FRAME_SIZE = 192; const RGBA_CHANNELS = 4; const ALPHA_CHANNEL_OFFSET = 3; const OPAQUE_ALPHA_THRESHOLD = 110; @@ -32,7 +32,8 @@ const TRANSPARENT_INDEX = 0; const CHECK_FLAG = "--check"; const RSVG_COMMAND = "rsvg-convert"; const MAX_RASTER_BYTES = FRAME_SIZE * FRAME_SIZE * RGBA_CHANNELS; -const INDICES_PER_LINE = 32; +const BASE64_CHARS_PER_LINE = 120; +const NIBBLES_PER_BYTE = 2; const PROCESS_SUCCESS = 0; const TEXT_ENCODING = "utf8"; @@ -180,10 +181,25 @@ function generatedModule(palette, indices) { const paletteText = palette .map(([r, g, b]) => ` [${r}, ${g}, ${b}],`) .join("\n"); - const indexLines = []; - for (let i = 0; i < indices.length; i += INDICES_PER_LINE) { - indexLines.push(` ${[...indices.subarray(i, i + INDICES_PER_LINE)].join(", ")},`); + + // 16 values (transparent + 15 palette entries) fit one nibble, so two pixels + // pack per byte. Base64 keeps the committed asset far smaller than a numeric + // array literal and inside the repository line-length limit when chunked. + const packed = Buffer.alloc(Math.ceil(indices.length / NIBBLES_PER_BYTE)); + for (let i = 0; i < indices.length; i += 1) { + const value = indices[i] & 0x0f; + if (i % NIBBLES_PER_BYTE === 0) { + packed[i >> 1] = value << 4; + } else { + packed[i >> 1] |= value; + } } + const base64 = packed.toString("base64"); + const lines = []; + for (let i = 0; i < base64.length; i += BASE64_CHARS_PER_LINE) { + lines.push(` "${base64.slice(i, i + BASE64_CHARS_PER_LINE)}",`); + } + return `// Generated by scripts/generate-jim-logo-frame.mjs from JimLogo.svg. // Do not edit this file by hand. Regeneration is a manual step and requires // rsvg-convert; building and testing jedit does not. @@ -195,8 +211,9 @@ export const JIM_LOGO_PALETTE: readonly (readonly [number, number, number])[] = ${paletteText} ]; -export const JIM_LOGO_INDICES = new Uint8Array([ -${indexLines.join("\n")} -]); +// Two 4-bit palette indices per byte, base64 encoded, row-major. +export const JIM_LOGO_PACKED_INDICES = [ +${lines.join("\n")} +].join(""); `; } diff --git a/spec/jim-logo-screen.spec.mjs b/spec/jim-logo-screen.spec.mjs new file mode 100644 index 00000000..ad226135 --- /dev/null +++ b/spec/jim-logo-screen.spec.mjs @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { importDist } from "./dist-helpers.mjs"; + +// The startup logo is the first thing Jim shows, so its size is a property of +// the viewport, not a constant. These lock the fit behaviour: it grows with the +// terminal, stays square, respects whichever axis runs out first, and steps +// aside entirely when there is no room to be legible. + +const BLANK_CHARS = new Set([" ", "⠀"]); + +async function renderAt(width, height) { + const screen = await importDist("ui", "jim-logo-screen.js"); + const themes = await importDist("ui", "jedit-themes.js"); + const [theme] = themes.availableJeditThemes(); + return screen.renderJimLogoScreen(width, height, theme); +} + +function inkBounds(surface) { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (let y = 0; y < surface.height; y += 1) { + for (let x = 0; x < surface.width; x += 1) { + if (BLANK_CHARS.has(surface.get(x, y).char)) { + continue; + } + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + if (maxX < minX) { + return undefined; + } + return { minX, minY, width: maxX - minX + 1, height: maxY - minY + 1 }; +} + +test("the logo grows to fill a larger terminal", async () => { + const small = inkBounds(await renderAt(60, 20)); + const large = inkBounds(await renderAt(190, 50)); + + assert.ok(small !== undefined, "expected ink at 60x20"); + assert.ok(large !== undefined, "expected ink at 190x50"); + assert.ok( + large.height > small.height * 2, + `expected the logo to scale up; got ${small.height} -> ${large.height} rows`, + ); +}); + +test("a tall narrow terminal is bounded by its width, not its height", async () => { + const bounds = inkBounds(await renderAt(40, 90)); + + assert.ok(bounds !== undefined); + assert.ok( + bounds.width <= 40, + `logo overflowed a 40-column terminal at ${bounds.width} columns`, + ); + assert.ok( + bounds.height < 40, + `width-bounded logo should stay well under 90 rows; got ${bounds.height}`, + ); +}); + +test("a wide short terminal is bounded by its height", async () => { + const bounds = inkBounds(await renderAt(200, 24)); + + assert.ok(bounds !== undefined); + assert.ok( + bounds.height <= 24 - 4, + `logo ignored the vertical margin; got ${bounds.height} of 24 rows`, + ); +}); + +test("the logo never paints outside the viewport", async () => { + for (const [width, height] of [[60, 20], [190, 50], [40, 90], [200, 24]]) { + const bounds = inkBounds(await renderAt(width, height)); + assert.ok(bounds !== undefined, `expected ink at ${width}x${height}`); + assert.ok(bounds.minX >= 0 && bounds.minY >= 0); + assert.ok( + bounds.minX + bounds.width <= width && bounds.minY + bounds.height <= height, + `logo escaped a ${width}x${height} viewport`, + ); + } +}); + +test("a viewport too small for a legible logo renders none", async () => { + assert.equal(inkBounds(await renderAt(10, 6)), undefined); +}); + +test("the logo keeps its own colours rather than one flat token", async () => { + const surface = await renderAt(120, 40); + const inkColours = new Set(); + for (let y = 0; y < surface.height; y += 1) { + for (let x = 0; x < surface.width; x += 1) { + const cell = surface.get(x, y); + if (BLANK_CHARS.has(cell.char)) { + continue; + } + inkColours.add(JSON.stringify(cell.fgRGB)); + } + } + assert.ok( + inkColours.size > 1, + `expected multi-colour artwork, got ${inkColours.size} colour(s)`, + ); +}); diff --git a/src/ui/jim-logo-frame-data.ts b/src/ui/jim-logo-frame-data.ts index dc30005b..ed7962c8 100644 --- a/src/ui/jim-logo-frame-data.ts +++ b/src/ui/jim-logo-frame-data.ts @@ -2,154 +2,232 @@ // Do not edit this file by hand. Regeneration is a manual step and requires // rsvg-convert; building and testing jedit does not. -export const JIM_LOGO_FRAME_SIZE = 64; +export const JIM_LOGO_FRAME_SIZE = 192; // Index 0 is transparent. Every other index is palette entry (index - 1). export const JIM_LOGO_PALETTE: readonly (readonly [number, number, number])[] = [ [0, 0, 0], [216, 216, 216], [120, 120, 120], + [0, 72, 216], + [48, 48, 48], + [0, 72, 192], + [0, 24, 72], [0, 0, 24], [24, 24, 24], - [0, 48, 120], [0, 24, 48], - [192, 192, 192], - [144, 144, 144], - [0, 48, 144], - [0, 24, 72], [72, 72, 72], - [0, 72, 192], [168, 168, 168], [96, 96, 96], + [0, 48, 144], + [0, 48, 120], ]; -export const JIM_LOGO_INDICES = new Uint8Array([ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, - 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 4, - 4, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 11, - 11, 4, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 1, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, - 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 1, 1, 0, 0, - 0, 0, 0, 0, 0, 1, 5, 14, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, - 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 15, 1, 1, 0, - 0, 0, 0, 0, 0, 1, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, - 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 8, 3, 5, 1, 0, - 0, 0, 0, 0, 0, 1, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, - 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 8, 3, 5, 1, 0, - 0, 0, 0, 0, 0, 1, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, - 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 14, 3, 5, 1, 0, - 0, 0, 0, 0, 0, 1, 15, 8, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, - 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 9, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 1, 0, - 0, 0, 0, 0, 0, 1, 1, 12, 15, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 3, 9, - 9, 9, 14, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 12, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 5, 1, 0, 0, - 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, - 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 4, 13, 10, 4, 11, 13, 4, 4, 1, 3, 3, - 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 4, 13, 10, 6, 10, 10, 13, 10, 11, 1, 3, 3, - 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 4, 13, 10, 4, 1, 1, 7, 10, 1, 1, 1, 3, 3, - 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 4, 13, 10, 11, 1, 1, 1, 7, 10, 1, 1, 1, 3, 3, - 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 4, 13, 10, 7, 6, 1, 1, 1, 4, 10, 1, 1, 1, 3, 3, - 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 4, 13, 10, 7, 6, 10, 11, 11, 11, 6, 13, 11, 7, 1, 3, 3, - 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 4, 13, 10, 11, 6, 10, 13, 6, 6, 6, 6, 13, 6, 7, 1, 3, 3, - 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 4, 13, 10, 4, 1, 1, 11, 6, 1, 1, 1, 4, 10, 1, 1, 1, 3, 3, - 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 11, 4, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 4, 13, 10, 11, 1, 1, 1, 11, 6, 1, 1, 1, 7, 10, 1, 1, 1, 3, 3, - 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 4, 13, 4, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 4, 13, 10, 11, 11, 1, 1, 1, 11, 6, 1, 1, 1, 4, 10, 1, 1, 1, 3, 3, - 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 11, 13, 4, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 4, 13, 10, 11, 13, 10, 6, 6, 6, 10, 13, 6, 6, 6, 10, 13, 6, 11, 1, 3, 3, - 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 4, 6, 6, 13, 4, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 4, 13, 10, 7, 7, 10, 6, 7, 11, 7, 6, 10, 7, 11, 11, 11, 13, 7, 4, 1, 3, 3, - 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 4, 11, 7, 6, 13, 4, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 1, 4, 13, 10, 4, 1, 1, 6, 11, 1, 1, 1, 11, 6, 1, 1, 1, 4, 10, 1, 1, 1, 3, 3, - 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 1, 1, 7, 6, 13, 4, 1, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 1, 4, 13, 10, 7, 1, 1, 1, 6, 11, 1, 1, 1, 11, 6, 1, 1, 1, 7, 10, 1, 1, 1, 3, 3, - 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 1, 1, 7, 10, 11, 13, 4, 1, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 1, 4, 13, 10, 6, 7, 1, 4, 1, 6, 11, 1, 4, 1, 11, 10, 1, 4, 1, 7, 10, 1, 1, 1, 3, 3, - 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 4, 1, 11, 10, 1, 11, 13, 4, 1, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 1, 4, 13, 10, 6, 13, 10, 10, 10, 10, 13, 13, 10, 10, 10, 13, 13, 10, 10, 10, 10, 13, 10, 11, 1, 3, 3, - 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 7, 10, 10, 10, 13, 10, 6, 6, 13, 4, 1, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 1, 4, 13, 10, 4, 1, 10, 7, 4, 4, 1, 10, 6, 4, 4, 4, 11, 10, 1, 4, 4, 7, 13, 4, 1, 1, 3, 3, - 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 4, 4, 11, 10, 1, 4, 4, 11, 13, 4, 1, 0, 0, 0, 0, - 0, 0, 0, 1, 4, 13, 10, 4, 1, 1, 10, 7, 1, 1, 1, 6, 11, 1, 1, 1, 11, 6, 1, 1, 1, 4, 10, 1, 1, 1, 3, 3, - 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 1, 1, 7, 10, 1, 1, 1, 4, 6, 13, 4, 1, 0, 0, 0, - 0, 0, 1, 4, 13, 10, 4, 1, 1, 1, 10, 7, 1, 1, 1, 6, 11, 1, 1, 1, 11, 6, 1, 1, 1, 4, 10, 1, 1, 1, 3, 3, - 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 1, 1, 7, 10, 1, 1, 1, 1, 10, 11, 13, 4, 1, 0, 0, - 0, 1, 4, 13, 10, 6, 7, 7, 7, 7, 13, 11, 7, 7, 7, 10, 6, 7, 7, 7, 6, 10, 7, 7, 7, 11, 13, 7, 4, 1, 3, 3, - 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 4, 7, 7, 11, 10, 7, 7, 7, 11, 13, 7, 11, 13, 4, 1, 0, - 0, 1, 4, 10, 6, 10, 10, 6, 6, 6, 13, 10, 6, 6, 6, 13, 10, 6, 6, 6, 10, 13, 6, 6, 6, 10, 13, 6, 11, 1, 3, 3, - 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 7, 6, 6, 10, 13, 6, 6, 6, 10, 13, 6, 11, 10, 4, 4, 0, - 0, 0, 1, 7, 13, 10, 4, 1, 1, 1, 10, 7, 1, 1, 1, 6, 11, 1, 1, 1, 11, 6, 1, 1, 1, 7, 10, 1, 1, 1, 3, 3, - 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 1, 1, 7, 10, 1, 1, 1, 4, 10, 11, 13, 4, 4, 0, 0, - 0, 0, 0, 1, 7, 13, 6, 1, 1, 1, 10, 7, 1, 1, 1, 6, 11, 1, 1, 1, 11, 6, 1, 1, 1, 7, 10, 1, 1, 1, 3, 3, - 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 1, 1, 7, 10, 1, 1, 1, 4, 6, 13, 4, 4, 0, 0, 0, - 0, 0, 0, 0, 1, 7, 13, 6, 1, 1, 10, 4, 1, 1, 1, 6, 11, 1, 1, 1, 11, 6, 1, 1, 1, 4, 10, 1, 1, 1, 3, 3, - 3, 3, 9, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 1, 1, 1, 1, 7, 10, 1, 1, 1, 11, 13, 4, 4, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 1, 7, 13, 6, 11, 13, 6, 6, 6, 11, 10, 10, 11, 6, 11, 10, 13, 11, 6, 6, 6, 13, 11, 7, 1, 3, 3, - 3, 3, 9, 2, 15, 1, 1, 1, 12, 8, 2, 2, 3, 3, 3, 1, 4, 6, 6, 6, 13, 11, 11, 11, 13, 4, 4, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 1, 7, 13, 6, 10, 6, 11, 11, 11, 10, 10, 11, 11, 11, 10, 10, 11, 11, 11, 6, 13, 11, 7, 1, 3, 3, - 3, 3, 9, 9, 1, 3, 9, 3, 1, 14, 2, 2, 3, 3, 3, 1, 4, 11, 11, 6, 13, 11, 11, 13, 4, 4, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 1, 7, 13, 6, 7, 1, 1, 1, 6, 11, 1, 1, 1, 11, 6, 1, 1, 1, 4, 10, 1, 1, 1, 3, 3, - 3, 3, 9, 12, 12, 2, 2, 9, 1, 8, 2, 2, 3, 3, 3, 1, 1, 1, 1, 7, 10, 11, 13, 4, 4, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 1, 7, 13, 6, 1, 1, 1, 6, 11, 1, 1, 1, 11, 6, 1, 1, 1, 7, 10, 1, 1, 1, 3, 3, - 3, 3, 9, 1, 3, 2, 2, 12, 12, 2, 2, 2, 3, 3, 3, 1, 1, 1, 1, 7, 6, 13, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 7, 13, 6, 1, 1, 6, 11, 1, 1, 1, 11, 6, 1, 1, 1, 7, 10, 1, 1, 1, 3, 3, - 3, 3, 9, 5, 5, 5, 5, 1, 9, 2, 2, 2, 3, 3, 3, 1, 1, 1, 1, 11, 13, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 7, 13, 6, 6, 13, 13, 6, 10, 6, 10, 13, 6, 10, 10, 10, 13, 6, 11, 1, 3, 3, - 3, 3, 9, 8, 3, 3, 3, 9, 2, 2, 2, 2, 3, 3, 3, 1, 7, 6, 11, 13, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 7, 13, 6, 6, 6, 7, 7, 7, 6, 10, 4, 7, 7, 11, 13, 7, 4, 1, 3, 3, - 3, 15, 3, 14, 14, 14, 14, 2, 2, 8, 14, 14, 3, 3, 3, 1, 1, 11, 13, 4, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 7, 13, 6, 11, 1, 1, 1, 11, 6, 1, 1, 1, 4, 10, 1, 1, 1, 3, 15, - 1, 1, 1, 1, 1, 1, 1, 14, 9, 1, 1, 1, 1, 1, 5, 1, 4, 6, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 7, 13, 6, 1, 1, 1, 11, 6, 1, 1, 1, 4, 10, 1, 1, 1, 3, 12, - 1, 12, 3, 8, 8, 9, 1, 8, 12, 5, 12, 14, 14, 14, 5, 1, 4, 1, 3, 14, 14, 3, 1, 1, 1, 5, 14, 14, 14, 3, 1, 0, - 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 7, 13, 6, 4, 4, 6, 10, 4, 4, 4, 11, 13, 4, 1, 1, 3, 15, - 5, 1, 3, 2, 2, 12, 12, 2, 3, 5, 5, 2, 2, 2, 8, 15, 15, 3, 2, 2, 2, 2, 9, 3, 3, 14, 2, 2, 2, 8, 1, 0, - 0, 0, 0, 0, 1, 1, 12, 12, 12, 12, 12, 12, 12, 1, 1, 1, 7, 13, 6, 6, 13, 13, 10, 10, 10, 10, 13, 10, 1, 12, 3, 3, - 12, 1, 8, 2, 8, 5, 3, 2, 2, 5, 15, 2, 8, 14, 14, 14, 14, 14, 8, 2, 2, 14, 9, 9, 9, 9, 14, 2, 2, 9, 1, 0, - 0, 0, 0, 1, 1, 15, 3, 9, 9, 9, 9, 9, 9, 12, 1, 1, 1, 7, 13, 6, 11, 6, 1, 1, 1, 7, 10, 1, 1, 3, 3, 3, - 5, 12, 2, 2, 9, 1, 8, 2, 14, 1, 14, 2, 9, 1, 1, 1, 1, 1, 3, 2, 8, 1, 1, 1, 1, 1, 15, 2, 2, 12, 1, 0, - 0, 0, 0, 0, 1, 15, 3, 2, 2, 2, 2, 2, 2, 9, 12, 1, 0, 1, 7, 13, 6, 6, 1, 1, 1, 7, 6, 1, 12, 3, 3, 3, - 1, 9, 2, 2, 12, 12, 2, 2, 15, 5, 2, 2, 12, 5, 5, 1, 0, 1, 8, 2, 9, 1, 0, 0, 0, 1, 14, 2, 8, 1, 0, 0, - 0, 0, 0, 0, 1, 5, 3, 14, 2, 2, 2, 2, 2, 2, 15, 5, 1, 1, 1, 7, 13, 6, 1, 1, 1, 1, 1, 12, 3, 3, 3, 12, - 1, 8, 2, 8, 1, 3, 2, 8, 5, 15, 2, 8, 5, 12, 1, 1, 1, 12, 2, 2, 15, 1, 0, 0, 1, 5, 2, 2, 9, 1, 0, 0, - 0, 0, 0, 0, 1, 1, 15, 14, 2, 2, 2, 2, 2, 2, 2, 3, 12, 5, 1, 1, 1, 1, 1, 1, 1, 5, 3, 3, 3, 3, 3, 5, - 12, 2, 2, 9, 1, 8, 2, 14, 1, 14, 2, 9, 1, 5, 1, 0, 1, 3, 2, 8, 5, 1, 0, 0, 1, 15, 2, 2, 15, 1, 0, 0, - 0, 0, 0, 0, 0, 1, 5, 8, 2, 2, 2, 2, 2, 2, 2, 2, 14, 15, 15, 3, 15, 15, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, - 9, 2, 2, 12, 12, 2, 2, 15, 5, 2, 2, 15, 5, 1, 0, 0, 1, 14, 2, 14, 1, 0, 0, 0, 1, 9, 2, 2, 5, 1, 0, 0, - 0, 0, 0, 0, 0, 1, 1, 15, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 8, 9, 9, 3, 3, 3, 3, 3, 3, 9, 9, 8, 3, 5, - 8, 2, 8, 1, 3, 2, 14, 5, 3, 2, 2, 5, 1, 0, 0, 1, 5, 2, 2, 15, 1, 0, 0, 1, 1, 8, 2, 14, 1, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 1, 1, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 5, 15, - 2, 2, 3, 1, 3, 3, 15, 1, 14, 2, 14, 1, 0, 0, 0, 1, 15, 2, 2, 5, 1, 0, 0, 1, 12, 2, 2, 15, 1, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 1, 1, 15, 8, 2, 2, 8, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 9, 3, 1, 14, - 2, 2, 12, 1, 12, 3, 12, 5, 2, 2, 15, 1, 0, 0, 0, 1, 14, 2, 14, 1, 1, 0, 0, 1, 9, 2, 2, 5, 1, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 5, 15, 9, 14, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 12, 5, 14, - 14, 14, 12, 5, 5, 5, 1, 12, 9, 9, 12, 1, 1, 0, 1, 1, 9, 9, 15, 1, 1, 0, 0, 1, 9, 9, 9, 5, 1, 1, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 4, 11, 7, 1, 11, - 1, 1, 4, 10, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 7, 13, 6, 10, - 1, 7, 13, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 7, 13, 6, - 7, 13, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 7, 13, - 13, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 7, - 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, - 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -]); +// Two 4-bit palette indices per byte, base64 encoded, row-major. +export const JIM_LOGO_PACKED_INDICES = [ + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAREQAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAERERAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABEREREAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAREX", + "cREQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAERF2/hERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAABERdG9OEREAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABERERERERERERERERERERERERER", + "EREREREREREREREREREREYqqiqoRERERERERERERERERERERERERERERERERERERERERERERERERERERERAAAAAAAAAAAAAAAAAAARERERERERERERERERER", + "EREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREAAAAAAAAAAAAAAAAAERERERERERER", + "EREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREQAAAAAAAAAAAAAAABERER", + "ERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERAAAAAAAAAAAA", + "AAARERGZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZEREREAAA", + "AAAAAAAAAAARERk8zMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM", + "mREREQAAAAAAAAAAAAEREZMiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIi", + "IiIiIiIiu5EREQAAAAAAAAAAAAERGbIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIi", + "IiIiIiIiIiIiIiIis7kREQAAAAAAAAAAAAERFcIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIi", + "IiIiIiIiIiIiIiIiIiIiIiIiszkREQAAAAAAAAAAAAERFcIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIi", + "IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiszkREQAAAAAAAAAAAAERFcIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIi", + "IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiszkREQAAAAAAAAAAAAERFcIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIi", + "IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiszkREQAAAAAAAAAAAAERFcIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIi", + "IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiszkREQAAAAAAAAAAAAERFcIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIi", + "IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiszkREQAAAAAAAAAAAAERFcIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIi", + "IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiszkREQAAAAAAAAAAAAERFcIiIiIiIiIiIiIiIiIiIiIiIiIi", + "IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIsszkREQAAAAAAAAAAAAERFcIiIiIiIiIiIiIiIiIi", + "IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiLLMzkREQAAAAAAAAAAAAERFcIiIiIiIiIi", + "IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiLMzMzMzMzMzMzMzMzMzMzMzMzMzMzMyzMzkREQAAAAAAAAAAAAERFcIi", + "IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiJd3d3d3d3d3d3d3d3d3d3d3d3d3d3d0zMzkREQAAAAAAAAAA", + "AAEREZwiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiKzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM5EREQAA", + "AAAAAAAAAAERERnCIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiKzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMz", + "OREREQAAAAAAAAAAAAARERGVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVm7u7u7u7siIiIiIiIiIiIiIiIiIiJTMzM9lVVVVVVVVVVVVVVVVV", + "VVVVVVVVkREREAAAAAAAAAAAAAABERERERERERERERERERERERERERERERERERERERERERERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9ERERERERER", + "ERERERERERERERERERERAAAAAAAAAAAAAAAAERERERERERERERERERERERERERERERERERERERERERERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9ER", + "EREREREREREREREREREREREREREQAAAAAAAAAAAAAAAAARERERERERERERERERERERERERERERERERERERERERERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIi", + "KzMzM9EREREREREREREREREREREREREREREAAAAAAAAAAAAAAAAAABERERERERERERERERERERERERERERERERERERERERERERERUzMzMzMzUiIiIiIiIiIi", + "IiIiIiIiKzMzM9ERERERERERERERERERERERERERERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABERdEREoREXZHERERERUzMzMzMzUiIi", + "IiIiIiIiIiIiIiIiKzMzM9EREQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAREXRERKEREXZHERERERUzMz", + "MzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAERF0REShEREXZHER", + "ERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABERdEREeH", + "d3d/ZOd3ehERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAR", + "EXRERHdERERERERETxERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAERF0RER3RERERERERETxERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAABERdEREoREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAREXRERKEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAERF0RERxEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABERdEREdxEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREQAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAREXRERHdhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREQAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAERF0RER6RhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREQAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABERdEREeORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9ER", + "EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAREXRERHGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIi", + "KzMzM9EREQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAERF0RERxGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIi", + "IiIiIiIiKzMzM9EREQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABERdEREcRGORhEREREREXZHERERERUzMzMzMzUiIi", + "IiIiIiIiIiIiIiIiKzMzM9EREQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAREXRERHERGORhEREREREXZHERERERUzMz", + "MzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAERF0RER4d3d+Rqd3d3d3evZPp3", + "ehERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9ERERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABERdEREd0RERERERERE", + "RERERERETxERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREREAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAREXRERHdERE", + "RERERERERERERERETxERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREREQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAERF0", + "RERxiIiIGORhiIiIiIgXZHGIgRERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9ERERERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAABERdEREcRERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREfEREAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAREXRERHERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREW4REQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAERF0RERxERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREWThERAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAABERdEREeBERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREWROEREAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAREXRERHehERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREWRE4REQAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAERF0RER3ShERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREaZEThER", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABERdEREeGShERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9ER", + "ERpkROEREAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAREXRERHF2ShERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIi", + "KzMzM9ERERGmRE4REQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAERF0RERxF2ShERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIi", + "IiIiIiIiKzMzM9EREREaZEThERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABERdEREcRF2ShERERERGORhEREREREXZHERERERUzMzMzMzUiIi", + "IiIiIiIiIiIiIiIiKzMzM9ERERERpkROEREAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAREXRERHiqr2R6qqqqqqp+RqqqqqqqqvZPqqqBERUzMz", + "MzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREaqqimRE4REQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAERGkRER3ZmZkRmZmZmZmZkRGZmZmZmZmRGZm", + "bxERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREWZmZ3ZEThERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABERpEREd0RERERERERERERERERERE", + "RERERERETxERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREWRERndkROEREAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAREaRERHiqqqr2R6qqqqqq", + "quRoqqqqqqqvZPiqqBERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREaqqqqimRE4REQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAERGkRERxERERF2", + "ShERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREREREREXZEThERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABERpERE", + "cRERERF2ShERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREREREREadkROEREAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AREaRERHERERERF2ShERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREREREREXd2RE4REQAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAARGkRERxERERERF2ShERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREREREREfZ6ZEThERAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAERpEREcRERERERF2ShERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREREREREfRhpkROEREAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAABEaRERPoRERERERF2ShERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREREREREfRIGmRE4R", + "EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAARGkRER3YRERERERF2ShERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREREREREf", + "RIEaZEThERAAAAAAAAAAAAAAAAAAAAAAAAAAAAERpEREekYRERERERF2ShERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9ER", + "EREREREfRIERpkROEREAAAAAAAAAAAAAAAAAAAAAAAAAABEaRERHjkYRERERERF2ShERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIi", + "KzMzM9EREREREREfRIERGmRE4REQAAAAAAAAAAAAAAAAAAAAAAAAARGkRERxjkYRERERERF2ShERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIi", + "IiIiIiIiKzMzM9EREREREREfRIEREaZEThERAAAAAAAAAAAAAAAAAAAAAAAAERpEREeIfkaKqqqqqqj2R4qqqqqqiuRoqqqqqqqPZPiqqBERUzMzMzMzUiIi", + "IiIiIiIiIiIiIiIiKzMzM9EREYqqqqqvRHiqqIpkROEREAAAAAAAAAAAAAAAAAAAAAABEaRERPpmZkRmZmZmZmZkRmZmZmZmZmRGZmZmZmZmRGZmbxERUzMz", + "MzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREeZmZmZmRGZmZud2RE4REQAAAAAAAAAAAAAAAAAAAAARGkRET6RERERERERERERERERERERERERERERERERERERE", + "TxERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREWREREREREREREb3ZEThERAAAAAAAAAAAAAAAAAAAAERpEREeHd39kZ3d3d3d3fkT3d3d3d3d+Rnd3d3", + "d3d/ZPd3ehERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREXd3d3d+RPd3d3d4dkROEREAAAAAAAAAAAAAAAAAABEaRERHERERjkYRERERERF2ShERERER", + "EfRhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREREREREXRIERERERGmRE4REQAAAAAAAAAAAAAAAAARGkRERxERERjkYRERERERF2", + "ShERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREREREREfRIEREREREaZEThERAAAAAAAAAAAAAAAAERpEREcRERERjkYR", + "ERERERF2ShERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREREREREfRIEREREREYdkROEREAAAAAAAAAAAAAABEaRERHER", + "ERERjkYRERERERF2ShERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREREREREfRIEREREREYd2RE4REQAAAAAAAAAAAAAR", + "GkRERxERERERjkYRERERERF2ShERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREREREREfRIEREREREa/3ZEThERAAAAAA", + "AAAAAAERpEREcRERERERjkYRERERERF2ShERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREREREREfRIEREREREa5npkRO", + "EREAAAAAAAAAABEaRERPgRERERERjkYRERERERF2ShERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREREREREfRIERERER", + "Ea5PGmRE4REQAAAAAAAAARGkRET6gRERERERjkYRERERERF2ShERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREREREREf", + "RIEREREREa5OEaZEThERAAAAAAAAERpERE+kgRERERERjkYRERERERF2ShERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9ER", + "EREREREfRIEREREREa5OERpkROEREAAAAAABEaRERHhEgRERERERjkYRERERERF2ShERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIi", + "KzMzM9EREREREREfRIEREREREY5OERGmRE4REQAAAAARGkRERx9EqIiIiIiIrkaIiIiIiIj2R4iIiIiIiuRoiIiIiIiHZPiIiBERUzMzMzMzUiIiIiIiIiIi", + "IiIiIiIiKzMzM9EREYiIiIiPRKiIiIiIiK5GiIgaRETxERAAAAERF3d3ePZE7u7u7u7u5kbu7u7u7u5kTu7u7u7u7mRO7u7u7u7mRG7u5xERUzMzMzMzUiIi", + "IiIiIiIiIiIiIiIiKzMzM9EREf7u7u7mRO7u7u7u7uZG7u6qd3cREQAAAAAREWRERoZERERERERERERERERERERERERERERERERERERERERERERETxERUzMz", + "MzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREWREREREREREREREREREREd0RGgREAAAAAABERZERGhk7///////5kb////////kTv///////2RP///////+ZO//", + "9xERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREf/////+RP///////+ZG/6dERoERAAAAAAAAERFkREaGgRERERERjkYRERERERF2ShEREREREfRhERER", + "EREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREREREREXRIEREREREY5OEaREaBEQAAAAAAAAAREWRERogRERERERjkYRERERERF2ShERERER", + "GORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREREREREfRIEREREREa5OGkRGgREAAAAAAAAAABERZERGgRERERERjkYRERERERF2", + "ShERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREREREREfRIEREREREa5HpERoERAAAAAAAAAAAAERFkREaBERERERjkYR", + "ERERERF2ShERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREREREREfRIEREREREa53REaBEQAAAAAAAAAAAAAREWRERoER", + "ERERjkYRERERERF2ShERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREREREREfRIEREREREad0RGoREAAAAAAAAAAAAAAB", + "ERZERGgRERERjkYRERERERF2ShERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREREREREfRIEREREREYdERqERAAAAAAAA", + "AAAAAAAAERFkREaBERERjkYRERERERF2ShERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREREREREfRIEREREREaREahEQ", + "AAAAAAAAAAAAAAAAAREWRERoERERjkYRERERERF2ShERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiIiIiIiIiIiIiIiKzMzM9EREREREREfRIERERER", + "GkRGoREAAAAAAAAAAAAAAAAAABERZERGgRERjkYRERERERF2ShERERERGORhEREREREXZHERERERUzMzMzMzUiIiIiLMzMzCIiIiIiIiKzMzM9EREREREREf", + "RIERERERpERqERAAAAAAAAAAAAAAAAAAAAERFkREaBERjkYRERERERF2ShERERERGORhEREREREXZHERERERUzMzMzMzUiIiIjkRERERkyIiIiIiKzMzM9ER", + "EREREREfRIEREREaREahEQAAAAAAAAAAAAAAAAAAAAAREWRERoiBrkYYiIiIiIF2RxiIiIiIiuRhiIiIiIgXZHGIgRERUzMzMzMzUiIiLRERERERETIiIiIi", + "KzMzM9EREYiIiIiPRKiIiIGkRGoREAAAAAAAAAAAAAAAAAAAAAABERZERGj/5kb////////kTv///////2RP///////+ZO//9xERUzMzMzMzUiIisRERERER", + "ERwiIiIiKzMzM9EREf/////+RO///6pERqERAAAAAAAAAAAAAAAAAAAAAAAAERFkREaGRERERERERERERERERERERERERERERERERERETxERUzMzMzMzUiIj", + "EREVVVVRERUiIiIiKzMzM9EREWRERERERERER6REahEQAAAAAAAAAAAAAAAAAAAAAAAAAREWRERo5kbu7u7u7u5kRu7u7u7u7mRO7u7u7u7mRG7u5xERUzMz", + "MzMzUiIpERnCIiIsERkiIiIiKzMzM9EREe7u7u7mRO7uekRGoREAAAAAAAAAAAAAAAAAAAAAAAAAABERZERGj0YRERERERF2ShEREREREfRhEREREREXZHER", + "ERERUzMzMzMzUiLBEZwiIiIjERsiIiIiKzMzM9EREREREREXRIERpERqERAAAAAAAAAAAAAAAAAAAAAAAAAAAAERHkREaGYRERERERF2ShERERERGORhERER", + "EREXZHERERERUzMzMzMzUiKxEdIiIiIrERMiIiIiKzMzM9EREREREREfRIEaREahEQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAREeRERo4RERERERF2ShERERER", + "GORhEREREREXZHERERERUzMzMzMzUiIREcIiIiIpERwiIiIiKzMzM9EREREREREfRIGkRGoREAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABER5ERGgRERERERF2", + "ShERERERGORhEREREREXZHERERERUzMzMzMzUiMRFSIiIiLBEVIiIiIiKzMzM9EREREREREfRIpERqERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAERHkREaB", + "ERERERF2ShERERERGORhEREREREXZHERERERUzMzMzMzUisREyIiIiLREdIiIiIiKzMzM9EREREREREfR6REahEQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AREeRERoERERERF2ShERERERGORhEREREREXZHERERERUzMzMzMzUsERHCIiIiKREcIiIiIiKzMzM9EREREREREfekRGoREAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAABER5ERGgRERERF2ShERERERGORhEREREREXZHERERERUzMzMzMzUsERkiIiIiMRGSIiIiIiKzMzM9EREREREREapERqERAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAERHkREaBERERF2ShERERERGORhEREREREXZHERERERUzMzMzMzUsERGzMzM9ERGyIiIiIiKzMzM9EREREREREaREahEQAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAREeRERoERERF2ShERERERGORhEREREREXZHERERERUzMzMzMzUiURERERERERnCIiIiIiKzMzM9ERERERERGkRGoREAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABER5ERGgRERF2ShERERERGORhEREREREXZHERERERUzMzMzMzUixREREREREZwiIiIiIiKzMzM9ERERERERpE", + "RqERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAERHkREaBERF2RxERERERGORhEREREREXZHERERERUzMzMzMzUiLFERERERGcIiIiIiIiKzMzM9ER", + "EREREaREahEQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAREeRERof/fkT3//////f2Rn//////9+ZOf/+hERUzMzMzMzUiIiwzMzMzMiIiIiIiIi", + "KzMzM9EREX//qkRGoREAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABER5ERGhkRERERERERERERERERERERERERETxERUzMzMzMzUiIiIiIiIiIi", + "IiIiIiIiKzMzM9EREWRPpERqERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAERHkREaOZkRmZmZmZmZkRGZmZmZmZmRGZmbxERUzMzMzMzUiIi", + "IiIiIiIiIiIiIiIiKzMzM9EREWZ6REahEQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAREeRERhF2ShEREREREfRhEREREREXZHERERERUzMz", + "MzMzUiIiIiIiIiIiIiIiIiIiKzMzM9ERERGERGoREAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABER5ERGF2ShERERERGORhEREREREXZHER", + "ERERUzMz1VVVm7u7u7u7uzIiIiwzMzMzNbu709ERERhEb4EREREQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAERHkREaOShERERERGORhERER", + "EREXZHERERERUzM7ERERERERERERER0iIjERERERERERGdEREYROERERERERAAAAAAAREREREREREAAAAAAAAAAAAAAAAAAAAAAAAAAAAREeRERoahERERER", + "GORhEREREREXZHERERERUzM5ERERERERERERERkiIpEREREREREREZEREWThEREREREREAAAAAEREREREREREQAAAAAAAAAAAAAAAAAAAAAAAAAAABER5ERG", + "ihERERERGORhEREREREXZHERERERUzMxEREREREREREREREiIxEREREREREREREREWYREREREREREQAAABEREREREREREQAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAERHkREaBERERERGORhEREREREXZHERERERUzOxEVzMzMzMzMzLERsiKxEbMzMzMzM1EREREfgRETMzMzORERERERERUzMzMzNRERAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAREeRERhERERERGORhEREREREXZHERERERUzNREV3d3SIiIiIlERMiIREb3dwiIiIisRERERERHCIiIiLBEREREREVIiIiIiLJEREAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAABER5EROERERERGORhEREREREXZHERERERUzORERERFSIiIiLBEZIiwRERERwiIiIiJRERERERMiIiIiIsERERERGcIiIiIiIsEREA", + "AAAAAAAAAAAREREREREREREREREAERHkRE4RERERGORhEREREREXZHERERERUzNREREREyIiIiLREbIiwREREZIiIiIiIlEREREdIiIiIiIiMRERERnCIiIi", + "IiIiUREAAAAAAAAAAAEREREREREREREREREQAREeREThERERGORhEREREREXZHERERERUzPRERERHCIiIiKREcIiLREREdIiIiIiIi3d3d0yIiIiIiIiLDMz", + "MzwiIiIiIiIsEREAAAAAAAAAABEREREREREREREREREREBER5EROERERGORhEREREREXZHERERER0zMz3VERUiIiIiwRGSIiIsMREcIiIiIiIiIiIiIiIiIi", + "IiIiIiIiIiIiIiIiIiIjEREAAAAAAAAAAREREREREREREREREREREQERHkRE6Hd3d+Rnd3d3d3d/ZPd3oREZMzMzM5ERMiIiIi0RGyIiIiwRGSIiIiIiIiIi", + "IiIiIiIiIiIiIiIiIiIiIiIiIiIlERAAAAAAAAAAERERlVVVVVVVVVVVWRERERAREeREToZEREREREREREREREREoREVMzMzMxERIiIiIikRHCIiIisRHSIi", + "IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIhERAAAAAAAAABEREZ0zMzMzMzMzMzPVEREREBER5EROhkREREREREREREREROEREbMzMzOxEbIiIiIsERkiIi", + "IikRHCIiIsu7u7u7u7u7syIiIixVVVVVVVVVVcIiIiIxERAAAAAAAAARERGdMzPd3d3d3d3d3bmREREAERHkRE4RGORhEREREREXZHERERGTMzMzORETIiIi", + "ItER0iIiIsERkiIiItEREREREREREyIiIi0RERERERERGSIiIiKxERAAAAAAAAARERmzMz0yIiIiIiIiIi1ZEREQAREeREThGORhEREREREXZHERERFTMzMz", + "MRESIiIiIpERwiIiIrER0iIiIpERERERERERHCIiIikRERERERERHSIiIiKREQAAAAAAAAARERkzM9MiIiIiIiIiIiJVEREQABER5EROGORhEREREREXZHER", + "ERnTMzMzsRGyIiIiLBEZIiIiIpERwiIiLBERERERERERUiIiIsERERERERERHCIiIiwREQAAAAAAAAABERHTPTIiIiIiIiIiIiI7kRERAAERHkRE4fRhERER", + "EREXZHERERszMzMzkRHCIiIiLREdIiIiLBEVIiIiLREVURERABERMiIiIjEREAAAAAERkiIiIi0REQAAAAAAAAABERFTO8IiIiIiIiIiIiIrUREREAAREeRE", + "ToZhEREREREXZhEREVMzMzM9ERkiIiIiKREcIiIiKxEdIiIiKREdkRERABERwiIiIlERAAAAABER0iIiIikREAAAAAAAAAABERGTM9IiIiIiIiIiIiIiWRER", + "EQABER5EROjhEREREREX8RERGdMzMzM1ER0iIiIiwRGSIiIiIREcIiIiwREbEREQABEZIiIiLBERAAAAABERwiIiIsEREAAAAAAAAAAAEREbM7IiIiIiIiIi", + "IiIiyZEREREAERHkRE6BEREREREYERERnTMzMzM5ERwiIiIisRHSIiIiMRFSIiIiMRFVEREQAREdIiIiIxERAAAAABEZIiIiItEREAAAAAAAAAAAEREVM9Mi", + "IiIiIiIiIiIiLJkRERERAREeREThEREREREREREZ0zMzMzPREZIiIiIikRHCIiIisREyIiIiURFREREAAREcIiIiJREQAAAAAREbIiIiIlERAAAAAAAAAAAA", + "EREZ0zsiIiIiIiIiIiIiIjmRERERERER5EROERERERERERFdMzMzMzNREdIiIiIjERUiIiIiERHCIiIsERGREREAARGSIiIiwREQAAAAARETIiIiLBERAAAA", + "AAAAAAAAARERUzvCIiIiIiIiIiIiIiyVkREREREREYqogREREREREZUzMzMzMzMREcIiIiIrER0iIiIjERUiIiIjERERERAAERGyIiIiMREQAAAAARESIiIi", + "IxERAAAAAAAAAAAAARERnTPCIiIiIiIiIiIiIiLFVRERERERERERERERERERldMzMzMzMzsRGSIiIiIhERwiIiIrERMiIiIlERERERAAERHCIiIisREAAAAA", + "ARGyIiIiJREQAAAAAAAAAAAAABERHSIiIiIiIiIiIiIiIiIiu7URERERERERERERERGV0zMzMzMzM9kRHSIiIiIxEVIiIiIhERwiIiLBEREREQAAEREiIiIi", + "EREAAAAAEREyIiIiIREQAAAAAAAAAAAAABERGSIiIiIiIiIiIiIiIiIiLF3bWRERERERERERFVszMzMzMzMzO9ERHCIiIiKxETIiIiIxEVIiIiIxEREREAAA", + "ERsiIiIjEREAAAAAERHCIiIiMREQAAAAAAAAAAAAABEREdIiIiIiIiIiIiIiIiIiIiNdPdtVVZmZVVW70zMzMzMzMzM7XDERUiIiIiIREcIiIiKxETIiIiKx", + "EREREAABERMiIiIrEREAAAAAERUiIiIisREQAAAAAAAAAAAAAAEREZwiIiIiIiIiIiIiIiIiIiIjWzMzMzMzMzMzMzMzMzMzMztTIrERMiIiIiMRFSIiIiIR", + "EcIiIiIRERERAAABERwiIiIpERAAAAABERMiIiIikREAAAAAAAAAAAAAAAERERsiIiIiIiIiIiIiIiIiIiIiLLu9MzMzMzMzMzMzMzMz27wiLBERwiIiIisR", + "EyIiIiMRFSIiIiMREREQAAABEVIiIiLBERAAAAABERwiIiIsEREAAAAAAAAAAAAAAAAREREyIiIiIiIiIiIiIiIiIiIiIiIju7vTMzMzMzMzM9u7MiIiIxEV", + "IiIiIiERHCIiIikREyIiIisREREAAAARETIiIiKxERAAAAABEVIiIiItEREAAAAAAAAAAAAAAAABERGcIiIiIiIiIiIiIiIiIiIiIiIiIiw7u7u7u7u7vTIi", + "IiIiJRETIiIiIjERUiIiK1ERHCIiIikRERAAAAAREcIiIiKREQAAAAARETIiIiIpERAAAAAAAAAAAAAAAAABEREZwiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIi", + "IiIiIiIiIiIiwREcIiIiIlERMiLLs7ERUiIiIsEREQAAAAARFSIiIiwREQAAAAAREcIiIiLBERAAAAAAAAAAAAAAAAAAERERXCIiIiIiIiIiIiIiIiIiIiIi", + "IiIiIiIiIiIiIiIiIiIiMRGyIiIiLBERwtWzM1ERMiIiIrEREAAAAAERHSIiIi0REQAAAAARGSIiIiLRERAAAAAAAAAAAAAAAAAAARERFcIiIiIiIiIiIiIi", + "IiIiIiIiIiIiIiIiIiIiIiIiIiIiUREyIiIiIxEZu9MzMxERwiIiIpERAAAAAAERHCIiIikREAAAAAERHSIiIiJREQAAAAAAAAAAAAAAAAAAABEREZMiIiIi", + "IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIsEREiIiIiJREVMzMzOxEVIiIiLBERAAAAAAERkiIiIsEREAAAAAERHCIiIiwREQAAAAAAAAAAAAAAAAAAAAER", + "ERmyIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiItERsiIiIiwRERGdMzORETIiIiLREREAAAABER0iIiItEREQAAAAERkiIiIiMREREAAAAAAAAAAAAA", + "AAAAAAAREREZMiIiIiItu7u7u7u7u7u7u7u7u7u7u7u7u7u7u7uxERwiIiIiMREREVM9UREcIiIiKREREQAAABERwiIiIlERERAAABER0iIiIisREREQAAAA", + "AAAAAAAAAAAAAAABERERFdwiIiIsUzMzMzMzMzMzMzMzMzMzMzMzMzMzMzPREZIiIiIiURERERVZERGyIiIiwREREQAAABEZIiIiLBERERAAABERwiIiIiER", + "EREQAAAAAAAAAAAAAAAAAAAAERERERlTwiIixTMzMzMzMzMzMzMzMzMzMzMzMzMzMzNREdIiIiIiu7uREREREREyIiIiy7UREQAAAREbIiIiI5kRERAAABEZ", + "IiIiIsu7kREQAAAAAAAAAAAAAAAAAAAAARERERERlb08zFMzMzMzMzMzMzMzMzMzMzMzMzMzMzOREbMzMzMzMzNRERERERGVVVVVVVkREQAAAREZVVVVVVWR", + "ERAAARERVVVVVVVVEREAAAAAAAAAAAAAAAAAAAAAAAERERERERERGZlVVVVVVVVVVVVVVVVVVVVVVVVVVVkREREREREREREREREREREREREREREREAAAARER", + "EREREREREQAAAREREREREREREREAAAAAAAAAAAAAAAAAAAAAAAABERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERER", + "EAAAAREREREREREREQAAABERERERERERERAAAAAAAAAAAAAAAAAAAAAAAAAAARERERERERERERERERERERERERERERERERERERERERERERERERERGIERERER", + "ERERERERAAAAABEREREREREREAAAABERERERERERERAAAAAAAAAAAAAAAAAAAAAAAAAAAAARERERERERERERERERERERERERERERERERERERERERERERERqn", + "ZxERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEREREREREREREREREREREREREREREYiIiBERiq", + "ERERGEREcREQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABER", + "5ERPER9EERERhERHEREAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAERHkRE8R9EEREYRERxERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAREeRETx9EERGEREcREQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAABER5ERPhkERhERHEREAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAERHkRE+GEYZERxERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAREeRET4GEREcREQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABER5ERPhERHEREAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAERHkRG9ERxERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAREeRG9EcREQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABER5G9HEREAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAERHm9xERAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAREfoREQAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABEREREAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAERERAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAR", + "EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAABEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", +].join(""); diff --git a/src/ui/jim-logo-screen.ts b/src/ui/jim-logo-screen.ts index 17c4ca9d..7dfb2bc6 100644 --- a/src/ui/jim-logo-screen.ts +++ b/src/ui/jim-logo-screen.ts @@ -4,11 +4,10 @@ import { rasterToGlyphSurface, type RgbaFrame } from '@flyingrobots/bijou-tui'; import type { JeditTheme } from './jedit-theme.js'; import { JIM_LOGO_FRAME_SIZE, - JIM_LOGO_INDICES, + JIM_LOGO_PACKED_INDICES, JIM_LOGO_PALETTE, } from './jim-logo-frame-data.js'; -const MAX_LOGO_ROWS = 18; const MIN_LOGO_ROWS = 4; const LOGO_COLUMNS_PER_ROW = 2; const LOGO_HORIZONTAL_MARGIN = 2; @@ -25,6 +24,7 @@ const GLYPH_SURFACE_RENDERER_KIND = 'braille'; const RGBA_CHANNEL_COUNT = 4; const OPAQUE_ALPHA = 255; const TRANSPARENT_INDEX = 0; +const FULL_OPACITY = 1; // Built once at module load. The frame is static, so the startup screen renders // without a frame pulse and without loading any mesh or scene. @@ -47,7 +47,24 @@ export function renderJimLogoScreen( if (bounds == null) { return surface; } - const glyphs = rasterToGlyphSurface(JIM_LOGO_FRAME, { + blitInkOnly(surface, glyphsFor(bounds), bounds.x, bounds.y); + return surface; +} + +// Rasterising the 192px frame is the expensive half and depends only on the +// glyph grid, so one cached result per size keeps a resize cheap. Only +// blitInkOnly reads it, and it never writes, so sharing the surface is safe. +let cachedGlyphs: { columns: number; rows: number; surface: Surface } | undefined; + +function glyphsFor(bounds: JimLogoBounds): Surface { + if ( + cachedGlyphs != null + && cachedGlyphs.columns === bounds.width + && cachedGlyphs.rows === bounds.height + ) { + return cachedGlyphs.surface; + } + const surface = rasterToGlyphSurface(JIM_LOGO_FRAME, { columns: bounds.width, rows: bounds.height, fit: GLYPH_SURFACE_FIT, @@ -58,17 +75,21 @@ export function renderJimLogoScreen( threshold: BRAILLE_DARKNESS_THRESHOLD, }, }); - blitInkOnly(surface, glyphs, bounds.x, bounds.y); + cachedGlyphs = { columns: bounds.width, rows: bounds.height, surface }; return surface; } function fillWithWorkspace(width: number, height: number, theme: JeditTheme): Surface { const token = theme.surface.workspace; const surface = createSurface(width, height, { char: SURFACE_BLANK, empty: false }); + // Written as a literal rather than a spread of surface.get(x, y): every cell + // starts identical, so re-reading each one only to copy it back costs a full + // extra pass at full-terminal size. for (let y = 0; y < surface.height; y += 1) { for (let x = 0; x < surface.width; x += 1) { surface.set(x, y, { - ...surface.get(x, y), + char: SURFACE_BLANK, + opacity: FULL_OPACITY, fg: token.fg, fgRGB: token.fgRGB, bg: token.bg, @@ -82,7 +103,6 @@ function fillWithWorkspace(width: number, height: number, theme: JeditTheme): Su function jimLogoBounds(width: number, height: number): JimLogoBounds | undefined { const availableRows = Math.min( - MAX_LOGO_ROWS, height - (LOGO_VERTICAL_MARGIN * 2), Math.floor((width - (LOGO_HORIZONTAL_MARGIN * 2)) / LOGO_COLUMNS_PER_ROW), ); @@ -126,9 +146,10 @@ function blitInkOnly( function createJimLogoFrame(): RgbaFrame { const size = JIM_LOGO_FRAME_SIZE; + const indices = unpackIndices(size * size); const data = new Uint8ClampedArray(size * size * RGBA_CHANNEL_COUNT); - for (let pixel = 0; pixel < JIM_LOGO_INDICES.length; pixel += 1) { - const index = JIM_LOGO_INDICES[pixel] ?? TRANSPARENT_INDEX; + for (let pixel = 0; pixel < indices.length; pixel += 1) { + const index = indices[pixel] ?? TRANSPARENT_INDEX; if (index === TRANSPARENT_INDEX) { continue; } @@ -144,3 +165,13 @@ function createJimLogoFrame(): RgbaFrame { } return { width: size, height: size, data }; } + +function unpackIndices(count: number): Uint8Array { + const packed = Buffer.from(JIM_LOGO_PACKED_INDICES, 'base64'); + const indices = new Uint8Array(count); + for (let i = 0; i < count; i += 1) { + const byte = packed[i >> 1] ?? 0; + indices[i] = (i % 2 === 0 ? byte >> 4 : byte) & 0x0f; + } + return indices; +} From 3bb2e061a26ce2f46e17b505fff4b9d5b6f1bce0 Mon Sep 17 00:00:00 2001 From: James Ross Date: Mon, 7 Sep 2026 12:16:59 -0700 Subject: [PATCH 3/5] fix: stop theme tokens erasing the page background, and theme the logo Two colour faults, one shared cause: a style token that specifies no background was being written as though it specified black. The markdown preview composed each text run on a scratch surface and blitted it onto the page. A scratch cell has no background of its own, and every markdown token except Code and InlineCode leaves bg undefined, so the blit carried an undefined background over the page and punched a hole through to the terminal's own. On a dark theme that hole resembles the workspace and nobody notices; on a light theme it is black blocks behind every line of text. Segments are now written straight into the page, and a token without its own background inherits the cell's instead of erasing it -- the idiom already used in workspace-focus-edge.ts. The source viewer was checked for the same defect and does not have it; the spec covers both so it cannot acquire it. The startup logo had the mirror-image problem. Its palette is baked from the artwork, which is ink-on-paper near-black and deep navy: five of its fifteen entries sit below a 1.2 contrast ratio on the graphite background, which is why it ghosted rather than read. The artwork now supplies structure and the theme supplies colour. Each colour keeps its rank in the artwork's own lightness order and is re-sited on an OKLCH ramp between the theme's titleLogoShadow and titleLogo tokens, retaining a share of its own hue in proportion to how colourful it was, so the diamond stays distinct from the J instead of the mark collapsing onto one hue. Anything still short of 3:1 against the workspace background is walked away from the background in lightness -- not clipped in RGB, which would shift the hue. Worst-case contrast per theme, before -> after: graphite 1.06 -> 5.28 morning 1.26 -> 4.05 monokai 1.13 -> 3.03 solarized-dark 1.14 -> 3.47 solarized-light 1.32 -> 3.02 dracula 1.08 -> 3.06 nord 1.01 -> 4.18 catppuccin 1.05 -> 5.84 The recolour runs on the rasterised cells, not on the frame. The Braille renderer lights a dot by darkness against a white ground, so recolouring the frame extinguished the entire mark on any theme whose ink came out light -- graphite, nord and catppuccin rendered zero cells. Recolouring afterwards leaves the mask exactly as drawn, and the specs assert all eight themes produce an identical 1468-cell shape. A theme whose two logo tokens share a lightness would flatten the mark to a silhouette, so a degenerate ramp is widened away from the background rather than collapsing. That is what the existing fast-startup spec caught. OKLCH conversion is hand-rolled in src/ui/oklch.ts rather than adding culori, since this is the only colour maths jedit needs and a runtime dependency is not free here. It is checked against Ottosson's published reference values and round-trips the sRGB cube, so a mistyped matrix constant fails loudly. Cost is unchanged: 2.43 ms per render at 190x50, the glyph cache now keyed on the theme's colours as well as the size so a theme switch invalidates it. Also adds a spec proving a resize re-renders the logo at the new size -- the render gate only repaints when the model identity changes, so an in-place resize would have left a stale logo on screen. --- spec/jim-logo-screen.spec.mjs | 162 +++++++++++++++++++++++- spec/oklch.spec.mjs | 68 +++++++++++ spec/theme-background-opacity.spec.mjs | 122 ++++++++++++++++++ src/ui/jim-logo-palette.ts | 163 +++++++++++++++++++++++++ src/ui/jim-logo-screen.ts | 55 +++++++-- src/ui/markdown-preview.ts | 57 +++++---- src/ui/oklch.ts | 96 +++++++++++++++ 7 files changed, 690 insertions(+), 33 deletions(-) create mode 100644 spec/oklch.spec.mjs create mode 100644 spec/theme-background-opacity.spec.mjs create mode 100644 src/ui/jim-logo-palette.ts create mode 100644 src/ui/oklch.ts diff --git a/spec/jim-logo-screen.spec.mjs b/spec/jim-logo-screen.spec.mjs index ad226135..bd49fc12 100644 --- a/spec/jim-logo-screen.spec.mjs +++ b/spec/jim-logo-screen.spec.mjs @@ -9,11 +9,40 @@ import { importDist } from "./dist-helpers.mjs"; const BLANK_CHARS = new Set([" ", "⠀"]); -async function renderAt(width, height) { +async function renderAt(width, height, theme) { const screen = await importDist("ui", "jim-logo-screen.js"); const themes = await importDist("ui", "jedit-themes.js"); - const [theme] = themes.availableJeditThemes(); - return screen.renderJimLogoScreen(width, height, theme); + return screen.renderJimLogoScreen(width, height, theme ?? themes.availableJeditThemes()[0]); +} + +function channelLuminance(channel) { + const ratio = channel / 255; + return ratio <= 0.03928 ? ratio / 12.92 : Math.pow((ratio + 0.055) / 1.055, 2.4); +} + +function contrastRatio(foreground, background) { + const first = 0.2126 * channelLuminance(foreground[0]) + + 0.7152 * channelLuminance(foreground[1]) + + 0.0722 * channelLuminance(foreground[2]); + const second = 0.2126 * channelLuminance(background[0]) + + 0.7152 * channelLuminance(background[1]) + + 0.0722 * channelLuminance(background[2]); + const high = Math.max(first, second); + const low = Math.min(first, second); + return (high + 0.05) / (low + 0.05); +} + +function inkCells(surface) { + const cells = []; + for (let y = 0; y < surface.height; y += 1) { + for (let x = 0; x < surface.width; x += 1) { + const cell = surface.get(x, y); + if (!BLANK_CHARS.has(cell.char)) { + cells.push({ x, y, cell }); + } + } + } + return cells; } function inkBounds(surface) { @@ -107,3 +136,130 @@ test("the logo keeps its own colours rather than one flat token", async () => { `expected multi-colour artwork, got ${inkColours.size} colour(s)`, ); }); + +// WCAG 1.4.11 puts non-text graphics at 3:1, the same floor the rest of jedit's +// chrome is held to. +const MIN_LOGO_CONTRAST = 3; + +test("the logo is legible against every theme's workspace background", async () => { + const themes = await importDist("ui", "jedit-themes.js"); + const offenders = []; + + for (const theme of themes.availableJeditThemes()) { + const surface = await renderAt(120, 40, theme); + let worst = Infinity; + for (const { cell } of inkCells(surface)) { + worst = Math.min(worst, contrastRatio(cell.fgRGB, theme.surface.workspace.bgRGB)); + } + if (worst < MIN_LOGO_CONTRAST) { + offenders.push(`${theme.name} (${theme.mode}) worst ${worst.toFixed(2)}`); + } + } + + assert.deepEqual(offenders, []); +}); + +test("the logo's shape does not depend on the theme", async () => { + const themes = await importDist("ui", "jedit-themes.js"); + const available = themes.availableJeditThemes(); + const reference = inkCells(await renderAt(120, 40, available[0])) + .map(({ x, y, cell }) => `${x},${y},${cell.char}`) + .join(" "); + + for (const theme of available.slice(1)) { + const shape = inkCells(await renderAt(120, 40, theme)) + .map(({ x, y, cell }) => `${x},${y},${cell.char}`) + .join(" "); + assert.equal(shape, reference, `${theme.name} drew a different mask`); + } +}); + +test("the logo takes its colours from the theme, not from the artwork", async () => { + const themes = await importDist("ui", "jedit-themes.js"); + const available = themes.availableJeditThemes(); + + const paletteFor = async (theme) => new Set( + inkCells(await renderAt(120, 40, theme)).map(({ cell }) => String(cell.fgRGB)), + ); + + const first = await paletteFor(available[0]); + const second = await paletteFor(available[1]); + const shared = [...first].filter((colour) => second.has(colour)); + + assert.ok( + shared.length * 2 < Math.min(first.size, second.size), + `themes should mostly disagree on colour; ${shared.length} shared of ${first.size}/${second.size}`, + ); +}); + +test("the logo keeps more than one hue so the diamond stays distinct", async () => { + const themes = await importDist("ui", "jedit-themes.js"); + const { rgbToOklch } = await importDist("ui", "oklch.js"); + const offenders = []; + + for (const theme of themes.availableJeditThemes()) { + const hues = new Set(); + for (const { cell } of inkCells(await renderAt(120, 40, theme))) { + const { chroma, hue } = rgbToOklch(cell.fgRGB); + if (chroma > 0.02) { + hues.add(Math.round(hue / 10)); + } + } + if (hues.size < 2) { + offenders.push(`${theme.name} collapsed to ${hues.size} hue band(s)`); + } + } + + assert.deepEqual(offenders, []); +}); + +test("resizing the terminal re-renders the logo at the new size", async () => { + const [init, viewerContent, themes] = await Promise.all([ + importDist("app", "workspace", "init.js"), + importDist("app", "workspace", "viewer-content.js"), + importDist("ui", "jedit-themes.js"), + ]); + const { mockI18n, mockJeditTheme } = await import("./workspace-helpers.mjs"); + const { discoverRepoRoot } = await import("./dist-helpers.mjs"); + const root = discoverRepoRoot(); + + const small = init.createInitialModel(root, 80, 24, { + entries: [], + jeditTheme: mockJeditTheme(), + i18n: mockI18n(), + nowMs: 0, + }); + + // The runtime rebuilds the model on a resize message; the renderer only + // repaints when the model identity changes, so a resize that mutated in + // place would silently leave the old logo on screen. + const grown = { ...small, columns: 190, rows: 50 }; + assert.notEqual(grown, small, "resize must produce a new model reference"); + + const before = viewerContent.renderViewer(small, 80, 24); + const after = viewerContent.renderViewer(grown, 190, 50); + + const inkHeight = (surface, width, height) => { + let min = Infinity; + let max = -Infinity; + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const char = surface.get(x, y)?.char ?? " "; + if (char >= "⠁" && char <= "⣿") { + min = Math.min(min, y); + max = Math.max(max, y); + } + } + } + return max < min ? 0 : max - min + 1; + }; + + const smallRows = inkHeight(before, 80, 24); + const largeRows = inkHeight(after, 190, 50); + + assert.ok(smallRows > 0 && largeRows > 0, `expected ink in both, got ${smallRows}/${largeRows}`); + assert.ok( + largeRows > smallRows, + `logo should grow with the terminal; ${smallRows} -> ${largeRows} rows`, + ); +}); diff --git a/spec/oklch.spec.mjs b/spec/oklch.spec.mjs new file mode 100644 index 00000000..3594f652 --- /dev/null +++ b/spec/oklch.spec.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { importDist } from "./dist-helpers.mjs"; + +// Checked against Ottosson's published reference values rather than against +// this implementation's own output, so a transcription error in a matrix +// constant fails here instead of quietly skewing every colour that uses it. +const REFERENCE = [ + { name: "white", rgb: [255, 255, 255], lightness: 1.0, chroma: 0.0 }, + { name: "black", rgb: [0, 0, 0], lightness: 0.0, chroma: 0.0 }, + { name: "red", rgb: [255, 0, 0], lightness: 0.6279554, chroma: 0.2576833, hue: 29.23 }, + { name: "green", rgb: [0, 255, 0], lightness: 0.8664396, chroma: 0.2948272, hue: 142.5 }, + { name: "blue", rgb: [0, 0, 255], lightness: 0.4520137, chroma: 0.3132143, hue: 264.05 }, +]; + +const LIGHTNESS_TOLERANCE = 0.0005; +const CHROMA_TOLERANCE = 0.0005; +const HUE_TOLERANCE = 0.1; + +test("rgbToOklch matches published reference values", async () => { + const { rgbToOklch } = await importDist("ui", "oklch.js"); + + for (const entry of REFERENCE) { + const actual = rgbToOklch(entry.rgb); + assert.ok( + Math.abs(actual.lightness - entry.lightness) < LIGHTNESS_TOLERANCE, + `${entry.name} lightness ${actual.lightness} != ${entry.lightness}`, + ); + assert.ok( + Math.abs(actual.chroma - entry.chroma) < CHROMA_TOLERANCE, + `${entry.name} chroma ${actual.chroma} != ${entry.chroma}`, + ); + if (entry.hue !== undefined) { + assert.ok( + Math.abs(actual.hue - entry.hue) < HUE_TOLERANCE, + `${entry.name} hue ${actual.hue} != ${entry.hue}`, + ); + } + } +}); + +test("oklchToRgb round-trips every channel of the sRGB cube", async () => { + const { rgbToOklch, oklchToRgb } = await importDist("ui", "oklch.js"); + const offenders = []; + + for (let red = 0; red <= 255; red += 17) { + for (let green = 0; green <= 255; green += 17) { + for (let blue = 0; blue <= 255; blue += 17) { + const original = [red, green, blue]; + const [r, g, b] = oklchToRgb(rgbToOklch(original)); + if (Math.abs(r - red) > 1 || Math.abs(g - green) > 1 || Math.abs(b - blue) > 1) { + offenders.push(`${original} -> ${[r, g, b]}`); + } + } + } + } + + assert.deepEqual(offenders.slice(0, 5), []); +}); + +test("mixHue crosses zero the short way round", async () => { + const { mixHue } = await importDist("ui", "oklch.js"); + + assert.ok(Math.abs(mixHue(350, 10, 0.5) - 0) < 0.001, "350 -> 10 should pass through 0"); + assert.ok(Math.abs(mixHue(10, 350, 0.5) - 0) < 0.001, "10 -> 350 should pass through 0"); + assert.ok(Math.abs(mixHue(0, 180, 0.5) - 90) < 0.001); + assert.equal(mixHue(120, 240, 0), 120); +}); diff --git a/spec/theme-background-opacity.spec.mjs b/spec/theme-background-opacity.spec.mjs new file mode 100644 index 00000000..27137495 --- /dev/null +++ b/spec/theme-background-opacity.spec.mjs @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { importDist } from "./dist-helpers.mjs"; + +// A theme owns the background of every cell it paints. A style token that +// specifies no background means "inherit the surface underneath", not "erase +// it" -- writing an undefined bg punches a hole through to the terminal's own +// background, which on a dark theme happens to resemble the workspace and on a +// light theme shows up as black blocks behind the text. + +const PAGE_WIDTH = 80; +const PAGE_HEIGHT = 24; + +const SAMPLE_MARKDOWN = [ + "# Heading", + "", + "Body text with `inline code` in it.", + "", + "- a list item", + "> a quote", + "", + "```", + "code fence", + "```", + "", + "---", +].join("\n"); + +async function workspacePage(theme) { + const { createSurface } = await import("@flyingrobots/bijou"); + const token = theme.surface.workspace; + const surface = createSurface(PAGE_WIDTH, PAGE_HEIGHT, { char: " ", empty: false }); + for (let y = 0; y < surface.height; y += 1) { + for (let x = 0; x < surface.width; x += 1) { + surface.set(x, y, { + char: " ", + opacity: 1, + fg: token.fg, + fgRGB: token.fgRGB, + bg: token.bg, + bgRGB: token.bgRGB, + empty: false, + }); + } + } + return surface; +} + +function cellsWithoutBackground(surface) { + const holes = []; + for (let y = 0; y < surface.height; y += 1) { + for (let x = 0; x < surface.width; x += 1) { + const cell = surface.get(x, y); + if (cell.bg == null || cell.bgRGB == null) { + holes.push({ x, y, char: cell.char }); + } + } + } + return holes; +} + +test("the markdown preview never punches a hole in the page background", async () => { + const themes = await importDist("ui", "jedit-themes.js"); + const preview = await importDist("ui", "markdown-preview.js"); + const offenders = []; + + for (const theme of themes.availableJeditThemes()) { + const surface = await workspacePage(theme); + preview.paintMarkdownPreview(surface, { + text: SAMPLE_MARKDOWN, + scrollRow: 0, + x: 0, + y: 0, + width: PAGE_WIDTH, + height: PAGE_HEIGHT, + theme, + }); + const holes = cellsWithoutBackground(surface); + if (holes.length > 0) { + const sample = holes.slice(0, 3).map((h) => `(${h.x},${h.y})"${h.char}"`).join(" "); + offenders.push(`${theme.name} (${theme.mode}): ${holes.length} cells, e.g. ${sample}`); + } + } + + assert.deepEqual(offenders, []); +}); + +test("the source viewer never punches a hole in the page background", async () => { + const themes = await importDist("ui", "jedit-themes.js"); + const viewer = await importDist("ui", "source-viewer.js"); + const offenders = []; + + const lines = [ + "const answer = 42;", + "// a comment", + "function greet(name) {", + " return `hello ${name}`;", + "}", + ]; + + for (const theme of themes.availableJeditThemes()) { + const surface = await workspacePage(theme); + viewer.renderSourceViewer( + surface, + { lines, cursorRow: 0, cursorCol: 0, scrollRow: 0, mode: "normal" }, + undefined, + { + viewport: { width: PAGE_WIDTH - 8, height: PAGE_HEIGHT - 2 }, + leftPad: 0, + topPad: 0, + theme, + }, + ); + const holes = cellsWithoutBackground(surface); + if (holes.length > 0) { + const sample = holes.slice(0, 3).map((h) => `(${h.x},${h.y})"${h.char}"`).join(" "); + offenders.push(`${theme.name} (${theme.mode}): ${holes.length} cells, e.g. ${sample}`); + } + } + + assert.deepEqual(offenders, []); +}); diff --git a/src/ui/jim-logo-palette.ts b/src/ui/jim-logo-palette.ts new file mode 100644 index 00000000..bfed7331 --- /dev/null +++ b/src/ui/jim-logo-palette.ts @@ -0,0 +1,163 @@ +// Recolours the baked logo artwork into a theme's own colours. +// +// The generated palette in jim-logo-frame-data.ts is the reference layer: the +// artwork's own ink, which is near-black and deep navy because it was drawn as +// ink on paper. Painted literally it disappears on a dark terminal -- five of +// its fifteen entries sit at a contrast ratio under 1.2 against the graphite +// background. +// +// So the artwork supplies structure and the theme supplies colour. Each colour +// keeps its rank in the artwork's own lightness order and is re-sited on a ramp +// between the theme's titleLogoShadow and titleLogo tokens, which is why the +// logo reads as part of whichever theme is loaded instead of as a pasted-in +// bitmap. Hue is retained in proportion to how colourful the original was, so +// the diamond stays distinguishable from the J rather than the whole mark +// collapsing onto one hue. +// +// This runs on the rasterised glyph cells, not on the frame that feeds the +// rasteriser. The Braille renderer decides which dots to light by darkness +// against a white ground, so recolouring the frame itself would extinguish the +// whole mark on any theme whose ink ends up light. Recolouring afterwards +// leaves the mask exactly as the artwork drew it. + +import { JIM_LOGO_PALETTE } from './jim-logo-frame-data.js'; +import { JEDIT_THEME_MODE, type JeditTheme } from './jedit-theme.js'; +import { mixHue, oklchToRgb, rgbToOklch, type Oklch, type Rgb } from './oklch.js'; + +// WCAG 1.4.11 puts non-text graphics at 3:1, which is also the floor the theme +// suite already holds other chrome to. +const MIN_CONTRAST = 3; +const CONTRAST_STEP = 0.02; +const MAX_CONTRAST_STEPS = 40; +// Above this the artwork counts as fully coloured; the navy sits near 0.13. +const ARTWORK_CHROMA_REFERENCE = 0.12; +// How much of its own hue a fully coloured artwork entry keeps. Low enough +// that the theme leads, high enough that the diamond stays its own colour. +const HUE_RETENTION = 0.4; +// Neutral artwork still takes most of the theme's chroma so the mark reads as +// tinted rather than as grey pasted onto a coloured theme. +const MIN_CHROMA_SHARE = 0.55; +const SRGB_MAX = 255; +const CONTRAST_OFFSET = 0.05; +const ZERO = 0; +const ONE = 1; +// A theme may leave a token's true-colour value unset and rely on the palette +// index alone. The logo needs real channels to interpolate, so the mode picks +// the sane extreme rather than the code asserting the value is present. +const WHITE: Rgb = [255, 255, 255]; +const BLACK: Rgb = [0, 0, 0]; +// A theme whose two logo tokens are the same colour would flatten the mark to a +// silhouette, losing the artwork's internal shading. Below this separation the +// ramp is widened away from the background instead, so the structure survives +// any theme rather than only the ones that happen to differentiate the tokens. +const MIN_RAMP_SPAN = 0.18; + +interface LogoRamp { + readonly shadow: Oklch; + readonly ink: Oklch; + readonly ground: Rgb; + readonly darkest: number; + readonly span: number; +} + +// Returns the mapping rather than a fixed palette because rasterising averages +// several artwork pixels into one cell, so cells carry blends that are not +// palette entries. The transform is defined for any colour, so the blends are +// mapped as faithfully as the pure entries. +export function themeLogoInk(theme: JeditTheme): (artwork: Rgb) => Rgb { + const ramp = logoRamp(theme); + const cache = new Map(); + return (artwork: Rgb) => { + const key = (artwork[0] << 16) | (artwork[1] << 8) | artwork[2]; + const hit = cache.get(key); + if (hit != null) { + return hit; + } + const mapped = recolour(rgbToOklch(artwork), ramp); + cache.set(key, mapped); + return mapped; + }; +} + +function logoRamp(theme: JeditTheme): LogoRamp { + const lightnesses = JIM_LOGO_PALETTE.map((entry) => rgbToOklch(entry).lightness); + const darkest = Math.min(...lightnesses); + const lightest = Math.max(...lightnesses); + const foreground = theme.mode === JEDIT_THEME_MODE.Dark ? WHITE : BLACK; + const background = theme.mode === JEDIT_THEME_MODE.Dark ? BLACK : WHITE; + const ground = theme.surface.workspace.bgRGB ?? background; + const ink = rgbToOklch(theme.chrome.titleLogo.fgRGB ?? foreground); + return { + shadow: separated(rgbToOklch(theme.chrome.titleLogoShadow.fgRGB ?? foreground), ink, ground), + ink, + ground, + darkest, + span: lightest - darkest, + }; +} + +function separated(shadow: Oklch, ink: Oklch, ground: Rgb): Oklch { + if (Math.abs(ink.lightness - shadow.lightness) >= MIN_RAMP_SPAN) { + return shadow; + } + const away = ink.lightness >= rgbToOklch(ground).lightness ? ONE : -ONE; + return { ...shadow, lightness: clamp(ink.lightness - (away * MIN_RAMP_SPAN)) }; +} + +function recolour(artwork: Oklch, ramp: LogoRamp): Rgb { + const rank = ramp.span === ZERO ? ZERO : (artwork.lightness - ramp.darkest) / ramp.span; + const colourfulness = Math.min(ONE, artwork.chroma / ARTWORK_CHROMA_REFERENCE); + const themeHue = mixHue(ramp.shadow.hue, ramp.ink.hue, rank); + const chromaShare = MIN_CHROMA_SHARE + ((ONE - MIN_CHROMA_SHARE) * colourfulness); + return legibleAgainst({ + lightness: lerp(ramp.shadow.lightness, ramp.ink.lightness, rank), + chroma: lerp(ramp.shadow.chroma, ramp.ink.chroma, rank) * chromaShare, + hue: mixHue(themeHue, artwork.hue, HUE_RETENTION * colourfulness), + }, ramp.ground); +} + +// Lightness is what carries contrast, so a colour short of the floor is walked +// away from the background's lightness rather than desaturated or clipped -- +// that keeps its hue, which clipping RGB channels would not. +function legibleAgainst(colour: Oklch, ground: Rgb): Rgb { + const groundLightness = rgbToOklch(ground).lightness; + const direction = colour.lightness >= groundLightness ? ONE : -ONE; + let candidate = colour; + for (let step = ZERO; step < MAX_CONTRAST_STEPS; step += 1) { + const rgb = oklchToRgb(candidate); + if (contrastRatio(rgb, ground) >= MIN_CONTRAST) { + return rgb; + } + candidate = { + ...candidate, + lightness: clamp(candidate.lightness + (direction * CONTRAST_STEP)), + }; + } + return oklchToRgb(candidate); +} + +function lerp(from: number, to: number, amount: number): number { + return from + ((to - from) * amount); +} + +function clamp(value: number): number { + return Math.min(ONE, Math.max(ZERO, value)); +} + +function contrastRatio(foreground: Rgb, background: Rgb): number { + const first = relativeLuminance(foreground); + const second = relativeLuminance(background); + const high = Math.max(first, second); + const low = Math.min(first, second); + return (high + CONTRAST_OFFSET) / (low + CONTRAST_OFFSET); +} + +function relativeLuminance(rgb: Rgb): number { + const [red, green, blue] = rgb.map(channelLuminance); + return (0.2126 * (red ?? ZERO)) + (0.7152 * (green ?? ZERO)) + (0.0722 * (blue ?? ZERO)); +} + +function channelLuminance(channel: number): number { + const ratio = channel / SRGB_MAX; + return ratio <= 0.03928 ? ratio / 12.92 : Math.pow((ratio + 0.055) / 1.055, 2.4); +} diff --git a/src/ui/jim-logo-screen.ts b/src/ui/jim-logo-screen.ts index 7dfb2bc6..f33c640f 100644 --- a/src/ui/jim-logo-screen.ts +++ b/src/ui/jim-logo-screen.ts @@ -7,6 +7,7 @@ import { JIM_LOGO_PACKED_INDICES, JIM_LOGO_PALETTE, } from './jim-logo-frame-data.js'; +import { themeLogoInk } from './jim-logo-palette.js'; const MIN_LOGO_ROWS = 4; const LOGO_COLUMNS_PER_ROW = 2; @@ -26,8 +27,9 @@ const OPAQUE_ALPHA = 255; const TRANSPARENT_INDEX = 0; const FULL_OPACITY = 1; -// Built once at module load. The frame is static, so the startup screen renders -// without a frame pulse and without loading any mesh or scene. +// Built once at module load from the artwork's own colours. The Braille mask is +// derived from this frame's darkness, so it must stay the artwork as drawn; the +// theme's colours are applied to the rasterised cells afterwards. const JIM_LOGO_FRAME = createJimLogoFrame(); interface JimLogoBounds { @@ -47,18 +49,41 @@ export function renderJimLogoScreen( if (bounds == null) { return surface; } - blitInkOnly(surface, glyphsFor(bounds), bounds.x, bounds.y); + blitInkOnly(surface, glyphsFor(bounds, theme), bounds.x, bounds.y); return surface; } // Rasterising the 192px frame is the expensive half and depends only on the -// glyph grid, so one cached result per size keeps a resize cheap. Only -// blitInkOnly reads it, and it never writes, so sharing the surface is safe. -let cachedGlyphs: { columns: number; rows: number; surface: Surface } | undefined; +// glyph grid and the theme's colours, so one cached result per (theme, size) +// keeps both a resize and a theme switch cheap. Only blitInkOnly reads it, and +// it never writes, so sharing the surface is safe. +// +// The key is built from the colours themselves rather than the theme's name so +// that a renamed or generated variant carrying identical tokens still hits, and +// an edited theme keeping its name still misses. +interface CachedGlyphs { + readonly key: string; + readonly columns: number; + readonly rows: number; + readonly surface: Surface; +} + +let cachedGlyphs: CachedGlyphs | undefined; + +function logoCacheKey(theme: JeditTheme): string { + return JSON.stringify([ + theme.chrome.titleLogo.fgRGB, + theme.chrome.titleLogoShadow.fgRGB, + theme.surface.workspace.bgRGB, + theme.mode, + ]); +} -function glyphsFor(bounds: JimLogoBounds): Surface { +function glyphsFor(bounds: JimLogoBounds, theme: JeditTheme): Surface { + const key = logoCacheKey(theme); if ( cachedGlyphs != null + && cachedGlyphs.key === key && cachedGlyphs.columns === bounds.width && cachedGlyphs.rows === bounds.height ) { @@ -75,10 +100,24 @@ function glyphsFor(bounds: JimLogoBounds): Surface { threshold: BRAILLE_DARKNESS_THRESHOLD, }, }); - cachedGlyphs = { columns: bounds.width, rows: bounds.height, surface }; + recolourToTheme(surface, theme); + cachedGlyphs = { key, columns: bounds.width, rows: bounds.height, surface }; return surface; } +function recolourToTheme(glyphs: Surface, theme: JeditTheme): void { + const ink = themeLogoInk(theme); + for (let y = 0; y < glyphs.height; y += 1) { + for (let x = 0; x < glyphs.width; x += 1) { + const cell = glyphs.get(x, y); + if (cell.fgRGB == null) { + continue; + } + glyphs.set(x, y, { ...cell, fgRGB: ink(cell.fgRGB) }); + } + } +} + function fillWithWorkspace(width: number, height: number, theme: JeditTheme): Surface { const token = theme.surface.workspace; const surface = createSurface(width, height, { char: SURFACE_BLANK, empty: false }); diff --git a/src/ui/markdown-preview.ts b/src/ui/markdown-preview.ts index 75bbb986..c26e4746 100644 --- a/src/ui/markdown-preview.ts +++ b/src/ui/markdown-preview.ts @@ -1,4 +1,4 @@ -import { clipToWidth, stringToSurface, type Surface } from '@flyingrobots/bijou'; +import { clipToWidth, type Surface } from '@flyingrobots/bijou'; import { JEDIT_MARKDOWN_TOKEN, type JeditMarkdownToken, type JeditStyleToken, type JeditTheme } from './jedit-theme.js'; const FENCE_RE = /^\s*```/; @@ -242,31 +242,44 @@ function paintPreviewSegments( continue; } - const segmentSurface = stringToSurface(clipped, [...clipped].length, 1); - applyToken(segmentSurface, tokenForTone(options.theme, segment.tone)); - surface.blit(segmentSurface, cursor, options.y); - cursor += [...clipped].length; + cursor = paintSegmentText(surface, { + text: clipped, + x: cursor, + y: options.y, + token: tokenForTone(options.theme, segment.tone), + }); } } -function applyToken(surface: Surface, token: JeditStyleToken) { - for (let row = 0; row < surface.height; row += 1) { - for (let column = 0; column < surface.width; column += 1) { - const cell = surface.get(column, row); - if (cell.empty) { - continue; - } - surface.set(column, row, { - ...cell, - fg: token.fg, - fgRGB: token.fgRGB, - bg: token.bg, - bgRGB: token.bgRGB, - modifiers: token.modifiers == null ? undefined : [...token.modifiers], - empty: false, - }); - } +interface PaintSegmentTextOptions { + readonly text: string; + readonly x: number; + readonly y: number; + readonly token: JeditStyleToken; +} + +// Written into the page directly rather than composed on a scratch surface and +// blitted. A scratch cell has no background of its own, so blitting one carried +// an undefined background over the page and punched a hole through to the +// terminal's own -- invisible on a dark theme, black blocks on a light one. +function paintSegmentText(surface: Surface, options: PaintSegmentTextOptions): number { + const { token } = options; + let column = options.x; + for (const char of options.text) { + const cell = surface.get(column, options.y); + surface.set(column, options.y, { + ...cell, + char, + fg: token.fg, + fgRGB: token.fgRGB, + bg: token.bg ?? cell.bg, + bgRGB: token.bgRGB ?? cell.bgRGB, + modifiers: token.modifiers == null ? undefined : [...token.modifiers], + empty: false, + }); + column += 1; } + return column; } function tokenForTone(theme: MarkdownPreviewTheme, tone: PreviewSegmentTone): JeditStyleToken { diff --git a/src/ui/oklch.ts b/src/ui/oklch.ts new file mode 100644 index 00000000..dec4522a --- /dev/null +++ b/src/ui/oklch.ts @@ -0,0 +1,96 @@ +// sRGB <-> OKLab/OKLCH, from Björn Ottosson's published derivation. +// +// Hand-rolled rather than pulled from culori because jedit ships no colour +// dependency today and this is the whole of what it needs: two matrices, a +// cube root, and the sRGB transfer function. The constants are exact values +// from the reference implementation, and oklch.spec.mjs checks them against +// known conversions rather than against themselves. +// +// OKLCH is used instead of HSL because the logo has to keep a stable perceived +// lightness across hues: HSL's L is a channel average, so recolouring artwork +// through it makes blues read far darker than yellows at the same nominal L. + +export interface Oklch { + readonly lightness: number; + readonly chroma: number; + readonly hue: number; +} + +export type Rgb = readonly [number, number, number]; + +const SRGB_MAX = 255; +const GAMMA_THRESHOLD_ENCODED = 0.04045; +const GAMMA_THRESHOLD_LINEAR = 0.0031308; +const GAMMA_LINEAR_SLOPE = 12.92; +const GAMMA_OFFSET = 0.055; +const GAMMA_SCALE = 1.055; +const GAMMA_EXPONENT = 2.4; +const GAMMA_INVERSE_EXPONENT = 1 / 2.4; +const CUBE = 3; +const DEGREES_PER_TURN = 360; +const HALF_TURN = 180; +const ZERO = 0; +const ONE = 1; + +function toLinear(channel: number): number { + const ratio = channel / SRGB_MAX; + return ratio <= GAMMA_THRESHOLD_ENCODED + ? ratio / GAMMA_LINEAR_SLOPE + : Math.pow((ratio + GAMMA_OFFSET) / GAMMA_SCALE, GAMMA_EXPONENT); +} + +function toEncoded(linear: number): number { + const clamped = Math.min(ONE, Math.max(ZERO, linear)); + const ratio = clamped <= GAMMA_THRESHOLD_LINEAR + ? clamped * GAMMA_LINEAR_SLOPE + : (GAMMA_SCALE * Math.pow(clamped, GAMMA_INVERSE_EXPONENT)) - GAMMA_OFFSET; + return Math.round(ratio * SRGB_MAX); +} + +export function rgbToOklch(rgb: Rgb): Oklch { + const red = toLinear(rgb[0]); + const green = toLinear(rgb[1]); + const blue = toLinear(rgb[2]); + + const long = Math.cbrt((0.4122214708 * red) + (0.5363325363 * green) + (0.0514459929 * blue)); + const medium = Math.cbrt((0.2119034982 * red) + (0.6806995451 * green) + (0.1073969566 * blue)); + const short = Math.cbrt((0.0883024619 * red) + (0.2817188376 * green) + (0.6299787005 * blue)); + + const lightness = (0.2104542553 * long) + (0.7936177850 * medium) - (0.0040720468 * short); + const greenRed = (1.9779984951 * long) - (2.4285922050 * medium) + (0.4505937099 * short); + const blueYellow = (0.0259040371 * long) + (0.7827717662 * medium) - (0.8086757660 * short); + + const hue = (Math.atan2(blueYellow, greenRed) * DEGREES_PER_TURN) / (2 * Math.PI); + return { + lightness, + chroma: Math.hypot(greenRed, blueYellow), + hue: hue < ZERO ? hue + DEGREES_PER_TURN : hue, + }; +} + +export function oklchToRgb(color: Oklch): Rgb { + const radians = (color.hue * 2 * Math.PI) / DEGREES_PER_TURN; + const greenRed = color.chroma * Math.cos(radians); + const blueYellow = color.chroma * Math.sin(radians); + + const long = ((color.lightness + (0.3963377774 * greenRed) + (0.2158037573 * blueYellow)) ** CUBE); + const medium = ((color.lightness - (0.1055613458 * greenRed) - (0.0638541728 * blueYellow)) ** CUBE); + const short = ((color.lightness - (0.0894841775 * greenRed) - (1.2914855480 * blueYellow)) ** CUBE); + + return [ + toEncoded((4.0767416621 * long) - (3.3077115913 * medium) + (0.2309699292 * short)), + toEncoded((-1.2684380046 * long) + (2.6097574011 * medium) - (0.3413193965 * short)), + toEncoded((-0.0041960863 * long) - (0.7034186147 * medium) + (1.7076147010 * short)), + ]; +} + +// Shortest way round the hue circle, so a rotation from 350 to 10 travels 20 +// degrees forward rather than 340 back. Exact opposites are equally far in +// both directions; the tie is broken forwards so the result is a stated +// convention rather than a consequence of how the modulo happens to land. +export function mixHue(from: number, to: number, amount: number): number { + const forward = (((to - from) % DEGREES_PER_TURN) + DEGREES_PER_TURN) % DEGREES_PER_TURN; + const delta = forward > HALF_TURN ? forward - DEGREES_PER_TURN : forward; + const mixed = (from + (delta * amount)) % DEGREES_PER_TURN; + return mixed < ZERO ? mixed + DEGREES_PER_TURN : mixed; +} From d91009c58169ce9afbd2607023bd6ae607f6dcba Mon Sep 17 00:00:00 2001 From: James Ross Date: Mon, 7 Sep 2026 20:16:21 -0700 Subject: [PATCH 4/5] Refactor: reach the logo screen without touching the frozen title path The startup logo was routed through ui/title-screen.ts, which already re-exports viewer surfaces, because importing jim-logo-screen directly puts viewer-content.ts at thirteen imports against a limit of twelve. title-screen.ts is under the title-scene freeze leash, so that one-line re-export failed CI and would have needed the title-unfreeze label -- for a line that adds no title-scene behaviour whatsoever. The leash is right to refuse it, and taking the label to get past a re-export would be using the escape hatch to avoid a two-minute fix. The re-export moves to app/workspace/surface-fill.ts, which is the viewer's own surface module and carries no leash. viewer-content.ts now reaches both fillSurface and renderJimLogoScreen through it, staying at twelve imports, and src/ui/title-screen.ts is untouched by this branch. --- src/app/workspace/surface-fill.ts | 8 ++++++++ src/app/workspace/viewer-content.ts | 3 +-- src/ui/title-screen.ts | 2 -- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/app/workspace/surface-fill.ts b/src/app/workspace/surface-fill.ts index 4b5f8e82..8939e0d6 100644 --- a/src/app/workspace/surface-fill.ts +++ b/src/app/workspace/surface-fill.ts @@ -28,3 +28,11 @@ export function applyBackground(surface: Surface, token: JeditStyleToken): void } } } + +// Re-exported here so the viewer reaches every surface it paints through one +// module. Importing jim-logo-screen directly puts viewer-content.ts over the +// twelve-import limit, and routing it through ui/title-screen.ts -- the other +// module that already re-exports viewer surfaces -- would touch a path under +// the title-scene freeze leash for a re-export that adds no title-scene +// behaviour. The leash is right to refuse that; this avoids asking. +export { renderJimLogoScreen } from '../../ui/jim-logo-screen.js'; diff --git a/src/app/workspace/viewer-content.ts b/src/app/workspace/viewer-content.ts index de14974b..24054f30 100644 --- a/src/app/workspace/viewer-content.ts +++ b/src/app/workspace/viewer-content.ts @@ -2,7 +2,6 @@ import { createSurface, type Surface } from "@flyingrobots/bijou"; import { paintMarkdownPreview } from "../../ui/markdown-preview.js"; import { renderSourceViewer } from "../../ui/source-viewer.js"; import { - renderJimLogoScreen, TITLE_BACKDROP_KIND, TITLE_RENDER_MODE, paintTitleScreenPresentation, @@ -29,7 +28,7 @@ import { sourceHighlightForWorkspaceProjection, sourceWindowForWorkspaceModel, } from "./workspace-source-projection.js"; -import { fillSurface } from "./surface-fill.js"; +import { fillSurface, renderJimLogoScreen } from "./surface-fill.js"; import { governTitleSceneRender, staticTitleScenePerformanceFacts, diff --git a/src/ui/title-screen.ts b/src/ui/title-screen.ts index 92f7f59f..6bb7e2b8 100644 --- a/src/ui/title-screen.ts +++ b/src/ui/title-screen.ts @@ -465,5 +465,3 @@ function getRayDir( ), ); } - -export { renderJimLogoScreen } from './jim-logo-screen.js'; From fd174269e746b30abcf1fe0c9c2a964a4d58e87a Mon Sep 17 00:00:00 2001 From: James Ross Date: Mon, 7 Sep 2026 20:33:06 -0700 Subject: [PATCH 5/5] Fix: restore escape sanitizing, share one contrast rule, bound the cache Five review findings from Codex on this PR. All five were real. P1, and the serious one: painting preview cells by hand lost what bijou's stringToSurface does for free. It strips terminal control bytes before they reach a cell; my loop wrote them verbatim, and the diff writer concatenates cell characters straight into terminal output. Opening a Markdown file containing a CSI clear-screen or a BEL was enough to drive the terminal. Verified: stringToSurface turns "hi[2Jbye" into "hibye", the hand loop keeps the escapes. The blit is back, and the background problem it originally caused is solved properly instead: the background each cell is about to land on is read from the page and kept, so a token with no background of its own inherits rather than erases. That also restores grapheme and display-width placement, so CJK and emoji stop being split across cells -- the second finding, fixed by the same change. A spec now asserts no C0 control byte or DEL ever reaches a cell. P1: Buffer.from(..., 'base64') put a Node-specific API inside src/ui. Replaced with a plain lookup decoder. The input is a committed build artifact, not external data, but the runtime coupling was the concrete objection and it is gone; src/ui now names Buffer only in a comment. P2: the logo palette carried its own contrast correction that walked lightness in one direction chosen from the background -- exactly the dead end fixed in theme-contrast.ts, reintroduced as a second runtime truth. A mapped entry below a near-black workspace clamped at zero and returned near 1:1 while 3:1 was reachable the other way. legibleOn is now exported and shared. Every theme still clears the floor, and several move less far from the artwork's colour than before because the shared walk finds the nearest passing solution rather than the first one in a fixed direction. P1: the glyph cache held a single slot, so two viewers alternating themes or sizes evicted each other every frame and re-rasterised the 192px source each time. Measured: 7.68 ms per render alternating between two themes, against 2.26 ms steady. It is a small bounded map now -- 2.31 ms alternating, so the thrash is gone. Left module-level deliberately: it memoises a pure function, so it can affect timing but never output, and a renderer factory would buy isolation jedit has no second viewer to need. --- spec/theme-background-opacity.spec.mjs | 39 +++++++++++++ src/ui/jim-logo-palette.ts | 63 ++++++--------------- src/ui/jim-logo-screen.ts | 72 +++++++++++++++++++----- src/ui/markdown-preview.ts | 77 +++++++++++++++----------- src/ui/theme-contrast.ts | 2 +- 5 files changed, 162 insertions(+), 91 deletions(-) diff --git a/spec/theme-background-opacity.spec.mjs b/spec/theme-background-opacity.spec.mjs index 27137495..e9301df2 100644 --- a/spec/theme-background-opacity.spec.mjs +++ b/spec/theme-background-opacity.spec.mjs @@ -120,3 +120,42 @@ test("the source viewer never punches a hole in the page background", async () = assert.deepEqual(offenders, []); }); + +// Painting cells by hand loses what bijou's stringToSurface does for free: +// terminal control bytes are stripped before they reach a cell. Without that, a +// Markdown file carrying a CSI clear-screen or a BEL has them written verbatim +// into the surface and concatenated into terminal output by the diff writer -- +// opening a file becomes enough to drive the terminal. +const ESC = String.fromCharCode(27); +const BEL = String.fromCharCode(7); + +test("terminal control bytes in Markdown never reach a cell", async () => { + const themes = await importDist("ui", "jedit-themes.js"); + const preview = await importDist("ui", "markdown-preview.js"); + const [theme] = themes.availableJeditThemes(); + const surface = await workspacePage(theme); + + preview.paintMarkdownPreview(surface, { + text: `before ${ESC}[2J ${BEL} ${ESC}]0;title${BEL} after`, + scrollRow: 0, + x: 0, + y: 0, + width: PAGE_WIDTH, + height: PAGE_HEIGHT, + theme, + }); + + const offenders = []; + for (let y = 0; y < surface.height; y += 1) { + for (let x = 0; x < surface.width; x += 1) { + const char = surface.get(x, y).char; + const code = char.codePointAt(0) ?? 0; + // C0 controls and DEL. Space and the Braille blank are ordinary content. + if (code < 0x20 || code === 0x7f) { + offenders.push(`(${x},${y}) U+${code.toString(16).padStart(4, "0")}`); + } + } + } + + assert.deepEqual(offenders, []); +}); diff --git a/src/ui/jim-logo-palette.ts b/src/ui/jim-logo-palette.ts index bfed7331..beab8db3 100644 --- a/src/ui/jim-logo-palette.ts +++ b/src/ui/jim-logo-palette.ts @@ -23,12 +23,11 @@ import { JIM_LOGO_PALETTE } from './jim-logo-frame-data.js'; import { JEDIT_THEME_MODE, type JeditTheme } from './jedit-theme.js'; import { mixHue, oklchToRgb, rgbToOklch, type Oklch, type Rgb } from './oklch.js'; +import { legibleOn } from './theme-contrast.js'; // WCAG 1.4.11 puts non-text graphics at 3:1, which is also the floor the theme // suite already holds other chrome to. const MIN_CONTRAST = 3; -const CONTRAST_STEP = 0.02; -const MAX_CONTRAST_STEPS = 40; // Above this the artwork counts as fully coloured; the navy sits near 0.13. const ARTWORK_CHROMA_REFERENCE = 0.12; // How much of its own hue a fully coloured artwork entry keeps. Low enough @@ -37,8 +36,6 @@ const HUE_RETENTION = 0.4; // Neutral artwork still takes most of the theme's chroma so the mark reads as // tinted rather than as grey pasted onto a coloured theme. const MIN_CHROMA_SHARE = 0.55; -const SRGB_MAX = 255; -const CONTRAST_OFFSET = 0.05; const ZERO = 0; const ONE = 1; // A theme may leave a token's true-colour value unset and rely on the palette @@ -109,55 +106,31 @@ function recolour(artwork: Oklch, ramp: LogoRamp): Rgb { const colourfulness = Math.min(ONE, artwork.chroma / ARTWORK_CHROMA_REFERENCE); const themeHue = mixHue(ramp.shadow.hue, ramp.ink.hue, rank); const chromaShare = MIN_CHROMA_SHARE + ((ONE - MIN_CHROMA_SHARE) * colourfulness); - return legibleAgainst({ - lightness: lerp(ramp.shadow.lightness, ramp.ink.lightness, rank), - chroma: lerp(ramp.shadow.chroma, ramp.ink.chroma, rank) * chromaShare, - hue: mixHue(themeHue, artwork.hue, HUE_RETENTION * colourfulness), - }, ramp.ground); + // The one contrast correction, shared with the theme palettes rather than + // reimplemented here. An earlier local copy walked lightness in a single + // direction chosen from the background, which dead-ends: a mapped entry below + // a near-black workspace clamps at zero and returns near 1:1 while the + // advertised 3:1 was reachable by going lighter. + return legibleOn( + oklchToRgb({ + lightness: lerp(ramp.shadow.lightness, ramp.ink.lightness, rank), + chroma: lerp(ramp.shadow.chroma, ramp.ink.chroma, rank) * chromaShare, + hue: mixHue(themeHue, artwork.hue, HUE_RETENTION * colourfulness), + }), + [ramp.ground], + MIN_CONTRAST, + ); } -// Lightness is what carries contrast, so a colour short of the floor is walked -// away from the background's lightness rather than desaturated or clipped -- -// that keeps its hue, which clipping RGB channels would not. -function legibleAgainst(colour: Oklch, ground: Rgb): Rgb { - const groundLightness = rgbToOklch(ground).lightness; - const direction = colour.lightness >= groundLightness ? ONE : -ONE; - let candidate = colour; - for (let step = ZERO; step < MAX_CONTRAST_STEPS; step += 1) { - const rgb = oklchToRgb(candidate); - if (contrastRatio(rgb, ground) >= MIN_CONTRAST) { - return rgb; - } - candidate = { - ...candidate, - lightness: clamp(candidate.lightness + (direction * CONTRAST_STEP)), - }; - } - return oklchToRgb(candidate); + +function clamp(value: number): number { + return Math.min(ONE, Math.max(ZERO, value)); } function lerp(from: number, to: number, amount: number): number { return from + ((to - from) * amount); } -function clamp(value: number): number { - return Math.min(ONE, Math.max(ZERO, value)); -} -function contrastRatio(foreground: Rgb, background: Rgb): number { - const first = relativeLuminance(foreground); - const second = relativeLuminance(background); - const high = Math.max(first, second); - const low = Math.min(first, second); - return (high + CONTRAST_OFFSET) / (low + CONTRAST_OFFSET); -} -function relativeLuminance(rgb: Rgb): number { - const [red, green, blue] = rgb.map(channelLuminance); - return (0.2126 * (red ?? ZERO)) + (0.7152 * (green ?? ZERO)) + (0.0722 * (blue ?? ZERO)); -} -function channelLuminance(channel: number): number { - const ratio = channel / SRGB_MAX; - return ratio <= 0.03928 ? ratio / 12.92 : Math.pow((ratio + 0.055) / 1.055, 2.4); -} diff --git a/src/ui/jim-logo-screen.ts b/src/ui/jim-logo-screen.ts index f33c640f..330b2af6 100644 --- a/src/ui/jim-logo-screen.ts +++ b/src/ui/jim-logo-screen.ts @@ -27,6 +27,19 @@ const OPAQUE_ALPHA = 255; const TRANSPARENT_INDEX = 0; const FULL_OPACITY = 1; +// Decoded with a plain lookup rather than Buffer.from(..., 'base64') so this +// module stays free of Node-specific APIs; src/ui renders, it does not depend +// on a runtime. The input is a committed build artifact generated by +// scripts/generate-jim-logo-frame.mjs, not external data. +const BASE64_ALPHABET = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; +const BASE64_PAD = '='; +const BITS_PER_BASE64_CHAR = 6; +const BITS_PER_BYTE = 8; +const BYTE_MASK = 0xff; +const HIGH_NIBBLE_SHIFT = 4; +const LOW_NIBBLE_MASK = 0x0f; + // Built once at module load from the artwork's own colours. The Braille mask is // derived from this frame's darkness, so it must stay the artwork as drawn; the // theme's colours are applied to the rasterised cells afterwards. @@ -61,14 +74,21 @@ export function renderJimLogoScreen( // The key is built from the colours themselves rather than the theme's name so // that a renamed or generated variant carrying identical tokens still hits, and // an edited theme keeping its name still misses. +// Keyed on the theme's colours and the glyph grid together, and holding a few +// entries rather than one. A single slot meant two viewers alternating themes +// or sizes evicted each other every frame and re-rasterised the 192px source +// each time; a small map makes alternation free while staying bounded. +// +// This is a memo of a pure function -- same key, same surface -- so it can +// affect timing but never output, and a renderer factory would buy isolation +// jedit has no second viewer to need. interface CachedGlyphs { readonly key: string; - readonly columns: number; - readonly rows: number; readonly surface: Surface; } -let cachedGlyphs: CachedGlyphs | undefined; +const GLYPH_CACHE_LIMIT = 8; +const cachedGlyphs = new Map(); function logoCacheKey(theme: JeditTheme): string { return JSON.stringify([ @@ -80,14 +100,10 @@ function logoCacheKey(theme: JeditTheme): string { } function glyphsFor(bounds: JimLogoBounds, theme: JeditTheme): Surface { - const key = logoCacheKey(theme); - if ( - cachedGlyphs != null - && cachedGlyphs.key === key - && cachedGlyphs.columns === bounds.width - && cachedGlyphs.rows === bounds.height - ) { - return cachedGlyphs.surface; + const key = `${logoCacheKey(theme)}|${bounds.width}x${bounds.height}`; + const hit = cachedGlyphs.get(key); + if (hit != null) { + return hit.surface; } const surface = rasterToGlyphSurface(JIM_LOGO_FRAME, { columns: bounds.width, @@ -101,7 +117,13 @@ function glyphsFor(bounds: JimLogoBounds, theme: JeditTheme): Surface { }, }); recolourToTheme(surface, theme); - cachedGlyphs = { key, columns: bounds.width, rows: bounds.height, surface }; + if (cachedGlyphs.size >= GLYPH_CACHE_LIMIT) { + const oldest = cachedGlyphs.keys().next().value; + if (oldest != null) { + cachedGlyphs.delete(oldest); + } + } + cachedGlyphs.set(key, { key, surface }); return surface; } @@ -206,11 +228,33 @@ function createJimLogoFrame(): RgbaFrame { } function unpackIndices(count: number): Uint8Array { - const packed = Buffer.from(JIM_LOGO_PACKED_INDICES, 'base64'); + const packed = decodeBase64(JIM_LOGO_PACKED_INDICES); const indices = new Uint8Array(count); for (let i = 0; i < count; i += 1) { const byte = packed[i >> 1] ?? 0; - indices[i] = (i % 2 === 0 ? byte >> 4 : byte) & 0x0f; + indices[i] = (i % 2 === 0 ? byte >> HIGH_NIBBLE_SHIFT : byte) & LOW_NIBBLE_MASK; } return indices; } + +function decodeBase64(text: string): Uint8Array { + const bytes: number[] = []; + let accumulator = 0; + let bits = 0; + for (const character of text) { + if (character === BASE64_PAD) { + break; + } + const value = BASE64_ALPHABET.indexOf(character); + if (value < 0) { + continue; + } + accumulator = (accumulator << BITS_PER_BASE64_CHAR) | value; + bits += BITS_PER_BASE64_CHAR; + if (bits >= BITS_PER_BYTE) { + bits -= BITS_PER_BYTE; + bytes.push((accumulator >> bits) & BYTE_MASK); + } + } + return Uint8Array.from(bytes); +} diff --git a/src/ui/markdown-preview.ts b/src/ui/markdown-preview.ts index c26e4746..7e2068be 100644 --- a/src/ui/markdown-preview.ts +++ b/src/ui/markdown-preview.ts @@ -1,4 +1,4 @@ -import { clipToWidth, type Surface } from '@flyingrobots/bijou'; +import { clipToWidth, stringToSurface, type Surface } from '@flyingrobots/bijou'; import { JEDIT_MARKDOWN_TOKEN, type JeditMarkdownToken, type JeditStyleToken, type JeditTheme } from './jedit-theme.js'; const FENCE_RE = /^\s*```/; @@ -242,44 +242,59 @@ function paintPreviewSegments( continue; } - cursor = paintSegmentText(surface, { - text: clipped, - x: cursor, - y: options.y, - token: tokenForTone(options.theme, segment.tone), - }); + const segmentSurface = stringToSurface(clipped, [...clipped].length, 1); + applyToken( + segmentSurface, + tokenForTone(options.theme, segment.tone), + { target: surface, x: cursor, y: options.y }, + ); + surface.blit(segmentSurface, cursor, options.y); + cursor += [...clipped].length; } } -interface PaintSegmentTextOptions { - readonly text: string; +interface PaintedBeneath { + readonly target: Surface; readonly x: number; readonly y: number; - readonly token: JeditStyleToken; } -// Written into the page directly rather than composed on a scratch surface and -// blitted. A scratch cell has no background of its own, so blitting one carried -// an undefined background over the page and punched a hole through to the -// terminal's own -- invisible on a dark theme, black blocks on a light one. -function paintSegmentText(surface: Surface, options: PaintSegmentTextOptions): number { - const { token } = options; - let column = options.x; - for (const char of options.text) { - const cell = surface.get(column, options.y); - surface.set(column, options.y, { - ...cell, - char, - fg: token.fg, - fgRGB: token.fgRGB, - bg: token.bg ?? cell.bg, - bgRGB: token.bgRGB ?? cell.bgRGB, - modifiers: token.modifiers == null ? undefined : [...token.modifiers], - empty: false, - }); - column += 1; +// Composed on a scratch surface and blitted rather than written cell by cell. +// stringToSurface strips terminal control bytes -- a Markdown file carrying a +// CSI clear-screen or a BEL would otherwise have them written verbatim into +// cells and concatenated straight into terminal output by the diff writer -- +// and it places graphemes by display width, so CJK and emoji occupy the cells +// they actually need. +// +// A scratch cell has no background of its own, and every markdown token except +// Code and InlineCode leaves bg undefined, so blitting one carried an undefined +// background over the page and punched a hole through to the terminal's own: +// invisible on a dark theme, black blocks on a light one. The background each +// cell is about to land on is read from the page and kept, so a token that +// specifies no background inherits rather than erases. +function applyToken( + surface: Surface, + token: JeditStyleToken, + beneath: PaintedBeneath, +) { + for (let row = 0; row < surface.height; row += 1) { + for (let column = 0; column < surface.width; column += 1) { + const cell = surface.get(column, row); + if (cell.empty) { + continue; + } + const under = beneath.target.get(beneath.x + column, beneath.y + row); + surface.set(column, row, { + ...cell, + fg: token.fg, + fgRGB: token.fgRGB, + bg: token.bg ?? under.bg, + bgRGB: token.bgRGB ?? under.bgRGB, + modifiers: token.modifiers == null ? undefined : [...token.modifiers], + empty: false, + }); + } } - return column; } function tokenForTone(theme: MarkdownPreviewTheme, tone: PreviewSegmentTone): JeditStyleToken { diff --git a/src/ui/theme-contrast.ts b/src/ui/theme-contrast.ts index d6054268..e8d12c8d 100644 --- a/src/ui/theme-contrast.ts +++ b/src/ui/theme-contrast.ts @@ -54,7 +54,7 @@ function contrastAdjustedPalette(palette: ThemePalette): ThemePalette { // toward black or white instead -- the obvious approach -- desaturates as it // goes and drags the hue with it, so a theme's amber warning arrives washed out // and slightly wrong rather than simply lighter. -function legibleOn( +export function legibleOn( color: RgbTuple, backgrounds: readonly RgbTuple[], minContrastRatio: number,