- Introduced `EmojiQuickGrid`, `FontPresetPicker`, and `PresetSizeToggle` classes for emoji selection, font presets, and size toggles respectively. - Implemented configuration methods for each component to handle dynamic content and theming. - Created tests for the new components to ensure functionality and integration with the existing UI. - Added a comprehensive architecture specification for Phase 7 UI Polish detailing scope, implementation, and validation strategies. - Updated existing theme handling to support new UI elements and ensure consistent styling across the application.
30 KiB
Phase 7 UI Polish — Architecture Specification
Status: proposed; awaiting user approval
Source plan: 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/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/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
- Replace the stage-local JSON parsing and scattered style constants with a validated, cached
SandboxThemeservice and schema version 2. - Give the existing mode switcher, viewport frame, badge, status bar, tooltips, popups, dialogs, selector, and overlays a coherent theme-driven appearance.
- 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.
- Harden the existing thumbnail cache with a memory tier and stale-request protection. Rendering remains serialized on the main thread.
- 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
speakactions. 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
.tscnconversion of the code-built Sandbox UI.
2. Files
Add
scripts/sandbox_theme.gd—class_name SandboxTheme, schema validation, legacy aliases, typed accessors, font/style/theme caches.scripts/ui/asset_card.gd—class_name AssetCard, reusable palette/selector card and pointer-drag threshold handling.scripts/ui/color_swatch_selector.gd—class_name ColorSwatchSelector.scripts/ui/preset_size_toggle.gd—class_name PresetSizeToggle.scripts/ui/font_preset_picker.gd—class_name FontPresetPicker.scripts/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 schema2.0.scripts/sandbox_stage.gd— ownSandboxTheme; 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_spawner.gd— expose registry-backed palette descriptors without exposing_registry.scripts/asset_selector.gd— build cells withAssetCard; accept aSandboxTheme; keep current signals and pagination.scenes/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/thumbnails/thumbnail_cache.gd— add an in-memory texture tier and versioned cache API while preserving current path helpers.scripts/stage_placement_overlay.gd— accept resolved overlay colors/metrics through one style method; drawing/input behavior is unchanged.scripts/stage_director_visuals.gd— consume a resolved style dictionary rather than reading raw schema internals; preserveset_style(Dictionary)as a compatibility wrapper.scripts/action_editor.gd,scripts/rule_editor.gd,scripts/queue_panel.gd,scripts/rule_panel.gd— addapply_theme(theme: SandboxTheme)wrappers and retain currentapply_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/sandbox_stage.tscnscripts/action_registry.gdscripts/stage_scene_io.gdscripts/stickman_rig.gdscripts/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:
- Hard-coded
SandboxTheme.DEFAULT_CONFIGvalue. - Loaded
globalvalue. - Loaded subsystem
popups.style_defaultsvalue. - Loaded component
popups.action_menuvalue.
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_warningand 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
StyleBoxFlattemplates 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
Themeis cached. Callinvalidate_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
AssetCard.activated(payload)reachesSandboxStage._on_palette_card_activated(payload).- Terrain/Area calls existing
set_placement_mode(placement_id). - Prop/Stickman opens the existing selector; selecting an entry updates the spawner and arms the existing
prop/stickmanplacement ID. - 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.
AssetCard.drag_started(payload)calls_begin_palette_drag(payload).- The stage stores
_palette_drag_payload, snapshots the prior placement ID, resolves current Prop/Stickman selection, and creates the normal collision-disabled placement ghost. - While a palette drag is active,
SandboxStage._inputhandles mouse motion and LMB release before pan/zoom routing. Tooltip/modal/rule input is not entered. - A release is valid only inside the stage viewport and outside visible interactive chrome (
gui_get_hovered_control() == nullor 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. - Release over UI, RMB, Escape, mode change, selector open, or focus loss cancels and frees the drag ghost.
- 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, andoverlays.invalid; classification values and commit rules do not change. StageDirectorVisuals.apply_style(style: Dictionary, fonts: Dictionary)receives already-resolved sizes/colors/fonts. Keepset_style(Dictionary)as a legacy wrapper through Phase 7 tests.- All PopupMenus use
popups.action_menu; Accept/Confirmation/File dialogs usepopups.dialog;AssetSelectorusespopups.asset_selector. _apply_popup_theme()remains temporarily as a wrapper aroundSandboxThemeso 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, and2.0are 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_sizekeys → matchingfonts.sizes.*keys. fonts.action_popup.{size,bold,italic}→fonts.sizes.action_popupandfonts.flags.action_popup_*.- Existing bold flags and
assignment_badge_radiusretain 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.jsonvalues win over theme defaults. If a settings key is absent, the loaded theme value—not the old hard-coded constant—is used. .sceneserialization remains byte-contract compatible because Phase 7 changes no scene payload fields.- Existing public stage signals (
mode_changed,object_placed, selection/deletion signals),AssetSelectorsignals, placement IDs, and action dictionaries remain unchanged. - Existing private test-visible fields remain present.
_palette_buttonsvalues remainButton-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
SandboxThemeloads 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_buttonsstate 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:
- Resize the Sandbox Stage from 1280×720 down to a small window and confirm chrome/palette remain usable.
- Switch all modes and confirm frame, badge, segment, status, cursor, and visibility changes.
- Click-arm and drag-drop each card type; cancel by UI release, RMB, Escape, and a mode change.
- Open Prop and Stickman selectors twice to confirm thumbnail reuse and no visible stale updates.
- Exercise Direct-mode Walk To and rule targets to verify tooltip/trajectory/reticle layering.
- Terrain-paint empty, same-type, and conflicting cells with and without Shift.
- 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
- 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.
- 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
Themestill supplies normal Control inheritance. - 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.
- 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.
- 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.
- 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.