HomeDeveloper docsFrontend architecture
Project reference 41 min read

Frontend architecture

Jinja2, Datastar, native JavaScript, PixiJS, styles and asset preparation.

Documentation index · Português · API reference

The application renders Jinja2 HTML on the server, uses Datastar for reactive signals and fragment updates, and runs JavaScript controllers for interactive workspaces. The game board uses PixiJS and a separate source/build pipeline. There is no single application-wide SPA build step.

Follow a page from its template

base.html loads the shared stylesheet, vendored Datastar, and gravewright_modules/frontend-api.js, which creates window.gravewright. The /inside workspace and game table extend that shell. table/page.html composes feature templates and loads table, map, journal, actor, dice and module controllers.

Useful areas to inspect:

LocationPurpose
gravewright/web/jinja2/Shared shell, Inside pages and reusable HTML
gravewright/accounts/jinja2/Authentication and initial account setup templates
gravewright/web/static/gravewright_web/css/Design tokens, base styles and component classes
gravewright/web/static/gravewright_web/inside/Inside account/campaign/admin controllers and styles
gravewright/web/static/gravewright_web/windows.jsMovable/resizable window behavior
gravewright/table/static/gravewright_table/realtime.jsSocket connection, subscriptions, authenticated fragments and event dispatch
gravewright/modules/static/gravewright_modules/Public browser API, signed-module runtime, transport and lifecycle
gravewright/maps/frontend/Editable board, wall, token, lighting, selection and tool sources
gravewright/maps/static/gravewright_maps/workspace.jsNative map workspace integration
gravewright/pdf_system/frontend/Editable PDF mapping/controller sources
App-specific jinja2/, static/, and messages.jsonDomain UI, styling and text catalogs

Templates carry IDs and data-* attributes that native controllers and extension surfaces depend on. Changing one may affect the corresponding controller, Datastar signals, and module attachment. Check all three before renaming selectors or replacing an entire panel.

Map Builder workspace

GET /game/<campaign_id>/map-builder renders maps/builder.html through maps/builder.py. It checks current GM membership and validates the optional folder query against that campaign. The new-scene chooser performs normal same-tab navigation; the builder uses the base shell with its own static/gravewright_maps/builder/ controllers, without mounting table runtime, scene subscriptions or the table workspace. Native effects use the dedicated preview described below.

The table's scene directory reads the optional builderProjectId from the GM projection of maps.services.state(). It is resolved in one campaign-scoped ID query from the project's current scene link; non-GM projections do not include it. Linked scenes keep the same compact row and map icon, colored with --danger, and add Edit with MapBuilder beside the normal edit action in the context menu. Shift+F10 or the context-menu key opens the menu from a focused scene. Menus render in the directory's document, including detached windows; the edit action opens the editor in the main game tab. The project endpoint rechecks GM membership and campaign ownership on load.

native-effects.js is a builder adapter to the same light, particle and shader catalogs as the tabletop. Its small eager builder-catalog.js bundle supplies presets without starting WebGL; builder-native.js loads lazily for visible sources. The editable entries are frontend/native/builder-catalog.js and frontend/native/builder-preview.js. The latter reuses PixiBoardRenderer and SceneLayers on a transparent overlay synchronized to the SVG camera and wall geometry. Hidden/empty layers, page hiding and disposal release rendering and animation resources. Native effects remain separate from the generated raster.

frontend/native/builder-editors.js hosts the same LightEditor and EffectEditor widgets used by the game against a detached builder draft. Its builder-editor-model.js adapter maps the builder's light radius names and resolves compact preset references for GLSL editing without expanding an unchanged source. Transactional widget footers are enabled only in this host; the game's existing autosave behavior remains available to its normal host. Draft validation and preview never call scene commands. Saving returns one candidate to workspace.js, which checks editability and commits one project history step. Cancel/close disposes preview timers, shader validation and any nested prompt, preserving the project and live scene.

static/gravewright_maps/wall-editor.js and the wall_editor.html template share the wall configuration window with the table's wall controller. Its builder host writes optional native presentation, channel, elevation and discovery settings to the source wall/door. Door and lever Smart Object behavior windows remain separate from physical wall configuration.

builder/torch-model.js binds a decoration to native light/effect source rows. Mechanism state and flame illumination are independent; preview projects the mechanism's quarter turn and gates enabled components without rewriting source coordinates. model.js expands composed selections once for transforms and duplication, while object-removal.js reconciles ownership and Blueprint light references atomically. Component editor buttons reuse builder-editors.js and retain the selected parent. On the table, lever-controls.js hosts both lever and torch interactions, and image-components.js projects linked render nodes through image/selection previews without recreating GPU resources. The domain's torches.py validates scene-local ownership, applies authoritative transforms and flame gating, and keeps native group operations within map transactions.

Entry is read-only. The Studio's workspace.js composes the editor controllers, navigation and drawing gestures. Its directly served sibling modules own these boundaries:

Module under static/gravewright_maps/builder/Responsibility
project-persistence.jsDraft recovery/storage, HTTP/CSRF, receipt identities and the save queue
workspace-library.jsCampaign asset catalog, brush choices and material controls
workspace-import.jsAsset import/edit modals and their listener lifetime
inspector-controller.jsShared selection fields, section modals and region/torch configuration
object-menu.jsObject context menu, keyboard navigation and model submenu
interactive-presets.jsConfigurable generic object Blueprints and their catalog modal
layer-controller.jsLayer controls, contents, selection and drag listeners
canvas-renderer.jsSVG artwork, selection overlays and disposable terrain/wall caches

Controllers receive explicit state and callbacks rather than importing the workspace or sharing globals. Persistence tests import its public factory; they do not extract function text from workspace.js. A disposed save owner cannot write a late acknowledgement over a recovered draft. model.js owns cell-space geometry and undo history, and viewport.js owns the independent SVG camera. Camera listeners, pointer capture, requests and resize observation end on pagehide; history restoration remounts the editor. builder_services.py owns campaign-scoped project revisions, retry receipts, validation, scene generation and updates. See Map Builder for the source schema, native geometry and raster boundaries.

newDocument() creates one empty, generically named layer with a fresh UUID; the controller localizes its initial Layer 1 name. It creates no layers by object type. Schema-3 normalization preserves authored layers, including an empty layer list after removing the final empty layer. The server accepts that list only with no objects, and empty-map generation renders just the background. Version-1/2 upgrades retain their existing Base-layer conversion.

