Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Theme: panel

panel in themes/default.json holds presets — named looks for the dialogue panel. A story picks a preset per node or per character; the theme decides what each one looks like.

{
  "panel": {
    "default": {
      "width": "100%",
      "anchor_v": "bottom",
      "min_height": 200,
      "padding": { "top": 20, "left": 24, "right": 24, "bottom": 24 },
      "bg": "#1e2133e0",
      "corner_radius": 12
    },
    "whisper": {
      "extends": "default",
      "width": "60%",
      "anchor_h": "center",
      "bg": "#00000099"
    }
  }
}

Preset names are yours. default is the only one the engine falls back to.

Usually you need

KeyTypeDefaultEffect
width"NN%", "100%", "stretch", or a number"100%"Panel width
anchor_h / anchor_vsee size and position"stretch" / "bottom"Where the panel sits
offset_x / offset_ynumber0Nudge from the anchors (negative to move inward from right/bottom)
min_heightnumber, "NN%", or "full"160Floor for height
max_heightnumber, "NN%", or "full"legacy formulaCeiling; overflow scrolls the body text
paddingobjectsee belowInner margins
bgHTML color#343a52e0Flat plate colour
corner_radiusint0Rounded corners on the flat plate
extendsstring—Inherit another preset, then override
scrollbar_stylestring—Optional element pack for the body scrollbar (same overlay as screen scroll "style")
scrollbar_layout"default" | "reserve" | "overlay""default"How the body scrollbar column is laid out — see Body scroll
scroll_pin_durationnumber ≥ 00Seconds to ease the body to the new append edge after settle; 0 snaps
append_push_durationnumber ≥ 00Seconds to ease lines when an append beat adds text. append_direction: "up": earlier lines slide down as the new top line comes in. "down": the body eases to the newest line when it scrolls. 0 snaps
typingobjectinherit game.jsonPer-preset text reveal / caret / beep — Typing per preset
typographyobjectinherit project typographyPer-preset font/sizes for body, labelplate, panel-hosted choices/input — Typography / colors per preset
colorsobjectinherit project colorsPer-preset body / speaker colours — Typography / colors per preset

Height grows with text and choices up to max_height; past that the body scrolls. Floor + ceiling are enough for a first theme. Everything below is the full surface.

Size and position

KeyTypeDefaultEffect
width"NN%", "100%", "stretch", or a number"100%"Panel width. "stretch" and "100%" fill the window, minus offset_x on both sides when anchor_h is "stretch"
min_heightnumber, "NN%", or "full"160Floor for the panel height
max_heightnumber, "NN%", or "full"see belowCeiling for the panel height
anchor_h"left" | "center" | "right" | "stretch""stretch"Horizontal anchor
anchor_v"top" | "center" | "bottom" | "stretch""bottom"Vertical anchor
offset_xnumber0Pixels from the horizontal anchor
offset_ynumber0Pixels from the vertical anchor
xnumber—Absolute left edge; ignores anchor_h and offset_x
ynumber—Absolute top edge; ignores anchor_v and offset_y
resize_durationnumber0Seconds to ease plate height when the same preset gains/loses content (new line or choices). Preset or panel switches snap. Typing growth snaps each frame. Not show/hide — that is transition_*. 0 snaps
scroll_pin_durationnumber ≥ 00Seconds to ease body scroll to the append edge after the plate height is stable. 0 snaps
append_push_durationnumber ≥ 00Seconds to ease lines when an append beat adds text. append_direction: "up": earlier lines slide down as the new top line comes in. "down": the body eases to the newest line when it scrolls. 0 snaps
scrollbar_stylestring—Element style pack name for the body scrollbar; omit to use global scrollbar.*
scrollbar_layout"default" | "reserve" | "overlay""default"Body scrollbar column layout (see Body scroll)
typographyobject—Overlay onto project typography for this preset (see below)
colorsobject—Overlay onto project colors for body / speaker only (see below)
modulateHTML color#ffffffTint for the whole panel
scalenumber or [sx, sy]1Scale for the whole panel

Offsets are measured from the anchored edge, so a right- or bottom-anchored panel needs a negative offset to move inward:

{ "anchor_h": "right", "offset_x": -20, "anchor_v": "bottom", "offset_y": -20 }

