Files
stickman/docs/specs/phase_7_ui_polish.md
T

30 KiB
Raw Blame History

Phase 7 UI Polish — Architecture Specification

Status: proposed; awaiting user approval
Source plan: docs/archive/plans/PHASE_7_UI_POLISH.md
Target: Godot 4.7.1 Sandbox Stage only. The configured Stickman Editor main scene is not changed.

1. Scope and current-state audit

Phase 7 is an incremental polish pass, not a rewrite of SandboxStage. The stage scene remains deliberately small and the runtime UI remains owned by SandboxStage._build_ui(). Existing tests also access several stage fields directly, so moving the full UI into a new scene would add migration risk without improving the requested behavior.

Already implemented; preserve and restyle

Plan item Existing implementation
Three mode controls _mode_buttons in scripts/editors/stage/sandbox_stage.gd
Mode badge, status text, live world coordinates _mode_badge, _status_label, _status_cursor_coords
Mode cursor and target reticle _apply_cursor(), _build_action_cursor()
Cursor tooltip and direct-action trajectory _tooltip, StagePlacementOverlay.set_action_trajectory()
Terrain AABB stride, Bresenham painting, Shift lock _terrain_stride(), _bresenham_cells(), _update_terrain_drag()
Three-state terrain ghosts _classify_cell(), _refresh_terrain_ghosts()
Asset selector with thumbnail cards and dimmer asset_selector.tscn, asset_selector.gd, _selector_dim
Sequential off-screen thumbnail rendering and disk cache stage _thumbnail_queue; scripts/shared/thumbnails/*
Popup and editor font overrides _apply_popup_theme() and widget apply_font() methods

These behaviors are integration points, not new Phase 7 systems. Their public behavior and existing headless tests must continue to pass.

In Phase 7

  1. Replace the stage-local JSON parsing and scattered style constants with a validated, cached SandboxTheme service and schema version 2.
  2. Give the existing mode switcher, viewport frame, badge, status bar, tooltips, popups, dialogs, selector, and overlays a coherent theme-driven appearance.
  3. Replace the top-row spawn text buttons with a scrollable graphical card palette. Clicking keeps the existing persistent placement behavior; dragging a card performs one placement.
  4. Harden the existing thumbnail cache with a memory tier and stale-request protection. Rendering remains serialized on the main thread.
  5. Add reusable color, size, font-preset, and emoji controls, with stable typed APIs, ready for later parameter dialogs.

Explicitly deferred

  • Speech bubble geometry/styles, custom speech rendering, and changes to persisted speak actions. Section 10 defines the future contract, but Phase 7 does not emit or consume it.
  • Embedding the four new reusable widgets into the Speak dialog. That is the first consumer in the future speech-customization phase.
  • True background-thread thumbnail rendering. Godot scene/viewport rendering remains main-thread work; Phase 7 limits generation to one job at a time and avoids duplicate/stale work.
  • Dragging cards out of the modal AssetSelector. Direct drag uses the currently selected Prop or Stickman card in the persistent palette; the modal remains click-to-select.
  • New bitmap icon art. Phase 7 uses registry-provided emoji glyphs plus existing generated thumbnails. Art assets can replace them later without changing card APIs.
  • A full .tscn conversion of the code-built Sandbox UI.

2. Files

Add

  • scripts/stage/sandbox_theme.gd — class_name SandboxTheme, schema validation, legacy aliases, typed accessors, font/style/theme caches.
  • scripts/shared/ui/asset_card.gd — class_name AssetCard, reusable palette/selector card and pointer-drag threshold handling.
  • scripts/shared/ui/color_swatch_selector.gd — class_name ColorSwatchSelector.
  • scripts/shared/ui/preset_size_toggle.gd — class_name PresetSizeToggle.
  • scripts/shared/ui/font_preset_picker.gd — class_name FontPresetPicker.
  • scripts/shared/ui/emoji_quick_grid.gd — class_name EmojiQuickGrid.
  • tests/test_phase7_theme.gd — schema, fallback, alias, and resource/cache behavior.
  • tests/test_phase7_widgets.gd — widget data contracts and signals.
  • tests/test_phase7_palette.gd — card activation/drag state, mode visibility, and placement behavior.

The reusable controls are script-built Control subclasses, not new scenes. They have no project-specific node paths and can be instantiated in any popup.

Change

  • sandbox_theme.json — migrate the committed theme to schema 2.0.
  • scripts/editors/stage/sandbox_stage.gd — own SandboxTheme; build/stylize the chrome and palette; route card drag input; use theme accessors; apply theme to popups/dialogs; add thumbnail request generation tokens.
  • scripts/stage/stage_spawner.gd — expose registry-backed palette descriptors without exposing _registry.
  • scripts/shared/ui/asset_selector.gd — build cells with AssetCard; accept a SandboxTheme; keep current signals and pagination.
  • scenes/stage/asset_selector.tscn — name layout containers used for theme spacing/margins and remove hard-coded visual values now owned by the theme. Node unique names and popup behavior remain compatible.
  • scripts/shared/thumbnails/thumbnail_cache.gd — add an in-memory texture tier and versioned cache API while preserving current path helpers.
  • scripts/stage/stage_placement_overlay.gd — accept resolved overlay colors/metrics through one style method; drawing/input behavior is unchanged.
  • scripts/stage/stage_director_visuals.gd — consume a resolved style dictionary rather than reading raw schema internals; preserve set_style(Dictionary) as a compatibility wrapper.
  • scripts/editors/stage/action_editor.gd, scripts/editors/stage/rule_editor.gd, scripts/editors/stage/queue_panel.gd, scripts/editors/stage/rule_panel.gd — add apply_theme(theme: SandboxTheme) wrappers and retain current apply_font(...) methods for existing tests/callers.
  • Existing Phase 3b/3c/4b tests only where assertions must reflect the always-visible mode frame or v2 key names; behavioral coverage is not removed.

Not changed in Phase 7

  • scenes/stage/sandbox_stage.tscn
  • scripts/stage/action_registry.gd
  • scripts/stage/stage_scene_io.gd
  • scripts/characters/stickman/stickman_rig.gd
  • scripts/speech/stickman_speech_bubble.gd

3. sandbox_theme.json schema 2.0

The JSON remains hand-editable. Colors are HTML strings (#RRGGBB or #RRGGBBAA), resource paths are strings, dimensions are logical screen pixels, and preset IDs are stable lowercase strings.

{
  "version": "2.0",
  "global": {
    "font_preset": "ui",
    "font_size": 18,
    "text_color": "#F5F7FA",
    "muted_text_color": "#AAB2C0",
    "bg_color": "#171B24F2",
    "surface_color": "#252B38FA",
    "border_color": "#FFFFFF33",
    "border_width": 1,
    "corner_radius": 10,
    "padding": 8,
    "spacing": 8
  },
  "fonts": {
    "ui_font": "",
    "emoji_font": "",
    "ui_font_bold": "",
    "ui_font_italic": "",
    "presets": {
      "ui": { "label": "Standard", "path": "" },
      "comic": { "label": "Comic", "path": "" },
      "pixel": { "label": "Pixel", "path": "" },
      "scary": { "label": "Scary", "path": "" }
    },
    "sizes": {
      "action_popup": 24,
      "action_popup_emoji": 22,
      "assignment_badge": 20,
      "rule_label": 16,
      "status_pill": 16,
      "tooltip": 18,
      "queue_panel": 18,
      "rule_panel": 18,
      "action_editor": 18,
      "rule_editor": 18,
      "panel_row": 16,
      "panel_title": 18,
      "asset_label": 14,
      "asset_badge": 12
    },
    "flags": {
      "panel_title_bold": true,
      "rule_label_bold": false,
      "badge_bold": true,
      "action_popup_bold": false,
      "action_popup_italic": false
    }
  },
  "mode_colors": {
    "edit": "#22C6FF",
    "direct": "#FFB300",
    "play": "#33DD77",
    "guide": "#22C6FF"
  },
  "grid": { "snap_size": 15.0 },
  "chrome": {
    "style_defaults": { "bg_color": "#171B24F2", "corner_radius": 10 },
    "top_bar": { "height": 48 },
    "status_bar": { "height": 32 },
    "mode_switcher": { "segment_min_width": 104 },
    "mode_frame": { "border_width": 3 },
    "mode_badge": { "corner_radius": 12, "padding": 10 },
    "tooltip": { "bg_color": "#101010E6", "border_width": 1 }
  },
  "popups": {
    "style_defaults": { "bg_color": "#202633FA", "border_color": "#FFFFFF33", "corner_radius": 12, "padding": 12 },
    "action_menu": { "font_size": 24 },
    "dialog": {},
    "asset_selector": { "backdrop_alpha": 0.55 }
  },
  "palette": {
    "style_defaults": { "bg_color": "#252B38FA", "corner_radius": 10, "padding": 6 },
    "dock_width": 116,
    "card_size": [96, 94],
    "icon_size": 30,
    "drag_threshold": 8,
    "selected_border_width": 3
  },
  "overlays": {
    "style_defaults": { "line_width": 2, "dash_length": 8, "dash_gap": 5 },
    "terrain_guide": { "color": "#22C6FF" },
    "valid": { "color": "#4DE66680" },
    "same_type": { "color": "#FFFFFF26" },
    "invalid": { "color": "#FF4D4D80" }
  },
  "widgets": {
    "swatches": ["#FFFFFF", "#111111", "#22C6FF", "#FFB300", "#33DD77", "#FF4D4D"],
    "size_presets": [
      { "id": "small", "label": "Small", "value": 20 },
      { "id": "medium", "label": "Medium", "value": 28 },
      { "id": "huge", "label": "HUGE", "value": 40 }
    ],
    "emoji": ["😀", "😂", "😍", "😮", "😢", "😡", "👍", "❤️"]
  },
  "speech_bubbles": {
    "style_defaults": {
      "font_preset": "ui",
      "size_preset": "medium",
      "color": "#FFFFFFFF",
      "text_color": "#111111FF",
      "border_color": "#00000099",
      "border_width": 2,
      "corner_radius": 8,
      "padding": 14
    },
    "styles": {
      "standard": { "label": "Standard", "geometry": "rounded" },
      "yell": { "label": "Yell", "geometry": "burst" },
      "thought": { "label": "Thought", "geometry": "cloud" },
      "whisper": { "label": "Whisper", "geometry": "dashed" }
    }
  }
}

Array values such as card_size must contain exactly two finite numbers. Unsupported or malformed values fall back independently; one bad field does not discard the rest of the file.

Resolution order

For a component such as popups.action_menu, the service deep-merges, from lowest to highest priority:

  1. Hard-coded SandboxTheme.DEFAULT_CONFIG value.
  2. Loaded global value.
  3. Loaded subsystem popups.style_defaults value.
  4. Loaded component popups.action_menu value.

Font resolution is: component font_preset/explicit path → subsystem default → global.font_preset → fonts.ui_font → ThemeDB.fallback_font. An unavailable named preset falls back to ui. Emoji font resolution is fonts.emoji_font → resolved UI font → ThemeDB.fallback_font.

Validation, warnings, and caching

  • Missing, unreadable, malformed, or non-dictionary JSON produces one non-blocking push_warning and a complete default config.
  • A wrong type, non-finite number, bad color, or missing/non-Font resource warns once per key and falls back only that key.
  • Unknown keys are retained in raw_config() for forward compatibility but are ignored by typed accessors.
  • Fonts are loaded once per normalized non-empty resource path in _font_cache.
  • Resolved StyleBoxFlat templates are cached by the canonical resolved style dictionary. stylebox() returns a duplicate so mode-specific color mutation cannot leak into another control.
  • The generated Godot Theme is cached. Call invalidate_caches() only after an explicit reload; Phase 7 does not add live file watching.
  • No theme resource failure may prevent Sandbox startup.

4. SandboxTheme public contract

class_name SandboxTheme
extends RefCounted

const SCHEMA_VERSION: String = "2.0"
const DEFAULT_PATH: String = "res://sandbox_theme.json"

func load_from(path: String = DEFAULT_PATH) -> bool
func raw_config() -> Dictionary
func value(path: String, fallback: Variant = null) -> Variant
func color(path: String, fallback: Color) -> Color
func number(path: String, fallback: float, minimum: float = -INF, maximum: float = INF) -> float
func integer(path: String, fallback: int, minimum: int = -2147483648, maximum: int = 2147483647) -> int
func font(component_path: String = "", preset_id: String = "ui") -> Font
func emoji_font() -> Font
func style(component_path: String) -> Dictionary
func stylebox(component_path: String) -> StyleBoxFlat
func control_theme() -> Theme
func swatches() -> Array[Color]
func size_presets() -> Array[Dictionary]
func font_presets() -> Array[Dictionary]
func emoji_items() -> Array[String]
func invalidate_caches() -> void

raw_config() returns a deep duplicate. The preset array entries returned by size_presets() are {id: String, label: String, value: int}; font preset entries are {id: String, label: String, font: Font}. Invalid entries are omitted, while a built-in medium size and UI font are always available.

No reload signal is needed in Phase 7 because the theme loads once before UI construction. A later live-theme editor may add theme_reloaded() without changing the accessor API.

5. Palette descriptors and controls

StageSpawner API

Add:

func get_palette_entries() -> Array[Dictionary]

Each returned deep-duplicated entry has this shape:

{
    "placement_id": String, # existing id accepted by set_placement_mode()
    "kind": String,         # "terrain" | "prop" | "stickman" | "area"
    "label": String,
    "icon": String,         # emoji fallback, not a resource path
    "category": String,     # visible badge/group label
    "opens_selector": bool
}

Registry defaults are Ground/🧱/Terrain, Ramp/📐/Terrain, Step/🪜/Terrain, Prop/📦/Props, Stickman/🧍/Cast, and Area/🎯/Events. get_spawnable_ids() and get_label() remain unchanged.

AssetCard

class_name AssetCard
extends Button

signal activated(payload: Dictionary)
signal drag_started(payload: Dictionary)

func configure(payload: Dictionary, icon_texture: Texture2D = null, theme: SandboxTheme = null) -> void
func set_thumbnail(texture: Texture2D) -> void
func set_selected(selected: bool) -> void
func payload() -> Dictionary

The payload is deep-copied on input and signal emission. The card owns icon/thumbnail, label, and optional category badge children. It emits activated on a release that stayed below palette.drag_threshold; after the threshold it emits drag_started once and suppresses activation. It does not mutate stage or spawner state directly.

Reusable widget APIs

# ColorSwatchSelector
signal color_selected(color: Color)
func configure(colors: Array[Color], selected: Color, theme: SandboxTheme = null) -> void
func set_selected(color: Color, emit_signal: bool = false) -> void
func get_selected() -> Color

# PresetSizeToggle
signal preset_selected(id: String, value: int)
func configure(presets: Array[Dictionary], selected_id: String, theme: SandboxTheme = null) -> void
func set_selected(id: String, emit_signal: bool = false) -> void
func get_selected_id() -> String
func get_selected_value() -> int

# FontPresetPicker
signal preset_selected(id: String)
func configure(presets: Array[Dictionary], selected_id: String, theme: SandboxTheme = null) -> void
func set_selected(id: String, emit_signal: bool = false) -> void
func get_selected_id() -> String

# EmojiQuickGrid
signal emoji_selected(text: String)
func configure(items: Array[String], columns: int = 8, theme: SandboxTheme = null) -> void

All configure methods replace prior children, tolerate empty inputs, use a ButtonGroup for exclusive preset selection, expose keyboard focus, and meet a minimum 44×44 logical-pixel pointer target.

6. Node ownership and layout

SandboxStage continues to create one CanvasLayer named UI. Its runtime ownership becomes:

SandboxStage (Node2D; state and orchestration)
├── GridLayer / World / GhostHolder / PlacementOverlay / DirectorVisuals (world space)
└── UI (CanvasLayer)
    ├── ModeFrame (Panel; full rect; mouse ignore)
    ├── TopBar (PanelContainer)
    │   └── FileMenu + ModeSwitcher + edit/direct utility controls
    ├── PaletteDock (PanelContainer; left, below TopBar and above StatusBar)
    │   └── ScrollContainer/PaletteCards (VBoxContainer of AssetCard)
    ├── ModeBadge (PanelContainer; mouse ignore)
    ├── ActionTooltip (Label; mouse ignore)
    ├── BottomStatusBar (PanelContainer)
    ├── SelectorDim (ColorRect; visible only with selector)
    └── Popups/dialogs/selectors/editors

PaletteDock is visible only in EDIT. Existing grid/snap/size controls remain in TopBar; spawn buttons leave the top row. _palette_buttons is retained and maps placement IDs to AssetCard (a Button subclass), preserving callers that inspect button_pressed/visible.

The mode segments share a ButtonGroup, have adjacent rounded styles, and retain _on_mode_segment_toggled. _mode_frame is visible in all three modes and uses the active accent; it remains behind all chrome and ignores mouse input. The badge remains below the top bar and displays the existing text.

The stage assigns theme.control_theme() to the UI root and explicitly to Window-derived popups/dialogs that do not reliably inherit across a native/embedded window boundary. Component styleboxes are then applied only where a named component override is needed.

7. Input and signal flow

Click placement

  1. AssetCard.activated(payload) reaches SandboxStage._on_palette_card_activated(payload).
  2. Terrain/Area calls existing set_placement_mode(placement_id).
  3. Prop/Stickman opens the existing selector; selecting an entry updates the spawner and arms the existing prop/stickman placement ID.
  4. LMB remains repeated placement and RMB/Escape cancels, matching existing behavior.

Direct card drag

Godot Control drag-and-drop is not used because the drop surface is a Node2D world and a full-screen Control would compete with existing _unhandled_input selection/gizmo routing.

  1. AssetCard.drag_started(payload) calls _begin_palette_drag(payload).
  2. The stage stores _palette_drag_payload, snapshots the prior placement ID, resolves current Prop/Stickman selection, and creates the normal collision-disabled placement ghost.
  3. While a palette drag is active, SandboxStage._input handles mouse motion and LMB release before pan/zoom routing. Tooltip/modal/rule input is not entered.
  4. A release is valid only inside the stage viewport and outside visible interactive chrome (gui_get_hovered_control() == null or a mouse-ignore overlay). Valid release places exactly one object at _viewport_to_world(event.position), using existing snap/spawn/finalize/signals/dirty-state code.
  5. Release over UI, RMB, Escape, mode change, selector open, or focus loss cancels and frees the drag ghost.
  6. A card drag is one-shot and restores the previous persistent placement selection. It never starts terrain line-painting; terrain drag-painting still starts from a stage press after click-arming the tool.

Add private state and helpers in sandbox_stage.gd:

var _palette_drag_payload: Dictionary = {}
var _palette_drag_previous_id: String = ""

func _on_palette_card_activated(payload: Dictionary) -> void
func _begin_palette_drag(payload: Dictionary) -> void
func _update_palette_drag(viewport_pos: Vector2) -> void
func _finish_palette_drag(viewport_pos: Vector2) -> void
func _cancel_palette_drag() -> void
func _is_stage_drop_position(viewport_pos: Vector2) -> bool

Priority while active is palette drag → modal suppression → pan/zoom → existing world tools. Existing rule/capture/terrain/gizmo priorities remain unchanged when no palette drag is active.

8. Thumbnail pipeline

The existing renderer nodes and queue remain stage-owned. Do not introduce threads.

Extend ThumbnailCache with:

func get_texture(cache_path: String) -> Texture2D
func store_texture(cache_path: String, texture: Texture2D) -> Error
func forget_memory(cache_path: String) -> void
func clear_memory() -> void

get_texture() checks an in-memory dictionary, then PNG, and memoizes success. store_texture() memoizes and persists. Existing load_png()/save_png() remain as compatibility wrappers.

The stage increments _thumbnail_generation: int whenever selector entries change or the selector closes. Each queued job carries {generation, kind, entry, cache_path}. After each await, results update the selector only when the generation still matches, the selector is open, and the entry is still present. Cache writes may complete for a stale UI generation because they remain useful. Queue keys prevent duplicate work in the same generation. One job at a time preserves frame stability and the existing renderer assumptions.

9. Overlay, popup, and dialog integration

  • StagePlacementOverlay.apply_style(style: Dictionary) receives resolved guide/valid/invalid metrics. Existing public setters and screen-constant line math remain intact.
  • The three terrain ghost modulates come from overlays.valid, overlays.same_type, and overlays.invalid; classification values and commit rules do not change.
  • StageDirectorVisuals.apply_style(style: Dictionary, fonts: Dictionary) receives already-resolved sizes/colors/fonts. Keep set_style(Dictionary) as a legacy wrapper through Phase 7 tests.
  • All PopupMenus use popups.action_menu; Accept/Confirmation/File dialogs use popups.dialog; AssetSelector uses popups.asset_selector.
  • _apply_popup_theme() remains temporarily as a wrapper around SandboxTheme so existing direct tests do not break.
  • Tooltip positioning continues clamping to the visible viewport. Palette dock bounds are also treated as UI, so a tooltip never becomes a valid world drop region.

10. Future speech customization contract (blueprint only)

When implemented in a later phase, a flat queue action will optionally carry one nested visual object:

{
    "type": "speak",
    "text": String,
    "duration": float,
    "stop_current": bool, # optional, existing
    "visual": {           # optional; absent means theme defaults
        "font_preset": String,
        "size_preset": String,
        "color": String,       # HTML RGBA
        "bubble_style": String # standard/yell/thought/whisper
    }
}

Rule actions will store the same visual dictionary under their existing params. ActionRegistry.to_rule_action()/from_rule_action() will copy it unchanged, StageSceneIO will validate known scalar fields while preserving absence, and SpeechBubble.show_text() will eventually accept a style dictionary. Unknown preset/style IDs must resolve to theme defaults rather than invalidating the action.

Phase 7 only reserves the theme IDs and supplies the reusable controls. It deliberately does not modify save files or runtime speech behavior.

11. Compatibility and migration

  • Theme schema 1.0, missing version, and 2.0 are accepted. Unsupported future major versions warn and use recognized keys plus defaults.
  • Legacy aliases are applied before validation:
    • mode_colors.edit_accent/direct_accent/play_accent/guide_line → mode_colors.edit/direct/play/guide.
    • Existing flat fonts.*_font_size keys → matching fonts.sizes.* keys.
    • fonts.action_popup.{size,bold,italic} → fonts.sizes.action_popup and fonts.flags.action_popup_*.
    • Existing bold flags and assignment_badge_radius retain their current meaning.
  • A v2 key wins when both v2 and its legacy alias exist.
  • No automatic write-back of user theme files occurs.
  • Existing user://sandbox_settings.json values win over theme defaults. If a settings key is absent, the loaded theme value—not the old hard-coded constant—is used.
  • .scene serialization remains byte-contract compatible because Phase 7 changes no scene payload fields.
  • Existing public stage signals (mode_changed, object_placed, selection/deletion signals), AssetSelector signals, placement IDs, and action dictionaries remain unchanged.
  • Existing private test-visible fields remain present. _palette_buttons values remain Button-compatible.
  • Missing emoji/custom fonts degrade to ThemeDB.fallback_font; labels stay usable even if a glyph renders as a fallback box.

12. Delivery phases and acceptance criteria

7.1 Theme service and schema

  • SandboxTheme loads the committed v2 file and produces complete resolved values.
  • Missing/malformed files, invalid individual keys, and missing font paths never abort startup.
  • v1 aliases reproduce current sizes/colors.
  • Font/style/theme caches reuse resources; caller mutation of one returned stylebox does not affect another.
  • Existing grid setting precedence is preserved with the corrected theme fallback for missing settings keys.

7.2 Chrome and mode polish

  • Mode segments are one exclusive group with clear active accent and keyboard focus.
  • Frame, badge, and status bar update immediately in EDIT/DIRECT/PLAY; the frame uses each mode color in every mode.
  • Existing cursor, tooltip, target trajectory, status coordinate, and Escape behavior pass unchanged.
  • At 1280×720, top/bottom chrome and the palette do not cover each other; at smaller windows, the palette scrolls instead of overflowing.

7.3 Reusable widgets

  • All four controls configure from the theme contract, rebuild safely, emit exactly once per user choice, and expose their selected value.
  • Empty/malformed preset arrays fall back safely.
  • Every interactive item has keyboard focus and a minimum 44×44 target.

7.4 Palette, selector, and thumbnail cache

  • Six registry-driven graphical cards appear only in EDIT and preserve _palette_buttons state semantics.
  • Click-arming, modal Prop/Stickman selection, repeated placement, RMB/Escape cancellation, and mode transitions behave as before.
  • Dragging a palette card over the stage places exactly one item; release over UI/cancel places none and leaves no ghost.
  • The selector retains pagination, dimming, Browse/Refresh, generated previews, and click selection.
  • Cached textures are reused from memory/disk, duplicate jobs are suppressed, and stale async completion never updates a closed or repopulated selector.

7.5 Popup/overlay consistency and regression

  • Popup menus, dialogs, panels, selector cards, tooltip, terrain guides, ghosts, and action trajectories use the resolved theme.
  • Terrain painting still uses template AABB stride, Bresenham cells, three classifications, Shift axis lock, and skip-on-conflict commits.
  • Queue/rule/action editor behavior and font APIs remain compatible.
  • No changes occur to speech rendering or saved action schemas.

13. Validation strategy

Headless tests

Run the new suites plus the regression suites most directly touched:

& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase7_theme.gd
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase7_widgets.gd
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase7_palette.gd
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase4b_stage.gd
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase4b_logic.gd
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase4b_terrain.gd
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase4b1_fixes.gd
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase3b_library.gd
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase3b_ui_fixes.gd
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase3c_bugfix.gd

Run --check-only on every added/changed .gd script before those suites. Tests should exercise public methods/signals where possible; source-text assertions are only a last resort for engine behavior that cannot be driven headlessly.

User verification in Godot (spec-pipeline hand-off)

After implementation and green headless tests, stop and ask the user to verify:

  1. Resize the Sandbox Stage from 1280×720 down to a small window and confirm chrome/palette remain usable.
  2. Switch all modes and confirm frame, badge, segment, status, cursor, and visibility changes.
  3. Click-arm and drag-drop each card type; cancel by UI release, RMB, Escape, and a mode change.
  4. Open Prop and Stickman selectors twice to confirm thumbnail reuse and no visible stale updates.
  5. Exercise Direct-mode Walk To and rule targets to verify tooltip/trajectory/reticle layering.
  6. Terrain-paint empty, same-type, and conflicting cells with and without Shift.
  7. Open Speak/Wait/Rename, queue/rule editors, and context menus to judge font size, contrast, and emoji fallback.

Do not update README/project documentation until the user explicitly confirms the feature works.

14. Design decisions and trade-offs requiring approval

  1. Keep the Sandbox UI code-built. This minimizes churn in a 4,000-line controller and preserves existing test-visible fields. Replacing it with a complete UI scene is deferred.
  2. Use a dedicated theme service, not raw dictionary reads or a JSON-to-Theme singleton. The stage owns one instance, making tests deterministic and avoiding global state; a cached Godot Theme still supplies normal Control inheritance.
  3. Use custom card pointer capture for world drops. A full-screen Control drop target risks intercepting the existing Node2D world input stack. Drag placement is intentionally one-shot; click placement remains persistent.
  4. Show the mode frame in all modes. The plan says it reflects the active mode; the current implementation shows it only in DIRECT. Phase 7 changes that behavior.
  5. Reserve but do not implement speech customization. Theme presets and reusable controls land now, while action persistence/runtime rendering stay unchanged until a dedicated speech phase.
  6. Serialized, cached thumbnail work is preferred over threading. It is safer with Godot viewports and already matches the working architecture; memory caching and generation tokens address the actual stutter/stale-update risks.