All element kinds accept an authored name of up to 120 characters. Optional names stay omitted on unnamed legacy rows; regions and shaders retain their required names. The inspector supplies the existing Empty Object label for older unnamed controllers. element_names.py validates type, whitespace, length and NUL at the server boundary. Native edits, scene generation and linked updates preserve labels without replacing UUIDs or Blueprint targets. Static floor artwork remains baked; a dynamic surface carries its source name on the native image. The Selection inspector owns one name field per selected element, and layers, behavior catalogs and light/door choices prefer authored labels.

The library's persistent tool strip exposes its additional controls through data-builder-dock-tool; existing panel tool selectors remain distinct. Tool changes flush the focused property before returning focus to the canvas. The strip scrolls independently on narrow screens and keeps a stable layout when the native light/effect library replaces the asset cards.

The header mode selector honors optional editingMode: "tiles" | "paint"; omission defaults to Tiles without changing the source. Tiles and terrain Painting share layers, native objects, camera and project history. Existing floor.paint color/opacity still renders independently through SVG and the raster, multiplying shape coverage, texture alpha and opacity. Selecting the new terrain brush does not reinterpret those earlier styles or convert existing geometry. Floors retain their layer and transform contracts. SVG and Pillow use different antialiasing at coincident contour/cut boundaries; pixel comparisons keep interiors exact and isolate that subpixel boundary band.

floor-painter.js owns transient floor gestures and commits one complete edit. model.js supplies swept brush footprints, family-aware flood fill, material sampling and atomic floor subtraction. floor-geometry.js supplies continuous contours and excluded cuts when snapping is disabled, along with shape-aware hit testing and source/raster budgets. The server validates the same optional floor shape contract and rasterizes its union-minus-cuts mask with bounded antialiasing. Preview fragments retain stable UUIDs; no-op strokes do not consume undo steps. layer-model.js counts layer contents and moves or merges them while preserving object identities and paint order. Layer duplication reuses atomic object duplication with an exact one-cell offset on both axes, new IDs and remapped internal Smart Object references. Bounds, global door overlap and project limits can reject the entire copy. Isolation is an editor filter; project visibility and native generation retain their existing semantics. Drawing controls belong to the auxiliary panel's Tools tab, leaving the canvas space available when the panel is closed.

The Panels launcher opens the drawer's six linked tabs in a three-column grid. Each tab retains its own scroll position; the heading and tabs stay visible above the scrolling content. Arrow keys, Home and End navigate the tabs, while closing the drawer returns focus to its opener. Active brush settings precede the compact tool palette, and detailed brush help is a separate disclosure. Panel navigation and scrolling do not change the saved project or its history.

Tiles repainting compares the topmost floor material before replacing coverage. An unchanged cell or completely matching free stroke retains its source rows without rerolling eligible family variants or adding history. Coverage accounts for the union of several floors and their cuts; another material above them blocks a match. Snapped gestures filter matching cells independently. Mixed free gestures replace their complete footprint, keeping geometry outside it. Coverage proofs have a bounded geometry work budget and reject atomically when it is exceeded. Terrain Painting uses its own radial brush representation rather than the earlier flat floor.paint opacity composition.

Map Builder terrain painting

terrain-painter.js owns a transient circular gesture, distance-spaced dabs and 50 ms stationary accumulation. It commits one floor.brush row per completed gesture and clears its timer on cancel, completion and disposal. The workspace owns pointer capture, shared brush size, mode controls, history and rendering; switching tools or modes cancels an active stroke. Tiles continues using floor-painter.js and its existing no-op and replacement semantics.

terrain-brush.js and maps/terrain_brush.py evaluate the same radial kernel at absolute raster pixel centers. Hardness defines an inner plateau with linear falloff to the radius; dab alpha accumulates with bounded integer source-over rounding. The source stores local normalized points, horizontal/vertical radii, hardness and paint/erase operation in the floor box. Texture sampling retains the existing map-space phase and rotation, while the brush supplies coverage.

An erase row applies destination-out only to earlier floors on its own layer. SVG nests the accumulated layer under its mask; Pillow keeps a separate RGBA surface for that layer. Later paint is outside the earlier eraser's scope. Merging an upper layer with erasers into a lower layer containing floors is refused, retaining that isolation. terrain-render.js caches disposable alpha-mask images by source signature and releases them on pruning/disposal; they are not campaign image assets. Limits bound dabs per stroke/document and the actual per-dab pixel work before raster allocation, and reject an oversized operation atomically.

The Dungeondraft TerrainBrush reference and Terrain reference informed the separation between brush size/intensity and the terrain surface. Gravewright implements and persists its own radial brush contract.

Shift and a left-button drag resize the floor brush using horizontal screen distance (40 pixels per cell). The anchored live guide follows Tiles snapping or the continuous terrain radius. Completion updates only editor preferences; Escape restores the previous size without a source/history write.

builder/region-model.js authors named rectangles in cells and the explicit door pressure-plate preset. Regions share selection, layers, duplication and project undo, but are absent from the raster and player preview. regions.py derives private occupancy from validated token commands in their transaction; zones.py establishes a silent baseline for authored edits. The native features/regions modal displays GM state/count and edits zone settings.

blueprint-editor.js edits a door, lever, torch or region's private draft through the shared mountModal helper; blueprint-model.js validates connections and simulates the selected event locally. The editor is native DOM/SVG without a graph library: nodes stay focusable elements on a translated and scaled plane, and native scrolling caused by focus is folded into that camera. Its own undo history covers only the open draft and ends with the modal. Saving applies one undo operation to the project. Generated doors keep the native door_state, while lever images keep lever.state; blueprint_runtime.py executes effects inside the existing map command transaction and receipt, which also records a GM-only run for automation-log.js, the table's Automation window. Lights and sounds use their current domains and realtime transport. Audience projections include only the active appearance, keeping graphs and inactive images private to the GM.

blueprint-templates.js captures campaign-owned reusable snapshots, replacing only typed resource fields with bindings and exposing allowlisted scalar parameters. Applying a resolved copy is one draft undo operation; it creates no live dependency on the template. scene-network.js derives relationships from these same element-owned graphs, renders at most 40 cards per page and opens their existing editor. blueprint_network.py saves native rules with object/project revisions and a payload-bound receipt, atomically translating owned references back to the Builder source. The overview never executes rules.

