Web UI
The web-ui plugin is Inkweaver’s browser frontend. It mounts the React application, renders the novel to a WebGL <canvas>, and turns the novel plugin’s draw instructions into pixels on screen. Where novel decides what to draw, web-ui decides how it looks.
Rendering runs on the canvas with GLSL shaders compiled at runtime, layer groups, blend modes, and clipping. Heavy work is offloaded to worker threads so the main thread stays responsive.
Web-ui adds no commands of its own. Instead, it decorates commands that core and novel already provide, and returns a selectors collection that exposes its React components and hooks.
What it decorates
Section titled “What it decorates”Rather than defining new verbs, web-ui hooks rendering into the existing pipeline by wrapping three commands:
initcreates a root DOM node and mounts the React application into it. This is how the UI comes to exist at startup.drawSceneruns after novel produces its beat instructions and pushes the result into the frontend’s rendering store. That result includes the new and old beats, the active cue, and whether the reader is rewinding. It runs at a low priority so it sees the fully resolved scene.resolveShaderssupplies the shaders for characters and transitions, layering visual effects onto what novel draws.
This is the decoration pattern at work. Web-ui never replaces novel’s behavior, it wraps it.
Selectors
Section titled “Selectors”A plugin’s runtime module is content-addressed and only exports createPlugin, so its React components and hooks can’t be imported directly. These selectors are how you reach them. Call the selector to get the collection, then destructure what you need.
getComponents
Section titled “getComponents”Returns web-ui’s React components, so other plugins can compose or replace parts of the interface. See Components for the full list.
getComponents(): ComponentsA plugin can pull these to build custom UI, or decorate the selector to swap a component for its own.
Example
const { getComponents } = getSelectors();const { App, InkCanvas } = getComponents();getHooks
Section titled “getHooks”Returns web-ui’s React hooks for reading the rendered scene. See Hooks for details.
getHooks(): HooksExample
const { getHooks } = getSelectors();const { useLayers } = getHooks();Components
Section titled “Components”These are the React components web-ui renders the novel with, exposed through getComponents. You can compose them into your own interface, or decorate getComponents to replace one with your own.
The root of the interface. Renders an ActionScope wrapping the current Scene.
App(): ReactNodeExample
const { App } = getComponents();
// mounted at startup:<App />;Renders the current beat. It wires up the global reader actions (advance, step forward and back, quicksave, and quickload), plays the active cue, and draws the beat’s content onto an InkCanvas.
Scene(): ReactNodeExample
const { Scene } = getComponents();
<Scene />;InkCanvas
Section titled “InkCanvas”The WebGL surface. It renders the drawn scene to a <canvas> and overlays its children on top, so text and UI sit above the artwork. Clicking the canvas fires onClick.
InkCanvas(props: { beats: null | SceneBeats; children?: ReactNode; onClick: () => void;}): ReactNodeExample
const { InkCanvas, BlockContent } = getComponents();
<InkCanvas beats={beats} onClick={advance}> <BlockContent lexias={lexias} /></InkCanvas>;ActionContext
Section titled “ActionContext”The top-level input provider. It translates keyboard and gamepad input into engine actions (like advance or stepBack) and dispatches them, including press-and-hold gestures. Supply a keymap to change the bindings.
ActionContext(props: { children: ReactNode; keymap?: Keymap;}): ReactNodeExample
const { ActionContext, App } = getComponents();
<ActionContext> <App /></ActionContext>;ActionScope
Section titled “ActionScope”Captures dispatched actions within a subtree. Provide captureAction to decide which actions this scope handles, and onAction to respond to them. This is how a region of the UI can intercept input before it reaches the rest of the app.
ActionScope(props: { children: ReactNode; captureAction?: (action: Action) => boolean; onAction?: (action: Action) => void;}): ReactNodeExample
const { ActionScope } = getComponents();
<ActionScope captureAction={(action) => action === "advance"} onAction={handleAdvance}> {children}</ActionScope>;BlockContent
Section titled “BlockContent”Renders a list of content lexias as a block of dialogue and narration, delegating each line to DialogContent or NarrationContent. It tracks the previous speaker so a name isn’t repeated on consecutive lines.
BlockContent(props: { lexias: Array<ContentLexia> }): ReactNodeExample
const { BlockContent } = getComponents();
<BlockContent lexias={lexias} />;DialogContent
Section titled “DialogContent”Renders a single line of dialogue, pairing a CharacterName with the line’s inline content.
DialogContent(props: { lastCharacter: null | string; lexia: DialogLexia;}): ReactNodeThe lastCharacter is the speaker of the previous line, used to hide the name when the same character speaks twice in a row.
Example
const { DialogContent } = getComponents();
<DialogContent lastCharacter={previous} lexia={lexia} />;NarrationContent
Section titled “NarrationContent”Renders a single narration line as a run of inline content.
NarrationContent(props: { lexia: NarrationLexia }): ReactNodeExample
const { NarrationContent } = getComponents();
<NarrationContent lexia={lexia} />;CharacterName
Section titled “CharacterName”Renders a speaker’s name. It renders nothing when canHide is set, which is how the same speaker’s name is suppressed across consecutive lines.
CharacterName(props: { canHide: boolean; name: undefined | null | string;}): ReactNodeExample
const { CharacterName } = getComponents();
<CharacterName canHide={false} name="Sophia" />;InlineContent
Section titled “InlineContent”Renders a single inline lexia by delegating to the right component for its type: TextContent, LinkContent, or ExpressionContent.
InlineContent(props: { lexia: InlineLexia }): ReactNodeExample
const { InlineContent } = getComponents();
{lexia.content.map((inline, key) => ( <InlineContent key={key} lexia={inline} />))}TextContent
Section titled “TextContent”Renders a run of plain text. Blank text renders nothing.
TextContent(props: { lexia: TextLexia }): ReactNodeExample
const { TextContent } = getComponents();
<TextContent lexia={lexia} />;LinkContent
Section titled “LinkContent”Renders an inline link. Clicking it calls followLink with the link’s target label, moving the story to wherever the link points.
LinkContent(props: { lexia: LinkLexia }): ReactNodeExample
const { LinkContent } = getComponents();
<LinkContent lexia={lexia} />;ExpressionContent
Section titled “ExpressionContent”Renders the evaluated value of an inline expression, read from the current beat’s expression values. Blank results render nothing.
ExpressionContent(props: { lexia: ExpressionLexia }): ReactNodeExample
const { ExpressionContent } = getComponents();
<ExpressionContent lexia={lexia} />;These React hooks, exposed through getHooks, let you read the rendered scene and position your own UI against the artwork on screen.
useLayers
Section titled “useLayers”Returns every layer or group matching a CSS-style selector in the current scene, as bounds-free structural nodes. It recomputes once per beat when the layout changes, never per frame. An empty array means nothing matched.
useLayers(selector: string): Array<Layer>Example
const { getHooks } = getSelectors();const { useLayers } = getHooks();
function Portraits() { const portraits = useLayers(".portrait"); return portraits.map((layer) => <Portrait key={layer.id} layer={layer} />);}useLayerBounds
Section titled “useLayerBounds”Invokes callback for each visible layer matching selector, every frame the camera moves, with live screen-space bounds in CSS pixels. Matching runs per beat and only the projection updates per frame. The callback also fires once on mount, so an element can position itself before its first paint.
useLayerBounds( selector: string, callback: (bounds: Bounds, layer: Layer) => void,): voidExample
const { getHooks } = getSelectors();const { useLayerBounds } = getHooks();
function SpeechBubble() { const ref = useRef(null); useLayerBounds(".mouth", (bounds) => { const el = ref.current; if (el == null) return; el.style.left = `${bounds.left}px`; el.style.top = `${bounds.top}px`; }); return <div ref={ref} className="speech-bubble" />;}