diff --git a/CLAUDE.md b/CLAUDE.md index 8f7a753..91c208e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -494,7 +494,13 @@ quest log, shop, pause/settings, dialogue, combat HUD, main exploration window) plus a `README.md` handoff. **Not production code** — each screen is recreated as a **Godot 4.7 Control-node scene** pulling from a shared **`Theme`** resource (the README's palette, three fonts, and reusable styleboxes keep the look one -system). Each `.dc.html`'s `Component` class is the **behavior spec** — the +system). **Author these scenes editor-first:** the layout lives in the `.tscn` +as real Control nodes with the `Theme` and type-variations set in the editor, so +the scene *previews* and can be arranged in the Godot editor — do **not** +build the node tree in `_ready()`. Scripts hold only on-load work: `@onready` +node refs, data/DM wiring, animations, and binding state into fixed authored +nodes (see ADR [0001](docs/adr/0001-editor-first-ui-scenes.md)). Each +`.dc.html`'s `Component` class is the **behavior spec** — the combat turn manager, dialogue graph, shop economy, and point-buy creation are the highest-value logic to port; the seed-data maps (items, quests, locations, units, abilities) are starting `Resource`s. Dashed "art slots" are placeholders for real diff --git a/client/assets/theme/game_theme.tres b/client/assets/theme/game_theme.tres index 8eef967..3b1a1ad 100644 --- a/client/assets/theme/game_theme.tres +++ b/client/assets/theme/game_theme.tres @@ -1,12 +1,13 @@ [gd_resource type="Theme" format=3] -[ext_resource type="FontFile" path="res://assets/theme/fonts/ArchitectsDaughter-Regular.ttf" id="1_cu4xe"] -[ext_resource type="FontFile" path="res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf" id="2_605w4"] -[ext_resource type="FontFile" path="res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf" id="3_4lwr2"] +[ext_resource type="FontFile" path="res://assets/theme/fonts/ArchitectsDaughter-Regular.ttf" id="1_605w4"] +[ext_resource type="FontFile" path="res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf" id="2_4lwr2"] +[ext_resource type="FontFile" path="res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf" id="3_eckwq"] +[ext_resource type="FontFile" path="res://assets/theme/fonts/EBGaramond-Italic-VariableFont_wght.ttf" id="4_i0b23"] -[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_eckwq"] +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_cqbvn"] -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_i0b23"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_q5gpk"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -22,7 +23,7 @@ corner_radius_top_right = 3 corner_radius_bottom_right = 3 corner_radius_bottom_left = 3 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_cqbvn"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bbpmc"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -38,7 +39,7 @@ corner_radius_top_right = 3 corner_radius_bottom_right = 3 corner_radius_bottom_left = 3 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_q5gpk"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_73lme"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -54,7 +55,7 @@ corner_radius_top_right = 3 corner_radius_bottom_right = 3 corner_radius_bottom_left = 3 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bbpmc"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_js3tr"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -70,7 +71,57 @@ corner_radius_top_right = 4 corner_radius_bottom_right = 4 corner_radius_bottom_left = 4 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_73lme"] +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_ube3r"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2tooq"] +content_margin_left = 12.0 +content_margin_top = 6.0 +content_margin_right = 12.0 +content_margin_bottom = 6.0 +bg_color = Color(0.05490196, 0.043137256, 0.03529412, 0.85) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.6627451, 0.5176471, 0.24705882, 1) +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_4kten"] +content_margin_left = 12.0 +content_margin_top = 6.0 +content_margin_right = 12.0 +content_margin_bottom = 6.0 +bg_color = Color(0.07058824, 0.05882353, 0.043137256, 0.7) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.41960785, 0.38039216, 0.32156864, 1) +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p6wtm"] +content_margin_left = 12.0 +content_margin_top = 6.0 +content_margin_right = 12.0 +content_margin_bottom = 6.0 +bg_color = Color(0.039215688, 0.03137255, 0.023529412, 0.9) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.6627451, 0.5176471, 0.24705882, 1) +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_0fjdt"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -86,7 +137,7 @@ corner_radius_top_right = 6 corner_radius_bottom_right = 6 corner_radius_bottom_left = 6 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_js3tr"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ns3kg"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -103,7 +154,73 @@ corner_radius_top_right = 6 corner_radius_bottom_right = 6 corner_radius_bottom_left = 6 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ube3r"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jly1r"] +content_margin_left = 12.0 +content_margin_top = 6.0 +content_margin_right = 12.0 +content_margin_bottom = 6.0 +bg_color = Color(0.8509804, 0.7882353, 0.63529414, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.6627451, 0.5803922, 0.39215687, 1) +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_lw8lk"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nh0e7"] +content_margin_left = 12.0 +content_margin_top = 6.0 +content_margin_right = 12.0 +content_margin_bottom = 6.0 +bg_color = Color(0.9019608, 0.8509804, 0.72156864, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.3529412, 0.2627451, 0.14901961, 1) +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_dtr07"] +content_margin_left = 12.0 +content_margin_top = 6.0 +content_margin_right = 12.0 +content_margin_bottom = 6.0 +bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.6627451, 0.5803922, 0.39215687, 1) +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nmowx"] +content_margin_left = 12.0 +content_margin_top = 6.0 +content_margin_right = 12.0 +content_margin_bottom = 6.0 +bg_color = Color(0.8509804, 0.7882353, 0.63529414, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.3529412, 0.2627451, 0.14901961, 1) +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_oi067"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -119,7 +236,7 @@ corner_radius_top_right = 6 corner_radius_bottom_right = 6 corner_radius_bottom_left = 6 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2tooq"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ukblj"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -135,7 +252,7 @@ corner_radius_top_right = 4 corner_radius_bottom_right = 4 corner_radius_bottom_left = 4 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_4kten"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_utt73"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -151,7 +268,7 @@ corner_radius_top_right = 4 corner_radius_bottom_right = 4 corner_radius_bottom_left = 4 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p6wtm"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_5u8fh"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -167,7 +284,7 @@ corner_radius_top_right = 4 corner_radius_bottom_right = 4 corner_radius_bottom_left = 4 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_0fjdt"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p0qbh"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -183,7 +300,7 @@ corner_radius_top_right = 4 corner_radius_bottom_right = 4 corner_radius_bottom_left = 4 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ns3kg"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_6y6ak"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -199,9 +316,9 @@ corner_radius_top_right = 4 corner_radius_bottom_right = 4 corner_radius_bottom_left = 4 -[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_jly1r"] +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_txxuc"] -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_lw8lk"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_7d6ug"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -217,7 +334,7 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nh0e7"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_najyk"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -233,7 +350,7 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_dtr07"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jtb1k"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -249,9 +366,9 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_nmowx"] +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_wmbj4"] -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_oi067"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_xdylb"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -267,7 +384,7 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ukblj"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_pjnll"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -283,7 +400,7 @@ corner_radius_top_right = 14 corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_utt73"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_klelq"] content_margin_left = 12.0 content_margin_top = 6.0 content_margin_right = 12.0 @@ -300,54 +417,72 @@ corner_radius_bottom_right = 14 corner_radius_bottom_left = 14 [resource] -default_font = ExtResource("3_4lwr2") +default_font = ExtResource("3_eckwq") default_font_size = 18 Accent/base_type = &"Label" Accent/colors/font_color = Color(0.56078434, 0.41568628, 0.16470589, 1) Accent/font_sizes/font_size = 24 -Accent/fonts/font = ExtResource("1_cu4xe") +Accent/fonts/font = ExtResource("1_605w4") Chip/base_type = &"Button" Chip/colors/font_color = Color(0.6039216, 0.56078434, 0.47058824, 1) Chip/font_sizes/font_size = 11 -Chip/fonts/font = ExtResource("2_605w4") -Chip/styles/focus = SubResource("StyleBoxEmpty_eckwq") -Chip/styles/hover = SubResource("StyleBoxFlat_i0b23") -Chip/styles/normal = SubResource("StyleBoxFlat_cqbvn") -Chip/styles/pressed = SubResource("StyleBoxFlat_q5gpk") +Chip/fonts/font = ExtResource("2_4lwr2") +Chip/styles/focus = SubResource("StyleBoxEmpty_cqbvn") +Chip/styles/hover = SubResource("StyleBoxFlat_q5gpk") +Chip/styles/normal = SubResource("StyleBoxFlat_bbpmc") +Chip/styles/pressed = SubResource("StyleBoxFlat_73lme") DarkPanel/base_type = &"PanelContainer" -DarkPanel/styles/panel = SubResource("StyleBoxFlat_bbpmc") +DarkPanel/styles/panel = SubResource("StyleBoxFlat_js3tr") +DockButton/base_type = &"Button" +DockButton/colors/font_color = Color(0.7882353, 0.7411765, 0.6392157, 1) +DockButton/styles/focus = SubResource("StyleBoxEmpty_ube3r") +DockButton/styles/hover = SubResource("StyleBoxFlat_2tooq") +DockButton/styles/normal = SubResource("StyleBoxFlat_4kten") +DockButton/styles/pressed = SubResource("StyleBoxFlat_p6wtm") Heading/base_type = &"Label" Heading/colors/font_color = Color(0.22745098, 0.18431373, 0.10980392, 1) Heading/font_sizes/font_size = 34 -Heading/fonts/font = ExtResource("3_4lwr2") +Heading/fonts/font = ExtResource("3_eckwq") ItemTile/base_type = &"PanelContainer" -ItemTile/styles/panel = SubResource("StyleBoxFlat_73lme") +ItemTile/styles/panel = SubResource("StyleBoxFlat_0fjdt") ItemTileEmpty/base_type = &"PanelContainer" -ItemTileEmpty/styles/panel = SubResource("StyleBoxFlat_js3tr") +ItemTileEmpty/styles/panel = SubResource("StyleBoxFlat_ns3kg") Mono/base_type = &"Label" Mono/colors/font_color = Color(0.6039216, 0.56078434, 0.47058824, 1) Mono/font_sizes/font_size = 13 -Mono/fonts/font = ExtResource("2_605w4") +Mono/fonts/font = ExtResource("2_4lwr2") +ParchmentButton/base_type = &"Button" +ParchmentButton/colors/font_color = Color(0.2901961, 0.24705882, 0.17254902, 1) +ParchmentButton/colors/font_disabled_color = Color(0.41568628, 0.3529412, 0.22745098, 1) +ParchmentButton/styles/disabled = SubResource("StyleBoxFlat_jly1r") +ParchmentButton/styles/focus = SubResource("StyleBoxEmpty_lw8lk") +ParchmentButton/styles/hover = SubResource("StyleBoxFlat_nh0e7") +ParchmentButton/styles/normal = SubResource("StyleBoxFlat_dtr07") +ParchmentButton/styles/pressed = SubResource("StyleBoxFlat_nmowx") ParchmentCard/base_type = &"PanelContainer" -ParchmentCard/styles/panel = SubResource("StyleBoxFlat_ube3r") +ParchmentCard/styles/panel = SubResource("StyleBoxFlat_oi067") ParchmentInset/base_type = &"PanelContainer" -ParchmentInset/styles/panel = SubResource("StyleBoxFlat_2tooq") +ParchmentInset/styles/panel = SubResource("StyleBoxFlat_ukblj") PrimaryCTA/base_type = &"Button" PrimaryCTA/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1) PrimaryCTA/colors/font_disabled_color = Color(0.6039216, 0.56078434, 0.47058824, 1) -PrimaryCTA/styles/disabled = SubResource("StyleBoxFlat_4kten") -PrimaryCTA/styles/hover = SubResource("StyleBoxFlat_p6wtm") -PrimaryCTA/styles/normal = SubResource("StyleBoxFlat_0fjdt") -PrimaryCTA/styles/pressed = SubResource("StyleBoxFlat_ns3kg") +PrimaryCTA/styles/disabled = SubResource("StyleBoxFlat_utt73") +PrimaryCTA/styles/hover = SubResource("StyleBoxFlat_5u8fh") +PrimaryCTA/styles/normal = SubResource("StyleBoxFlat_p0qbh") +PrimaryCTA/styles/pressed = SubResource("StyleBoxFlat_6y6ak") +RichTextLabel/colors/default_color = Color(0.2901961, 0.24705882, 0.17254902, 1) +RichTextLabel/font_sizes/normal_font_size = 20 +RichTextLabel/fonts/italics_font = ExtResource("4_i0b23") +RichTextLabel/fonts/normal_font = ExtResource("3_eckwq") Tab/base_type = &"Button" Tab/colors/font_color = Color(0.41568628, 0.3529412, 0.22745098, 1) -Tab/styles/focus = SubResource("StyleBoxEmpty_jly1r") -Tab/styles/hover = SubResource("StyleBoxFlat_lw8lk") -Tab/styles/normal = SubResource("StyleBoxFlat_nh0e7") -Tab/styles/pressed = SubResource("StyleBoxFlat_dtr07") +Tab/styles/focus = SubResource("StyleBoxEmpty_txxuc") +Tab/styles/hover = SubResource("StyleBoxFlat_7d6ug") +Tab/styles/normal = SubResource("StyleBoxFlat_najyk") +Tab/styles/pressed = SubResource("StyleBoxFlat_jtb1k") TabActive/base_type = &"Button" TabActive/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1) -TabActive/styles/focus = SubResource("StyleBoxEmpty_nmowx") -TabActive/styles/hover = SubResource("StyleBoxFlat_oi067") -TabActive/styles/normal = SubResource("StyleBoxFlat_ukblj") -TabActive/styles/pressed = SubResource("StyleBoxFlat_utt73") +TabActive/styles/focus = SubResource("StyleBoxEmpty_wmbj4") +TabActive/styles/hover = SubResource("StyleBoxFlat_xdylb") +TabActive/styles/normal = SubResource("StyleBoxFlat_pjnll") +TabActive/styles/pressed = SubResource("StyleBoxFlat_klelq") diff --git a/client/docs/README.md b/client/docs/README.md index 597233b..feb472f 100644 --- a/client/docs/README.md +++ b/client/docs/README.md @@ -12,6 +12,49 @@ Put here: Cross-cutting design (anything touching both client and api) goes in the root [`/docs`](../../docs), not here. +## UI scene conventions (shell + all screens) + +Screens are **authored editor-first** — the layout lives in the `.tscn`, not in +code. See ADR [0001](../../docs/adr/0001-editor-first-ui-scenes.md) for the why; +this is the how. Reference: `scenes/shell/{MainWindowShell,SystemDock,NarrationBook}.tscn` ++ `scripts/ui/shell/*.gd`. + +- **The `.tscn` owns the tree.** Author every node in the editor: containers, + panels, labels, buttons, art-slot placeholders. Assign the shared theme on the + scene root (`theme = game_theme.tres`) and set each node's `theme_type_variation` + (from `ThemeKeys`) in the editor, so the scene previews themed. Instance + sub-screens (`PackedScene` ext_resource) rather than rebuilding them. +- **The script does on-load work only.** `@onready var x := $Path/To/Node` refs, + signal/service wiring, animations, and binding state into the authored nodes + (label text, colours from `Palette`). Do **not** create nodes in `_ready()`. +- **Data-driven repeats = fixed authored slots, bound at runtime.** Author enough + turn-order tokens / consumable slots / etc. in the editor; the script fills the + first N from state and hides the rest. Previewable now; systems write them later. +- **Scene-root sizing.** Give the root a fixed size via offsets (e.g. 1920×1080) + for editor preview, but **no full-rect anchors on the root** — a root Control run + via F6 collapses to (0,0) under full-rect anchors. Keep `size = get_viewport_rect().size` + on `_ready` + a `size_changed` connection (the M3-a collapse lesson). +- **Button styling → Button-base variations only.** Never put a PanelContainer + variation (`DarkPanel`, `ParchmentCard`) on a `Button`; it has no `normal/hover/ + pressed` styleboxes so the button renders unskinned. Use the Button-base + variations (`PrimaryCTA`, `Tab`, `TabActive`, `Chip`, `DockButton`, `ParchmentButton`). +- **`RichTextLabel` prose** reads its dark serif ink from the theme's base + `RichTextLabel` style (it does *not* inherit `default_font`/`default_color`). + Just add the `RichTextLabel` — don't override its colour per-node. +- **Theme is generated.** All colours live in `palette.gd`; `game_theme.tres` is + built by `scripts/theme/build_game_theme.gd` (source of truth). To add/adjust a + variation, edit the builder + `theme_keys.gd`, then regenerate: + `godot --headless -s res://scripts/theme/build_game_theme.gd`. Never hand-edit + the `.tres` or put a hex literal in a script or `.tscn`. +- **Wiring the DM loop / injectable services.** Follow the shell: a `service` + var settable *between* `instantiate()` and `add_child()` lets tests inject a + `FakeTransport`-backed `DmService` so on-load calls stay hermetic; `_ready` + builds the real `HttpTransport`-backed one when none was injected. +- **Visual gate.** Headless GUT proves structure (nodes mount, sizes non-zero, + bindings run); it cannot prove rendering. Every screen needs a **human F6 + eyeball** before it's done. Note shader surfaces (`DarkBay`, vignette) render + their texture only on an F6 run, not in the editor. + ## Narrate live smoke (M2 client HTTP loop) The `narrate_harness` scene is a throwaway proof of the client → `/dm/narrate` diff --git a/client/scenes/shell/MainWindowShell.tscn b/client/scenes/shell/MainWindowShell.tscn new file mode 100644 index 0000000..f38f727 --- /dev/null +++ b/client/scenes/shell/MainWindowShell.tscn @@ -0,0 +1,300 @@ +[gd_scene load_steps=6 format=3] + +[ext_resource type="Script" path="res://scripts/ui/shell/main_window_shell.gd" id="1"] +[ext_resource type="Theme" path="res://assets/theme/game_theme.tres" id="2"] +[ext_resource type="PackedScene" path="res://scenes/theme/surfaces/DarkBay.tscn" id="3"] +[ext_resource type="PackedScene" path="res://scenes/shell/SystemDock.tscn" id="4"] +[ext_resource type="PackedScene" path="res://scenes/shell/NarrationBook.tscn" id="5"] + +[node name="MainWindowShell" type="Control"] +layout_mode = 3 +anchors_preset = 0 +offset_right = 1920.0 +offset_bottom = 1080.0 +theme = ExtResource("2") +script = ExtResource("1") + +[node name="Split" type="HBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +theme_override_constants/separation = 0 + +[node name="World" type="Control" parent="Split"] +layout_mode = 2 +size_flags_horizontal = 3 +clip_contents = true + +[node name="Bay" parent="Split/World" instance=ExtResource("3")] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="Caption" type="Label" parent="Split/World"] +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +grow_horizontal = 2 +grow_vertical = 2 +theme_type_variation = &"Mono" +text = "ISOMETRIC WORLD VIEW" + +[node name="TurnRail" type="PanelContainer" parent="Split/World"] +layout_mode = 1 +anchors_preset = 0 +offset_left = 28.0 +offset_top = 28.0 +offset_right = 300.0 +offset_bottom = 128.0 +theme_type_variation = &"DarkPanel" + +[node name="Col" type="VBoxContainer" parent="Split/World/TurnRail"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="Title" type="Label" parent="Split/World/TurnRail/Col"] +layout_mode = 2 +theme_type_variation = &"Mono" +text = "TURN ORDER" + +[node name="Tokens" type="HBoxContainer" parent="Split/World/TurnRail/Col"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="T0" type="Label" parent="Split/World/TurnRail/Col/Tokens"] +layout_mode = 2 +theme_type_variation = &"Accent" +text = "EL" + +[node name="T1" type="Label" parent="Split/World/TurnRail/Col/Tokens"] +layout_mode = 2 +theme_type_variation = &"Accent" +text = "DW" + +[node name="T2" type="Label" parent="Split/World/TurnRail/Col/Tokens"] +layout_mode = 2 +theme_type_variation = &"Accent" +text = "HU" + +[node name="T3" type="Label" parent="Split/World/TurnRail/Col/Tokens"] +layout_mode = 2 +theme_type_variation = &"Accent" +text = "✕" + +[node name="T4" type="Label" parent="Split/World/TurnRail/Col/Tokens"] +layout_mode = 2 +theme_type_variation = &"Accent" +text = "—" + +[node name="Minimap" type="PanelContainer" parent="Split/World"] +custom_minimum_size = Vector2(200, 200) +layout_mode = 1 +anchors_preset = 1 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -228.0 +offset_top = 28.0 +offset_right = -28.0 +offset_bottom = 228.0 +grow_horizontal = 0 +theme_type_variation = &"DarkPanel" + +[node name="Label" type="Label" parent="Split/World/Minimap"] +layout_mode = 2 +horizontal_alignment = 1 +vertical_alignment = 1 +theme_type_variation = &"Mono" +text = "MINIMAP" + +[node name="SystemDock" parent="Split/World" instance=ExtResource("4")] +layout_mode = 1 +anchors_preset = 6 +anchor_left = 1.0 +anchor_top = 0.5 +anchor_right = 1.0 +anchor_bottom = 0.5 +offset_left = -228.0 +offset_top = -210.0 +offset_right = -8.0 +offset_bottom = 210.0 +grow_horizontal = 0 +grow_vertical = 2 + +[node name="CommandBar" type="HBoxContainer" parent="Split/World"] +layout_mode = 1 +anchors_preset = 12 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 28.0 +offset_top = -150.0 +offset_right = -28.0 +grow_vertical = 0 +theme_override_constants/separation = 20 +alignment = 1 + +[node name="Vitals" type="VBoxContainer" parent="Split/World/CommandBar"] +custom_minimum_size = Vector2(120, 0) +layout_mode = 2 +size_flags_vertical = 8 +theme_override_constants/separation = 8 + +[node name="HP" type="Label" parent="Split/World/CommandBar/Vitals"] +layout_mode = 2 +theme_type_variation = &"Mono" +text = "HP 42 / 60" + +[node name="MP" type="Label" parent="Split/World/CommandBar/Vitals"] +layout_mode = 2 +theme_type_variation = &"Mono" +text = "MP 18 / 30" + +[node name="Gold" type="Label" parent="Split/World/CommandBar/Vitals"] +layout_mode = 2 +theme_type_variation = &"Mono" +text = "◈ 1240 gp" + +[node name="Consumables" type="HBoxContainer" parent="Split/World/CommandBar"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 8 +theme_override_constants/separation = 9 +alignment = 1 + +[node name="Slot1" type="PanelContainer" parent="Split/World/CommandBar/Consumables"] +custom_minimum_size = Vector2(93, 62) +layout_mode = 2 +size_flags_vertical = 4 +theme_type_variation = &"ItemTile" + +[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot1"] +layout_mode = 2 +horizontal_alignment = 1 +vertical_alignment = 2 +clip_text = true +theme_override_font_sizes/font_size = 11 +theme_type_variation = &"Mono" +text = "Salve" + +[node name="Slot2" type="PanelContainer" parent="Split/World/CommandBar/Consumables"] +custom_minimum_size = Vector2(93, 62) +layout_mode = 2 +size_flags_vertical = 4 +theme_type_variation = &"ItemTile" + +[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot2"] +layout_mode = 2 +horizontal_alignment = 1 +vertical_alignment = 2 +clip_text = true +theme_override_font_sizes/font_size = 11 +theme_type_variation = &"Mono" +text = "Draught" + +[node name="Slot3" type="PanelContainer" parent="Split/World/CommandBar/Consumables"] +custom_minimum_size = Vector2(93, 62) +layout_mode = 2 +size_flags_vertical = 4 +theme_type_variation = &"ItemTile" + +[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot3"] +layout_mode = 2 +horizontal_alignment = 1 +vertical_alignment = 2 +clip_text = true +theme_override_font_sizes/font_size = 11 +theme_type_variation = &"Mono" +text = "Bomb" + +[node name="Slot4" type="PanelContainer" parent="Split/World/CommandBar/Consumables"] +custom_minimum_size = Vector2(93, 62) +layout_mode = 2 +size_flags_vertical = 4 +theme_type_variation = &"ItemTile" + +[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot4"] +layout_mode = 2 +horizontal_alignment = 1 +vertical_alignment = 2 +clip_text = true +theme_override_font_sizes/font_size = 11 +theme_type_variation = &"Mono" +text = "Antidote" + +[node name="Slot5" type="PanelContainer" parent="Split/World/CommandBar/Consumables"] +custom_minimum_size = Vector2(93, 62) +layout_mode = 2 +size_flags_vertical = 4 +theme_type_variation = &"ItemTile" + +[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot5"] +layout_mode = 2 +horizontal_alignment = 1 +vertical_alignment = 2 +clip_text = true +theme_override_font_sizes/font_size = 11 +theme_type_variation = &"Mono" +text = "Ration" + +[node name="Slot6" type="PanelContainer" parent="Split/World/CommandBar/Consumables"] +custom_minimum_size = Vector2(93, 62) +layout_mode = 2 +size_flags_vertical = 4 +theme_type_variation = &"ItemTile" + +[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot6"] +layout_mode = 2 +horizontal_alignment = 1 +vertical_alignment = 2 +clip_text = true +theme_override_font_sizes/font_size = 11 +theme_type_variation = &"Mono" +text = "Torch" + +[node name="Slot7" type="PanelContainer" parent="Split/World/CommandBar/Consumables"] +custom_minimum_size = Vector2(93, 62) +layout_mode = 2 +size_flags_vertical = 4 +theme_type_variation = &"ItemTileEmpty" + +[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot7"] +layout_mode = 2 +horizontal_alignment = 1 +vertical_alignment = 2 +clip_text = true +theme_override_font_sizes/font_size = 11 +theme_type_variation = &"Mono" + +[node name="Slot8" type="PanelContainer" parent="Split/World/CommandBar/Consumables"] +custom_minimum_size = Vector2(93, 62) +layout_mode = 2 +size_flags_vertical = 4 +theme_type_variation = &"ItemTileEmpty" + +[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot8"] +layout_mode = 2 +horizontal_alignment = 1 +vertical_alignment = 2 +clip_text = true +theme_override_font_sizes/font_size = 11 +theme_type_variation = &"Mono" + +[node name="EndTurn" type="Button" parent="Split/World/CommandBar"] +custom_minimum_size = Vector2(140, 60) +layout_mode = 2 +size_flags_vertical = 4 +theme_type_variation = &"PrimaryCTA" +text = "END TURN" + +[node name="BookPanel" type="PanelContainer" parent="Split"] +custom_minimum_size = Vector2(740, 0) +layout_mode = 2 +theme_type_variation = &"ParchmentCard" + +[node name="Book" parent="Split/BookPanel" instance=ExtResource("5")] +layout_mode = 2 diff --git a/client/scenes/shell/NarrationBook.tscn b/client/scenes/shell/NarrationBook.tscn new file mode 100644 index 0000000..d46908d --- /dev/null +++ b/client/scenes/shell/NarrationBook.tscn @@ -0,0 +1,85 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://scripts/ui/shell/narration_book.gd" id="1"] +[ext_resource type="Theme" path="res://assets/theme/game_theme.tres" id="2"] + +[node name="NarrationBook" type="Control"] +layout_mode = 3 +anchors_preset = 0 +offset_right = 740.0 +offset_bottom = 1080.0 +theme = ExtResource("2") +script = ExtResource("1") + +[node name="Margin" type="MarginContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +theme_override_constants/margin_left = 40 +theme_override_constants/margin_top = 34 +theme_override_constants/margin_right = 40 +theme_override_constants/margin_bottom = 34 + +[node name="Col" type="VBoxContainer" parent="Margin"] +layout_mode = 2 +theme_override_constants/separation = 18 + +[node name="Header" type="Label" parent="Margin/Col"] +layout_mode = 2 +theme_type_variation = &"Accent" +text = "ROUND 4 · THE LOWER WARD" + +[node name="Prose" type="RichTextLabel" parent="Margin/Col"] +custom_minimum_size = Vector2(660, 320) +layout_mode = 2 +size_flags_vertical = 3 +bbcode_enabled = true +text = "The alley stinks of piss and rot. Rain beads on the cobbles and runs black into the gutter." +fit_content = true + +[node name="DegradedTag" type="Label" parent="Margin/Col"] +visible = false +layout_mode = 2 +theme_type_variation = &"Mono" +text = "THE MASTER'S VOICE COMES THIN" + +[node name="Prompt" type="Label" parent="Margin/Col"] +layout_mode = 2 +theme_type_variation = &"Accent" +text = "▸ Your turn. What do you do?" + +[node name="Choice1" type="Button" parent="Margin/Col"] +layout_mode = 2 +disabled = true +theme_type_variation = &"ParchmentButton" +text = "1 · Pay the fence double and keep your hand where he can see it" +alignment = 0 + +[node name="Choice2" type="Button" parent="Margin/Col"] +layout_mode = 2 +disabled = true +theme_type_variation = &"ParchmentButton" +text = "2 · Draw your blade — he's closer than his guards" +alignment = 0 + +[node name="Choice3" type="Button" parent="Margin/Col"] +layout_mode = 2 +disabled = true +theme_type_variation = &"ParchmentButton" +text = "3 · Lie — tell him the rest is coming with your dwarf" +alignment = 0 + +[node name="FreeText" type="Label" parent="Margin/Col"] +layout_mode = 2 +theme_type_variation = &"Mono" +text = "⌨ …or describe your own action" + +[node name="Refire" type="Button" parent="Margin/Col"] +layout_mode = 2 +theme_type_variation = &"ParchmentButton" +text = "▸ The Master continues…" + +[node name="Status" type="Label" parent="Margin/Col"] +layout_mode = 2 +theme_type_variation = &"Mono" diff --git a/client/scenes/shell/SystemDock.tscn b/client/scenes/shell/SystemDock.tscn new file mode 100644 index 0000000..7f03f26 --- /dev/null +++ b/client/scenes/shell/SystemDock.tscn @@ -0,0 +1,86 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://scripts/ui/shell/system_dock.gd" id="1"] +[ext_resource type="Theme" path="res://assets/theme/game_theme.tres" id="2"] + +[node name="SystemDock" type="Control"] +layout_mode = 3 +anchors_preset = 0 +offset_right = 220.0 +offset_bottom = 420.0 +theme = ExtResource("2") +script = ExtResource("1") + +[node name="Menu" type="VBoxContainer" parent="."] +layout_mode = 1 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -216.0 +offset_right = -36.0 +offset_bottom = 316.0 +grow_horizontal = 0 +theme_override_constants/separation = 10 + +[node name="Inventory" type="Button" parent="Menu"] +custom_minimum_size = Vector2(0, 42) +layout_mode = 2 +theme_type_variation = &"DockButton" +text = "I Inventory" +alignment = 0 +metadata/screen_id = &"inventory" + +[node name="Character" type="Button" parent="Menu"] +custom_minimum_size = Vector2(0, 42) +layout_mode = 2 +theme_type_variation = &"DockButton" +text = "C Character" +alignment = 0 +metadata/screen_id = &"character" + +[node name="QuestLog" type="Button" parent="Menu"] +custom_minimum_size = Vector2(0, 42) +layout_mode = 2 +theme_type_variation = &"DockButton" +text = "J Quest Log" +alignment = 0 +metadata/screen_id = &"quest_log" + +[node name="Spellbook" type="Button" parent="Menu"] +custom_minimum_size = Vector2(0, 42) +layout_mode = 2 +theme_type_variation = &"DockButton" +text = "K Spellbook" +alignment = 0 +metadata/screen_id = &"spellbook" + +[node name="WorldMap" type="Button" parent="Menu"] +custom_minimum_size = Vector2(0, 42) +layout_mode = 2 +theme_type_variation = &"DockButton" +text = "M World Map" +alignment = 0 +metadata/screen_id = &"world_map" + +[node name="Party" type="Button" parent="Menu"] +custom_minimum_size = Vector2(0, 42) +layout_mode = 2 +theme_type_variation = &"DockButton" +text = "P Party" +alignment = 0 +metadata/screen_id = &"party" + +[node name="Handle" type="Button" parent="."] +custom_minimum_size = Vector2(26, 104) +layout_mode = 1 +anchor_left = 1.0 +anchor_top = 0.5 +anchor_right = 1.0 +anchor_bottom = 0.5 +offset_left = -26.0 +offset_top = -52.0 +offset_right = 0.0 +offset_bottom = 52.0 +grow_horizontal = 0 +grow_vertical = 2 +theme_type_variation = &"TabActive" +text = "›" diff --git a/client/scripts/theme/build_game_theme.gd b/client/scripts/theme/build_game_theme.gd index 96b08f6..ec1c137 100644 --- a/client/scripts/theme/build_game_theme.gd +++ b/client/scripts/theme/build_game_theme.gd @@ -16,6 +16,7 @@ extends SceneTree const OUT := "res://assets/theme/game_theme.tres" const SERIF := "res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf" +const SERIF_ITALIC := "res://assets/theme/fonts/EBGaramond-Italic-VariableFont_wght.ttf" const ACCENT := "res://assets/theme/fonts/ArchitectsDaughter-Regular.ttf" const MONO := "res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf" @@ -33,8 +34,11 @@ func _init() -> void: static func build_theme() -> Theme: var theme := Theme.new() _fonts(theme) + _rich_text(theme) _primary_cta(theme) _tabs(theme) + _dock_button(theme) + _parchment_button(theme) _chip(theme) _tiles(theme) _cards_and_panels(theme) @@ -76,6 +80,18 @@ static func _fonts(theme: Theme) -> void: theme.set_color(&"font_color", ThemeKeys.MONO, Palette.MUTED_MONO) +static func _rich_text(theme: Theme) -> void: + # Base RichTextLabel styling — the narrative "prose" voice (mock README §Typography: + # Georgia/serif for DM/flavor body, italic for emphasis). RichTextLabel does NOT + # inherit default_font/default_color, so without this its text renders in the engine + # default (near-white) and is unreadable on parchment. Every parchment prose surface + # (the narration book, later quest briefs/dialogue) reads dark serif ink from here. + theme.set_font(&"normal_font", "RichTextLabel", load(SERIF)) + theme.set_font(&"italics_font", "RichTextLabel", load(SERIF_ITALIC)) + theme.set_font_size(&"normal_font_size", "RichTextLabel", 20) + theme.set_color(&"default_color", "RichTextLabel", Palette.INK_BODY) + + static func _primary_cta(theme: Theme) -> void: var k := ThemeKeys.PRIMARY_CTA theme.set_type_variation(k, "Button") @@ -106,6 +122,35 @@ static func _tabs(theme: Theme) -> void: theme.set_color(&"font_color", tab_active, Palette.CREAM_BRIGHT) +static func _dock_button(theme: Theme) -> void: + # Dark nav button for the slide-in system dock (mock 2a). Button-base so + # normal/hover/pressed styleboxes actually apply (a PanelContainer variation's + # lone `panel` stylebox does not skin a Button). Colours are dock-dark. + var k := ThemeKeys.DOCK_BUTTON + theme.set_type_variation(k, "Button") + theme.set_stylebox(&"normal", k, _flat(Palette.DARK_PANEL_70, Palette.BORDER_1, 1, 8)) + theme.set_stylebox(&"hover", k, _flat(Palette.DARK_PANEL_85, Palette.GOLD, 1, 8)) + theme.set_stylebox(&"pressed", k, _flat(Palette.DARK_PANEL_90, Palette.GOLD, 1, 8)) + theme.set_stylebox(&"focus", k, StyleBoxEmpty.new()) + theme.set_color(&"font_color", k, Palette.CREAM_SECONDARY) + + +static func _parchment_button(theme: Theme) -> void: + # Clickable parchment row/card for the narration book (mock 2a response + # choices + the refire affordance). Button-base so normal/hover/pressed/ + # disabled styleboxes actually apply (a PanelContainer variation's lone + # `panel` stylebox does not skin a Button). Colours are the mock's choice-card. + var k := ThemeKeys.PARCHMENT_BUTTON + theme.set_type_variation(k, "Button") + theme.set_stylebox(&"normal", k, _flat(Palette.PARCHMENT_CARD, Palette.PARCHMENT_BORDER_3, 1, 8)) + theme.set_stylebox(&"hover", k, _flat(Palette.PARCHMENT_INSET, Palette.INK_LABEL, 1, 8)) + theme.set_stylebox(&"pressed", k, _flat(Palette.PARCHMENT_INSET_2, Palette.INK_LABEL, 1, 8)) + theme.set_stylebox(&"disabled", k, _flat(Palette.PARCHMENT_INSET_2, Palette.PARCHMENT_BORDER_3, 1, 8)) + theme.set_stylebox(&"focus", k, StyleBoxEmpty.new()) + theme.set_color(&"font_color", k, Palette.INK_BODY) + theme.set_color(&"font_disabled_color", k, Palette.INK_MUTED) + + static func _chip(theme: Theme) -> void: # Base chip; the semantic colour (red/gold/grey) is applied per-use by script. var k := ThemeKeys.CHIP diff --git a/client/scripts/theme/theme_keys.gd b/client/scripts/theme/theme_keys.gd index 0a11113..3de8ff0 100644 --- a/client/scripts/theme/theme_keys.gd +++ b/client/scripts/theme/theme_keys.gd @@ -9,6 +9,8 @@ const PRIMARY_CTA := &"PrimaryCTA" const TAB := &"Tab" const TAB_ACTIVE := &"TabActive" const CHIP := &"Chip" +const DOCK_BUTTON := &"DockButton" +const PARCHMENT_BUTTON := &"ParchmentButton" const ITEM_TILE := &"ItemTile" const ITEM_TILE_EMPTY := &"ItemTileEmpty" const PARCHMENT_CARD := &"ParchmentCard" @@ -28,6 +30,8 @@ const ALL := { TAB: "Button", TAB_ACTIVE: "Button", CHIP: "Button", + DOCK_BUTTON: "Button", + PARCHMENT_BUTTON: "Button", ITEM_TILE: "PanelContainer", ITEM_TILE_EMPTY: "PanelContainer", PARCHMENT_CARD: "PanelContainer", diff --git a/client/scripts/ui/shell/main_window_shell.gd b/client/scripts/ui/shell/main_window_shell.gd new file mode 100644 index 0000000..fcdce62 --- /dev/null +++ b/client/scripts/ui/shell/main_window_shell.gd @@ -0,0 +1,134 @@ +class_name MainWindowShell +extends Control +## The exploration HUD — the frame every screen lives in (mock 2a). Two-panel +## tabletop split: a dark isometric world slot (turn rail, minimap, system dock, +## command bar) + the permanent DM narration book. §2: the shell owns HUD state +## (ShellState) and consumes DM text (never owns narrative truth). It reuses the +## proven Theme (M3-a) and DM loop (M2) — nothing here is reinvented. +## +## Layout is authored in MainWindowShell.tscn (and its SystemDock/NarrationBook +## sub-scenes) — open it in the editor to arrange it. This script binds the seed +## ShellState into the authored nodes, wires the DM loop, and keeps the runtime +## viewport-fit. The turn-order tokens and consumable slots are a FIXED set of +## authored nodes bound from state (extra slots hide); combat/inventory become the +## writers later. + +# HttpTransport carries no class_name (unlike DmService/FallbackLibrary) — every +# other call site (narrate_harness.gd, test_net_primitives.gd) reaches it via +# preload, so this mirrors the existing pattern rather than inventing a new one. +const HttpTransport = preload("res://scripts/net/http_transport.gd") + +var service: DmService = null # injection seam; real HttpTransport built if null + +var _state: ShellState +var _log: CanonLog +var _http: HTTPRequest + +@onready var book: NarrationBook = $Split/BookPanel/Book +@onready var dock: SystemDock = $Split/World/SystemDock +@onready var _tokens: HBoxContainer = $Split/World/TurnRail/Col/Tokens +@onready var _hp: Label = $Split/World/CommandBar/Vitals/HP +@onready var _mp: Label = $Split/World/CommandBar/Vitals/MP +@onready var _gold: Label = $Split/World/CommandBar/Vitals/Gold +@onready var _consumables: HBoxContainer = $Split/World/CommandBar/Consumables + + +func _ready() -> void: + # The theme is assigned on the scene root in the editor (so it previews). Keep + # the runtime viewport-fit: a root Control run via F6 is not sized by full-rect + # anchors — the .tscn root uses a fixed size + no full-rect preset (M3-a bug). + _fit_to_viewport() + get_viewport().size_changed.connect(_fit_to_viewport) + + _state = ShellState.seed() + _log = _build_seed_log() + _bind_state() + + if service == null: + _http = HTTPRequest.new() + add_child(_http) + service = DmService.new(HttpTransport.new(_http), FallbackLibrary.new()) + + book.setup(service, _log, _state) + dock.setup(_state) + dock.screen_requested.connect(_on_screen_requested) + book.narrate() # initial narration; async, fire-and-forget + + +func _fit_to_viewport() -> void: + size = get_viewport_rect().size + + +func _bind_state() -> void: + _bind_turn_rail() + _bind_vitals() + _bind_consumables() + + +func _bind_turn_rail() -> void: + # Fill the authored token labels from the turn order; hide any spare slots. + var tokens := _tokens.get_children() + for i in range(tokens.size()): + var label: Label = tokens[i] + if i < _state.turn_order.size(): + var e: TurnEntry = _state.turn_order[i] + label.text = "✕" if e.is_downed else e.initials + label.add_theme_color_override("font_color", _side_color(e)) + label.visible = true + else: + label.visible = false + + +func _side_color(e: TurnEntry) -> Color: + if e.is_downed: + return Palette.BLOOD + if e.is_active: + return Palette.GOLD + match e.side: + &"enemy": return Palette.BLOOD_BRIGHT + &"ally": return Palette.STEEL + _: return Palette.CREAM + + +func _bind_vitals() -> void: + _hp.text = "HP %d / %d" % [_state.vitals["hp"], _state.vitals["hp_max"]] + _mp.text = "MP %d / %d" % [_state.vitals["mp"], _state.vitals["mp_max"]] + _gold.text = "◈ %d gp" % _state.vitals["gold"] + _gold.add_theme_color_override("font_color", Palette.GOLD_BRIGHT) + + +func _bind_consumables() -> void: + # The 8 slots are authored; bind labels + fill/empty variation, hide spares. + var slots := _consumables.get_children() + for i in range(slots.size()): + var slot: PanelContainer = slots[i] + var label: Label = slot.get_node("Label") + if i < _state.consumables.size(): + var c: Dictionary = _state.consumables[i] + label.text = c["label"] + slot.theme_type_variation = ThemeKeys.ITEM_TILE if c["label"] != "" else ThemeKeys.ITEM_TILE_EMPTY + slot.visible = true + else: + slot.visible = false + + +func _on_screen_requested(_id: StringName) -> void: + pass # routing seam — the dock's screens land in later milestones (§17) + + +func _build_seed_log() -> CanonLog: + # Code owns the canon log (§11). Hand-built until character creation (M4) and + # save/load (M9) construct the real one — mirrors narrate_harness. No Luck + # number is rendered anywhere (§7); the descriptor is prose-only. + var log := CanonLog.new() + log.player = LogPlayer.new("Vexcca", "sellsword", "Fortune spits on you") + log.set_location("lower_ward", "the Lower Ward") + log.party.append(PartyMember.new("brannoc_thane", "Brannoc Thane", 40)) + log.party.append(PartyMember.new("cadwyn_vell", "Cadwyn Vell", 15)) + log.push_event("Followed the beastfolk fence into the Lower Ward after dark") + log.push_event("The sewer bite on Vexcca's left arm has begun to turn") + log.add_fact("the fence deals out of the Lower Ward") + log.add_fact("Vexcca carries an infected bite on the left arm") + log.active_quests.append(Quest.new("the_fences_price", "The Fence's Price", "active", "Settle with the beastfolk fence")) + log.add_humiliation("h_0001", "haggled badly and got a wrist crushed for it", 6, 4) + return log diff --git a/client/scripts/ui/shell/main_window_shell.gd.uid b/client/scripts/ui/shell/main_window_shell.gd.uid new file mode 100644 index 0000000..32a41b2 --- /dev/null +++ b/client/scripts/ui/shell/main_window_shell.gd.uid @@ -0,0 +1 @@ +uid://bobihkpvsax2u diff --git a/client/scripts/ui/shell/narration_book.gd b/client/scripts/ui/shell/narration_book.gd new file mode 100644 index 0000000..0dbec7e --- /dev/null +++ b/client/scripts/ui/shell/narration_book.gd @@ -0,0 +1,61 @@ +class_name NarrationBook +extends Control +## The permanent DM narration book (mock 2a, right panel). §2: text — it consumes +## /dm/narrate prose and never treats it as truth; only harvested [FACT] tags are +## written back to the canon log (§11). Prose-only this milestone: the numbered +## response choices and the "describe your own action" line are faithful but inert +## (no action system yet); one in-world refire affordance re-runs narration. +## +## Layout is authored in NarrationBook.tscn (editor-previewable). This script only +## binds the header, drives the narrate loop, and swaps prose text. + +var last_degraded: bool = false + +var _service: DmService +var _log: CanonLog +var _state: ShellState +var _indicator: ConsideringIndicator + +@onready var _header: Label = $Margin/Col/Header +@onready var _prose: RichTextLabel = $Margin/Col/Prose +@onready var _degraded_tag: Label = $Margin/Col/DegradedTag +@onready var _refire: Button = $Margin/Col/Refire +@onready var _status: Label = $Margin/Col/Status + + +func _ready() -> void: + # The considering indicator is a non-visual Node (a Timer + label driver), so + # it stays code-created rather than authored in the scene. + _indicator = ConsideringIndicator.new() + add_child(_indicator) + _refire.pressed.connect(_on_refire_pressed) + + +func setup(service: DmService, log: CanonLog, state: ShellState) -> void: + _service = service + _log = log + _state = state + _header.text = "%s · %s" % [state.round_label, state.location_label] + + +func narrate() -> void: + if _service == null or _log == null: + return + _refire.disabled = true + _indicator.start(_status) + var result: NarrateResult = await _service.narrate(_log) + _indicator.stop() + show_prose(result) + for f in result.facts: + _log.add_fact(f) # §11: code applies the state write, explicitly + _refire.disabled = false + + +func show_prose(result: NarrateResult) -> void: + _prose.text = result.display_text + last_degraded = result.degraded + _degraded_tag.visible = result.degraded + + +func _on_refire_pressed() -> void: + narrate() diff --git a/client/scripts/ui/shell/narration_book.gd.uid b/client/scripts/ui/shell/narration_book.gd.uid new file mode 100644 index 0000000..5d65a8f --- /dev/null +++ b/client/scripts/ui/shell/narration_book.gd.uid @@ -0,0 +1 @@ +uid://blmbc06xpea3x diff --git a/client/scripts/ui/shell/shell_state.gd b/client/scripts/ui/shell/shell_state.gd new file mode 100644 index 0000000..27caa65 --- /dev/null +++ b/client/scripts/ui/shell/shell_state.gd @@ -0,0 +1,36 @@ +class_name ShellState +extends RefCounted +## The HUD state the Main Window shell owns (§2: state). Seed values mirror mock +## 2a; later systems (combat vitals, inventory consumables) become the writers. +## No Luck/LCK value lives here — it is never surfaced (§7). + +var vitals: Dictionary = {"hp": 0, "hp_max": 0, "mp": 0, "mp_max": 0, "gold": 0} +var turn_order: Array[TurnEntry] = [] +var consumables: Array = [] +var round_label: String = "" +var location_label: String = "" +var dock_open: bool = true + + +func toggle_dock() -> bool: + dock_open = not dock_open + return dock_open + + +static func seed() -> ShellState: + var s := ShellState.new() + s.vitals = {"hp": 42, "hp_max": 60, "mp": 18, "mp_max": 30, "gold": 1240} + s.turn_order = [ + TurnEntry.new("EL", 17, &"you", true, false), + TurnEntry.new("DW", 12, &"ally"), + TurnEntry.new("HU", 9, &"ally"), + TurnEntry.new("GK", 0, &"enemy", false, true), + ] + var labels := ["Salve", "Draught", "Bomb", "Antidote", "Ration", "Torch", "", ""] + s.consumables = [] + for i in range(labels.size()): + s.consumables.append({"hotkey": i + 1, "label": labels[i]}) + s.round_label = "ROUND 4" + s.location_label = "THE LOWER WARD" + s.dock_open = true + return s diff --git a/client/scripts/ui/shell/shell_state.gd.uid b/client/scripts/ui/shell/shell_state.gd.uid new file mode 100644 index 0000000..e57742c --- /dev/null +++ b/client/scripts/ui/shell/shell_state.gd.uid @@ -0,0 +1 @@ +uid://f8vix48wmisv diff --git a/client/scripts/ui/shell/system_dock.gd b/client/scripts/ui/shell/system_dock.gd new file mode 100644 index 0000000..b42d05e --- /dev/null +++ b/client/scripts/ui/shell/system_dock.gd @@ -0,0 +1,61 @@ +class_name SystemDock +extends Control +## The slide-in system dock (mock 2a): a gold handle toggles a column of screen +## buttons in/out. §2: state (owns the open toggle via ShellState). The buttons +## are the routing seam — they emit screen_requested; the screens they open land +## in later milestones, so the signal is currently a no-op for the shell. +## +## Layout is authored in SystemDock.tscn (editor-previewable). This script only +## wires the buttons' signals, drives the slide animation, and toggles state. Each +## dock button carries a `screen_id` StringName in its scene metadata; add/reorder +## buttons in the editor and the wiring follows. + +signal screen_requested(id: StringName) + +const SLIDE_SECONDS := 0.32 +const HIDDEN_SHIFT := 230.0 + +var _screen_buttons: Dictionary = {} +var _state: ShellState +var _menu_open_pos: Vector2 + +@onready var _menu: VBoxContainer = $Menu +@onready var _handle: Button = $Handle + + +func _ready() -> void: + # The buttons live in the scene; harvest each one's authored screen_id so the + # routing seam follows editor edits (add/remove/reorder needs no code change). + for child in _menu.get_children(): + if child is Button and child.has_meta("screen_id"): + var id: StringName = child.get_meta("screen_id") + child.pressed.connect(func(): screen_requested.emit(id)) + _screen_buttons[id] = child + _handle.pressed.connect(_on_handle_pressed) + # Capture the authored open position so the slide is relative to wherever the + # menu is placed in the editor — moving it in the editor just works. + _menu_open_pos = _menu.position + + +func setup(state: ShellState) -> void: + _state = state + _apply_open(_state.dock_open) + + +func _on_handle_pressed() -> void: + if _state == null: + return + _apply_open(_state.toggle_dock()) + + +func _apply_open(open: bool) -> void: + _handle.text = "›" if open else "‹" + var target := _menu_open_pos if open else _menu_open_pos + Vector2(HIDDEN_SHIFT, 0.0) + var alpha := 1.0 if open else 0.0 + if not is_inside_tree(): + _menu.position = target + _menu.modulate.a = alpha + return + var tween := create_tween().set_parallel(true) + tween.tween_property(_menu, "position", target, SLIDE_SECONDS) + tween.tween_property(_menu, "modulate:a", alpha, SLIDE_SECONDS) diff --git a/client/scripts/ui/shell/system_dock.gd.uid b/client/scripts/ui/shell/system_dock.gd.uid new file mode 100644 index 0000000..a2b81dc --- /dev/null +++ b/client/scripts/ui/shell/system_dock.gd.uid @@ -0,0 +1 @@ +uid://dvbg24rvye3qw diff --git a/client/scripts/ui/shell/turn_entry.gd b/client/scripts/ui/shell/turn_entry.gd new file mode 100644 index 0000000..df28f45 --- /dev/null +++ b/client/scripts/ui/shell/turn_entry.gd @@ -0,0 +1,20 @@ +class_name TurnEntry +extends RefCounted +## One combatant in the turn-order rail (§2: state). Seed/placeholder now; the +## combat turn manager (M5) becomes its writer. `side` colours the rail: +## &"you" gold, &"ally" teal-muted, &"enemy" blood. + +var initials: String +var initiative: int +var side: StringName +var is_active: bool +var is_downed: bool + + +func _init(p_initials: String, p_initiative: int, p_side: StringName, + p_is_active := false, p_is_downed := false) -> void: + initials = p_initials + initiative = p_initiative + side = p_side + is_active = p_is_active + is_downed = p_is_downed diff --git a/client/scripts/ui/shell/turn_entry.gd.uid b/client/scripts/ui/shell/turn_entry.gd.uid new file mode 100644 index 0000000..311cb23 --- /dev/null +++ b/client/scripts/ui/shell/turn_entry.gd.uid @@ -0,0 +1 @@ +uid://toetic2iu7b8 diff --git a/client/tests/unit/test_main_window_shell.gd b/client/tests/unit/test_main_window_shell.gd new file mode 100644 index 0000000..44413ab --- /dev/null +++ b/client/tests/unit/test_main_window_shell.gd @@ -0,0 +1,37 @@ +extends "res://addons/gut/test.gd" + +const SCENE := "res://scenes/shell/MainWindowShell.tscn" +const FakeTransport = preload("res://tests/doubles/fake_transport.gd") +const DmResponse = preload("res://scripts/net/dm_response.gd") + + +func _shell() -> MainWindowShell: + # Inject a fake service BEFORE _ready (add_child) so the initial narrate does + # not touch the network — instantiate() does not run _ready(); add_child does. + var node = load(SCENE).instantiate() + var transport = FakeTransport.new() + transport.set_response(DmResponse.ok(200, {"prose": "Rain on the cobbles."})) + node.service = DmService.new(transport, FallbackLibrary.new()) + add_child_autofree(node) + return node + + +func test_shell_applies_theme_and_fits_viewport(): + var packed = load(SCENE) + assert_true(packed is PackedScene) + var node := _shell() + assert_true(node is Control) + assert_true(node.theme is Theme, "shell applies game_theme") + assert_gt(node.size.x, 0.0, "viewport-fit sized the root (M3-a collapse guard)") + + +func test_shell_mounts_both_panels(): + var node := _shell() + assert_not_null(node.book, "narration book mounted") + assert_not_null(node.dock, "system dock mounted") + + +func test_shell_builds_a_code_owned_seed_log(): + var node := _shell() + assert_not_null(node._log, "a canon log exists to post (§11)") + assert_gt(node._log.established_facts.size(), 0, "seed scene facts are code-owned") diff --git a/client/tests/unit/test_main_window_shell.gd.uid b/client/tests/unit/test_main_window_shell.gd.uid new file mode 100644 index 0000000..9547284 --- /dev/null +++ b/client/tests/unit/test_main_window_shell.gd.uid @@ -0,0 +1 @@ +uid://dbd4cq1awo4su diff --git a/client/tests/unit/test_narration_book.gd b/client/tests/unit/test_narration_book.gd new file mode 100644 index 0000000..5914a1f --- /dev/null +++ b/client/tests/unit/test_narration_book.gd @@ -0,0 +1,37 @@ +extends "res://addons/gut/test.gd" + +const SCENE := "res://scenes/shell/NarrationBook.tscn" +const FakeTransport = preload("res://tests/doubles/fake_transport.gd") +const DmResponse = preload("res://scripts/net/dm_response.gd") + + +func _book_with(response) -> NarrationBook: + var transport = FakeTransport.new() + transport.set_response(response) + var service := DmService.new(transport, FallbackLibrary.new()) + var book = load(SCENE).instantiate() + add_child_autofree(book) # _ready() builds nodes + the considering indicator + book.setup(service, CanonLog.new(), ShellState.seed()) + return book + + +func test_narrate_shows_prose_and_harvests_facts(): + var book := _book_with(DmResponse.ok(200, + {"prose": "The rain runs black into the gutter. [FACT: the eastern bridge is out]"})) + var log := CanonLog.new() + var transport = FakeTransport.new() + transport.set_response(DmResponse.ok(200, + {"prose": "The rain runs black into the gutter. [FACT: the eastern bridge is out]"})) + book.setup(DmService.new(transport, FallbackLibrary.new()), log, ShellState.seed()) + await book.narrate() + assert_string_contains(book._prose.text, "The rain runs black into the gutter.") + assert_false(book._prose.text.contains("[FACT"), "tags are stripped from displayed prose") + assert_true("the eastern bridge is out" in log.established_facts, "fact harvested to the log") + assert_false(book.last_degraded) + + +func test_degraded_response_shows_fallback_and_sets_flag(): + var book := _book_with(DmResponse.failed("proxy down")) + await book.narrate() + assert_true(book.last_degraded, "a failed call degrades") + assert_ne(book._prose.text, "", "the fallback line is still shown (§13)") diff --git a/client/tests/unit/test_narration_book.gd.uid b/client/tests/unit/test_narration_book.gd.uid new file mode 100644 index 0000000..bf30a1e --- /dev/null +++ b/client/tests/unit/test_narration_book.gd.uid @@ -0,0 +1 @@ +uid://dj6ly2ljf3ai4 diff --git a/client/tests/unit/test_shell_state.gd b/client/tests/unit/test_shell_state.gd new file mode 100644 index 0000000..cfea412 --- /dev/null +++ b/client/tests/unit/test_shell_state.gd @@ -0,0 +1,44 @@ +extends "res://addons/gut/test.gd" + + +func test_turn_entry_holds_fields(): + var e := TurnEntry.new("DW", 12, &"ally") + assert_eq(e.initials, "DW") + assert_eq(e.initiative, 12) + assert_eq(e.side, &"ally") + assert_false(e.is_active) + assert_false(e.is_downed) + + +func test_seed_vitals(): + var s := ShellState.seed() + assert_eq(s.vitals["hp"], 42) + assert_eq(s.vitals["hp_max"], 60) + assert_eq(s.vitals["mp"], 18) + assert_eq(s.vitals["mp_max"], 30) + assert_eq(s.vitals["gold"], 1240) + + +func test_seed_turn_order(): + var s := ShellState.seed() + assert_eq(s.turn_order.size(), 4) + assert_eq(s.turn_order[0].initials, "EL") + assert_true(s.turn_order[0].is_active, "first combatant is the active turn") + assert_true(s.turn_order[3].is_downed, "last combatant is downed") + + +func test_seed_consumables(): + var s := ShellState.seed() + assert_eq(s.consumables.size(), 8) + assert_eq(s.consumables[0]["label"], "Salve") + assert_eq(s.consumables[0]["hotkey"], 1) + assert_eq(s.consumables[6]["label"], "", "slot 7 is empty") + + +func test_toggle_dock_flips(): + var s := ShellState.seed() + assert_true(s.dock_open, "seed opens with the dock out") + assert_false(s.toggle_dock()) + assert_false(s.dock_open) + assert_true(s.toggle_dock()) + assert_true(s.dock_open) diff --git a/client/tests/unit/test_shell_state.gd.uid b/client/tests/unit/test_shell_state.gd.uid new file mode 100644 index 0000000..ae91db3 --- /dev/null +++ b/client/tests/unit/test_shell_state.gd.uid @@ -0,0 +1 @@ +uid://wkbpiulrv8rx diff --git a/client/tests/unit/test_system_dock.gd b/client/tests/unit/test_system_dock.gd new file mode 100644 index 0000000..233a9c6 --- /dev/null +++ b/client/tests/unit/test_system_dock.gd @@ -0,0 +1,32 @@ +extends "res://addons/gut/test.gd" + +const SCENE := "res://scenes/shell/SystemDock.tscn" + + +func _dock() -> SystemDock: + var d = load(SCENE).instantiate() + add_child_autofree(d) # _ready() builds the buttons + return d + + +func test_builds_all_six_screen_buttons(): + var d := _dock() + assert_eq(d._screen_buttons.size(), 6) + assert_true(d._screen_buttons.has(&"spellbook"), "spellbook kept for mock fidelity") + assert_true(d._screen_buttons.has(&"party")) + + +func test_pressing_a_button_emits_its_id(): + var d := _dock() + watch_signals(d) + d._screen_buttons[&"inventory"].emit_signal("pressed") + assert_signal_emitted_with_parameters(d, "screen_requested", [&"inventory"]) + + +func test_handle_toggles_state(): + var d := _dock() + var state := ShellState.seed() + d.setup(state) + assert_true(state.dock_open) + d._on_handle_pressed() + assert_false(state.dock_open, "handle flips the shared state") diff --git a/client/tests/unit/test_system_dock.gd.uid b/client/tests/unit/test_system_dock.gd.uid new file mode 100644 index 0000000..5629317 --- /dev/null +++ b/client/tests/unit/test_system_dock.gd.uid @@ -0,0 +1 @@ +uid://dllvm2kmia00h diff --git a/docs/adr/0001-editor-first-ui-scenes.md b/docs/adr/0001-editor-first-ui-scenes.md new file mode 100644 index 0000000..6755ddc --- /dev/null +++ b/docs/adr/0001-editor-first-ui-scenes.md @@ -0,0 +1,86 @@ +# 0001 — UI screens are editor-authored scenes, not code-built + +- **Status:** accepted +- **Date:** 2026-07-11 (M3-b, Main Window shell) + +## Context + +The M3-b Main Window shell was first built by constructing every Control node in +`_ready()` (a pattern borrowed from `theme_showcase.gd`, where it was fine because +the showcase is a throwaway proof). For a real screen this was wrong: + +- **It does not preview in the Godot editor.** Opening the `.tscn` showed a bare + root Control — every widget only existed at runtime — so the human could not see + or arrange the layout in the editor. Tuning meant editing pixel offsets in code, + running, eyeballing, repeating. +- It buried layout, styling, and content in GDScript, where it is verbose and + hard to reason about, and it drifted from charter §16's own words: *"each screen + is recreated as a Godot 4.7 Control-node scene."* + +The human asked to be able to arrange the scene in the editor "as much as +possible," with code only for genuine on-load work. + +## Decision + +**UI screens (and their sub-scenes) are authored editor-first as `.tscn` node +trees.** The split: + +- **In the `.tscn` (editor):** the whole node tree — containers, panels, labels, + buttons, art-slot placeholders — with the shared `Theme` assigned on the scene + root and every `theme_type_variation` set on its node. Authored placeholder + text/values so the screen previews with representative content. Sub-screens are + **instanced** (`ExtResource` PackedScene), not rebuilt. +- **In the script (on-load only):** `@onready` references to named nodes; wiring + (signals, the DM loop, injected services); animations (e.g. the dock slide); + and **binding state into the authored nodes** (setting label text/colours from + the model). Never construct the tree in `_ready()`. +- **Data-driven repeats** (turn-order tokens, consumable slots, later inventory + grids) are a **fixed set of authored nodes** bound from state at runtime — + spare slots hide. This keeps them previewable; combat/inventory become the + writers later. (Author enough slots for the common case; if a screen ever needs + a genuinely unbounded list, revisit with a code-instanced container then.) + +Supporting conventions established alongside this decision: + +- **Scene root sizing.** Give the `.tscn` root a fixed size (e.g. 1920×1080) via + offsets so it previews, but **no full-rect anchors on the root** — a root + Control run via F6 is not sized by full-rect anchors and collapses to (0,0). + The script keeps `size = get_viewport_rect().size` on `_ready` + + `size_changed` (the M3-a lesson). +- **Button styling uses Button-base theme variations.** Never apply a + PanelContainer-base variation (`DarkPanel`, `ParchmentCard`) to a `Button` — a + Button looks up `normal/hover/pressed`, which a `panel`-only variation lacks, so + it renders unskinned. M3-b added Button-base `DockButton` and `ParchmentButton` + to the theme for exactly this. +- **RichTextLabel prose reads its style from the theme.** `RichTextLabel` does + **not** inherit `default_font`/`default_color`, so without a theme entry its + text renders near-white (unreadable on parchment). The theme now sets a base + `RichTextLabel` style (dark `INK_BODY` serif + italic face). All colours still + come from `Palette` via the theme builder — no hex in scripts or `.tscn`. + +## Consequences + +**Easier:** + +- The human arranges any screen directly in the Godot editor; layout persists in + the `.tscn`. Fine-tuning is drag-and-drop, not code-run-eyeball. +- Screens preview with themed, representative content on open. +- Scripts shrink to logic; less code, easier to review, focused files. +- The look stays one system (theme assigned per scene root). + +**Caveats / harder:** + +- **Shader surfaces render only at runtime.** `DarkBay`/`VignetteOverlay` set + their `ShaderMaterial` in `_ready()` (M3-a accepted deviation), so their + texture appears only on an F6 run — the editor shows layout but a plain panel + behind it. Layout still previews fine. +- Tests reference nodes by their authored `@onready` paths; renaming/moving a + node in the editor means updating the path (and any test that reads it). +- The visual gate stays a **human F6 eyeball** — headless tests prove structure + (nodes mount, sizes are non-zero, bindings run) but not rendering. + +## Reference implementation + +M3-b shell scenes: `client/scenes/shell/{MainWindowShell,SystemDock,NarrationBook}.tscn` +with their `client/scripts/ui/shell/*.gd` scripts. Practical how-to lives in +[`client/docs/README.md`](../../client/docs/README.md) → "UI scene conventions." diff --git a/docs/adr/README.md b/docs/adr/README.md index 2f31b60..b183a2b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -4,7 +4,7 @@ One file per decision: `NNNN-short-title.md`. Record the context, the decision, ## Index -- _none yet_ +- [0001 — UI screens are editor-authored scenes, not code-built](0001-editor-first-ui-scenes.md) (accepted)