Height units and ceiling

min_height and max_height accept a pixel number, a percent of the window height ("40%"), or "full" (window height). Unset min_height uses 160. Unset max_height uses min(65% of the window, max(5 × min_height, 320)). If min ends up larger than max, max is raised to match (and a warning is logged).

A sticky { "type": "panel", "max_height": … } overrides the theme preset until reset — panel command.

Body scroll

Dialogue text lives in a body scroll area. When content would push the plate past max_height, the plate stops growing and the text scrolls. Name plate, continue/back with placement: "panel", and choices stay outside that scroll. Continue/back with placement: "text_end" (and the typing caret) hide while the body is scrolled away from the newest line, and return when pinned back to that edge.

Panel-hosted choices and text input (host: "panel") share the plate and shrink the body; the body stays on the newest line (the top line for append_direction: "up") until the player scrolls away. Overlay-hosted choices sit on the screen and do not take body space. There is no scroll on/off switch — the ceiling alone decides.

The body scrollbar uses global theme scrollbar.* unless the preset sets scrollbar_style to an element pack (same overlay path as screen scroll blocks — Theme: scrollbar).

scrollbar_layout controls the body scrollbar column:

ValueEffect
default (omit)Bar appears when needed and takes width from the text when it does
reserveAlways keep a scrollbar column (bar width + scrollbar.content_separation gap) so wrap width stays stable when the track appears; the plate keeps its preset width and the text column is narrower by that column. The track stays hidden until there is something to scroll
overlayNot available yet — falls back to default

Use reserve on plates that often scroll (phone, log, fixed height). Leave normal ADV presets on default.

Spacing

KeyTypeDefaultEffect
padding.topint15Inner top margin
padding.leftint20Inner left margin
padding.rightint20Inner right margin
padding.bottomint20Inner bottom margin
speaker_gapint6Gap between the speaker name and the text
choices_gapint2Gap between the text block and the choice list
choices_separationint4Gap between individual choices

Background

KeyTypeDefaultEffect
bgHTML color#343a52e0Flat plate colour, used when no usable texture is set
bg_texturetexture spec—Nine-slice plate image. Takes priority over bg
bg_9slice[left, top, right, bottom][0, 0, 0, 0]Stretch margins for bg_texture
corner_radiusint0Rounded corners on the flat plate. Has no effect once a texture is used

A texture spec is either "panels/plate.png", { "file": "…", "modulate": "#hex" }, or a flipbook { "frames": [...], "fps": 10 } — see Pictures (texture specs).

Speaker nameplate

KeyTypeDefaultEffect
labelplate_style_keystring—Which labelplate.<key> block styles the speaker name
labelplate_layoutobject—Present means the nameplate is docked to the panel edge instead of sitting inline above the text

labelplate_layout takes anchor_h (left, center, right; default left), anchor_v (top, center, bottom; default top), offset_x, and offset_y. Offsets apply only to the anchored side, so a plate hanging above a bottom panel uses anchor_v: "top" with a negative offset_y.

The mode is resolved per node: an explicit labelplate command mode wins, otherwise the preset is docked if labelplate_layout exists and inline if it does not. Asking for docked on a preset with no labelplate_layout falls back to inline and warns.

Panel slots

slots pins extra art to the panel — most often a portrait of whoever is speaking.

{
  "slots": [
    { "id": "speaker_art", "bind": "talking", "layout": { "anchor_h": "left" } },
    { "id": "corner", "object": "panel_corner_deco" }
  ]
}
KeyTypeEffect
idstringRequired, unique within the preset
bind"talking"Fills the slot with the current speaker’s panel art. "talking" is the only supported binding
objectstringFills the slot with an object from sprites/<id>.obj.json
layoutobjectAnchors within the panel

Each slot needs exactly one of bind or object. Which art a character supplies for a bound slot comes from ui.panel_slots in their pack.

Continue and back buttons

Each preset carries its own navigation buttons under continue_button and back_button. Both take the same keys.

