537 lines
30 KiB
Markdown
537 lines
30 KiB
Markdown
# 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.
|
||
|