Phase 14 - Organized directories.

This commit is contained in:
2026-10-03 19:13:39 -04:00
parent 6666173c4f
commit d950fd9d9a
307 changed files with 1452 additions and 1110 deletions
+536
View File
@@ -0,0 +1,536 @@
# 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.