KeyTypeDefaultEffect
visiblebooltrue for continue, false for backWhether the button is shown
labelstring—Caption. An empty string gives an icon-only button
label_keystring—Localisation key, used instead of label when set
align"left" | "center" | "right"—Corner placement inside the panel
placement"panel" | "text_end""panel""panel" parks it in a corner; "text_end" follows the last typed character
offset_xnumber0Nudge from the resolved position
offset_ynumber0Nudge from the resolved position
stylestring or objectfallback packElement style pack name, or an inline style object with an optional "source"
click_sfx / hover_sfxstring or nullinherit sounds.button_*Per-channel UI SFX override. Omit = inherit; "" or null = silent
interactionobject—hooks and hooks_override for on_show, on_hide, on_hover_enter, on_hover_exit, on_focus, on_blur

click_sfx / hover_sfx may also sit on the nested style object or pack. Button cfg keys win over style, then sounds.button_click / sounds.button_hover. If on_hover_enter already runs ui_sfx, inherited hover SFX is skipped for that button.

Any other key produces an “unknown panel button field” warning at load. There is no on_click hook: advancing and going back are wired to the buttons already.

When no style is given, the buttons fall back to the panel_continue_button_default and panel_back_button_default packs. They deliberately do not inherit the global buttons block, so a wide menu button style will not stretch your continue arrow.

Both were top-level theme keys in earlier versions. continue_button and back_button at the root of the theme file now warn and do nothing — move them inside a preset.

Inheritance with extends

extends names another preset to build on. Resolution is recursive, so a preset can extend a preset that extends another.

Merging is deep: nested objects such as continue_button or padding merge field by field rather than replacing wholesale. Setting a key to null deletes it from the inherited result.

{
  "quiet": {
    "extends": "default",
    "padding": { "top": 8 },
    "continue_button": { "visible": false },
    "corner_radius": null
  }
}

quiet keeps default’s left, right, and bottom padding, keeps the rest of its continue button configuration, and drops the inherited corner radius.

Typing per preset

A preset may set typing with the same keys as game.json → typing (except Settings-owned speed_scale and auto_advance_default). Set keys override; unset keys inherit through extends, then the project default. Nested objects (caret, beep, reveal_soft, …) merge key by key.

"panel": {
  "phone": {
    "extends": "default",
    "typing": {
      "reveal": "fade_line",
      "caret": { "enabled": false },
      "beep": { "enabled": false },
      "start_sfx": { "sample": "msg_in", "volume": 0.6 }
    }
  },
  "terminal": {
    "extends": "default",
    "typography": { "font": "JetBrainsMono" },
    "typing": {
      "cps": 45,
      "caret": { "glyph": "█", "color": "#7CFF8A" },
      "beep": { "sample": "key_click", "volume": 0.3 }
    }
  }
}

The player’s Text Speed slider still multiplies each panel’s cps. Typing Sound off silences the per-letter beep only — start_sfx / end_sfx still play (SFX bus). Full key list: Text speed and voice beeps.

Typography / colors per preset

A preset may nest typography and colors overlays. Set keys override; unset keys inherit through extends, then the project theme. Nested sizes merge key by key. null removes an inherited key.

Allowed on typography: font, font_dialogue, font_label, sizes.speaker, sizes.dialogue, sizes.choice.
Not allowed: size_scale, font_menu, sizes.button, sizes.notification (stay project / player global).

Allowed on colors: dialogue_text, speaker_text.
Not allowed: choice_text, notification_text (choice packs own caption colours; toasts stay global).

ChromeFontSizeColour
Dialogue bodyfont_dialogue → fontsizes.dialogue × live size_scaledialogue_text
Labelplatefont_label → fontsizes.speaker × scalespeaker_text
Choices (host: panel)pack font else panel dialoguepack font_size else sizes.choice × scalepack colours
[input] (host: panel)same as bodypack field_font_size else sizes.dialogue × scalepack / field colours

Overlay-hosted choices and input keep project typography (plus their packs). Project size_scale always applies; presets must not set it.

"panel": {
  "terminal": {
    "extends": "default",
    "typography": {
      "font": "JetBrainsMono",
      "sizes": { "dialogue": 18, "choice": 18, "speaker": 14 }
    },
    "colors": {
      "dialogue_text": "#c8ffd0",
      "speaker_text": "#7CFF8A"
    }
  }
}

Full project keys: Typography · Colors.

Plates