builder_dynamic.py promotes explicitly marked floors/walls to masked native image planes, leaving ordinary maps baked. Ordered paint/erase groups preserve floor alpha; surface-material.js samples replacement materials in the original texture frame and surface-group.js composes them on invalidation, outside the frame loop. Generated images belong to their scene and stay outside the asset library. Visibility and material actions mutate runtime state without creating files. Limits bound promoted surfaces, generated pixels, bytes and cache work. Preparation serializes dynamic/base raster work and releases each PNG buffer after storing an operation-owned artifact, before opening the write transaction. Repeated wall-material actions share a bounded request-local crop cache. Archive import validates the new surface metadata, aggregate buffer budgets and scene ownership of generated artwork/masks. Asset delivery enforces that ownership again for non-GM viewers; the renderer budgets all group buffers before allocation.

blueprint_world.py validates presentation audiences and same-scene transport inside the existing command transaction. Recipient-filtered automation.presentation events are sent after commit and rechecked at delivery. automation-presentation.js displays authored text with textContent, opens the native journal window and deduplicates recent event IDs. Camera focus applies only to the viewed scene and requires a per-profile/device preference, off by default. Teleport uses the original authenticated trigger token, validates a free whole-token footprint inside the destination region and queues arrival presence after the current object cache is persisted. A durable arrival gate prevents return loops until the token leaves the region; scene navigation and party membership are unchanged.

The Interactive object catalog in Builder selection, Ready-made models in the object context menu and the node editor's Object catalog share 14 editable generic lever Blueprints. Models cover switches, chests, secret passages, books, altars, warning traps, bells, pressure plates, dice requests, quest activation/completion, entrance signs, timed switches and door buttons. The configuration modal offers optional sound/volume and active-state artwork; unchecked options produce no sound node or image override. State artwork comes only from Object assets and restores the original artwork while inactive. Applying a model keeps object identity and geometry; replacing authored behavior requires confirmation in the application modal and can be undone. The trap warns on entry while armed; it does not apply system-specific damage. The inspector's Behavior choices open the same configuration modal. Property section modals reuse the inspector controls and save through its existing commits and history.

Blueprint V2 requestRoll stores a validated dice expression (up to 512 characters), label (up to 48), visibility (public or gm) and audience. Audience controls offer the triggering user, all players, one player or chosen users. One player persists as the compatible users scope with one UUID. Authorized recipients receive a native modal and explicitly choose Roll; the normal server dice service validates and posts the result to chat. The event UUID identifies the same logical roll per user across retries and tabs, and the originating scene stays fixed. Streamers cannot receive roll requests. The action continues immediately: it does not branch on a roll result.

Open journal uses the existing showHandout contract for diaries, quests and quest boards, without granting reading access. setQuestStatus stores a quest journalId and one of draft, available, active, completed, failed or archived. The server validates campaign/type and applies the fixed GM-authored action through the journal service within the map transaction; players do not gain journal edit permission. Rehearsals report quest changes and roll prompts without persisting journals or delivering player windows. Templates, delayed references and archives preserve these bindings and authored roll labels.

Blueprint V2 all/any are pure Boolean values with inputs a–d and result. Connected inputs evaluate in fixed order without short circuit; fewer than two returns False. Shared nodes are memoized only within the current event. Both Python and JavaScript enforce Boolean wires, one source per input and graph cycle/size limits. The palette groups the nodes under Logic, and the card shows connected-input count; simulation and the GM log display evaluated results.

Blueprint V2 elementGroup is a pure typed ordered set: name (1–80 characters), memberType (element, light, sound) and 1–32 unique UUID targetIds. Its targets output becomes elementset, lightset or soundset. lightReference.targetId outputs light: light; soundReference.trackId outputs sound: sound. Legacy reference retains its door: element output ID and also addresses ordinary live images, whose state is null. Baked walls/floors remain outside this contract. acceptsDataType(port, type) handles acceptedTypes on Target ports: element actions accept an element/set, light and sound actions their corresponding resource/set, transforms an element/light or either set. Get property remains single-element. A connected Target overrides the legacy resource binding or Self; stale UUIDs stay connected and are skipped/logged, never silently redirected. Group order is explicit and each expanded action counts against the existing action budget.

move stores finite dx/dy offsets in cells, each bounded by ±1,000; rotate stores finite relative degrees bounded by ±36,000. blueprint_actions.py uses native image, door, light and region validators inside the command transaction. Rotation uses each target's own center, and presence rectangles require multiples of 90 degrees. Linked torch components follow the image. Authorized transforms acknowledge owned geometry without changing the source; scene update reapplies authored transforms, preserving conflict protection for unrelated manual edits. setInteraction.enabled and setRegion.enabled are strict Booleans. The former gates direct player requests and pointer/keyboard/torch-flame controls while GMs retain manual override; it does not gate authored automation. The public native projection carries interactionEnabled; omitted authored flags retain the enabled default. Runtime interaction flags survive update while their authored defaults are unchanged; an explicit authored change wins. Runtime region enabled state is preserved while source enabled/layer settings stay unchanged; authored changes win and quietly baseline occupancy. Native copies and campaign archives retain configuration; UUID remapping touches typed references/group members, not authored scalar strings. Deleting a legacy resource action bridges only execution edges; data wires never become execution wires, and stale new refs/groups remain inert.

Archive import uses remap_omitted_bindings for new sound references/sound-group members and presence-filter actor/token IDs when their records are omitted. A model/UUID lookup allocates one fresh inert UUID per missing identity and reuses it in project documents, native data and generation manifests. Imported links remain inert until replacement resources are chosen; names, event/group labels and authored scalar text are preserved, including text that happens to resemble a UUID.

eventContext.property selects a readonly typed value. Text properties are event, sourceId, userId, userName, tokenId, actorId and presence; count is an integer from 0 to 1,000,000, hasToken is Boolean, and state/ previous carry State. blueprint_context.py builds original trigger metadata from authenticated users and validated region/token objects. Signals and durable jobs preserve it. Event/state/previous are always set by the evaluator for the current graph snapshot; Delay keeps its saved snapshot, while Timer takes fresh event state with the original trigger metadata. Missing token/actor IDs are empty, count defaults to zero and hasToken derives from token presence. Arbitrary payload/role fields are rejected. The optional context.eventContext simulation fixture is private preview data and cannot write server identity or trigger effects.

