# 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. ```json { "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 ```gdscript 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: ```gdscript func get_palette_entries() -> Array[Dictionary] ``` Each returned deep-duplicated entry has this shape: ```gdscript { "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` ```gdscript 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 ```gdscript # 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: ```text 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`: ```gdscript 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: ```gdscript 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: ```gdscript { "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: ```powershell & "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.