A preset may define named plates — per-beat plates inside the text column. Use them with text_mode: "plates" (plain append stays a single label). Keys mirror the panel plate and the shared shapes (shared shapes).

"panel": {
  "phone": {
    "extends": "default",
    "default_plate": "them",
    "plates": {
      "them": {
        "anchor_h": "left",
        "width": "80%",
        "speaker": "on_change",
        "bg": "#2b3552",
        "corner_radius": 14,
        "padding": { "top": 8, "left": 12, "right": 12, "bottom": 8 },
        "margin": { "top": 3, "bottom": 3 },
        "group_gap": 6,
        "transition_in": "slide_right",
        "transition_duration": 0.22,
        "colors": { "dialogue_text": "#e8ecff", "speaker_text": "#8aa0c8" }
      },
      "me": {
        "extends": "them",
        "anchor_h": "right",
        "bg": "#3f6fd8",
        "transition_in": "slide_left"
      }
    }
  }
}
KeyTypeDefaultEffect
platesobject—Named plate looks (same shape as nested panel presets)
default_platestring—Fallback name when the beat does not pick one
extendsstring—Another plate in the same preset
anchor_h"left" | "center" | "right" | "stretch""left"Side of the text column — same values as panel anchor_h
width"NN%", number, or "stretch""80%"Plate width; % is of the text column
speakerfalse | true | "on_change"falseIn-plate speaker header; gap uses speaker_gap
bg / corner_radius / bg_texture / bg_9sliceas panel—Same plate builder as the panel
shaderstring, object, or null—Background effect only (target: "bg") — Shaders
padding / marginsides objectsee exampleInner padding; outer spacing (same margin shape as choices)
group_gapnumber0Extra space when the speaker changes
typography / colorsoverlay—Same allowlists as the preset overlay
transition_instringinstantHow the live plate chrome enters on a new beat (fade, slide_up, slide_down, slide_left, slide_right, instant)
transition_outstring—Validated when present; not used on fold in v1 (committed plates spawn instantly)
transition_duration / durationnumber0Enter duration in seconds; 0 or instant = no motion (duration only when transition_duration is omitted)
transitionstring—Fallback when transition_in is omitted (same kinds as transition_in)

Motion is clipped inside the panel body scroll (not the whole panel). Enter can run at the same time as line typing (for example fade_line). Theme reload (F6) restyles plates without replaying enter. Preset-root transition_in / transition_out still control whole-panel show/hide only — do not reuse them for plates.

Slide kinds name the travel direction (same as stage chrome): slide_right enters from the left edge; slide_left enters from the right; slide_up from below; slide_down from above. Left-anchored chat plates usually use slide_right; right-anchored plates use slide_left.

Selection (first match): node plate → sticky [panel … plate NAME] → character ui.default_plate → preset default_plate. Clear the sticky name with plate null. Prefer character defaults for chat sides.

Panel labelplate vs plate speaker: they are independent. Hide the panel nameplate and use speaker: "on_change" for chat, or keep both. Set an explicit bg on each plate so it does not blend into the panel plate.

append_direction, append_gap_px, append_push_duration, and clear_append work with plates the same way they do with append. Use clear_append at scene boundaries on long threads.

How a preset gets chosen

For each node, the first of these that exists wins:

  1. The node’s panel field
  2. preset on a panel command
  3. While the panel’s sticky mode is already a thread (append or plates) and this line stays in a thread mode (same-node text_mode replace ends the hold), the panel’s sticky layout preset (speaker switches do not yank to a character default; Back/load restore that preset)
  4. ui.default_panel_preset from the speaking character’s pack, if a preset by that name exists
  5. A preset named after the resolved panel id, so panel id side uses panel.side
  6. default

An unknown preset name falls back to default too, warning when the panel id is not main.

Readonly appearance

When a panel stays on screen for a character who is not speaking, non_talking_mode: "readonly_last_text" keeps the last line visible in a dimmed state. Those colours come from readonly_styles.<key> at the top level of the theme, not from the preset:

KeyTypeEffect
text_colorHTML colorDialogue text colour while readonly
speaker_colorHTML colorSpeaker name colour while readonly
bg_opacitynumber 0–1Multiplies the panel alpha
font_scalenumber 0.5–2Scales dialogue text from its normal size

readonly_styles.default is used when the requested key is missing.