Architecture

September 1, 2026 · View on GitHub

How the running app is wired, for orientation before changing it. Setup and commands are in CONTRIBUTING.md; the invariants you must not break are in AGENTS.md.

A Vite + Mithril single-page app. Sprite art and JSON definitions in the repo are compiled into generated metadata modules at build time; the browser reads those through a catalog, composites the selected layers onto an offscreen canvas, and packages that canvas into a ZIP.

Generated metadata and the dist/ alias

sheet_definitions/ and palette_definitions/ are the source of truth. The Vite plugin in vite/vite-plugin-item-metadata.ts runs generateSources and writes five ES modules to dist/. See File Generation for what each one contains.

Source code never imports a dist/ path. It imports ../<name>-metadata.js, and itemMetadataResolveAliases() in vite/wiring.ts installs a resolve.alias regex that rewrites any specifier ending in a metadata basename to the dist/ copy:

// sources/install-item-metadata.ts — resolves to dist/index-metadata.js
import("../index-metadata.js");

This catches everyone once. The file is not next to the importer, and it does not exist at all until npm run dev or npm run build has run. A resolve failure here means dist/ was never built, not that the import is wrong.

Lite records intern repeated strings and palette maps: v / r index variantArrays / recolorVariantArrays, and each recolor's p indexes paletteArrays. Those tables live on index-metadata.js. Catalog register expands them into the runtime ItemLite shape (variants arrays and recolors[].palettes maps), so lite expansion depends on the index chunk as well as item-metadata.js. See resolve-hash-param.ts and catalog.ts.

Bootstrap

sources/main.ts is the composition root. As the entry module runs, before any DOM exists:

  1. createCatalog() produces separate reader/writer capabilities over shared private stores (sources/state/catalog.ts). createLoadedCatalog() starts metadata registration with the writer and returns only applicationCatalog, the reader. createState() produces applicationState (sources/state/state.ts).
  2. configureStateCatalog(applicationCatalog) points the state layer at it, so sources/state/ does not need the catalog instance threaded in. Tests may override effects with setStateDeps / resetStateDeps.
  3. DEBUG = getDebugParam(), then window.profiler = new PerformanceProfiler(...).
  4. createLoadedCatalog() starts the metadata fetch without blocking, so download and parse overlap HTML parsing while bootstrap receives only the catalog reader.

sources/install-item-metadata.ts does a parallel import() of all five chunks and calls the matching writer register*Metadata method as each one lands, so readiness is staged rather than all-or-nothing. Each registration triggers a coalesced redraw. Interned lite palettes restore when the index chunk registers (either order); until then getPaletteOptions treats missing palettes as {}.

On DOMContentLoaded, three separate Mithril roots mount against the same catalog and state instances:

Element in index.htmlComponent
#mithril-filtersApp
#mithril-previewAnimationPreview
#mithril-spritesheet-previewFullSpritesheetPreview

Each mount root starts with a loading-shell child (reserved height, spinner) so first paint does not use a 24×24 .loading root. Mithril replaces those children on mount; they are not part of the hydrated layout. Reserved height exists to limit lab CLS; measurement and debugging: CLS.md.

Mounting happens immediately; hash hydration waits. The async block after mount awaits onIndexReady and onLiteReady, then calls initCanvas(), initHashChangeListener(), and initState(). Layers arrive later, and renderCharacter awaits onLayersReady itself rather than holding up the UI.

Bootstrap also installs a few sanctioned globals for tooling, not for app code to read: window.profiler, window.canvasRenderer, __LPC_waitCatalogIndexReady, __LPC_waitCatalogLiteReady, __LPC_waitCatalogAllReady, and __LPC_arePaletteModalMetadataChunksReady.

Selection flow

Nothing calls the renderer directly from an event handler. Selections mutate plain state, Mithril redraws, and App.onupdate notices the change:

flowchart TD
  treeClick["Tree click, e.g. ItemWithVariants"] --> selectItem["selectItem() mutates state.selections"]
  selectItem --> redraw["Mithril auto-redraw"]
  redraw --> onupdate["App.onupdate diffs selections, bodyType, custom image"]
  onupdate --> hash["syncSelectionsToHash(catalog, state) writes the URL hash"]
  onupdate --> render["renderCharacter(catalog, state, state.selections, state.bodyType)"]
  render --> offscreen["Offscreen canvas in renderer.ts"]
  offscreen --> redraw2["m.redraw() after the async render resolves"]
  redraw2 --> preview["copyToPreviewCanvas / preview rAF loop"]
  hashchange["Back / forward: hashchange"] --> load["loadSelectionsFromHash(catalog, state)"]
  load --> redraw

App.onupdate compares JSON.stringify(state.selections), state.bodyType, state.customUploadedImage, and state.customImageZPos against the values it saw last, and does nothing when they match. If a change does not reach the canvas, that diff is the first place to look.

The URL hash is the shareable document format, so it is a compatibility surface rather than an implementation detail — see URL hash.

Render path