Presence filters live in optional source region.filters and native zone.region.filters: tokenIds/actorIds (up to 64 unique UUIDs each), dispositions (friendly, neutral, hostile, unknown) and visibility (any, visible, hidden). Lists are OR and criteria are AND; empty lists do not restrict. Hidden state is the token flag, independent of ownership or vision. The pure shared static/gravewright_maps/region-filters.js is re-exported by the Builder and native region models. normalizeRegionFilters fills editor defaults without mutating source; omitted filters remain omitted in legacy documents. Server regions.py validates identity ownership and computes occupancy/events from accepted token commands. Configuration or runtime region enable/transform changes quietly baseline occupancy, suppressing synthetic presence events. Disabling presence does not cancel unrelated named signals or scheduled work. The native editor uses the current scene token catalog and a cancellable GM actor-catalog request, discarding late work on close. Filter configuration and occupant identities remain private.

Blueprint V2 also implements getVariable, setVariable, compareValue and emitEvent. Variable nodes store scope (element/scene), name, valueType (boolean/number/text) and a typed default or value; comparisons store valueType, operator and expected. dataInputs/dataOutputs derive the Value port type from the node. Retyping removes incompatible wires in one undo step. signal:<name> graph keys are named receiving events; emitters queue them for Self, an Element Target or listeners in the same scene. Signals carry no payload. Graphs remain version 2, without top-level variable declarations or runtime memory.

blueprint_variables.py validates names, types, definitions and private stores: 64 entries per scope, ASCII names up to 64 characters beginning with a letter, finite numbers bounded by ±1,000,000, text up to 256 characters and 16 receiving graphs per blueprint. Element stores use SceneObject.data.automationVariables; scene stores use Scene.settings.automationVariables, both {name: {type, value}}. Type conflicts reject a command rather than coercing saved values. Each event reads one initial snapshot; queued assignments are visible to the next event, including signal receivers. Failures roll back memory and other effects together.

simulateGraph accepts an optional sixth argument {variables: {element: {...}, scene: {...}}}. It validates private preview memory and returns assignments/emissions as actions without writing it or executing receivers. The modal's Simulation variables controls configure only this preview. The GM-only automation response includes variables, and its Variables view can send automation/reset-variable through the normal command service with scope, name, optional elementId and the displayed {type, value} as expected. Reset compares that entry, removes only it and triggers no graph; conflicts need a refresh. Standard scene, player and streamer projections omit private stores. Linked updates and campaign archives preserve runtime memory, while native element duplication clears its own store without clearing shared scene memory.

Blueprint V2 switch and random are execution controls with dynamic ports, without per-node automationMemory. Switch stores valueType, typed fallback value and one to four exact {id, value} cases. Case IDs are unique members of case1–case4, and case values are unique within the selected Boolean/number/text type; Boolean permits at most two. Scalar limits match variables. Value input type derives from valueType. Strict equality selects one configured case ID or default, using the fallback only when Value is disconnected. Connected reads use the event snapshot. Retyping resets values, prunes incompatible Value wires and discarded cases in one undo step; removing one case preserves all surviving IDs and their wires.

Random stores two to four weights: integers from 0 to 1,000 with positive sum. Its ports are branch1 through the configured count. The draw covers the total weight of all configured outputs, including disconnected ones. A zero weight cannot win; a disconnected selected branch ends the path without another draw. executionOutputs(node) returns dynamic descriptors and nodeOutputs(node) derives their IDs. Choice nodes execute once per graph traversal, and the result decisions records selected port strings alongside existing Boolean decisions. The GM log records Switch's input type/value/output and Random's output, selected weight and total. Audience projections do not expose private choices.

The runtime derives each draw using an HMAC under the installation's private server key over request UUID, root-event ordinal, chain draw ordinal, element, event and node ID, then uses an isolated generator for the configured weight total. It does not consume or reset process-global RNG state. A receipt retry does not execute again; retrying a rolled-back logical command uses the same draw identity. Delay continuations preserve selected outputs and the visited-node set; both Delay and Timer carry the private budget.randomRoot and budget.randomDraws ordinals across segments; legacy jobs default both to zero. Token movement and actor/folder deletion keep the original command context, distinguishing separate presence-event roots without resetting their sequence on nested calls. Invalid private state is rejected, and ordinary permissions, transaction rollback and chain limits still apply. Authored Switch fallback/case values survive project generation, native copying and archive reference rewriting as content, including UUID-like text.

Local simulateGraph accepts context.randomDraw(nodeId, total), returning an integer in [0, total), or uses Math.random for this preview. Only reached Random nodes sample; validation rejects out-of-range/coerced samples. Preview choices do not consume the server's draw sequence, write runtime memory, execute signal receivers or resume scheduled work.

Blueprint V2 counter, once and cooldown are execution controls with one Input and true/false branches; they have no value ports. Counter's integer target is 1–1,000,000; count increments before comparison and saturates at 1,000,000. Once stores only true after its first visit. Cooldown's finite seconds is 0.1–86,400; an allowed visit stores integer UTC milliseconds now + ceil(seconds * 1000), and a blocked visit keeps its deadline. The runtime supplies server time, while pure evaluation defaults to zero. Elapsing time alone never starts an event; scheduled continuation uses the separate Delay node.

blueprint_memory.py validates and reconciles the private SceneObject.data.automationMemory store, shaped {event: {nodeId: {type, value}}} with at most 128 rows. Memory is distinct from typed variables and belongs to one element. Removing an event/node or changing its type prunes progress; parameter, layout and connection edits preserve it. An edited cooldown duration takes effect at its next allowed activation, keeping the current deadline. Progress participates in the command transaction and receipt, so failures roll it back and retries do not repeat it. Linked updates and campaign archives preserve it; native copies clear it. Audience projections omit the store for every role outside the GM-only automation response.

validateBlueprintMemory and reconcileBlueprintMemory mirror those rules in the pure model. simulateGraph additionally accepts {memory, now} in its sixth argument, clones/reconciles that memory and returns memory and branch decisions for control graphs. The modal's Persistent node preview inputs supply temporary starting values for each simulation. The automation response's memory.elements feeds the GM's Node memory view; automation/reset-memory takes elementId, event, nodeId and the displayed {type, value} as expected. Reset compares and removes that row without emitting events, with a refresh required on conflict.

