feat: add Phase 20a stage identities and overlap selection

This commit is contained in:
2026-10-26 15:10:28 -04:00
parent 0cc882ef4b
commit 35eb0e2503
74 changed files with 3854 additions and 217 deletions
+6
View File
@@ -27,6 +27,12 @@ Use this guide when creating or revising child-facing dialogs, popups, and short
- Preserve clear hover, pressed, selected, focused, and disabled states. Do not use color as the only indication of a state.
- Adapt the arrangement to each workflow while keeping this shared visual language. The Choose Scenery dialog in `scripts/shared/ui/asset_selector.gd` and its `popups.asset_selector` theme tokens are the current implementation reference.
## Anchored object chooser
An anchored chooser resolves a temporary canvas choice, such as picking between objects under one click. It is non-modal and remains visually attached to the stage context; it does not use the dim backdrop or centered card used by a confirmation dialog. Follow the rounded shell, row states, selected badge, footer Cancel, safe-viewport fit, and outside-click behavior in [Editor Visual Style Guide, section 4.6](../guides/editor_visual_style_guide.md#46-anchored-object-chooser). The current Stage implementation is **Objects here** and its tokens live in `SandboxTheme.popups.anchored_chooser`.
Keep **Cancel** visible even when Escape is supported. Outside dismissal consumes that click so the stage beneath the popup does not receive it. Keep previews transient until the child activates a candidate.
## Stable position during dynamic resizing
- When a dialog changes size after a tab, filter, or other in-place choice, preserve its current anchor so the clicked control stays under the pointer.
@@ -0,0 +1,216 @@
# Phase 20a — Stage identities, names, and overlap selection
**Status:** User-verified; identity, interaction, and UIUX visual gates passed. No merge or commit has been made.
**UI authority:** [Phase 20a UI/UX](phase_20a_stage_identities_and_names_ui_ux.md). The [Phase 20 design brief](../plans/action_reactions_design_brief.md) records the product decisions. This slice supplies stable identities and readable names for placed characters and props, and makes overlapping object selection match the approved mockup. Action plans, reaction links, and runtime event routing belong to Phases 20b–20e.
## Current system and boundaries
- `StageSceneAdapter.collect()` assigns `file_id` from each object's type and current World order on every save. Existing rule encoding maps `Object.get_instance_id()` through those temporary IDs. Keep that conversion intact. A `file_id` is not persistent object identity.
- `PropBlock.stage_object_id` already persists and targets prop actions. `StickmanRig` lacks the equivalent. Neither placed type has an instance display name. The selected-prop card currently shows the asset library name; there is no EDIT character card.
- `StageSelection.hit_test_all()` currently tests World AABBs and returns candidates in front-to-back order. `_begin_click_select()` and the scenery click path select the frontmost object before opening **Objects here**. `StageModalViews` builds an anchored `PopupPanel` whose labels use asset/node names. The same popup also resolves multiple props during DIRECT Use-target capture.
- Selection remains in `StageSelection`; Stage inspectors and `StageGizmos` render it. The new chooser preview must remain separate from that selected state.
## Identity and name contract
Persist these two **object entry** fields for every `type == "stickman"` or `type == "prop"` in `.scene`:
```json
{
"file_id": "stickman_1",
"type": "stickman",
"stage_object_id": "stickman_172...",
"stage_name": "Milo 2",
"stk_path": "res://content/stickmen/milo.stk"
}
```
`stage_object_id` is an opaque, nonempty, scene-unique string. It is assigned once per placed instance and does not change on rename, move, save, load, or asset repair. A duplicated placed instance receives a new ID. Keep existing prop IDs exactly, including values already referenced by queue/rule prop actions. A `stage_name` is a child-facing scene label, independent of `Node.name`, the `.stk` name, the `.prop` library name, and the asset path. Its trimmed, nonempty value is unique across **both** characters and props using a case-insensitive comparison. A capitalization-only rename of the same object is valid.
`file_id` remains the per-save key for the current `StageSceneIO.encode_rules()/decode_rules()` runtime-ID remap. Do not replace it in Phase 20a. Existing prop action payloads keep using `target_prop_id == stage_object_id`. Later phases may use the common persistent IDs for actor, responder, and movement target references without using display names as keys.
### Typed live APIs
| Class / file | Contract and responsibility |
| --- | --- |
| `StickmanRig` — `scripts/characters/stickman/stickman_rig.gd` | Add `var stage_object_id: String = ""` and `var stage_name: String = ""`. These are placed-instance state; the stickman factory does not write them to reusable `.stk` content. |
| `PropBlock` — `scripts/props/prop_block.gd` | Retain `stage_object_id`; add `var stage_name: String = ""`. Prop library templates remain unchanged. |
| `MissingStickmanPlaceholder` / `MissingPropPlaceholder` | Read both fields from `original_entry`, retain them while the asset is absent, and write their current values back over the cloned entry during collection. Asset repair must carry both values to the restored node. |
| `StageObjectIdentity` — proposed `scripts/stage/stage_object_identity.gd`, `class_name StageObjectIdentity extends RefCounted` | Scene-scoped allocation and lookup, not an autoload. Typed public functions: `func get_id(node: Node2D) -> String`, `func get_name(node: Node2D) -> String`, `func assign_new(node: Node2D, base_name: String, world: Node2D) -> void`, `func next_free_name(base_name: String, world: Node2D, exclude: Node2D = null) -> String`, `func validate_name(requested: String, world: Node2D, exclude: Node2D = null) -> Dictionary`, and `func rename(node: Node2D, requested: String, world: Node2D) -> Dictionary`. The result shape is `{ "ok": bool, "name": String, "suggestion": String, "reason": StringName }`, with `reason` `&""`, `&"blank"`, or `&"duplicate"`. Only named characters and props participate in the name search. |
| `SandboxStage` — `scripts/editors/stage/sandbox_stage.gd` | Own one identity helper for the active World. Call it after a real placement, before placement feedback; never assign IDs to ghosts. Handle rename requests, mark the document dirty on success, refresh the selected inspector and visible chooser labels, and preserve selection. Use the same helper when an existing placement is duplicated. |
Additional typed touchpoints:
| Existing or new class | Phase 20a interface |
| --- | --- |
| `class_name StageSceneIO extends RefCounted` | `const FILE_VERSION: String = "1.8"`; keep `static func read_file(path: String) -> Dictionary` and `_validate_root(data: Dictionary) -> Dictionary` as the non-destructive migration/validation boundary. |
| `class_name StageSceneAdapter extends RefCounted` | Keep `func collect(...) -> Dictionary` and `func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary`; include both object fields in each direction. |
| `class_name StageSelection extends RefCounted` | Keep `func hit_test_all(world_pos: Vector2) -> Array[Node2D]`; add a private typed visible-hit helper, such as `func _visible_hit(node: Node2D, world_pos: Vector2, screen_tolerance_px: float) -> bool`. |
| `class_name StageGizmos extends Node2D` | Add `func set_candidate_preview(node: Node2D) -> void`; a null node clears the preview. The candidate field is transient and distinct from selected targets and ordinary hover. |
| `class_name StageModalViews extends RefCounted` | Keep `signal object_selected(node: Node2D)`; add `signal candidate_preview_changed(node: Node2D)` and `signal objects_here_dismissed()`. `show_objects_here(...) -> void` retains the candidate list and prior-selection list. |
| `class_name StagePropSettingsView extends Control` | Add `signal rename_requested(node: Node2D, requested_name: String)`; show the stage name once in the shared header beside Rename; keep its existing prop role/spot signals and shared shell. The prop body has no duplicate static name row. |
| New `class_name StageCharacterSettingsView extends Control` in `scripts/editors/stage/stage_character_settings_view.gd` | `signal rename_requested(node: Node2D, requested_name: String)`; `func show_character(rig: StickmanRig, safe_rect: Rect2) -> void`; `func hide_card(reset_selection: bool = false) -> void`. Own the compact name-and-Rename header and inline naming presentation only. |
`StageObjectIdentity` should inspect the current World when allocating or validating. Scene sizes are small, and a scan avoids a second mutable registry that can drift after deletion, load, or replacement. The existing prop ID generator may be moved behind `assign_new`; it must check both named types and missing placeholders for collisions. Name suffixing chooses the smallest available positive suffix at or above 2 without renaming existing instances. A terminal ` <number>` suffix from a duplicated instance is interpreted as the same base, so duplicating **Milo 2** among **Milo** and **Milo 2** offers **Milo 3**, rather than **Milo 2 2**. Deleting **Milo 2** can make that suffix available to a future placement. Compare with `to_lower()` after trimming outer spaces; preserve the child's chosen spelling and internal spaces.
Use the Stickman Library entry's `name` as a new character's base name; that entry is derived from the `.stk` `stickman_name`, falling back to the `.stk` basename. Use the Prop Library's `name`/`label`, falling back to the asset basename or **Prop**, as a new prop's base name. If the chosen base is empty after trimming, use **Character** or **Prop**. Repeated placement and duplication allocate IDs and names from the current scene, without a naming dialog. An asset rename never silently changes already placed `stage_name` values.
### Save, migration, and validation
Advance `StageSceneIO.FILE_VERSION` from `1.7` to `1.8`. Extend `StageSceneAdapter.collect()/apply()` to write and restore both fields for real and missing objects. Assign restored values before `_finalize_placed_node()` can generate a fresh prop ID. Save and autosave use the same adapter, so both retain identity. Existing `file_id` generation and rule mapping continue unchanged.
For scene versions `1.0`–`1.7`, preserve all current migrations, then fill the two new fields in object order. Keep every existing nonempty prop `stage_object_id`. For each legacy character, derive a deterministic ID from its pre-migration `file_id` (for example `stickman:<file_id>`) and add a deterministic suffix only if it collides with another persistent ID. Do this once at the `1.8` migration boundary; after saving, the stored ID is authoritative. Derive initial names from each asset's familiar name where available, then allocate scene-wide suffixes in document order. The migration's base-name resolver can read the existing Stickman/Prop Library metadata but must not instantiate or modify World nodes. An unavailable asset uses its saved path basename as the base and retains the resulting name and ID in its placeholder. With unchanged asset metadata, opening the same old file before its first save must yield the same identities and names each time.
The current migration contains version-specific Sky/Plain Background and wall-tint conditions. When adding `1.7` to accepted legacy versions, ensure it does **not** receive pre-1.7 background defaults or pre-1.6 wall-color rewrites. Add the new identity/name pass after the existing older-version transformations.
For a version `1.8` document, `StageSceneIO._validate_root()` requires nonempty string IDs and trimmed, nonempty string names for character and prop entries; rejects duplicate IDs across both types and case-insensitive duplicate names; and does not silently repair ambiguous current-format references. Validate before `StageSceneAdapter.apply()` clears the current World, preserving the existing non-destructive read boundary. The legacy migration allocates missing fields before this validation. A successful manual rename trims outer spaces, permits capitalization-only edits, and returns `blank` or `duplicate` with a suggested free name without altering the saved or live name. A successful rename marks the scene dirty once; Cancel and invalid input do not.
```mermaid
flowchart LR
Asset[Reusable asset name] -->|placement base| Allocate[StageObjectIdentity]
Allocate --> Live[Placed stage_object_id + stage_name]
Live --> Adapter[StageSceneAdapter collect/apply]
Adapter --> File[.scene v1.8 object entry]
Live --> Card[EDIT inspector and Objects here labels]
Live --> Later[20b–20e stable links]
FileID[file_id per save] --> Rules[Existing rule ID remap]
```
## EDIT inspector flow
Adopt the shared `StageInspectorPanel` shell, `SandboxTheme.stage_panels` type/spacing/button tokens, safe rectangle, and fixed header. Show the selected object's name once in the header beside a visible **Rename** button. The prop inspector uses a disclosure chevron: **▴** collapses the settings body into the same named header and **▾** expands it. The character inspector stays as a compact name-and-Rename bar because naming is its only EDIT setting. Both compact headers remain one line; long names use an ellipsis and keep the full text in a tooltip. Clicking empty stage space clears selection and removes the bar. Activating Rename opens a prefilled inline `LineEdit` with explicit Done/Cancel controls and nearby validation feedback. Do not make the title itself an undisclosed rename hotspot. `StagePanelChoiceGroup` and `StagePanelToggleRow` remain available for choice and switch content; keep the modal **Objects here** layout under `StageModalViews` and `SandboxTheme.control_theme()`, since its point-click popup behavior differs from the persistent inspector.
The view owns only a draft name and inline error presentation. It emits intent, such as `signal rename_requested(node: Node2D, requested_name: String)`. `SandboxStage` validates and mutates via `StageObjectIdentity`, then refreshes the view and labels. On blank/duplicate results, keep the field and focus in place and show the UI spec's explanation and suggestion. Enter requests Done; Escape cancels the draft; selection change cancels it. Multiple selection shows the approved helper instead of an editable field. The header and inspector anchor stay stable while the body grows. Do not use `Node.name` as a shortcut for stage names.
The placement ghost displays `next_free_name()` as **Click to place …**, and the actual placed node displays brief **… placed** feedback. Only actual placement commits an ID or name. Deletion releases a name naturally when the node leaves World; no tombstone/name counter is needed. Existing objects are never renumbered.
## Visible-geometry hit model
`StageSelection.hit_test_all(world_pos: Vector2) -> Array[Node2D]` remains the single source for both immediate click selection and **Objects here**. Use `get_world_aabb()` as a broad phase, expanded only by a small tolerance converted from screen pixels with camera zoom. For `PropBlock` and `StickmanRig`, run a second pass against the actual visible polygons and stroked lines in their transformed visual subtree. Use `Geometry2D.is_point_in_polygon()` for filled `Polygon2D` shapes; for `Line2D`, compare distance to each transformed segment against half its rendered width plus an approximately 6–8 logical-pixel screen tolerance. Respect node transforms, rotation, scale, `is_visible_in_tree()`, and effective opacity. If a custom prop is visibly composed from multiple shapes, inspect the shapes rather than treating its collision hull or union AABB as the visible hit. A transparent gap or a door outside the click's visible area is not a candidate. Missing-asset markers use their drawn marker rectangle. Keep existing AABB behavior for terrain/scenery/areas in this slice, so their selection remains covered by debt item #12.
Preserve front-to-back sorting after the exact filter. The small line tolerance keeps thin limbs selectable, but it should not pull in a visually separate neighboring object. Hover and click should use the same hit model where practical so the cue predicts the candidate list. Do not replace the precise visible hit with a physics point query: an animated `StickmanRig` has no comparable physics body, and a prop's collision hull can differ from its rendered shape.
## Overlap chooser and transform gesture precedence
```text
EDIT left press → StageSelection.hit_test_all(point)
0 hits: existing empty-click / box-select behavior
1 hit: select immediately (existing drag handling remains)
2+ hits: defer the choice until movement or release
selected candidate + pointer moves ≥ 8 screen px → drag current selection, no chooser
selected gizmo handle → rotate/resize immediately, no chooser
pointer released before threshold → show Objects here, selection and inspector unchanged
row hover/focus → transient candidate preview
row click → validate candidate → select_only(candidate) → close
Cancel/Escape/outside → clear preview → close, no selection write
```
Use screen-space distance so the drag threshold feels the same at each camera zoom. The selected object under the press is the drag anchor even if it is behind another visible candidate; dragging it moves the current selection as usual. Keep rotate and prop resize handle hit tests ahead of overlap disambiguation. If no already-selected candidate is under the press, a multi-hit gesture remains a chooser click. After a chooser commit in EDIT, show the brief teaching toast **Drag to move · Click to choose**; do not show it for DIRECT Use or scenery selection.
Add `StageModalViews` signals `candidate_preview_changed(node: Node2D)` (`null` clears), `object_selected(node: Node2D)` (existing commit signal), and `objects_here_dismissed()`. Row hover and keyboard focus emit the same preview; pointer exit, Cancel, popup hide, and invalidated candidates clear it. The popup receives stage-name labels and a **Selected** badge for candidates in the previous selection. Rows stay at least 44 px high, preferably around 56 px, with bounded scrolling and a visible Cancel action.
`SandboxStage` owns `_objects_here_candidates: Array[Node2D]` for the one open chooser and routes preview to a new transient `StageGizmos.set_candidate_preview(node: Node2D)` API. This preview is separate from `_targets` and `_hovered`; it draws the EDIT-cyan visible outline/silhouette above overlap art and a nearby cyan-outlined rounded pill containing only the candidate's **name**. The pill appears while a chooser row is hovered or focused; do not add a “Preview:” prefix. The normal selected outline remains visible. Clear candidate preview when the chooser closes, selection changes, mode changes, or the candidate is deleted. Row commit checks the node is still valid, selectable, and in the stored candidate set before calling `select_only()` once. The chooser should snapshot the prior selection solely to display **Selected** badges; it need not restore a snapshot because opening and previewing never mutate `StageSelection`.
Give the popup a full-viewport transparent input guard below its anchored card, or an equivalent centralized consumed-event path. The guard closes on an outside press and consumes that press, so it cannot also run Stage click selection. Escape and Cancel call the same close path. `popup_hide` is a final cleanup path and should not commit selection. The EDIT click path and scenery overlap path must remove their eager `select_only(hit)` calls before opening the chooser. Shift and multi-select behavior can continue its existing direct-toggle path when no chooser is requested.
The DIRECT Use-target picker already filters `hit_test_all()` down to props and calls `_select_use_target_prop()` after row choice. Retain that prop-only filter and target-commit path. Store a chooser context (`&"edit_select"` or `&"direct_use_prop"`) in `SandboxStage` when opening the shared popup; route its existing `object_selected(node)` signal according to that context. This avoids mixing the EDIT selection commit with a DIRECT target commit or opening an EDIT inspector during capture. Both contexts use the same precise hit list and stage-name labels. Dismissing a DIRECT chooser leaves the pending target capture active and unchanged.
```mermaid
sequenceDiagram
participant Child
participant Stage as SandboxStage
participant Hits as StageSelection
participant Popup as StageModalViews
participant Gizmos as StageGizmos
Child->>Stage: Press shared visible point
Stage->>Hits: hit_test_all(point)
Hits-->>Stage: [Milo, Ava]
alt Selected candidate under pointer, drag reaches 8 screen px
Stage->>Gizmos: begin_translate_drag(selected candidate)
Child->>Stage: Move and release
Stage->>Gizmos: drag_to(point), end_drag()
Stage-->>Child: Keep selection; no chooser
else Pointer released before 8 screen px
Stage->>Popup: show_objects_here(candidates, prior selection)
Child->>Popup: Hover or focus Ava row
Popup->>Stage: candidate_preview_changed(Ava)
Stage->>Gizmos: set_candidate_preview(Ava)
Child->>Popup: Click Ava row
Popup->>Stage: object_selected(Ava)
Stage->>Hits: select_only(Ava)
Popup->>Stage: objects_here_dismissed()
Stage->>Gizmos: set_candidate_preview(null)
end
Note over Stage,Gizmos: Rotate and resize handles take priority on press
```
## Acceptance outcomes and observation path
1. Place two copies of a character and two copies of a prop with the same asset bases. The ghost announces the next free name; feedback confirms the placed name. Each object gets its own persistent ID; names are distinct across both types regardless of capitalization. Asset files and names do not change.
2. Select one character or prop in EDIT. Its inspector header shows the stage name once beside Rename. The prop disclosure collapses and expands the body while preserving the named header; the character inspector stays compact. A valid rename updates the header and future chooser labels, marks the scene dirty, and keeps its ID. Blank or conflicting names stay in the field with a suggestion. Cancel, Escape, and selection changes abandon the draft. A multiple selection offers no name editor.
3. Save, close, and reload. Observe the same names and IDs on both real objects and any missing-asset markers. A prior-version scene loads with deterministic generated character IDs and unique names; saving it writes v1.8. Existing prop targets and rule references still resolve through their respective IDs.
4. Put Milo and Ava over one click point, with a Blue Door nearby but outside that clicked visible geometry. The chooser lists Milo and Ava only. Hover or focus Ava: the exact Ava preview and name pill appear while prior selection and inspector remain. Cancel, Escape, or an outside click clears preview and leaves prior selection intact; that outside click causes no second stage selection. Choosing Ava selects her once and opens her inspector.
5. With an overlapping object selected, press its visible body and drag past 8 screen pixels: move the selected object or current selection without opening the chooser. Release before the threshold: open the chooser and preserve the previous selection. Rotate or resize from the selected gizmo handle: begin that transform immediately without opening the chooser.
6. Click where only one visible object is hit: select immediately. Click inside an object's AABB but outside all visible character/prop art: it is not selected. Thin visible limbs remain selectable with the documented tolerance. In DIRECT Use target capture, multiple touching props still offer a prop-only chooser and choosing one advances that target capture; dismissing it leaves capture pending.
A repeatable manual review is: create **Milo**, **Milo 2**, and **Blue Door**; rename the first to **Max**; overlap Max and Milo 2; position Blue Door close to, but outside, the test point; save and reopen; then run the click, preview, Cancel, and commit observations above. Record IDs from the saved `.scene` before and after reload to confirm they remain byte-for-byte equal while `file_id` continues to serve the existing rule remap. The user reports completing the sign-off workflows for names and persistence, overlap selection and movement, and the DIRECT **Use…** picker; see the User verification record below.
## Verification plan
Headless assertions, a production-path interaction run, and a render-capable visual review cover different failure classes; a successful launch or debug print alone does not satisfy a gate. The report below records the completed gates, including user verification at the end.
### 1. Automated behavior and persistence
- Add focused assertions for `StageObjectIdentity`: allocation for both types, cross-type case-insensitive conflicts, suffix selection, capitalization-only rename, blank/duplicate rejection with suggestions, duplicate placement receiving a fresh ID, and freeing a name when an object is deleted.
- Exercise `StageSceneIO` migrations from each supported version `1.0`–`1.7` to `1.8`. Assert deterministic character IDs/names, exact retention of existing prop IDs, and rejection of duplicate/empty IDs and names in current-format documents before the live World is cleared.
- Exercise production `StageSceneAdapter.collect()/apply()` round trips for real and missing-asset characters and props. Assert that rename, save/load, and asset repair preserve instance IDs and names while `file_id` continues to serve rule remapping.
- Cover the hit model with visible polygon interiors, points outside polygon art but inside the AABB, thin limb lines, transforms, visibility, camera zoom, and front-to-back ordering. A nearby object outside the click's geometry must not enter the candidates.
### 2. Production-path interaction
- Run a GUI-capable Stage harness that instantiates `SandboxStage`, creates the overlap arrangement from acceptance outcome 4, and drives the same click, pointer, focus, keyboard, and button routes a child uses. Use Godot input-event injection or desktop UI automation where supported; do not substitute direct calls to `select_only()` or popup callbacks for the interactions being verified.
- Assert selection and inspector state before opening the chooser, during row preview, after row commit, and after Cancel, Escape, and outside dismissal. Confirm candidate preview always clears on close, stale candidates cannot be committed, and the outside-dismissal press is consumed instead of selecting the stage object behind the popup.
- Drive the name editor through Done, Enter, Cancel, Escape, invalid-name feedback, and selection changes. Assert document dirty state and saved values change only after a valid commit.
- Exercise the shared DIRECT Use-target picker separately. Verify it remains prop-only, commits through the target-capture route, and leaves capture pending after dismissal.
### 3. Rendered visual and UIUX review
- Capture rendered PNGs from the same production-path flow in a render-capable GUI session. Include: prop inspector; character inspector using the colorful `break.stk` art; name editing with a duplicate-name message; overlap chooser with the previous selection visible; hover preview; keyboard-focus preview; Cancel/Escape/outside-dismissal result; and the nearby Blue Door omitted from candidates. Include a crowded chooser/scroll state, a long name in the collapsed prop header at 920×560, and a character near the top chrome to verify the rotation handle moves below the rectangle while retaining its 44 × 44 hit target.
- Capture an ordered set of states (or a short recording where available) so UIUX can judge how the controls respond across the flow, not only isolated static layouts. Ask UIUX to compare the rendered pixels with `docs/guides/editor_visual_style_guide.md`, `docs/specs/stage_panel_ui_system.md`, the approved UI/UX spec, and `docs/plans/action_reactions_mockups/06_edit_overlap_picker.png`.
- Have UIUX report concrete visual findings for hierarchy, theme-token use, target size, readable names, focus/hover/selection distinction, anchoring, and clipping. Address findings and recapture the affected states before considering visual review complete.
- Inspect the actual rendered pixels. A capture file existing or the scene launching does not prove the visual result. Follow the same visual-capture conventions used by `tests/phase19_gui_visual_capture.gd` and `tests/phase19_depth_inspector_visual_capture.gd`.
The verification report should list the commands and scenarios run, assertion results, capture locations, UIUX findings and resolutions, and any unverified outcome with its specific environment limitation. Keep debug prints or temporary overlays as diagnostic aids; they are not substitutes for assertions or rendered review. If GUI input or rendering cannot be established after trying an available route, mark the affected outcomes unverified and leave a precise reproducible user check. The popup interaction can follow the existing test precedent in `tests/test_phase4b_popup_anchor.gd`.
## Architectural decisions and trade-offs
- **Separate persistent ID, display name, and save-local `file_id`:** preserves current prop targeting and old rule serialization while preparing exact references for later action/reaction work. It adds two string fields per named object.
- **Scene-scoped helper over an autoload or name registry:** avoids stale indices after deletion and keeps preview scenes isolated. A World scan is sufficient for the expected child-authored scene size; index it later only if profiling shows a need.
- **Visible-geometry narrow phase for characters/props:** prevents false chooser rows and aligns one-hit selection with the mockup. Drawing and hit extraction must agree on transform and visibility; the line tolerance protects touchability. Terrain/scenery precision remains a separate issue in debt #12.
- **One chooser with explicit context:** reuses the established popup for EDIT and DIRECT while keeping each commit side effect explicit. The transient preview has no authority to mutate scene or selection state.
## Verification report
### Automated behavior and persistence
- `Godot_v4.7.1-stable_win64_console.exe --headless --path . --script res://tests/test_phase20a_identity.gd` — **200 checks passed**. Covers identity/name allocation and validation, deterministic migrations from 1.0–1.7, current-format rejection, real and missing-object collection/apply, and a file save/read round trip.
- `Godot_v4.7.1-stable_win64_console.exe --headless --path . --script res://tests/test_phase20a_hit_testing.gd` — **12 checks passed**. Covers polygon interior and empty AABB space, front-to-back order, transformed props, line touch tolerance at two zooms, and hidden/transparent shapes.
- `Godot_v4.7.1-stable_win64_console.exe --headless --path . --script res://tests/test_content_paths.gd` — **17 checks passed**. The old-path fixture now uses the pre-identity 1.7 schema plus its required background fields, then verifies migration, loading, and canonical asset paths.
- `Godot_v4.7.1-stable_win64_console.exe --headless --path . --script res://tests/test_phase4b_stage.gd` — **53 assertions passed**. Restored the documented bare-Escape fallback from DIRECT to EDIT after the existing regression exposed its absence.
- `git diff --check` — passed.
### Production-path interaction and rendered review
- `tests/phase20a_gui_visual_capture.gd` ran in a render-capable Godot 4.7.1 session. **104 production-input assertions passed** and 25 captures were saved at 1280×720 and 920×560. Scenarios include keyboard-driven naming and dirty state, duplicate-name feedback, prop and character overlaps with Blue Door excluded outside the clicked geometry, chooser preview/commit/Cancel/Escape/outside dismissal, DIRECT Use target progression, crowded-list scrolling at a smaller viewport, long-name one-line inspector headers, a top-edge rotation handle, and an actual rotation drag through the Stage input path.
- Captures are in `tests/phase20a_captures/`; key states include `prop_inspector.png`, `character_inspector.png`, `prop_inspector_collapsed.png`, `prop_inspector_collapsed_long_name_small_viewport.png`, `break_character_long_name_top_handle_small_viewport.png`, `character_duplicate_name_feedback.png`, `character_overlap_chooser.png`, `character_overlap_preview.png`, `character_overlap_committed.png`, `direct_use_chooser.png`, `direct_use_target_selected.png`, `placement_name_prompt.png`, `placement_feedback.png`, and both `crowded_chooser_*_small_viewport.png` states.
- UIUX compared the current captures against `docs/guides/editor_visual_style_guide.md`, the amended shared Stage panel spec, this UI/UX spec, and the approved overlap mockup. **Visual review accepted.** The long prop title truncates to one line while keeping the full name in its tooltip; the Break art reads clearly inside the stronger outline; the compact rotate handle moves below the selection near the top chrome and remains operable through its 44 px hit target. The preview-name pill may move toward the upper-right at 920×560 when space is tight; the cyan candidate outline remains distinct.
- The follow-up name-pill refinement uses `tests/phase20a_popup_style_preview.gd` to render the actual Stage chooser, overlapping characters, and selected **Blue Door** inspector without invoking any scene-recovery action. Its **14 assertions passed**. It refreshed `character_overlap_preview.png`, `overlap_hover_preview.png`, `overlap_candidate_preview.png`, and `overlap_chooser_popup_style.png` at 1280×720. The candidate label now contains the name alone inside a rounded cyan-outlined pill. UIUX reviewed `character_overlap_preview.png` and approved the pill's alignment, separation from the Selected badge, and spacing from the open inspector.
- The same render-capable Stage harness also drives overlap presses, pointer motion, and releases through production input. **32 assertions passed**: a press waits; movement below 8 px still resolves as a click; release opens the chooser without changing selection; dragging the selected object moves it without opening the chooser; and rotate/resize handles start their transforms without opening it. This checks the gesture decision alongside the actual popup styling and layout.
- A later full `tests/phase20a_gui_visual_capture.gd` attempt did not reach its first placement step because a pre-existing scene-recovery dialog was open. No recovery button was activated; the recovery copy was left intact. The isolated visual harness above verified the updated chooser and name pill.
- An additional run of `tests/test_phase4_final_bugs.gd` ended with **16 failures of 80 checks** across action-editor/payload assertions, synthetic headless prop-drag input, overlay speech assertions, left-idle mirroring checks, and a source-text contract. This broader script is outside the Phase 20a acceptance set and those failures were not changed here. The new below-box rotation behavior itself passes the render-capable production-input check above.
The Windows Godot runs print a root certificate-store warning; the render-capable run also reported shader-cache warnings. Neither prevented execution or capture. Missing-asset warnings in the identity suite are expected because that test deliberately verifies editable placeholders.
### User verification
- The user reports manually testing the remaining sign-off workflows and that they look good: stage names and persistence, overlapping selection and movement, and the DIRECT **Use…** prop-target chooser. The user had also reviewed the EDIT-mode appearance.
@@ -0,0 +1,80 @@
# Phase 20a — Stage identities and names UI/UX
**Status:** Updated from user feedback; current inspector and selection-cue captures received UIUX approval. The implementation and test report are in `phase_20a_stage_identities_and_names.md`.
## Current behavior
- EDIT has a prop inspector, but selected characters have no inspector or stage-name field.
- The current **Objects here** popup is opened for multiple AABB candidates, after the frontmost object has already been selected. Its rows use a prop library name or a character's node name.
- `StageSelection.hit_test_all()` currently treats the world-space AABB as the hit test, so empty corners of a rotated or irregular object's bounds can appear as candidates.
## Name controls
- Each placed character and prop receives a distinct scene display name automatically. The asset's familiar name is the base: for example, **Milo**, **Milo 2**, and **Blue Door**, **Blue Door 2**. Placing an object does not open a naming dialog or stop repeat placement. Existing instances keep their names when another instance is added or removed.
- Character and prop names share one scene-wide, case-insensitive uniqueness rule.
- Show the selected character or prop name once, in the inspector header beside its icon. Add a visible **Rename** button there; do not make the title text itself a hidden rename hotspot.
- A selected prop's header also has a 48 px disclosure button. **▴** collapses the settings body into the same named one-line bar; **▾** expands it. Keep the name and **Rename** visible when collapsed. Do not replace the selected prop name with a generic **Settings** button.
- A selected character stays as a compact name-and-**Rename** bar because naming is its only EDIT inspector setting. It has no disclosure control. Compact prop and character headers stay on one line; long names truncate with an ellipsis while the full name remains available as a tooltip. Clicking empty stage space clears the selection and removes the bar.
- Activating **Rename** reveals a prefilled single-line editor with **Done** and **Cancel**. Enter commits; Escape cancels. Show duplicate/blank feedback under the editor. Keep the header anchored as the card grows. Changing selection cancels an uncommitted draft.
- Trim outer spaces on commit. Compare the requested name case-insensitively against all other named character and prop instances. Blank or duplicate names remain in the field with a nearby kid-friendly explanation and a suggested free name. A capitalization-only change to the same object's current name is allowed. A successful rename updates its inspector header and chooser label without changing its instance identity.
- Do not offer name editing while multiple objects are selected.
## Overlapping-object chooser
- On a normal EDIT click, retain the AABB check as a broad phase, then check the clicked point against the candidate's visible geometry. Only actual geometry hits are selectable or listed. Do not show a nearby object merely because it is within another object's bounds.
- Apply visible-geometry filtering to both immediate single-object selection and chooser candidates so there is one consistent hit model.
- When a normal EDIT press hits two or more objects, wait briefly to tell a click from a drag. If the pointer is released without moving past the 8 px screen-space drag threshold, open the anchored, scrolling **Objects here** popup without changing the current selection or inspector. If the current selection includes an object under the pointer and the child drags past the threshold, drag that selected object (or the whole current selection) and do not open the chooser. The selected object wins as the drag target even when another candidate is drawn in front.
- Test gizmo handles before overlap selection. Pressing a selected object's rotate or resize handle starts that transform immediately, even when other objects are visible under the handle; do not open **Objects here** for a handle gesture.
- Each row has a character/prop icon and its unique stage name. Mark any candidate that is already in the prior selection with a distinct **Selected** badge.
- Hovering or keyboard-focusing a row previews that exact object with a bright EDIT-cyan outline or silhouette above overlapping art and a nearby cyan-outlined rounded pill containing only the candidate name, such as **Milo**. The name pill appears only while that row is hovered or focused; do not prepend “Preview:”. This transient cue does not update selection or inspector. Leaving the row clears it.
- Clicking a row commits selection once and opens that object's inspector. **Cancel**, Escape, and outside dismissal clear the preview and preserve the previous selection, including a multi-object selection. The dismissal click must not also select the stage object beneath it.
- After a child chooses an overlapping object in EDIT, briefly show **Drag to move · Click to choose**. This teaches that a drag manipulates the selected object while a click opens the chooser again.
- Anchor the popup near the click while keeping the clicked art visible. Limit its height and scroll when many objects truly share the hit point. Use large rows, at least 44 px high and preferably around 56 px, with readable names.
- Give the chooser a rounded dark shell with a visible 2 px border, 12–16 px corners, a local shadow, and 12 px content inset. Space the 56 px candidate rows by 8 px. Keep **Cancel** in its own rounded neutral footer button at least 48 px high. This is a non-modal picker: leave the stage undimmed, and consume outside clicks after dismissing it.
- The shared popup also serves DIRECT prop target capture. Preserve the existing prop-only candidate filter and target-commit semantics there while using the same stage-name labels and exact hit results.
- Make the regular selected-object outline a more pronounced light rectangle with a dark backing. Replace the large blue rotate ring with a compact curved-arrow rotate handle attached above the rectangle; retain an approximately 44 × 44 logical-pixel hit target and keep prop corner resize handles. The transient chooser preview remains EDIT cyan and visually distinct.
- Keep the rotation handle reachable near the top chrome. If the 44 × 44 hit target would overlap the toolbar or mode pill, place the handle below the selected object's rectangle with its connector attached to the lower edge.
## Canvas feedback and child-facing copy
- During repeated placement, show the next automatic name beside the placement ghost, such as **Click to place Milo 2**. After placement, briefly show **Milo 2 placed** beside the object. Keep names off the canvas at rest to avoid visual clutter.
- Retain the normal selected-object cue while a chooser preview is active; the candidate preview must be visually distinct and must not transfer selection.
- Use the approved overlap mockup at `../plans/action_reactions_mockups/06_edit_overlap_picker.png` as the visual reference.
| State | Copy |
|---|---|
| Name in inspector | The stage name appears once in the header |
| Start editing | **Rename** |
| Save or discard edit | **Done** / **Cancel** |
| Blank name | **Give this one a name.** |
| Duplicate name | **Milo is already here. Try Milo 3.** |
| Multiple selection | **Select one character or prop to change its name.** |
| Placement prompt | **Click to place Milo 2** |
| Placement feedback | **Milo 2 placed** |
| Chooser title | **Objects here** · **Choose one** |
| Previously selected row | **Selected** |
| Candidate name pill | **Milo** (name only; no “Preview:” prefix) |
| After choosing an overlapping object | **Drag to move · Click to choose** |
| Dismiss chooser | **Cancel** |
## Theme and accessibility
- Reuse `StageInspectorPanel` and `SandboxTheme.stage_panels` for selected-object inspector shell, header Rename action, disclosure behavior, type, spacing, buttons, focus, and safe placement. Use `SandboxTheme.control_theme()` for the chooser. Extend `stage_panels` tokens only when the shared system lacks a needed value.
- Use `SandboxTheme.popups.anchored_chooser` for the chooser shell, rows, selected badge, state borders, spacing, and Cancel button. Reuse the general name-and-Rename header and inline editing contract in the [Editor Visual Style Guide](../guides/editor_visual_style_guide.md#45-renameable-object-inspector-header) when adding renameable object inspectors. Object-specific validation remains owned by the identity system.
- Use the EDIT cyan from `mode_colors.edit` for candidate preview and active EDIT feedback. Do not use color alone: preserve a clear outline/silhouette, focus state, and textual preview name.
- Use a rounded dark fill with a cyan outline for the candidate name pill; keep its background, corner radius, border width, and padding in `SandboxTheme.overlays.gizmos.candidate_name_pill_*` tokens.
- Keep controls large and keyboard-operable. Hover and keyboard focus produce the same candidate preview. Use explicit **Rename**, **Done**, and **Cancel** labels; the prop disclosure control exposes **Expand settings** / **Collapse settings** tooltips.
## Intentional distinctions
- The selected prop inspector uses a named collapse bar instead of **Close → Settings**. The selected character is already a one-line name bar. Clicking empty stage space removes either inspector by clearing selection. The separate Director/action inspector's outside-click behavior remains scoped to that workflow.
- The chooser is a popup for resolving a point click, not a persistent side panel. It does not select the frontmost object before the child chooses.
- Visible geometry improves on the current AABB-only candidate behavior. The AABB remains a fast broad phase; the visible hit check and a small screen-space tolerance for thin lines define the actual candidates.
## Visual verification
UIUX reviewed the earlier Final6 captures against the style guide, shared Stage panel spec, and approved overlap mockup. The chooser placement, short and crowded lists, naming flow, placement feedback, preview, dismissal, commit, and DIRECT Use capture were accepted. That review predates the current inspector/selection refinement.
The current revision strengthens the rectangular selection outline, replaces the large rotation ring with a compact curved-arrow handle, puts Rename beside the single name in each inspector header, and changes the prop inspector's Close → Settings state to a named disclosure bar. The character inspector stays compact because naming is its only setting. UIUX reviewed the regenerated captures and approved the one-line long-name header, the Break artwork outline, and the below-box rotate handle near the top chrome. At 920×560, the overlap preview-name pill may move toward the upper-right edge when the click point and inspector leave little room.
The follow-up candidate label now shows only the character name in a rounded cyan-outlined pill. UIUX reviewed the refreshed [character overlap preview](../../tests/phase20a_captures/character_overlap_preview.png) with the **Blue Door** inspector open and approved its alignment and separation from the chooser's **Selected** badge and the inspector.
+22 -15
View File
@@ -1,10 +1,10 @@
# Stage selection panel UI system — architecture specification
> **Status:** Approved for implementation. **Scope:** In-stage selection and inspector cards, beginning with the placed-prop settings card. Modal dialogs, the Prop Editor, and the Stickman Editor are later candidates, outside this change.
> **Status:** Approved baseline, amended by Phase 20a user feedback. **Scope:** In-stage selection and inspector cards, beginning with the placed-prop settings card. The Phase 20a amendment below replaces the original selected-prop **Close → Settings** interaction for the prop and character naming inspectors. Other callers can retain Close when that fits their workflow. Modal dialogs, the Prop Editor, and the Stickman Editor are outside this change.
## Purpose and current reference
The placed-prop card is the visual reference for future Stage selection panels. Its **selected prop name is the header** beside an icon and Close button; there is no separate “Prop settings” title. Its depth and motion options show selection through a colored surface and border, with **no checkmark in the label**. Large full-row switches show ON/OFF and keep the switch in the same position when help appears. The header stays fixed while the content scrolls.
The placed-prop card is the visual reference for future Stage selection panels. Its **selected prop name is the header** beside an icon; there is no separate “Prop settings” title. Its depth and motion options show selection through a colored surface and border, with **no checkmark in the label**. Large full-row switches show ON/OFF and keep the switch in the same position when help appears. The header stays fixed while the content scrolls. Phase 20a adds a visible Rename action and replaces the prop card's Close/dismiss state with a disclosure chevron; the name remains visible in both expanded and collapsed states.
Today `SandboxStage` builds and styles that card in `sandbox_stage.gd`. `PropSettingsToggleRow` and its drawn switch implement one reusable control, but carry prop-specific names. `SandboxTheme` has `stage_prop_settings` tokens. `QueuePanel`, `RulePanel`, and action dialogs also create local style overrides, but they are popup workflows and do not join this first migration.
@@ -24,13 +24,15 @@ Targeting and placement steps in EDIT and DIRECT use a temporary capture bar, se
## Child-facing contract
- A selection card has a recognizable icon, the selected object's name, and a large Close button. Long names wrap to at most two lines and expose their full name in a tooltip; Close never overlaps the title.
- A selection card has a recognizable icon and the selected object's name. Long names wrap to at most two lines and expose their full name in a tooltip; header actions never overlap the title.
- When renaming is available, show a visible **Rename** button beside the name. Do not make the title text itself a hidden rename hotspot. Rename opens a prefilled inline field with visible **Done** and **Cancel** actions; validation feedback stays beside the field.
- A caller may configure the final header button as a large Close action or, for a collapsible inspector, as a disclosure button. The disclosure uses **▾** to expand and **▴** to collapse, with **Expand settings** / **Collapse settings** tooltips. Collapsing keeps the icon, name, and Rename button visible on one line; long names use an ellipsis and keep the full value in the title tooltip.
- Content uses the same heading, choice, switch, helper, spacing, corners, focus, hover, selected, and disabled styles. Each setting can still supply its own words, optional illustration, and reason for being unavailable.
- Choice labels stay stable when selected. Selection is shown by fill and border; text does not gain a checkmark or change width.
- A switch is clickable anywhere in its row. Showing or hiding help may change the row height, but the switch and ON/OFF label stay anchored at the top, so a second click at the same pointer position hits the same control.
- A card fits its visible content on a tall stage. On a short stage, only the body scrolls; the title and Close remain reachable. Focus and selected states remain distinct from hover.
- After Close, a compact **Settings** button appears at the card's usual top-right position for the selected object. It reopens the card without requiring the child to deselect and reselect the object.
- Keyboard users can Tab to choices, switches, and Close; Space/Enter activates the focused control. Disabled rows explain why. Text and colored states both convey ON/OFF; color alone is not the only cue.
- A card fits its visible content on a tall stage. On a short stage, only the body scrolls; the title and header actions remain reachable. Focus and selected states remain distinct from hover.
- A Close-enabled, non-collapsible caller may use its existing dismissal/reopen behavior. Phase 20a's selected prop inspector collapses to its named header instead of showing a generic **Settings** button. The character inspector stays as a compact name-and-Rename bar because renaming is its only content. Clicking empty stage space clears selection and removes either bar.
- Keyboard users can Tab to choices, switches, Rename, disclosure, and any Close action; Space/Enter activates the focused control. Disabled rows explain why. Text and colored states both convey ON/OFF; color alone is not the only cue.
## Component hierarchy
@@ -42,7 +44,8 @@ CanvasLayer (owned by SandboxStage)
├─ Header : HBoxContainer
│ ├─ Icon : Label
│ ├─ Title : Label (selected item name)
│ └─ Close : Button
│ ├─ Rename : Button (optional)
│ └─ Disclosure or Close : Button (caller-configured)
└─ ScrollContainer (vertical only)
└─ Body : VBoxContainer (caller adds sections and controls)
├─ Section heading : Label
@@ -58,7 +61,7 @@ The shell owns layout and presentation. The caller owns the body composition and
| Target | Public contract | Responsibility |
| --- | --- | --- |
| `scripts/shared/stage_ui/stage_inspector_panel.gd`, `class_name StageInspectorPanel extends PanelContainer` | `signal close_requested`; `func apply_theme(stage_theme: SandboxTheme) -> void`; `func set_header(title: String, icon_text: String = "") -> void`; `func body() -> VBoxContainer`; `func set_safe_rect(rect: Rect2) -> void`; `func request_fit() -> void`; `func reset_scroll() -> void` | Fixed header, scroll body, viewport fit, local theme. Emits Close; does not mutate selection or hide itself. `request_fit()` coalesces repeated calls into one deferred fit. |
| `scripts/shared/stage_ui/stage_inspector_panel.gd`, `class_name StageInspectorPanel extends PanelContainer` | `signal close_requested`; `signal rename_requested`; `signal collapsed_changed(collapsed: bool)`; `func set_rename_enabled(enabled: bool) -> void`; `func set_collapsible(enabled: bool, collapsed: bool = false) -> void`; `func set_close_visible(enabled: bool) -> void`; `func set_body_visible(enabled: bool) -> void`; `func set_collapsed(collapsed: bool, notify: bool = true) -> void`; `func set_title_single_line(enabled: bool) -> void`; plus `apply_theme`, `set_header`, `body`, `set_safe_rect`, `request_fit`, `reset_scroll` | Fixed header, optional Rename and Close/disclosure actions, scroll body, viewport fit, local theme. Compact headers use one-line ellipsis with a full-name tooltip. Emits intent; does not mutate selection or hide itself. `request_fit()` coalesces repeated calls into one deferred fit. |
| `scripts/shared/stage_ui/stage_panel_choice_group.gd`, `class_name StagePanelChoiceGroup extends VBoxContainer` | `signal choice_selected(id: StringName)`; `func add_choice(id: StringName, caption: String, cue: Control = null) -> Button`; `func select_no_signal(id: StringName) -> void`; `func clear_no_signal() -> void`; `func apply_theme(stage_theme: SandboxTheme) -> void` | Exclusive choices with stable captions and native focus/activation. `clear_no_signal()` supports migrated prop draw order. Repressing the selected option leaves it selected. An optional cue is decorative, not clickable. |
| `scripts/shared/stage_ui/stage_panel_toggle_row.gd`, `class_name StagePanelToggleRow extends Button` | `signal preferred_height_changed`; inherited `signal toggled(toggled_on: bool)`; `func configure(caption: String) -> void`; `func set_help_text(text: String) -> void`; `func set_disabled_state(value: bool, reason: String = "") -> void`; `func set_checked_no_signal(value: bool) -> void`; `func apply_theme(stage_theme: SandboxTheme) -> void` | One focusable full-row toggle, large drawn switch, ON/OFF label, help layout. Stores `_normal_help` and `_disabled_reason` separately; displayed help derives from `disabled`. Re-enabling restores normal help and never leaves a stale disabled reason. Child labels and switch ignore pointer events. |
| `scripts/shared/stage_ui/stage_panel_switch_visual.gd`, `class_name StagePanelSwitchVisual extends Control` | `func set_state(enabled: bool, unavailable: bool) -> void`; `func apply_theme(stage_theme: SandboxTheme) -> void` | Vector switch drawing only. No input or business signals. Reuses the current 64 × 34 track and 26 px knob. |
@@ -77,11 +80,15 @@ flowchart LR
Stage --> Save[Save placement, reconcile, mark dirty]
Stage --> Refresh[Refresh controls without signals]
Refresh --> Fit[Panel request_fit, deferred once]
Close[Close pressed] --> CloseSignal[StageInspectorPanel.close_requested]
Rename[Rename pressed] --> RenameSignal[StageInspectorPanel.rename_requested]
RenameSignal --> Caller[Caller opens inline name editor]
Disclosure[Disclosure pressed] --> CollapseSignal[StageInspectorPanel.collapsed_changed]
CollapseSignal --> Fit[Refit named header or expanded body]
Close[Optional Close pressed] --> CloseSignal[StageInspectorPanel.close_requested]
CloseSignal --> Stage
```
Signal payloads are `StringName` for a choice ID and `bool` for a toggle. The group ID is UI-local and maps explicitly to the existing prop role string; no UI object or dictionary is serialized. Refresh calls `select_no_signal()` / `set_checked_no_signal()`, avoiding another mutation signal. The panel emits `close_requested`; `SandboxStage` records the currently selected object as dismissed and keeps its card hidden through unrelated refreshes. `SandboxStage` shows a compact Settings button at the panel anchor while dismissed. A different selection or pressing Settings clears that dismissal. Closing does not undo a saved setting.
Choice payloads are `StringName` IDs and toggle payloads are `bool`. The group ID is UI-local and maps explicitly to the existing prop role string; no UI object or dictionary is serialized. Refresh calls `select_no_signal()` / `set_checked_no_signal()`, avoiding another mutation signal. Rename and disclosure signals carry presentation intent only. A caller that exposes Close may retain its own dismissal/reopen policy; a collapsible inspector keeps its selected name in the header and uses `collapsed_changed` to synchronize any caller state. Collapsing does not change selection or undo a saved setting.
### Example prop adapter
@@ -125,8 +132,8 @@ The shell's `set_safe_rect()` receives the unobstructed CanvasLayer area from `S
2. Switch and ON/OFF remain at a fixed top alignment while help grows below the caption. Control height comes from wrapped content and a minimum, never from vertically centering the switch in a changing row.
3. Selected, hovered, focused, and disabled styles each have a visible state. Focus remains visible over selected fill. Disabled rows retain readable text and show their reason in the row. If no reason exists, the caller must provide one before disabling a child-facing setting.
4. Choice state is apparent through fill and border; its label stays unchanged. The toggle also has ON/OFF text and knob position, so it does not depend on color alone.
5. The title can wrap to two lines; Close reserves its width. Full title is available as tooltip. The shell recomputes height after wrapping and keeps Close outside the scroll area.
6. The card consumes pointer events inside its rectangle so a settings click cannot select a stage object behind it. Escape follows the Stage's existing panel dismissal convention. Focus moves into the card when opened from keyboard and returns to the originating Stage control on Close when one exists.
5. The title can wrap to two lines; visible Rename and disclosure/Close actions reserve their widths. Full title is available as tooltip. The shell recomputes height after wrapping and keeps all header controls outside the scroll area.
6. The card consumes pointer events inside its rectangle so a settings click cannot select a stage object behind it. Escape collapses an expanded collapsible card; a Close-enabled non-collapsible caller may use its existing dismissal convention. Focus moves into the card when opened from keyboard.
## Migration sequence
@@ -137,10 +144,10 @@ The shell's `set_safe_rect()` receives the unobstructed CanvasLayer area from `S
## Acceptance criteria
1. The prop card retains the current selected-name header, fixed Close, no text checkmarks, front/behind cues, role semantics, and saved scene format. Legacy draw order still shows no selected depth until a child chooses one.
1. The prop card retains the selected-name header, no text checkmarks, front/behind cues, role semantics, and saved scene format. Under the Phase 20a amendment, it shows Rename and a disclosure chevron; the name remains visible when collapsed. Legacy draw order still shows no selected depth until a child chooses one.
2. At the same viewport and theme, the migrated prop card's width, spacing, typography, colors, switch size, and selected states match the current card. Long prop names and wrapped labels cannot collide with Close or the switch.
3. Switching a row On and Off at the same pointer position works twice without moving the switch. Help appears beneath the caption. A disabled reason disappears on re-enable and normal help returns.
4. On short viewports, every body control is reachable by scroll while header and Close stay visible. Scroll position remains stable during a setting change and resets for a different selected prop. After Close, the card stays hidden while that same object remains selected, including after role or viewport refreshes; a compact Settings button remains available at the card anchor to reopen it. Changing selection shows the new selection's card.
4. On short viewports, every body control is reachable by scroll while the header actions stay visible. Scroll position remains stable during a setting change and resets for a different selected prop. Collapsing keeps the selected prop name and Rename action visible; expanding restores the body. Changing selection shows the new selection's card.
5. Pointer, touch, and keyboard activation each change a setting once. Disabled controls cannot mutate a role, and focus remains visible on both selected and neutral choices.
6. The next Stage inspector can create a shell, a choice group, and a toggle row using only `SandboxTheme` and shared APIs, with no copied panel geometry or stylebox code.
@@ -153,6 +160,6 @@ The shell's `set_safe_rect()` receives the unobstructed CanvasLayer area from `S
## Decision and next adopter
1. **Panel dismissal — decided:** Closing a selection card keeps it dismissed while the same object remains selected. Changing selection or pressing the compact Settings button shows it again. The implementation tracks this in `SandboxStage`, separate from the shared shell.
1. **Panel visibility — amended by Phase 20a:** For the placed-prop inspector, the child collapses/expands the body with a disclosure while the named header stays visible. Character naming uses a compact header only. Callers with a different workflow may retain Close and own any dismissal/reopen behavior in `SandboxStage`.
2. **Next adopter — recommended:** Apply the shared kit to the next in-stage selected-object inspector as it is built. Existing queue/rule popups have different focus and sizing behavior and can be evaluated in a later pass.