sources/canvas/renderer.ts owns a single offscreen canvas. renderCharacter awaits the layers chunk and delegates to runRenderCharacter, which:

  1. Builds a drawCalls queue, walking layer_1 through layer_9 of every selection and skipping items whose meta.required excludes the current body type.
  2. Reads each layer's z-position with getZPos (canvas-utils.ts), defaulting to 100.
  3. Sorts with drawCalls.sort((a, b) => a.zPos - b.zPos), so lower zPos draws first and ends up behind.
  4. Loads every sprite in parallel, then draws in that sorted order, passing each image through getImageToDraw for palette recoloring. WebGL misses return the live canvas for the compositor; LRU snapshots are filled on idle after the render (or flushed before the next renderCharacter).
  5. Composites custom-animation regions below the standard sheet, sorted the same way.

The one WebGL/CPU branch

Palette recoloring is the only place with two implementations, and recolorImage() in sources/canvas/palette-recolor.ts is the only branch point:

const shouldUseWebGL = config.useWebGL && !config.forceCPU;
if (shouldUseWebGL) {
  try {
    return await recolorImageWebGL(sourceImage, paletteMappings);
  } catch (error) {
    return recolorImageCPU(sourceImage, paletteMappings);
  }
}
return recolorImageCPU(sourceImage, paletteMappings);

config.useWebGL is isWebGLAvailable() evaluated once at module load; config.forceCPU is toggled by setPaletteRecolorMode("cpu"). So the CPU path runs when WebGL is unavailable, when it is forced off, or when the WebGL call throws.

This is why canvas-render asks for both paths to be checked after a rendering change: the two implementations can silently disagree on output, and only one of them runs on any given machine. Verifying it needs a real browser.

Module roles

sources/state/

FileRole
catalog.tscreateCatalog() reader/writer capability pairs, Result-returning getters, registration methods, staged readiness, and intern expansion of v / r / p
constants.tsFRAME_SIZE, BODY_TYPES, ANIMATIONS, LICENSE_CONFIG, animation offsets and configs
filters.tsLicense and animation compatibility predicates that gate tree visibility
hash.tsHash read/write, loadSelectionsFromHash, syncSelectionsToHash, initHashChangeListener
json.tsCharacter JSON import/export and layer serialization
meta.tsLayer ordering and load planning (getSortedLayers, getLayersToLoad)
palettes.tsRecolor resolution: getMultiRecolors, getTargetPalette, parseRecolorKey
path.tsSprite URL building (getSpritePath, replaceInPath)
preview-canvas-loading.tsPreview overlay state machine
resolve-hash-param.tsHash value to item id resolution, including aliases, and expansion of interned v / r / p
state.tscreateState(), selectItem, selectDefaults, initState, configureStateCatalog, and the setStateDeps / resetStateDeps / getStateDeps test seam
zip.tsThe four ZIP export orchestrators

sources/canvas/

FileRole
renderer.tsOffscreen compositing; owns canvas, drawCalls, renderCharacter, renderSingleItem
palette-recolor.tsRecolor dispatch, WebGL/CPU branch, LRU recolor cache (idle-filled after renderCharacter)
webgl-palette-recolor.tsThe WebGL implementation and isWebGLAvailable()
load-image.tsCached, deduplicated image loading
draw-frames.tsFrame extraction and custom-animation drawing
mask.tsTransparency masking (keys out magenta #FF2CE6)
canvas-utils.tsget2DContext, canvasToBlob, getZPos
preview-canvas.tsCopies the offscreen canvas to the visible preview
preview-animation.tsThe rAF preview loop
download.tsSingle-PNG download

sources/models/

FileRole
application.tscreateApplicationModels() composition-root factories; ApplicationModels bag of lazy model constructors
search-control.tsRender-ready search value, loading presentation, and update command
license-filters.tsLicense filter options, readiness, compatibility summary, and mutation commands
animation-filters.tsAnimation filter options, compatibility summary, and mutation commands
current-selections.tsRender-ready Current Selections snapshot and remove commands
download.tsDownload readiness/progress snapshots and PNG, credits, ZIP, and clipboard commands
credits.tsLoading/empty/ready attribution snapshot and credit download commands

sources/components/

DirectoryRole
tree/Body-type selector and the recursive category tree; palette modal
filters/Search, license, and animation filter controls
selections/Read-only summary of the current selections
preview/Animated preview and full-spritesheet preview panels
download/PNG, JSON, and the four ZIP export buttons; credits panel
advanced/Custom PNG overlay upload and its z-position

App composes Download, FiltersPanel, Credits, and AdvancedTools; FiltersPanel composes the filters/, selections/, and tree/ components. The bootstrap in main.ts constructs the render-ready application model graph; components receive and forward lazy constructors for their model slice instead of constructing application dependencies. Leaves invoke those constructors from view, so a collapsed parent does not build models for children Mithril does not render. Components that own their CollapsibleSection, such as Download and Credits, keep the section wrapper model-free and invoke the constructor in an internal content leaf.

ZIP export

sources/state/zip.ts has four exports — exportSplitAnimations, exportSplitItemSheets, exportSplitItemAnimations, and exportIndividualFrames — all following the same shape: guard the environment, create a profiler, suspend UI redraws, build the JSZip tree, add character.json plus credits, then generate and download the blob in a finally that always restores the UI.

The animation-level exports slice the already composited offscreen canvas. The per-item exports re-render each item separately through renderSingleItem or renderSingleItemAnimation, which is why they are much slower. Canvas-to-zip helpers live in sources/utils/zip-helpers.ts.

Profiling these paths, including which headless script matches which change, is covered in PERFORMANCE_PROFILING.md and performance-profiling.