Blueprint V2 delay is an execution control with Input and Next, finite seconds from 0.1 to 86,400 and no value ports or automationMemory entry. simulateGraph returns delay: {nodeId, seconds} at the first reached wait and stops the entire remaining event traversal, including later Sequence branches. Its trace includes the Delay and its actions include only preceding effects; the browser neither schedules work nor simulates a continuation.

The server stores a private AutomationDelay checkpoint in the transaction that commits the preceding effects. Its DFS pending stack, visited execution nodes, memoized pure values and original event snapshots survive restarts, keeping Sequence order and preventing replay at convergent nodes. State, previous state, reference states and typed variables retain their original event snapshot even when first read after a wait; control-node memory is read live on resume. Claim, effects, completion and the continuation receipt commit atomically. Chain budgets span every segment: 32 element operations, 256 scene-signal deliveries, four operations per element, 1,024 actions and 32 timed resumptions. Delay and Timer share 64 pending jobs per scene and 256 per campaign. Resuming a continuation does not consume a new element-operation slot.

blueprint_delays.py validates semantic identity and referenced targets/resources, rechecks current membership/access without promoting the original role, and records cancellation/failure. Missing element targets used by the continuation cancel it; missing light, sound or image bindings anywhere in the owning blueprint also cancel it, even when used only before suspension. Blueprint behavior changes cancel pending work; x/y card movement does not. Scene pause preserves overdue work until resume. A server background worker processes due rows independently of HTTP/browser activity; no transaction remains open while waiting. Continuations are omitted from campaign content archives and copies, and from audience projections. The queue retains at most 50 finished rows per scene for private diagnostics.

The GM-only automation response's delays list feeds the Waiting view. A normal automation/cancel-delay command sends delayId and displayed expectedDueAt, compares that pending row and cancels without changing game state or firing a graph. A conflict requires refresh.

Blueprint V2 timer is an execution control with Input/Next, no value ports and no automationMemory entry. Its exact authored fields are name and action (start, stop or restart), with finite seconds from 0.1 to 86,400 required only for start/restart and absent for stop. Names use the signal/variable ASCII format and are case-sensitive. simulateGraph queues a cloned timer action and immediately follows Next; it never advances a countdown or runs its receiver. Timers reached before a Delay remain queued, while those after it await the server continuation. The modal hides duration for Stop and warns when the owning blueprint has no signal:name receiving graph, without rejecting that draft.

AutomationDelay also stores timer jobs with kind="timer" and name. A database constraint allows one pending timer per element/name; distinct nodes and graphs on that element control the same named job. control_timer applies actions in authored order within the command transaction. Start keeps an existing row's identity, deadline, actor and budget; Stop cancels it without emitting; Restart creates a replacement under the current event's actor and budget. Each action continues the graph immediately. Capacity or later-action failure rolls back timer operations with the other effects.

The same durable worker delivers expiry as a fresh signal:name on the owner, reading current state, references and variables instead of a saved event snapshot. A missing receiver completes without effects. Actor/access checks, semantic-edit/resource cancellation, pause/resume, private projections and archive/copy exclusion follow Delay's policy. Expiry effects, completion and receipt commit atomically, retaining chain budgets across worker runs. Timer expiry consumes an element operation; resuming a Delay checkpoint does not. Both consume the shared 32 timed-resumption budget, and per-element limits bound graphs that rearm timers. There is no repeating/global timer mode.

The GM delays projection marks timers with kind: "timer" and name. Waiting renders named expiry rows and adds Stop timer and Restart timer controls. Stop uses automation/cancel-delay; automation/restart-timer takes delayId and expectedDueAt, checks the displayed pending row and creates a replacement due after its stored duration. This GM restart preserves the original actor, role ceiling and chain budget. Both controls require refresh on stale work and emit no immediate gameplay event.

Lever decorations share the adapter registry and typed element references. features/images/ui/lever-controls.js handles native double-click interaction before mixed selection, with role/tool/layer checks, a pending-command guard and listener disposal. Geometry/configuration edits do not run their graphs.

floor-painter.js coordinates freehand brush, rectangle, circle and polygon gestures without registering events. It captures the document and brush options at the start, then retains each cell's image and object ID across previews. model.js enumerates the shape's cells and applies floor replacement atomically; workspace.js handles input and commits each completed gesture once to undo history. Cancellation or an object-limit failure preserves the source document.

The Studio has a full-width canvas and a horizontal asset library below it. The existing auxiliary drawer's Tools tab contains the tool buttons, material, brush settings, active layer and snap toggle. These controls retain their existing data-builder-* hooks and listeners without duplicated DOM controls. The header contains the quick map-name input, Select/Paint/Erase shortcuts and a brush-size input, then undo/redo before the save status button, preview and scene generation. The quick controls use their own hooks and share the existing tool and brush state with Tools. Paint restores the freehand floor brush and paint operation and selects the Floor library category; Erase retains whole-object erasure. The size mirrors Tools and is enabled for the freehand floor brush, including floor area erasure. It retains the snapped 1–20 and unsnapped 0.05–20-cell limits and bracket shortcuts. Selection retains Shift-click and box selection. Preview and pending requests disable these editing controls. The quick name mirrors Settings: Enter or blur commits one history operation, Escape cancels the draft, and Ctrl+S commits the current name before saving. History shortcuts remain Ctrl+Z, Ctrl+Shift+Z and Ctrl+Y. The library keeps search, import, collection and type filters accessible when its cards are collapsed. Its initial Floor highlight shows all available campaign images; choosing a Floor, Wall, Door or Object category also activates its drawing tool. Choosing a native light/effect preset activates its placement tool. The material selector in Tools follows the active brush and includes individual images, asset families and its default appearance. Family cards also appear in the library. Import uses the standard application modal to choose files, image type and an optional family; double-clicking an image card opens its type and family editor. Lights show placement instructions without becoming an image asset classification. Panels, at the upper right of the canvas, opens the same auxiliary drawer with tools, layers, selection, settings, projects and review. The drawer starts closed; selection and painting do not open it. Closing that drawer leaves the library accessible. Existing property and setup forms remain in these panels, and hidden legacy DOM hooks preserve controller compatibility. Object properties stay in Selection; this layout adds no properties modal. Components remains planned. Review shows a summary, generation and the linked-scene update action. View controls and dimensions in cells sit at the bottom of the canvas. Drawer, library and view state belong to the editor rather than the project document; they must not change the generated scene. Named project layers are different: their order, names, visibility and locks are persisted in schema 3, along with floor orientation, decorations, lights and project-local asset metadata. Layer handles support drag reordering alongside the arrow controls. Expandable contents lists select individual objects without changing the canvas layout. The optional asset family field stays in schema 3; its members share one image type. Floor painting saves a concrete assetId for each 1 × 1 cell, so preview, undo/redo, reopening and generation preserve the chosen family variations. Visibility and locking control hit testing and edits, but generation includes every project layer. model.js applies group moves, duplication and rotation atomically, retaining exact quarter turns and legacy door endpoints. Preview disables editing, hides editing guides and temporarily shows every layer, restoring previous visibility on exit. It does not change the camera or the source document; save and generation remain available. Rasterization contains floors and wall strips; native images, lights and door art remain live objects. The layer order preserves the existing raster passes: all floors precede all wall strips. Native objects retain their VTT rendering contracts. Generation records resource ownership on the project. Updating the linked scene replaces only those resources and its tiles, preserving manual content and live door state. Project/scene revisions and original object data are checked again in the write transaction; conflicting VTT edits stop the update. The table reloads a history-cached document on pageshow.persisted because its game renderer and module lifetimes have already been disposed.

