Files
stickman/specs/phase_7_ui_polish.md
ryan 1a2a43c805 Add reusable UI components for Phase 7 UI Polish
- 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.
2026-09-18 09:42:48 -04:00

537 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
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/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 schema `2.0`.
- `scripts/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_spawner.gd` — expose registry-backed palette descriptors without exposing `_registry`.
- `scripts/asset_selector.gd` — build cells with `AssetCard`; accept a `SandboxTheme`; 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; preserve `set_style(Dictionary)` as a compatibility wrapper.
- `scripts/action_editor.gd`, `scripts/rule_editor.gd`, `scripts/queue_panel.gd`, `scripts/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/sandbox_stage.tscn`
- `scripts/action_registry.gd`
- `scripts/stage_scene_io.gd`
- `scripts/stickman_rig.gd`
- `scripts/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.