- Added a new specification for the speech style toolbar live-edit, detailing immediate style application, scoped effect demonstration, and hover feedback. - Introduced refinements to the speech style toolbar, including improved chooser sizing, exclusive stage editing during speech composition, and removal of redundant controls. - Documented new stage controls for managing how the whole speech shows, including options for bubble arrangement, word appearance, and bubble motion. - Created a new log file for speech padding syntax to track engine versioning.
39 KiB
Phase 13 — Comic Speech Bubbles
2026-10-01 behavior amendment: Comic chain is a third Show bubbles presentation mode. It reveals bubbles cumulatively and joins each new bubble to the previous one with a broad attachment. Stack and Same spot remain placement actions. See Comic Chain Speech Mode for the current behavior and legacy payload migration.
Status: Approved follow-up implemented; saved talk.scene load verified (2026-09-28). The base feature, first composer refinement, and approved §9 follow-up are implemented. The reported scene now loads through the Sandbox Stage CLI without a skipped-speech warning; visual GUI verification remains with the user. User deferred bundled font files; previews use available system fonts until a later font-bundling task.
Source: plans/PHASE_13_SPEECH_BUBBLES.md, the Phase 0 UI proposal, the user's Phase 1 decisions, and the subsequent composer review.
1. Product decisions and scope
The Speak editor is a comic composition panel beside the live Sandbox Stage. The selected stickman and an attached, temporary bubble preview remain visible while the child writes, changes frames and text, replays animations, and places the bubble. The panel supports Talk, Shout, Think, Whisper, and Sound Effect frames; bubble-wide font, size, and text color; emoji; and whole-word font, color, Shake, Wave, and Bounce choices applied by tapping one word and then one control. It also supports All at once, Letter by letter, and Word by word reveal and an entrance choice: Bounce in or Simply appear. Every bubble card has its own content and appearance.
An action can contain several bubbles. Show all is the default: bubbles appear simultaneously as a connected vertical stack, with the bottom bubble pointing to the speaker. One at a time shows exactly one bubble at a time. When its reading interval ends, it transitions to the next; previous bubbles disappear. These labels retain the serialized together and take_turns IDs. speech_finished fires once after the entire action, never between cards.
Follow-up review: the user reported inspector scroll zoom leaking to the camera, stack overlap, word-marker clipping, and speech missing from the queue after scene reload. The approved changes and acceptance checks are recorded in §9; implementation is ready for in-editor verification.
Reading time is calculated automatically. The child chooses Normal, Slow, or Slower; the composer does not display calculated seconds. These are display labels for the existing just_enough, longer, and longest saved IDs and retain their current timing formulas. A child can move the composed group on the stage. The saved position is relative to the character, so it follows movement and changes facing with the character. Reset placement restores automatic above-head placement. There are no placement arrow buttons.
An event-triggered Speak group temporarily takes visual priority over queued speech on that character. The queued group's time and reveal animation pause and resume when the event group ends. A later event Speak replaces the earlier event group. stop_current retains its existing sequential behavior: it interrupts the current action rather than using the visual overlay lane.
Scope includes direct Speak, the rule builder, queue add/edit/insert, and rule add/edit; .scene save/load; the shared preview/playback renderer; and Phase 12 pause/resume behavior. No text-to-speech, audio recording, collaborative dialogue timeline, arbitrary user-authored BBCode, or document-wide undo is included.
2. Existing seams and target hierarchy
SandboxStage currently opens a modal AcceptDialog with one LineEdit and hardcodes a two-second direct/rule Speak. ActionEditor has a separate one-line Speak field and numeric duration for queue/rule edits. ActionRegistry flattens and nests rule actions. StageSceneIO v1.1 writes only text, duration, and stop_current. StickmanActionRunner owns one primary and one overlay timer and lazily attaches one SpeechBubble to the rig at (0, -640). The bubble draws a fallback-font string in _draw(). The rig mirrors its root for left facing and the runner counter-mirrors bubble content.
SandboxStage (Node2D; composition context and stage input)
├── Camera2D
├── World (Node2D)
│ └── StickmanRig
│ ├── RigComponents/ActionRunner (playback state)
│ ├── SpeechBubble (runner-owned, created lazily)
│ └── SpeechPreview (stage-owned, temporary; same renderer class)
└── UI (CanvasLayer)
└── SpeechComposer (Control, side panel)
├── Numbered bubble tabs with snippets / one active TextEdit
├── Scrolling inspector: Frame / Text / Effects / Show / Timing
└── Move bubble / Reset / Replay / Save speech / Cancel
The stage owns the preview lifecycle. It is an unsaved child with no queue or rule side effects. If the rig already displays runtime speech during paused DIRECT, the stage suppresses only that runtime renderer while the preview is open and restores it when the composer closes; playback state is preserved.
3. Static contracts and signals
| Target | Contract |
|---|---|
class_name SpeechPayload extends RefCounted (new scripts/speech_payload.gd) |
Pure, statically typed helpers: static func normalize(raw: Dictionary) -> Dictionary, static func validate(raw: Variant) -> String, static func duration_for(raw: Dictionary) -> float, static func bubble_intervals(raw: Dictionary) -> PackedFloat32Array, static func plain_summary(raw: Dictionary) -> String, static func word_span_at(text: String, index: int) -> Vector2i, and static func rich_text_runs(text: String, word_styles: Array[Dictionary]) -> Array[Dictionary]. word_span_at returns Vector2i(-1, -1) for non-words. No scene-tree access. Constants enumerate valid style/font/size/reveal/effect IDs and caps. Returns deep copies, never aliases mutable action arrays. The run compiler accepts only validated whole-word spans and trusted font/color/effect IDs. |
class_name SpeechComposer extends Control (scripts/speech_composer.gd and scenes/speech_composer.tscn) |
Retain open_for(actor: StickmanRig, initial: Dictionary, reactive_options: bool) -> void, get_draft() -> Dictionary, draft_changed(action: Dictionary), committed(action: Dictionary), and cancelled(). Show one active TextEdit bound to the selected numbered tab; inactive bubble text lives in the staged draft. Keep one transient whole-word target {bubble_index: int, start: int, end: int, text: String} and a graphical, input-transparent word cue. Own a preview state STATIC / PLAYING / BLANK_HOLD; expose visibility and elapsed time to the stage without touching runner playback. |
class_name SpeechBubble extends Node2D (scripts/stickman_speech_bubble.gd) |
Keep the shared, input-transparent RichTextLabel renderer. Factor frame geometry so runtime frames and small frame-choice thumbnails use the same shape rules. Given the rig's live head geometry, draw one filled triangular tail from the nearest visible lower frame boundary to just outside the head rim; never draw a fallback connector line through the character. SFX has no line or tail. Derive each card's Bounce in entrance from that card's local elapsed time; the true bounce overshoots and settles, while Simply appear has no entrance scale animation. |
class_name StickmanActionRunner extends Node |
Keep primary and overlay playback records {action: Dictionary, elapsed: float, duration: float}. Add speak_action(action: Dictionary) -> void, speak_overlay_action(action: Dictionary) -> void, and set_speech_preview_suppressed(value: bool) -> void; retain speak(text: String, duration: float) and speak_overlay(text: String, duration: float) as adapters. Tick only the visible overlay while it has priority, pause the primary speech clock beneath it, and render the winning record. Emit speech_finished exactly once when each completed group ends. |
class_name StickmanRig extends Node2D |
Keep speak_action(action: Dictionary) -> void, set_speech_preview_suppressed(value: bool) -> void, and legacy speak(text: String, duration: float). Expose live head center and radius from the mounted Body/Head circle, with a ragdoll-head or safe rig fallback when that visual is unavailable. Preview and playback use the same geometry after facing/camera transforms. |
class_name SandboxStage extends Node2D |
Continue routing every Speak entry point through one composer session: actor, origin context, action ID/target, draft, and return target. Manage temporary renderer, stage placement capture, actor-first capture for rule-add, and final commit through existing queue/rule methods. Preserve the recovery payload on non-explicit dismissal, validate origin identity on Restore, and clear recovery on successful Save speech or explicit Cancel. The preview never calls rig.speak() or changes an authored action before Save speech. |
class_name ActionEditor extends PopupPanel |
On selecting/editing speak, emit speech_requested(initial: Dictionary) to the stage instead of rebuilding its old Speak LineEdit and duration SpinBox. Keep all other action types and existing committed(action: Dictionary) routing. |
class_name ActionRegistry extends RefCounted |
Preserve complete normalized Speak data when converting flat queue actions to nested rule params and back. summarize() shows a short plain first-line preview, bubble count/mode, and total reading time; never display raw effect syntax. |
class_name StageSceneIO extends RefCounted |
Explicitly encode/decode the optional Speak fields in both queue and rule actions, validate type/range/enum/array bounds through SpeechPayload, and convert placement.center between Vector2 and {x,y}. Preserve v1.0 migration and v1.1 loading. Malformed optional speech data falls back safely to the legacy single bubble or causes only that action to be skipped with an existing warning; it must not corrupt other actions or the scene. |
class_name SandboxTheme extends RefCounted and sandbox_theme.json |
Provide validated speech tokens for five frame styles, preview ghost, layout, timing, size examples, and a speech-specific palette of 16 named text colors. Preserve the existing global widgets.swatches API. Keep font resolution: selected configured preset → configured UI font → ThemeDB.fallback_font; previews use available SystemFont aliases for now. The user deferred bundling licensed font files with attribution to a later task, so exact typefaces may vary by platform. |
The stage forwards SpeechComposer.committed to the same queue/rule destination used by ActionEditor.committed, after rechecking that the actor, action ID, and insertion point remain valid. For rule-add with no chosen actor, capture the actor before opening the composer so the preview has a real stage anchor. Save speech commits the staged action to that destination, then clears the recovery draft. It is not a disk Save Scene command. Cancel discards the staged action and its recovery draft, then returns to the prior queue/rule panel or editor. The composer never mutates a queued action, rule, scene dirty flag, or runner state while its draft is being edited.
3.1 Recoverable draft lifecycle
SandboxStage maintains one speech-composer recovery record at user://speech_composer_draft.json. Every draft edit refreshes an atomic write after a 0.75-second debounce. The envelope includes a schema version, modified time, normalized speech payload, selected bubble index, and origin context (direct/new/edit/insert/rule plus the scene and target action identity when available). It contains no live Object references. It is private editor recovery data and is never part of .scene JSON.
Recovery data is not an action commit: it never edits the queue, rule, runner, scene dirty flag, or .scene file. If the composer is dismissed without Save speech or Cancel, the recovery record stays available. On the next eligible composer open, show Restore draft / Discard draft. When the saved origin and target can be revalidated, Restore resumes that edit; when they cannot, Restore loads the speech as an unbound draft and requires the user to choose an actor and destination before Save speech. A stale runtime instance ID or queue position alone is never enough to replace an action. A malformed or unsupported recovery record is discarded with a plain status message, without affecting the scene.
Successful Save speech clears the recovery file only after the existing queue/rule commit succeeds. Explicit Cancel clears it and returns without mutation. If a new composition starts while an older recovery record exists, the user must choose Restore or Discard before the new draft can replace it. The ordinary Save Scene/autosave flow remains separate and unchanged.
4. Speech action data
Flat queue actions are the canonical in-memory shape; rule actions nest the same fields in params. JSON uses the same scalar/dictionary/array fields, except its placement.center is {x,y} instead of Vector2.
{
"type": "speak",
"text": "Look out!", # derived first-bubble summary for older consumers
"duration": 1.5, # effective whole-group duration in seconds
"stop_current": false,
"arrangement": "together", # or "take_turns"
"duration_mode": "just_enough", # or "longer" / "longest"
"placement": {"mode": "manual", "center": Vector2(40.0, -710.0)},
"bubbles": [
{
"text": "Look out!", # literal plain text, including emoji/newlines
"visual": {
"bubble_style": "shout", # talk/shout/think/whisper/sfx
"font_preset": "comic", # IDs offered by SandboxTheme
"size_preset": "huge", # small/medium/huge
"color": "#FF3333", # text color; frame colors come from theme
"reveal": "letter", # instant/letter/word
"pop_in": true,
"word_styles": [
{"start": 5, "end": 8, "effect": "shake", "font_preset": "pixel", "color": "#FFB300"}
]
}
}
]
}
placement.mode = "auto" omits center; the renderer centers the group above the speech anchor. Manual center is the group frame center in rig-local coordinates, so a stage click places the actual frame center under the cursor, not its tail tip. In One at a time, each single frame uses that same center; in Show all, the stack as a whole uses it. The frame center and its saved position stay fixed when text changes; geometry grows around it. The filled tail points toward the live head rim without a separate connector line. This is an authored coordinate, never a screen coordinate. The rig transform handles movement and facing; the counter-mirrored visual root keeps letters readable.
text and duration remain at the top level because current consumers and older scene files expect them. For new actions, text is copied from the first bubble and duration is recalculated on each edit. bubbles is authoritative when present. A legacy {type:"speak", text, duration} action normalizes to one Talk/medium/instant bubble and keeps its exact saved duration via an in-memory fixed_legacy timing mode; the writer keeps its original legacy fields until the composer edits it. Opening it in the composer selects Normal and recalculates on Save speech. Optional legacy top-level visual from the plan is accepted as that bubble's style. New writes may include the optional fields while keeping .scene file version 1.1; no scene-wide migration is needed. Unknown optional fields are ignored; unknown enum values fall back to defaults. Set defensive implementation bounds of eight bubbles and 240 visible characters per bubble to bound layout work and saved payload size; these are safety caps, not a suggested story length. Reject non-finite, negative, or out-of-range manual coordinates and durations at the IO boundary.
visual.font_preset and visual.color are defaults for the whole bubble. Optional visual.word_styles records override them for individually targeted words and may also set one effect. Each record is {start: int, end: int, font_preset?: String, color?: String, effect?: String} with half-open offsets over the plain text. Its range must cover exactly one complete word token, 0 <= start < end <= text.length(). Records cannot overlap or repeat the same word; omitted keys inherit the bubble default, and a record with no override keys is removed. Font IDs must resolve through the validated theme presets, colors must be valid hex, and effects must be one of shake, wave, or bounce. These structured fields never contain user-authored BBCode or resource paths.
In the multiline editor, a child clicks or taps one word once. The composer maps the pointer to a plain-text offset and uses the same word_span_at() helper as the validator: a word is a run of letters/numbers with an internal apostrophe, while a standalone emoji is one targetable grapheme; whitespace and punctuation have no target. The composer records that entire word's bubble and range before a style button takes focus. A graphical, input-transparent marker outlines the whole targeted word and follows editor wrapping/scrolling; a small word chip with an effect icon remains beside the controls as a second cue. It never uses native text selection, so typing cannot overwrite the targeted word. With a word target, changing font/color or pressing Shake/Wave/Bounce updates that word only; the controls show the word's effective values, including inherited defaults. With no word target, font and color controls edit the whole bubble's defaults and the effect buttons are disabled. Tapping I → Wave, then GHOST → Wave creates two separate saved word records; the second tap does not alter the first. Applying the same effect again is idempotent; choosing another effect replaces the previous effect on that word. No effect removes only that word's effect. Use bubble defaults removes its font/color overrides, and if no effect remains, removes the record. Clicking punctuation or blank space clears the target. Drag highlighting, Shift-click, and multiselect/append have no styling meaning and are not part of this workflow.
The composer shows a compact style marker on each affected word. Text edits shift unaffected later records and drop a record whose word is edited; if an edit, card removal, or card reorder invalidates the transient target, it is cleared visibly before any control can apply to a stale range. SpeechPayload.rich_text_runs() splits escaped literal text at validated word boundaries and returns trusted segments; the renderer applies RichTextLabel font/color overrides and approved effect tags or custom effects to each segment. If the installed Godot build lacks a built-in bounce tag, use one small custom RichTextEffect for bounce; the saved effect value remains stable. The renderer measures wrapped content before drawing the frame. Emoji and multiline text remain literal input; no raw BBCode entry box appears in the child UI.
For a new bubble, the base reading time is max(1.5, 1.0 + 0.2 × word_count) seconds, counting visible words in plain text. longer multiplies by 1.5; longest by 2.0. Bound each interval to [1.5, 12.0] seconds and ensure at least reveal_time + 0.8 seconds after choosing the reveal mode. Empty/whitespace-only cards cannot be committed. Together uses the maximum card interval; Take turns uses their sum. duration_for() is the single source for UI preview, saved total, and runner timing. Legacy actions bypass this calculation until edited in the new composer. Word-style records and emoji do not count as extra words.
5. Playback, geometry, placement, and pause
stateDiagram-v2
[*] --> Hidden
Hidden --> Primary: queued Speak starts
Primary --> Overlay: reactive Speak starts
Overlay --> Primary: overlay group ends; resume elapsed primary time
Primary --> Hidden: primary group ends
Overlay --> Hidden: overlay ends with no primary
Primary --> Primary: next card in Take turns
Overlay --> Overlay: next card in Take turns
The runner stores elapsed time, not a free-running tween, and passes that time to SpeechBubble.render_action(). This makes Replay, pause, overlay restoration, and deterministic scene playback use the same visual clock. For Show all, all items are visible for the whole interval and reveal from time zero. For One at a time, bubble_intervals() locates one current card, computes local time, and transitions to the next at the boundary; the outgoing card is gone before the new one appears. Bounce in uses a short overshoot-and-settle entrance based on each card's local elapsed time, including every later One at a time card. Simply appear uses no entrance scale animation. The composer shows a small, understandable example for each choice. Letter/word reveal maps elapsed reveal time to RichTextLabel.visible_characters; existing pop_in boolean and reveal IDs stay unchanged in files.
All frames share one text/layout path and deterministic shape helpers used by the frame-choice thumbnails. Talk uses a rounded frame; Shout a starburst; Think a cloud with descending dots; Whisper a translucent dashed frame; SFX a high-contrast banner. In Show all, the renderer measures each frame, stacks them bottom-up with bounded gaps, and draws bridges between adjacent frames; only the bottom frame points to the head. In One at a time, one frame uses the common manual/auto center. SFX draws no tail or connector line. Body, tail, inset, and selected-outline details follow Speech Bubble Visual Geometry.
The stage converts the exact stage click through StickmanRig.to_local(world_pos) and saves that as manual placement.center; it shows a ghost frame outline while hovering. The composer suspends its controls only enough to accept the stage click. Esc/right-click cancels placement and restores the previous center; Reset placement writes auto into the draft. The selected tab controls the editing widgets, while Move bubble moves the whole group. There are no placement arrow buttons or line from the frame through the character. The preview updates on every edit without mutating the action queue or scene dirty flag. Actor deletion, scene load/reset, mode exit, or other non-explicit dismissal preserves only the recovery payload; on Restore, context is revalidated and stale context becomes an unbound draft that requires an explicit actor/destination. Explicit Cancel discards the recovery payload.
At render time, compute the visual group bounds in screen space after the actor/camera transforms, then translate only the displayed frame group inside the visible stage rectangle (excluding the side panel and chrome) with a small margin. The rig supplies the live head center and radius from Body/Head, or the ragdoll head/fallback when appropriate. Convert that geometry through the final bubble transform and connect applicable frames to the head rim with the single-contour balloon treatment specified in Speech Bubble Visual Geometry. Think uses its dot chain toward the same rim; SFX has no connection. Use the final displayed frame, camera zoom, and facing so clamping/mirroring cannot detach the tail. Display fit never changes serialized placement. At a window too small for a readable simultaneous stack, show a separate overflow cue; never silently crop text or show calculated reading seconds.
The runner's pause flag stops both speech clocks. Inline animated effects obey the selected group's visual clock. The composer controls Replay with STATIC → PLAYING → BLANK_HOLD → STATIC: start at a clean first frame, play each entrance/reveal/effect and every One at a time transition, hide the preview when the full action duration ends, leave the stage blank for about 1 second, then restore the full static editing preview. Edits during Replay return to STATIC using the latest draft. The stage must honor an explicit preview-hidden state during BLANK_HOLD; rendering the action at its final elapsed time would leave the last card visible. Replay never calls the runner or changes the saved action. An overlay ending restores the primary group's saved elapsed time, exact current card, and reveal position. A canceled/replaced overlay does not emit a spurious speech_finished.
6. Composer layout and entry flows
Visible stage and selected character Speech Composer side panel
┌──────────────────────────────┐ ┌─────────────────────────────┐
│ character ◯◀ filled tail │ │ [1 Hello…] [2 Help…] [+] │
│ comic frame │ │ one active multiline editor │
│ ghost frame while placing │ │ marked word + visual chip │
│ │ │ Frame: five shape thumbnails│
│ │ │ Text: font samples / sizes │
│ │ │ Color: 4×4 named text tiles │
│ │ │ Effects: Shake Wave Bounce │
│ │ │ Entrance: Bounce / Simply │
│ │ │ Show: all / one at a time │
│ │ │ Timing: Normal Slow Slower │
│ │ │ Move / Reset / Replay │
│ │ │ Save speech / Cancel │
└──────────────────────────────┘ └─────────────────────────────┘
The panel keeps its title and Save speech/Cancel footer fixed. A vertical-only ScrollContainer holds the inspector; it must not produce a horizontal scrollbar or clip controls at the supported minimum window width. Use HFlowContainer or responsive vertical groups for variable-width choice rows. Place text swatches in a fixed four-column GridContainer, with 16 large filled squares and accessible names/tooltips such as “Sunset red”; show no hex values in the child UI. These squares set visual.color (bubble text default) when no word is targeted and visual.word_styles[].color for a targeted word. They never change the bubble frame fill. Add a strong selected outline/checkmark that remains visible on pale and dark colors.
The bubble chooser uses high-contrast numbered tabs with a short text snippet and a clear active state; tabs wrap or scroll within the panel as needed. Only the selected bubble has a live TextEdit. Switching tabs commits its current text into the staged draft and rebinds one editor, preserving each card's text and optional transient caret/scroll position without duplicating editors. A graphical word marker overlays the targeted full word inside this editor without using native drag selection; it follows wrapping, scrolling, and resize, ignores pointer input, and remains visible while the child uses font, color, or effect controls. A small graphic chip shows the targeted word with its active style/effect cue; there is no plain Target Word label. A Clear target button remains available. The target is cleared if its text/card is changed or removed.
Frame buttons show actual Talk/Shout/Think/Whisper/SFX silhouettes from the same geometry helper as the runtime renderer, plus a short label. Font buttons render a sample word in their selected font and show the selected state; until font files are bundled in a later task, the available system-font aliases provide the samples and may vary by platform. Size choices show a text-size example. The inspector has separate Text, Effects, Show, and Timing headings. Effects contains Shake, Wave, Bounce, No effect, and Use bubble defaults with the current one-word targeting behavior. Entrance presents Bounce in and Simply appear as mutually exclusive choices with a small animated/graphic example; the saved field stays pop_in: bool. Show presents Show all / One at a time and Timing presents Normal / Slow / Slower. The automatic seconds label is removed; a separate contextual warning may report actual layout overflow without a duration number. Move bubble and Reset placement remain; arrow nudges are removed. Reorder and Remove stay available for tabs, and Add explains the defensive eight-card limit only when reached.
Direct Speak and the old rule-builder Speak button open the composer with their already selected target rig. Queue/rule add/edit from ActionEditor hand off to the composer when type Speak is selected; existing Speak edit may open it directly. For rule-add where an actor is not selected, the stage first uses its existing stickman capture path, then opens the composer attached to that actor. The stage retains an action ID for delayed queue edits/inserts and validates it on Save, following Phase 12's pending-only edit rules. Rule action data passes through ActionRegistry and StageSceneIO without losing bubble fields. Stop-current remains visible only in reactive/rule contexts. Reopening a saved action restores every card, style, interval mode, and placement.
7. Refinement implementation sequence and acceptance checks
- Refactor the composer to display one active bubble editor, high-contrast numbered snippet tabs, and a vertical-only responsive inspector. Add frame thumbnails, per-font samples, size samples, 16 named 4×4 text swatches, a graphical word marker, and the separate Effects/Show/Timing sections. Preserve existing speech serialization IDs while changing only the child-facing labels.
- Replace the approximate/line connector with shared dynamic head geometry from
StickmanRig; draw one elongated filled tail for applicable frames, ending just outside the live head rim. Remove the fallback connector line and all SFX lines. Make Bounce in an actual overshoot-and-settle animation and keep its clock local to each card. - Implement the
STATIC → PLAYING → BLANK_HOLD → STATICcomposer replay state. Replay exercises entrances, text reveal, inline word effects, One at a time transitions, then hides all bubbles for about one second before returning to the full editing preview. Keep the runner untouched. - Add the debounced local recovery record. Verify crash/reopen recovery, valid-context resume, stale-context fallback to an unbound draft requiring actor/destination selection, and that Restore never commits or dirties the scene. Verify successful Save speech commits exactly once and clears recovery; Cancel discards the draft and clears recovery; failed commits retain recovery.
- Perform Phase 2 headless syntax verification per spec-pipeline, plus focused validation of recovery envelope/version, debounce/atomic write, context matching, discard/clear paths, queue/rule round-trip, old scenes, word-boundary records, timing-ID mapping, per-card replay and blank hold, head/tail geometry across facing/zoom/clamping, and Cancel leaving authored state untouched. Manually verify supported narrow-window layouts have no horizontal scroll; frame/font/size samples match the preview; all 16 text colors remain visible on light and dark swatches; and all direct, queue, insert, rule-add, and rule-edit entry points preserve their destination.
Acceptance means a child can compose and rearrange a multi-bubble comic in the one-editor inspector, tap separate words to style each with an effect, font, and text color, preview the frame and size choices, place the group with a tail that points to the character's head, replay the complete sequence, and save/reload the result. Bubble tabs remain readable and controls never require horizontal scrolling. Show all stacks every bubble; One at a time never displays two. Replay ends with a one-second blank stage and restores the static draft preview. If the composer is dismissed unexpectedly, Restore recovers the latest draft without changing the authored action until Save speech; Cancel clears the draft. Normal/Slow/Slower retain their existing timing math and serialized IDs. Old single-text Speak scenes keep their original saved duration. No malformed recovery or speech data crashes scene loading.
8. Architectural tradeoffs
- A dedicated stage-side composer keeps the actual character visible and lets one stage-click placement flow serve every Speak entry point. It requires a small context handoff from existing
PopupPanels and careful cleanup of the temporary renderer. - A
Dictionarypayload with one pure validator fits the existing action/JSON pipeline. Per-bubbleResourcefiles would add conversion and identity rules without helping this scene-local feature yet. - Plain text plus validated single-word style records gives children direct writing and safe literal brackets. One persistent transient word target avoids relying on native TextEdit selection focus, while bubble-level font/color defaults keep unstyled words coherent. Arbitrary BBCode and external content markup are deferred.
- A runner-owned elapsed clock makes overlay restoration and Phase 12 pause deterministic. Time-derived visual animation is more predictable here than independent tweens per bubble.
- Character-relative manual centers keep placement stable as the actor moves. Screen fit is a render-only adjustment; tiny viewports may need a later responsive layout or paging pass.
- A local recoverable draft addresses accidental dismissal without treating each keystroke as an action/scene commit. It adds one bounded, versioned user-data file and context validation; stale targets restore only as an unbound draft, never as an implicit replacement.
- Dynamic head geometry avoids a guessed fixed offset and removes long lines that can cross the character. The tradeoff is a small rig API and explicit ragdoll/fallback behavior; the same renderer keeps preview and playback aligned.
- Bundled comic/pixel/scary font assets would make samples consistent across platforms, but are deferred by the user; system-font aliases remain the interim behavior.
9. Approved follow-up review (2026-09-27)
Interaction and inspector
- Treat the visible composer panel as the owner of wheel and touchpad scrolling. Stage camera zoom and pan gestures must not run when their pointer is over the panel. A middle-mouse pan may begin only over the stage. Keep the inspector vertical-only.
- Hide Remove when only one bubble remains.
- Keep the main inspector sections open. Use vertical scrolling, readable section cards, and larger high-contrast ribbon headers. Keep the bubble selector and Save speech / Cancel controls reachable while editing; do not add collapse toggles to the core sections.
- Rename the message editor section Message. Present word styling in a separate Make words move section with the instruction Tap a word in the bubble, then choose a style. Keep the selected word visible as a simple word chip. Place Shake, Wave, Bounce, and No effect together in Effects. No effect clears that word's animation; Done exits word editing. No selection means the child is choosing a bubble-wide style; one tapped word means the child is choosing that word's style.
- Make font and size choices keep a clear selected fill or border until the selection changes; keyboard focus must not look like a selected value.
- When a word is selected, font, size, and color choices apply to that word. With no selected word, those same controls set the bubble defaults. Use bubble defaults clears per-word font, size, and color while preserving its effect.
- Label the palette Color. Use sixteen equal, larger square swatches in four rows. Do not print color names, hex values, or checkmarks on the squares. Use a high-contrast border for the selected color and retain color names only in tooltips.
Word selection marker
- The graphical marker must cover the full rendered word, including the first and last glyph, with a little padding and softly rounded corners. A wrapped word gets one marker segment per visual line. Skip invalid/offscreen glyph rectangles and clip the marker to the active text editor; recompute it after caret changes, scrolling, wrapping, resize, or font changes. Keep punctuation only when it is part of the word target according to the shared word-span helper.
Per-bubble placement and default layout
- Add optional per-bubble
placementdata with a character-local manual center. Preserve the existing top-level placement as the legacy/group placement and fallback, so old speech keeps its current saved positions. Encode/decode every vector through the scene JSON boundary. - New automatic bubbles appear above the character with a slight alternating stagger. The default layout must leave the character's head and body unobscured. If the group will clip at the top of the visible stage, compact its spacing or move it beside the character; do not shift the stack down across the character to make it fit.
- Add Move this bubble and Reset spot for the selected bubble. Let the child drag that bubble in the preview or choose a spot with a stage click; either gesture edits only that bubble. During placement, keep other bubbles visible as faint guides. Show all displays every bubble at its saved spot; One at a time uses each bubble's saved spot on its turn. Recompute the applicable tail/connection from the final displayed position to the live head. Keep authored positions separate from render-time viewport fitting.
Save and reload integrity
- Keep the full validated speech payload intact through queue save/load, including legacy single-text speech, bubble visuals, word styles, arrangement, timing, top-level placement, and per-bubble placement.
- A confirmed failure mode in the current save path is that
encode_queue()can reject an invalid action while_collect_scene_data()discards its warnings and still writes the remaining queue. Do not report a successful scene save if any authored queue action was omitted. Surface a clear save error naming the affected action/character and leave the scene file unchanged. On load, report which action could not be restored instead of silently presenting an empty queue. - Acceptance requires a saved multi-bubble Speak action to remain visible exactly once in the same stickman's queue after Save speech → Save Scene → close/reload, with its word styles and positions intact. Include a legacy
{text, duration}action in the compatibility round trip.
Acceptance checks
- Scrolling over the composer changes its scroll position without changing camera zoom; scrolling over the stage still zooms. Touchpad scroll/pinch follows the same ownership rule.
- A one-bubble draft has no Remove control. Core sections remain open and visually separated by readable cards. Selected font, size, and color are visually apparent without checkmarks or labels inside swatches.
- Tapping a word highlights every visible glyph of that word, including wrapped words, and styling remains bound to that occurrence when focus moves to a control. Font, size, and color choices can be applied independently to that word.
- Added bubbles do not cover the character by default. Each bubble can be moved and reset independently; its position follows the character and survives scene save/load.
- Valid speech survives scene save/load. Invalid speech prevents a misleading partial save and produces an actionable message.