diff --git a/Theriapolis.Godot/Scenes/Steps/StepClade.cs b/Theriapolis.Godot/Scenes/Steps/StepClade.cs
index b6f1c8f..533113d 100644
--- a/Theriapolis.Godot/Scenes/Steps/StepClade.cs
+++ b/Theriapolis.Godot/Scenes/Steps/StepClade.cs
@@ -1,5 +1,6 @@
using Godot;
using Theriapolis.Core.Data;
+using Theriapolis.GodotHost.Scenes.Widgets;
using Theriapolis.GodotHost.UI;
namespace Theriapolis.GodotHost.Scenes.Steps;
@@ -69,55 +70,41 @@ public partial class StepClade : VBoxContainer, IStep
{
bool selected = _draft.CladeId == clade.Id;
- var btn = new Button
+ // PanelContainer (a Container subclass) so the card height is
+ // driven by its inner VBoxContainer's content. Switching from
+ // Button avoids the issue where Button's intrinsic min size
+ // doesn't aggregate from non-Button children, causing chips to
+ // overflow into the cards below at high trait counts.
+ var card = new PanelContainer
{
- Text = "",
- Flat = false,
- ToggleMode = true,
- ButtonPressed = selected,
- FocusMode = Control.FocusModeEnum.None,
- // 200 wide so 3 cards + separators (≈ 632) + page margins +
- // Aside fit inside ≥ 1024-px viewports (the smaller-screen
- // floor we want to support). Height held at 200 so the inner
- // labels render at readable size without a content-driven
- // height collapse (Button isn't a Container, so child vbox
- // height doesn't bubble up to the button's intrinsic min size).
- CustomMinimumSize = new Vector2(200, 200),
- ClipText = false,
- Alignment = HorizontalAlignment.Left,
+ CustomMinimumSize = new Vector2(200, 0),
+ MouseFilter = MouseFilterEnum.Stop,
};
- btn.Pressed += () =>
+ if (selected) card.Modulate = new Color(1f, 0.95f, 0.85f); // gild tint placeholder until theming
+
+ card.GuiInput += (InputEvent e) =>
{
- // Default species for the new clade — match React app.jsx:
- // when clade changes, species defaults to first species in clade.
- string speciesId = "";
- foreach (var s in CodexContent.SpeciesOfClade(clade.Id))
+ if (e is InputEventMouseButton mb && mb.Pressed && mb.ButtonIndex == MouseButton.Left)
{
- speciesId = s.Id;
- break;
+ // Default species for the new clade — match React app.jsx:
+ // when clade changes, species defaults to first species in clade.
+ string speciesId = "";
+ foreach (var s in CodexContent.SpeciesOfClade(clade.Id))
+ {
+ speciesId = s.Id;
+ break;
+ }
+ _draft.Patch(new Godot.Collections.Dictionary
+ {
+ { "clade_id", clade.Id },
+ { "species_id", speciesId },
+ });
}
- _draft.Patch(new Godot.Collections.Dictionary
- {
- { "clade_id", clade.Id },
- { "species_id", speciesId },
- });
};
- // Label content stacked inside the button via an anchored VBoxContainer
- // (Button isn't a Container, so we anchor the vbox to fill the button's
- // rect and let the children flow within it).
- var box = new VBoxContainer
- {
- MouseFilter = MouseFilterEnum.Ignore,
- };
- box.AnchorRight = 1f;
- box.AnchorBottom = 1f;
- box.OffsetLeft = 12;
- box.OffsetTop = 12;
- box.OffsetRight = -12;
- box.OffsetBottom = -12;
+ var box = new VBoxContainer { MouseFilter = MouseFilterEnum.Pass };
box.AddThemeConstantOverride("separation", 6);
- btn.AddChild(box);
+ card.AddChild(box);
box.AddChild(new Label { Text = clade.Name, MouseFilter = MouseFilterEnum.Ignore });
box.AddChild(new Label
@@ -139,15 +126,34 @@ public partial class StepClade : VBoxContainer, IStep
});
}
- if (clade.Traits.Length > 0)
+ if (clade.Traits.Length > 0 || clade.Detriments.Length > 0)
{
- box.AddChild(new Label
+ var chips = new HFlowContainer { MouseFilter = MouseFilterEnum.Pass };
+ chips.AddThemeConstantOverride("h_separation", 6);
+ chips.AddThemeConstantOverride("v_separation", 4);
+ box.AddChild(chips);
+
+ foreach (var t in clade.Traits)
{
- Text = $"{clade.Traits.Length} traits, {clade.Detriments.Length} detriments",
- MouseFilter = MouseFilterEnum.Ignore,
- });
+ var chip = new TraitChip
+ {
+ TraitName = t.Name,
+ Description = t.Description,
+ };
+ chips.AddChild(chip);
+ }
+ foreach (var d in clade.Detriments)
+ {
+ var chip = new TraitChip
+ {
+ TraitName = d.Name,
+ Description = d.Description,
+ Detriment = true,
+ };
+ chips.AddChild(chip);
+ }
}
- return btn;
+ return card;
}
}
diff --git a/Theriapolis.Godot/Scenes/Widgets/PopoverLayer.cs b/Theriapolis.Godot/Scenes/Widgets/PopoverLayer.cs
new file mode 100644
index 0000000..288b5f5
--- /dev/null
+++ b/Theriapolis.Godot/Scenes/Widgets/PopoverLayer.cs
@@ -0,0 +1,130 @@
+using Godot;
+
+namespace Theriapolis.GodotHost.Scenes.Widgets;
+
+///
+/// Shared overlay layer that owns one reusable trait popover panel.
+/// Per GODOT_PORTING_GUIDE.md §6.1 — TraitChip triggers ask
+/// to show the popover at their global rect; the
+/// popover stays open while either the trigger or the popover itself is
+/// hovered (80 ms grace via close timer).
+///
+/// One PopoverLayer per scene; lives as a CanvasLayer child of
+/// Wizard.tscn so popovers float above every step's content. Mirrors
+/// src/trait-hint.jsx's viewport-clamp / flip-above behaviour.
+///
+public partial class PopoverLayer : CanvasLayer
+{
+ public static PopoverLayer? Instance { get; private set; }
+
+ private const float GracePeriodSec = 0.08f;
+ private const float ArrowOffsetPx = 6f;
+ private const int ViewportPadPx = 8;
+
+ private PanelContainer _popup = null!;
+ private Label _titleLabel = null!;
+ private Label _tagLabel = null!;
+ private Label _descLabel = null!;
+ private Timer _closeTimer = null!;
+
+ public override void _EnterTree()
+ {
+ Instance = this;
+ }
+
+ public override void _ExitTree()
+ {
+ if (Instance == this) Instance = null;
+ }
+
+ public override void _Ready()
+ {
+ Layer = 100;
+ BuildPopover();
+ }
+
+ private void BuildPopover()
+ {
+ _popup = new PanelContainer
+ {
+ Visible = false,
+ MouseFilter = Control.MouseFilterEnum.Pass,
+ ZIndex = 100,
+ };
+ _popup.MouseEntered += CancelClose;
+ _popup.MouseExited += ScheduleClose;
+ AddChild(_popup);
+
+ var v = new VBoxContainer { CustomMinimumSize = new Vector2(240, 0) };
+ v.AddThemeConstantOverride("separation", 6);
+ _popup.AddChild(v);
+
+ var nameRow = new HBoxContainer();
+ nameRow.AddThemeConstantOverride("separation", 8);
+ v.AddChild(nameRow);
+
+ _titleLabel = new Label();
+ nameRow.AddChild(_titleLabel);
+
+ _tagLabel = new Label { Visible = false };
+ nameRow.AddChild(_tagLabel);
+
+ _descLabel = new Label
+ {
+ AutowrapMode = TextServer.AutowrapMode.WordSmart,
+ CustomMinimumSize = new Vector2(220, 0),
+ };
+ v.AddChild(_descLabel);
+
+ _closeTimer = new Timer { OneShot = true, WaitTime = GracePeriodSec };
+ _closeTimer.Timeout += HidePopover;
+ AddChild(_closeTimer);
+ }
+
+ public void ShowFor(Control trigger, string title, string description, string tag, bool detriment)
+ {
+ CancelClose();
+ _titleLabel.Text = title;
+ _descLabel.Text = description;
+ _tagLabel.Visible = !string.IsNullOrEmpty(tag) || detriment;
+ _tagLabel.Text = !string.IsNullOrEmpty(tag) ? tag.ToUpperInvariant()
+ : (detriment ? "DETRIMENT" : "");
+
+ // M6.3 default-theme tint: detriment popover gets a red modulate so
+ // it reads visually distinct from a regular trait. The proper
+ // codex StyleBox swap lands in the theming pass.
+ _popup.Modulate = detriment ? new Color(1f, 0.78f, 0.78f) : Colors.White;
+
+ _popup.Visible = true;
+ _popup.ResetSize();
+ Reposition(trigger);
+ }
+
+ public void ScheduleClose() => _closeTimer.Start();
+ public void CancelClose() => _closeTimer.Stop();
+
+ private void HidePopover() => _popup.Visible = false;
+
+ private void Reposition(Control trigger)
+ {
+ var trigRect = trigger.GetGlobalRect();
+ var popSize = _popup.GetCombinedMinimumSize();
+ var vp = trigger.GetViewportRect().Size;
+
+ // Default: under trigger, left-aligned with trigger.
+ float left = trigRect.Position.X;
+ float top = trigRect.End.Y + ArrowOffsetPx;
+
+ // Flip above if no room below and there's room above.
+ if (top + popSize.Y + ViewportPadPx > vp.Y &&
+ trigRect.Position.Y - ArrowOffsetPx - popSize.Y >= ViewportPadPx)
+ {
+ top = trigRect.Position.Y - ArrowOffsetPx - popSize.Y;
+ }
+
+ // Clamp horizontally + vertically to keep popover in-viewport.
+ left = Mathf.Clamp(left, ViewportPadPx, vp.X - popSize.X - ViewportPadPx);
+ top = Mathf.Clamp(top, ViewportPadPx, vp.Y - popSize.Y - ViewportPadPx);
+ _popup.Position = new Vector2(left, top);
+ }
+}
diff --git a/Theriapolis.Godot/Scenes/Widgets/TraitChip.cs b/Theriapolis.Godot/Scenes/Widgets/TraitChip.cs
new file mode 100644
index 0000000..44ccf0e
--- /dev/null
+++ b/Theriapolis.Godot/Scenes/Widgets/TraitChip.cs
@@ -0,0 +1,56 @@
+using Godot;
+
+namespace Theriapolis.GodotHost.Scenes.Widgets;
+
+///
+/// Small chip widget. The hover trigger for a trait/skill/detriment.
+/// Per GODOT_PORTING_GUIDE.md §6.2: on mouse-enter, asks the shared
+/// to show the popover at this chip's global
+/// rect; on exit, schedules close (popover stays alive if the cursor
+/// then enters the popover itself).
+///
+/// Lightweight — every clade trait list, every skill row, every bonus
+/// pill spawns several of these. The actual popover panel is a single
+/// reused instance owned by PopoverLayer, not one per chip.
+///
+public partial class TraitChip : PanelContainer
+{
+ [Export] public string TraitName { get; set; } = "";
+ [Export] public string Description { get; set; } = "";
+ [Export] public string Tag { get; set; } = "";
+ [Export] public bool Detriment { get; set; }
+
+ private Label _label = null!;
+
+ public override void _Ready()
+ {
+ MouseFilter = MouseFilterEnum.Stop;
+ _label = new Label
+ {
+ Text = TraitName,
+ MouseFilter = MouseFilterEnum.Ignore,
+ };
+ AddChild(_label);
+ MouseEntered += OnHoverEntered;
+ MouseExited += OnHoverExited;
+ }
+
+ public void SetTrait(string name, string description, string tag = "", bool detriment = false)
+ {
+ TraitName = name;
+ Description = description;
+ Tag = tag;
+ Detriment = detriment;
+ if (_label is not null) _label.Text = name;
+ }
+
+ private void OnHoverEntered()
+ {
+ PopoverLayer.Instance?.ShowFor(this, TraitName, Description, Tag, Detriment);
+ }
+
+ private void OnHoverExited()
+ {
+ PopoverLayer.Instance?.ScheduleClose();
+ }
+}
diff --git a/Theriapolis.Godot/Scenes/Wizard.tscn b/Theriapolis.Godot/Scenes/Wizard.tscn
index 458b4e2..153ff83 100644
--- a/Theriapolis.Godot/Scenes/Wizard.tscn
+++ b/Theriapolis.Godot/Scenes/Wizard.tscn
@@ -1,8 +1,9 @@
-[gd_scene load_steps=4 format=3 uid="uid://wizard6m6v1"]
+[gd_scene load_steps=5 format=3 uid="uid://wizard6m6v1"]
[ext_resource type="Script" path="res://Scenes/Wizard.cs" id="1_wizard"]
[ext_resource type="Script" path="res://UI/Widgets/CodexStepper.cs" id="2_stepper"]
[ext_resource type="PackedScene" path="res://Scenes/Aside.tscn" id="3_aside"]
+[ext_resource type="Script" path="res://Scenes/Widgets/PopoverLayer.cs" id="4_popover"]
[node name="Wizard" type="Control"]
anchors_preset = 15
@@ -85,3 +86,6 @@ text = "1 / 8"
[node name="NextButton" type="Button" parent="Wrap/Layout/NavBar"]
unique_name_in_owner = true
text = "Next ›"
+
+[node name="PopoverLayer" type="CanvasLayer" parent="."]
+script = ExtResource("4_popover")