Skip to content

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.

Rather than defining new verbs, web-ui hooks rendering into the existing pipeline by wrapping three commands:

  • init creates a root DOM node and mounts the React application into it. This is how the UI comes to exist at startup.
  • drawScene runs 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.
  • resolveShaders supplies 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.

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.

Returns web-ui’s React components, so other plugins can compose or replace parts of the interface. See Components for the full list.

getComponents(): Components

A 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();

Returns web-ui’s React hooks for reading the rendered scene. See Hooks for details.

getHooks(): Hooks
Example
const { getHooks } = getSelectors();
const { useLayers } = getHooks();

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(): ReactNode
Example
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(): ReactNode
Example
const { Scene } = getComponents();
<Scene />;

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;
}): ReactNode
Example
const { InkCanvas, BlockContent } = getComponents();
<InkCanvas beats={beats} onClick={advance}>
<BlockContent lexias={lexias} />
</InkCanvas>;

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;
}): ReactNode
Example
const { ActionContext, App } = getComponents();
<ActionContext>
<App />
</ActionContext>;

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;
}): ReactNode
Example
const { ActionScope } = getComponents();
<ActionScope
captureAction={(action) => action === "advance"}
onAction={handleAdvance}
>
{children}
</ActionScope>;

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> }): ReactNode
Example
const { BlockContent } = getComponents();
<BlockContent lexias={lexias} />;

Renders a single line of dialogue, pairing a CharacterName with the line’s inline content.

DialogContent(props: {
lastCharacter: null | string;
lexia: DialogLexia;
}): ReactNode

The 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} />;

Renders a single narration line as a run of inline content.

NarrationContent(props: { lexia: NarrationLexia }): ReactNode
Example
const { NarrationContent } = getComponents();
<NarrationContent lexia={lexia} />;

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;
}): ReactNode
Example
const { CharacterName } = getComponents();
<CharacterName canHide={false} name="Sophia" />;

Renders a single inline lexia by delegating to the right component for its type: TextContent, LinkContent, or ExpressionContent.

InlineContent(props: { lexia: InlineLexia }): ReactNode
Example
const { InlineContent } = getComponents();
{lexia.content.map((inline, key) => (
<InlineContent key={key} lexia={inline} />
))}

Renders a run of plain text. Blank text renders nothing.

TextContent(props: { lexia: TextLexia }): ReactNode
Example
const { TextContent } = getComponents();
<TextContent lexia={lexia} />;

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 }): ReactNode
Example
const { LinkContent } = getComponents();
<LinkContent lexia={lexia} />;

Renders the evaluated value of an inline expression, read from the current beat’s expression values. Blank results render nothing.

ExpressionContent(props: { lexia: ExpressionLexia }): ReactNode
Example
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.

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} />);
}

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,
): void
Example
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" />;
}