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
| Key | Type | Default | Effect |
|---|---|---|---|
width | "NN%", "100%", "stretch", or a number | "100%" | Panel width |
anchor_h / anchor_v | see size and position | "stretch" / "bottom" | Where the panel sits |
offset_x / offset_y | number | 0 | Nudge from the anchors (negative to move inward from right/bottom) |
min_height | number, "NN%", or "full" | 160 | Floor for height |
max_height | number, "NN%", or "full" | legacy formula | Ceiling; overflow scrolls the body text |
padding | object | see below | Inner margins |
bg | HTML color | #343a52e0 | Flat plate colour |
corner_radius | int | 0 | Rounded corners on the flat plate |
extends | string | — | Inherit another preset, then override |
scrollbar_style | string | — | 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_duration | number ≥ 0 | 0 | Seconds to ease the body to the new append edge after settle; 0 snaps |
append_push_duration | number ≥ 0 | 0 | Seconds 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 |
typing | object | inherit game.json | Per-preset text reveal / caret / beep — Typing per preset |
typography | object | inherit project typography | Per-preset font/sizes for body, labelplate, panel-hosted choices/input — Typography / colors per preset |
colors | object | inherit project colors | Per-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
| Key | Type | Default | Effect |
|---|---|---|---|
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_height | number, "NN%", or "full" | 160 | Floor for the panel height |
max_height | number, "NN%", or "full" | see below | Ceiling for the panel height |
anchor_h | "left" | "center" | "right" | "stretch" | "stretch" | Horizontal anchor |
anchor_v | "top" | "center" | "bottom" | "stretch" | "bottom" | Vertical anchor |
offset_x | number | 0 | Pixels from the horizontal anchor |
offset_y | number | 0 | Pixels from the vertical anchor |
x | number | — | Absolute left edge; ignores anchor_h and offset_x |
y | number | — | Absolute top edge; ignores anchor_v and offset_y |
resize_duration | number | 0 | Seconds 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_duration | number ≥ 0 | 0 | Seconds to ease body scroll to the append edge after the plate height is stable. 0 snaps |
append_push_duration | number ≥ 0 | 0 | Seconds 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_style | string | — | 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) |
typography | object | — | Overlay onto project typography for this preset (see below) |
colors | object | — | Overlay onto project colors for body / speaker only (see below) |
modulate | HTML color | #ffffff | Tint for the whole panel |
scale | number or [sx, sy] | 1 | Scale 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:
| Value | Effect |
|---|---|
default (omit) | Bar appears when needed and takes width from the text when it does |
reserve | Always 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 |
overlay | Not available yet — falls back to default |
Use reserve on plates that often scroll (phone, log, fixed height). Leave normal ADV presets on default.
Spacing
| Key | Type | Default | Effect |
|---|---|---|---|
padding.top | int | 15 | Inner top margin |
padding.left | int | 20 | Inner left margin |
padding.right | int | 20 | Inner right margin |
padding.bottom | int | 20 | Inner bottom margin |
speaker_gap | int | 6 | Gap between the speaker name and the text |
choices_gap | int | 2 | Gap between the text block and the choice list |
choices_separation | int | 4 | Gap between individual choices |
Background
| Key | Type | Default | Effect |
|---|---|---|---|
bg | HTML color | #343a52e0 | Flat plate colour, used when no usable texture is set |
bg_texture | texture 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_radius | int | 0 | Rounded 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
| Key | Type | Default | Effect |
|---|---|---|---|
labelplate_style_key | string | — | Which labelplate.<key> block styles the speaker name |
labelplate_layout | object | — | 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" }
]
}
| Key | Type | Effect |
|---|---|---|
id | string | Required, unique within the preset |
bind | "talking" | Fills the slot with the current speaker’s panel art. "talking" is the only supported binding |
object | string | Fills the slot with an object from sprites/<id>.obj.json |
layout | object | Anchors 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.
| Key | Type | Default | Effect |
|---|---|---|---|
visible | bool | true for continue, false for back | Whether the button is shown |
label | string | — | Caption. An empty string gives an icon-only button |
label_key | string | — | 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_x | number | 0 | Nudge from the resolved position |
offset_y | number | 0 | Nudge from the resolved position |
style | string or object | fallback pack | Element style pack name, or an inline style object with an optional "source" |
click_sfx / hover_sfx | string or null | inherit sounds.button_* | Per-channel UI SFX override. Omit = inherit; "" or null = silent |
interaction | object | — | 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).
| Chrome | Font | Size | Colour |
|---|---|---|---|
| Dialogue body | font_dialogue → font | sizes.dialogue × live size_scale | dialogue_text |
| Labelplate | font_label → font | sizes.speaker × scale | speaker_text |
Choices (host: panel) | pack font else panel dialogue | pack font_size else sizes.choice × scale | pack colours |
[input] (host: panel) | same as body | pack field_font_size else sizes.dialogue × scale | pack / 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"
}
}
}
}
| Key | Type | Default | Effect |
|---|---|---|---|
plates | object | — | Named plate looks (same shape as nested panel presets) |
default_plate | string | — | Fallback name when the beat does not pick one |
extends | string | — | 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 |
speaker | false | true | "on_change" | false | In-plate speaker header; gap uses speaker_gap |
bg / corner_radius / bg_texture / bg_9slice | as panel | — | Same plate builder as the panel |
shader | string, object, or null | — | Background effect only (target: "bg") — Shaders |
padding / margin | sides object | see example | Inner padding; outer spacing (same margin shape as choices) |
group_gap | number | 0 | Extra space when the speaker changes |
typography / colors | overlay | — | Same allowlists as the preset overlay |
transition_in | string | instant | How the live plate chrome enters on a new beat (fade, slide_up, slide_down, slide_left, slide_right, instant) |
transition_out | string | — | Validated when present; not used on fold in v1 (committed plates spawn instantly) |
transition_duration / duration | number | 0 | Enter duration in seconds; 0 or instant = no motion (duration only when transition_duration is omitted) |
transition | string | — | 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:
- The node’s
panelfield preseton apanelcommand- While the panel’s sticky mode is already a thread (
appendorplates) and this line stays in a thread mode (same-nodetext_mode replaceends the hold), the panel’s sticky layout preset (speaker switches do not yank to a character default; Back/load restore that preset) ui.default_panel_presetfrom the speaking character’s pack, if a preset by that name exists- A preset named after the resolved panel id, so panel id
sideusespanel.side 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:
| Key | Type | Effect |
|---|---|---|
text_color | HTML color | Dialogue text colour while readonly |
speaker_color | HTML color | Speaker name colour while readonly |
bg_opacity | number 0–1 | Multiplies the panel alpha |
font_scale | number 0.5–2 | Scales dialogue text from its normal size |
readonly_styles.default is used when the requested key is missing.
Related
- Planes and slots — where the panel sits relative to characters
- Panel routing — panel ids, presence, presets
- How these docs work — why this page is dense
panel— switching presets and content modes from a story