Map Builder wall networks

builder/wall-edit.js owns atomic node moves, splitting, extension and endpoint connection on schema-three wall center lines. Shared nodes are derived from coincident endpoints in one editable layer, regardless of material or thickness. Adding or extending a wall onto an editable edge splits that edge to form a T node. Doors remain independent source objects. Failed bounds, minimum-length or resource checks leave the document and allocated fragment IDs unchanged.

builder/wall-controls.js owns pointer and keyboard gestures for the selected wall. The workspace commits each finished operation once; cancellation restores the pre-gesture document. SVG endpoint, midpoint and extension controls retain their screen size as the camera zooms. Their focus targets are restored after rendering; Preview, busy state and layer permissions disable editing. Armed extension uses a separate preview marker and returns focus to the viewport.

builder/wall-joins.js and maps/wall_joins.py derive the matching artwork mesh after door intervals are removed. Corners use a miter limited to four times the larger half-thickness, with a bevel fallback. Compatible contiguous collinear runs share a canonical frame; isolated walls keep their authored frame. Compatible legacy collinear spans are deduplicated for rendering. A wall's polygons form one coverage mask, preserving artwork alpha without applying it twice at its own joins. Layers remain separate, and door openings are never bridged by a join. The mesh is presentation data: source coordinates, IDs and native collision center lines retain their existing persistence contract.

Shared dimensions and design tokens

tokens.css defines the shared visual values. Fixed dimensional tokens use plain px values. Reuse existing tokens when they fit the component, and introduce a new shared value only when it is needed across components. The Fibonacci scale and its audit script have been retired; core CSS uses the spacing, radius and typography rules below instead of --fib-* or --step-*.

Use --space-* for padding, margins and gaps, including their shorthand, logical and row/column forms. When migrating a fixed length, choose the nearest value in this scale; on a tie, choose the smaller value. Preserve the sign of negative margins. The token suffix counts four-pixel steps, with separate names for zero, one and two pixels:

TokenValue
--space-00
--space-px1px
--space-half2px
--space-14px
--space-28px
--space-312px
--space-416px
--space-520px
--space-624px
--space-728px
--space-832px
--space-1040px
--space-1248px
--space-1456px
--space-1664px

The former --space-9 value of 34px is replaced by --space-8 at 32px. Border radii use these shared names:

TokenValue
--radius-sm4px
--radius-md8px
--radius-lg12px
--radius-xl16px
--radius-pill999px
--radius-circle50%

Host interface CSS must render text at 12px or larger. Check effective sizes from inherited styles, relative units and font shorthands as well as explicit font-size. The existing --text-3xs, --text-2xs and --text-xs names remain available at 12px. Authored text in PDFs and drawings, and scene labels scaled by the camera, retain their authored sizes.

Widths, heights and other component geometry keep their required dimensions. A value specific to one component may use a local px literal; reserve shared --size-* tokens for values reused across components. Responsive geometry can still use relative units and layout expressions such as calc() and clamp() where needed.

Form controls

The shared base.css supplies dark, low-specificity defaults for native fields and buttons, file chooser buttons, checkboxes, sliders and color swatches, including forms mounted after page load. Use gw-field for a label, control and help text; checkboxes, radios, ranges and colors retain their own geometry rather than inheriting text-field dimensions. Native invalid fields and aria-invalid="true" use the field error treatment. Associate hints and errors with aria-describedby.

Single-choice selects inherit their chevron from the base styles. Override their surface with background-color, since the background shorthand removes that image, and reserve end padding for the chevron. Multiple/visible-list selects do not use a chevron. Custom upload surfaces must keep the real file input reachable by keyboard, show its focus, and prevent window dragging from the upload target. Check empty, selected, disabled and error states at narrow viewport widths with synthetic data; operating-system file selection remains the native file picker.

Native state and realtime

Native UI code calls the shared domain API where implemented and still has dedicated HTTP/socket adapters for specialized flows such as uploads, board subscriptions and rendered fragments. The public browser API does not replace every native transport.

The realtime controller connects the current table's authenticated WebSocket, tracks subscriptions and reconnect state, and dispatches gravewright:* browser events. The server sends user-authorized projections and rendered HTML; private actor data, chat audiences and scene visibility are enforced before delivery. Insert user-provided text with textContent. Existing HTML fragment insertion is for HTML rendered by the trusted server templates, not arbitrary module or chat text.

gravewright:api-event is the internal adapter event consumed by the domain API; api.changed notifications trigger fresh authorized reads. Public integrations should use api.events.on(...) as documented in API events, rather than copying the native socket command protocol. Subscriptions are not a durable change stream: reconnect and reread current state. Creating gravewright.forTable(...) alone does not open a socket.

The native item and compendium directories sort copies of visible entries by their displayed names using the active locale; stored document order and IDs stay unchanged. Compendium packs, filters and previews use compact rows and content-sized default windows, with internal scrolling for long content. Manual window layouts retain the shared drag, resize, minimize and reset controls. GMs can create the first compendium from the empty directory; read access does not expose import or permission controls. System-owned item editors remain in their packages and use the existing host contracts. Repeated item projections preserve open action menus and focus; document or permission changes invalidate them.

Sources and generated assets

Most app-specific JavaScript files under static/ are directly served source. Some files there are committed build outputs. In particular, maps/scripts/build.cjs bundles the following:

Editable entry/sourceGenerated destination
maps/frontend/widgets/game-board/ui/board.jsmaps/static/gravewright_maps/vendor/board.js and named runtime/ modules
maps/frontend/shared/rendering/render-profile.jsmaps/static/gravewright_maps/render-profile.js
maps/frontend/native/tools.jsmaps/static/gravewright_maps/tools.js
maps/frontend/native/builder-catalog.js, builder-preview.jsmaps/static/gravewright_maps/builder-catalog.js, builder-native.js
maps/frontend/native/builder-editors.jsmaps/static/gravewright_maps/builder-editors.js
maps/frontend/native/table-modules.jstable/static/gravewright_table/media-workspace.js
maps/frontend/features/walls/{ui,model}/walls.jsmaps/static/gravewright_maps/walls/
Scene-layer sources and lighting profilesmaps/static/gravewright_maps/sources/
maps/frontend/calibration.js, directory.jsCorresponding map static model/controller files
maps/frontend/shared/lib/dom/tree-drag.jsactors/static/gravewright_actors/tree-drag.js
maps/frontend/features/tokens/ui/controller.jstokens/static/gravewright_tokens/controller.js
pdf_system/frontend/controller.jspdf_system/static/gravewright_pdf_system/controller.js

Paths in this table are relative to gravewright/. Edit the source and rebuild these destinations. Do not make one-off fixes in minified board chunks or vendored libraries.

Tools, the board, Builder catalogs, editors and preview retain their public entry URLs while importing named ES modules from gravewright_maps/runtime/, following the source's feature paths. Shared modules have one browser identity; PixiJS and its unsafe-eval adapter are emitted once as vendor/pixi.js. Preview and editor imports stay lazy, and the visibility worker keeps its relative resource URL plus the existing public worker entry. The Runner manifest includes every emitted module and license notice. Ship the complete generated graph. This layout uses more small requests while avoiding repeated bundled copies; artifact sizes alone do not establish a browser performance improvement.

From the repository root:

sh
npm ci --include=dev --prefix gravewright/maps/frontend
npm run build --prefix gravewright/maps/frontend
npm test --prefix gravewright/maps/frontend

The build uses the locked PixiJS and esbuild dependencies in maps/frontend/package-lock.json, targets ES2022, and keeps /static/* imports external. A shared render-profile module stays external so all features use the same runtime profile. The build also collects package license/notice files and writes vendor/packages.json; retain those outputs when redistributing built assets. It explicitly rejects including Vue packages.

Windows runner preparation

The Gravewright Runner reuses verified Node.js/npm inside data/runner, or downloads its managed copy into that installation when necessary. Its CMD batch file calls scripts/prepare_frontend.py in --plan mode to check package versions, fingerprints and a real esbuild operation. A matching healthy dependency installation can be reused even on first launch. The batch file itself executes npm ci --include=dev --include=optional when dependencies need installation, and the existing npm run build for the first preparation or an invalidated build. It then calls the helper in --record mode to verify and save the completed output state. esbuild is required during preparation even though Python development dependencies are omitted from normal runner installation.

The plan returns exit code 0 for current assets, 10 for npm installation plus build, or 11 for build only. The batch launcher handles these decisions explicitly. Both helper modes accept --node, --npm-cli and --state-dir; the helper's default mode remains available to perform complete preparation in developer scripts and regression tests.

The npm project remains gravewright/maps/frontend; its ignored node_modules/ is installed there and generated static assets are written to the destinations above. These outputs are committed application assets, so a source change followed by runner preparation can produce a version-control diff. Review and include the appropriate generated outputs and notices when contributing frontend changes.

Preparation state stays inside the extracted project under data/runner/frontend/<source-location-hash>/state.json. Managed tools and npm/uv caches are also local to this installation. Dependency checks include the package files and selected Node/npm versions. Build checks include editable sources, the build script and hashes of generated outputs. The batch file requests an output manifest, including vendor notices, from build.cjs through its optional GRAVEWRIGHT_BUILD_MANIFEST process variable. A later launch skips a valid unchanged build; changed inputs or missing/modified outputs cause preparation again. A failed build stops launcher startup instead of serving an unverified frontend. collectstatic then copies the prepared public assets to the project-local data/staticfiles directory.

The module domain-method registry has its own generation command:

sh
uv run --locked python scripts/generate_frontend_api.py

This generates only modules/static/gravewright_modules/frontend-contract.js from contracts/frontend.json. The original SDK's generated.js and JSON schemas are separate artifacts; this script does not regenerate them. collectstatic gathers deployable assets but does not run either JavaScript build.

Board layers and resource lifetimes

The maps/frontend/ tree separates entity models, feature models/UI, shared utilities, and the game-board widget. The board widget coordinates rendering, input, tile scheduling/cache/prefetch, native layers, vision polygons, lighting and shaders. Feature code contains walls, tokens, drawings, measurements, effects, and selection. Algorithm tests live beside the relevant models and are bundled by maps/scripts/test_models.cjs for Node's test runner.

Use one consistent coordinate system across controllers and renderer calls: logical scene pixels, top-left origin, token centers as anchors. Camera pan/zoom converts scene positions into CSS viewport coordinates; device pixel ratio affects rendering resolution. Mixing these units causes selection, token and lighting misalignment. The extension overlay API exposes explicit conversion helpers instead of exposing the PixiJS renderer.

Async work belongs to a page, board, module activation, or individual mount. Lifetime cancels API calls and disposes subscriptions; map source code has its own shared lifecycle helpers. Add/remove listeners and observers with their owner. Release rendering resources and pending work during board teardown or scene changes. A listener or cached API handle that outlives its owner can update the next scene with old data.

Updates to the current scene preserve camera pan/zoom, token art and mounted layers, including revisions caused by teleport automation. Only a scene change resets the camera. Raster revisions refresh existing tile keys by URL and keep the previous decoded tiles visible until replacements are ready; superseded requests cannot paint an older revision. A failed replacement retains its current art while the bounded retry runs. Region occupancy and geometry refresh their own overlay, keeping unrelated floor, light and shader resources mounted.

Token acknowledgements and realtime snapshots can carry native teleports: [{id, version}] metadata, filtered against the recipient's visible tokens and their exact revisions. The token workspace remembers each accepted teleport revision across normal reads. It bypasses movement interpolation and recreates only that token's Pixi object at its destination; repeated delivery keeps the same replacement. Logical identity, selection and sheet data remain unchanged, and older asynchronous art loads cannot restore an earlier object.

Successful token commands apply the authorized token snapshot in their acknowledgement. They do not issue another token GET or request scene layers; committed scene changes arrive through the existing realtime subscription. Failures and version conflicts still reload authoritative state. A movement with no displacement and no traversed path does not save or enter the busy state. Paths that leave and return to the same position remain real operations so region entry/exit automation is preserved.

The token workspace saves silently, including when a confirmation takes longer. Pending commands keep the interaction guard for concurrent edits. Errors and route instructions remain immediate; there is no saving notice or notice timer. Unchanged status text is not rewritten on every paint.

Local token previews reuse the static scene key and image reconciliation while their immutable scene inputs remain the same. They do not walk all native image transforms or reconstruct the component-origin index on every frame. A new image snapshot still reconciles its transforms and artwork; changed walls, effects, scene identity and role still invalidate their owning resources.

Movement notifications refresh scene layers and tokens without rebuilding the unchanged actor directory or combat panel. Actor/combat edits and other token commands retain the relevant notifications. Paired layer/token notifications deliver spatial audio once through the token refresh; independent layer edits still refresh audio. Live authorization and extension change hooks remain in place.

Same-scene teleport advances token revisions, not scene metadata or raster revisions. Its layer/token notifications carry region and appearance changes without requesting a new scene manifest or changing existing tile URLs. Repeated portals therefore retain the decoded floors and raster textures already loaded.

Native image updates

SceneImageArt reconciles ordinary image textures independently of the static scene key. A source change keeps the sprite and its previous art until the new texture is decoded; late results cannot overwrite newer state or a closed scene. Current art survives same-scene light/geometry rebuilds. Composed surfaces and grouped floors use a separate cache with the same lifecycle. Unchanged composites rebind synchronously during effect rebuilds; material replacement retains current art until decoding completes. Changed masks, geometry, group membership or visibility revoke obsolete coverage immediately. Composition closes all input bitmaps, including failed and cancelled loads, and publishes no partial floor. Each art cache retains at most eight idle textures and 16 MiB of idle decoded RGBA data, evicting the least recently used entries. Scene/role changes and disposal abort pending loads and release textures/bitmaps. Only art in the authorized projection is requested; inactive appearances are not prefetched.

Token-art references wait for a pending unload of the same URL before loading again. Pixi's loader can otherwise return its old cached texture while unloading is about to destroy it. Independent URLs remain concurrent. Dispose applications with explicit removeView: true, releaseGlobalResources: false options: boolean true also releases process-wide pools still used by other boards and previews. Scene-owned textures and renderer resources still end with their owner.

GM-private images and Empty Object markers remain visible at 55% opacity in all workspaces, with selection and interaction restricted to GM. Private drawings multiply their authored opacity by the same factor. Server projections still exclude private elements from player/streamer clients.

Realtime delivery sends committed maps.layers and tokens.state before their dependent audio/combat refreshes. Every delivery retains its own authorization check. Token-triggered changes may share the same scene revision; never discard snapshots solely because that revision is equal.

Lighting and vision work

SceneLayers calculates light occlusion once for each static scene key and reuses those polygons for masks, the light atlas and the GM darkness veil. createVisibilityContext shares wall indices and keeps at most eight filtered subsets for finite ranges or elevation. Preserve the original ray arithmetic and wall membership: changing a BVH can change rounding at coincident endpoints.

Torch and pulse frames update additive Pixi sprites over immutable falloff textures. Shaders receive a GPU render texture; only particles that respond to light request CPU samples, capped at eight samples per second. Profiles keep their existing animation rates. A scene owns the texture pool and releases it after shader bindings, the atlas and sprite layers.

Vision changes coalesce once per animation frame. Scenes with at least 128 wall segments use the same-origin module worker emitted as vendor/visibility-worker.js; smaller scenes and unavailable workers use the same geometry synchronously. The worker keeps one active job and the latest pending snapshot, transferring coordinate buffers. Native observers carry a sourceId so movement can retain the last completed view while the next calculation runs. Selection, range, elevation, cone and preview changes revoke that view immediately; wall and scene changes terminate the old worker. Never reuse visibility across those boundaries or quantize the configured vision range or token positions. Pending movement retains the existing veil texture until a result arrives; unrestricted GM vision reuses its static veil without repainting on token moves.

Compare full frames and completed worker jobs when profiling. Queuing work is not completion, and headless software WebGL results do not establish hardware GPU performance. Rebuild both board and worker entries together with the normal asset build.

Extending the interface

The host's SurfaceRegistry accepts gravewright.ui.register(domain, declaration) and returns an unregister function. It supports registered table surfaces plus app.shell and inside.content. A mount receives {root, context, api, signal, onDispose}. It gets a fresh lifetime and a dedicated root; failed mounts are cleaned up, and replaced native elements have their previous visibility restored. This global registry permits one replacement per domain.

Installed signed packages use ModuleRuntime instead. Its registrations are synchronous during module activation, its calls carry module identity/lease/revision, and competing replacements are chosen using table preferences. Use the module's register callback for installed packages; global UI registration is for host-page integrations and has different ownership/selection rules. Full examples and supported names are in modules.

Frontend verification

Run the checks affected by the source you changed:

sh
node --test tests/modules/*.test.mjs tests/effects/*.test.mjs
npm test --prefix gravewright/maps/frontend
uv run --locked python tests/e2e/frontend_api.py

For rendering or interaction changes, use relevant scenarios under tests/e2e/, including mixed_selection.py, token_routes.py, light_rendering.py, effect_geometry.py, effect_pixels.py, map_prefetch.py and realtime.py. The browser harness and dependencies are explained in testing. Test player and GM sessions where behavior is permission-dependent, and check disposal/reconnect when adding subscriptions. Keep labels and message catalogs aligned with the application's supported languages.