diff --git a/Theriapolis.Godot/Main.cs b/Theriapolis.Godot/Main.cs
index 1d549c8..967a9a9 100644
--- a/Theriapolis.Godot/Main.cs
+++ b/Theriapolis.Godot/Main.cs
@@ -1,6 +1,7 @@
using Godot;
using Theriapolis.GodotHost.Platform;
using Theriapolis.GodotHost.Rendering;
+using Theriapolis.GodotHost.UI;
namespace Theriapolis.GodotHost;
@@ -20,9 +21,15 @@ public partial class Main : Node
ulong? smokeTestSeed = null;
ulong? worldMapSeed = null;
bool runAssetTest = false;
+ bool runCodexTest = false;
(ulong seed, int tx, int ty)? tacticalArgs = null;
for (int i = 0; i < args.Length; i++)
{
+ if (args[i] == "--codex-test")
+ {
+ runCodexTest = true;
+ break;
+ }
if (args[i] == "--smoke-test")
{
ulong seed = 12345UL;
@@ -94,6 +101,14 @@ public partial class Main : Node
return;
}
+ if (runCodexTest)
+ {
+ foreach (Node child in GetChildren())
+ child.QueueFree();
+ AddChild(new KitchenSink());
+ return;
+ }
+
GD.Print("Theriapolis.Godot host ready (M0 hello-world).");
}
diff --git a/Theriapolis.Godot/UI/CodexPalette.cs b/Theriapolis.Godot/UI/CodexPalette.cs
new file mode 100644
index 0000000..6664325
--- /dev/null
+++ b/Theriapolis.Godot/UI/CodexPalette.cs
@@ -0,0 +1,87 @@
+using Godot;
+
+namespace Theriapolis.GodotHost.UI;
+
+///
+/// Color, font, and spacing tokens lifted from
+/// CharacterCreator.zip/index.html's :root block. Variable
+/// names mirror the React prototype's CSS custom properties so the audit
+/// trail is readable: --bg, --ink, --gild, --seal, etc.
+///
+/// Single theme ships: Dark (leather + candlelight). The React prototype
+/// shipped Parchment and Blood as alternates and Compact density as a dev
+/// toggle; per user direction during M5, only Dark is needed for this
+/// game and the rest are dropped from scope (port plan §10 resolved
+/// decisions).
+///
+public struct CodexPalette
+{
+ public Color Bg, Bg2, BgDeep, Ink, InkSoft, InkMute, Rule, Gild, Seal, Seal2, Accent;
+
+ public static readonly CodexPalette Dark = new()
+ {
+ Bg = Hex("#1c1410"),
+ Bg2 = Hex("#261b14"),
+ BgDeep = Hex("#100a07"),
+ Ink = Hex("#f0e2c4"),
+ InkSoft = Hex("#d4be90"),
+ InkMute = Hex("#8c7651"),
+ Rule = Hex("#6b5635"),
+ Gild = Hex("#d8a84a"),
+ Seal = Hex("#b03021"),
+ Seal2 = Hex("#7a1f12"),
+ Accent = Hex("#d8a84a"),
+ };
+
+ private static Color Hex(string s) => new(s);
+}
+
+///
+/// Spacing + radius tokens. Density is locked at "normal" per port plan
+/// §10 (compact mode dropped from scope).
+///
+public static class CodexSpacing
+{
+ public const int Gap = 24; // --gap
+ public const int Pad = 28; // --pad
+ public const int Radius = 2; // --radius
+}
+
+///
+/// Font-size + letter-spacing tokens. Sizes are in px, matching the CSS.
+/// Letter-spacing values are in em multiplied by 1000 (em isn't a Godot
+/// concept; we use it for documentation only — see CodexTheme for actual
+/// Theme constant assignment).
+///
+public static class CodexType
+{
+ // Headings (h1..h4)
+ public const int H1Size = 38;
+ public const int H2Size = 28;
+ public const int H3Size = 20;
+ public const int H4Size = 14;
+
+ // Body / display
+ public const int BodySize = 14;
+ public const int BodyLargeSize = 15;
+
+ // Mono micro-text (eyebrows, meta lines, codex-sub)
+ public const int MonoEyebrowSize = 11;
+ public const int MonoSmallSize = 10;
+ public const int MonoXSmallSize = 9;
+
+ // Codex title (.codex-title)
+ public const int CodexTitleSize = 28;
+
+ // Card components
+ public const int CardNameSize = 22;
+ public const int CardBodySize = 14;
+
+ // Buttons
+ public const int BtnSize = 16;
+ public const int BtnSmallSize = 13;
+
+ // Stepper
+ public const int StepperNumSize = 22;
+ public const int StepperNameSize = 10;
+}
diff --git a/Theriapolis.Godot/UI/CodexTheme.cs b/Theriapolis.Godot/UI/CodexTheme.cs
new file mode 100644
index 0000000..85317f7
--- /dev/null
+++ b/Theriapolis.Godot/UI/CodexTheme.cs
@@ -0,0 +1,311 @@
+using Godot;
+using System.IO;
+using Theriapolis.GodotHost.Platform;
+
+namespace Theriapolis.GodotHost.UI;
+
+///
+/// Programmatic Theme builder for the codex design system. Builds a Godot
+/// from a and the shared
+/// CodexSpacing / CodexType tokens; the result is applied to a root
+/// Control to cascade through all descendants.
+///
+/// Fonts: looks for FontFile assets under res://Fonts/ and falls
+/// back to Godot's default sans if missing. Drop these in to fully match
+/// the React prototype's typography:
+/// res://Fonts/CormorantGaramond-Regular.ttf (serif-display)
+/// res://Fonts/CormorantGaramond-Italic.ttf
+/// res://Fonts/CrimsonPro-Regular.ttf (serif-body)
+/// res://Fonts/JetBrainsMono-Regular.ttf (mono)
+///
+/// Theme variations (.tres) aren't authored in the editor — the entire
+/// theme tree is constructed in code so palette changes are atomic and
+/// reviewable as code diffs.
+///
+public static class CodexTheme
+{
+ private static FontFile? _serifDisplay;
+ private static FontFile? _serifDisplayItalic;
+ private static FontFile? _serifBody;
+ private static FontFile? _mono;
+ private static bool _fontsLoaded;
+
+ public static Theme Build()
+ {
+ EnsureFonts();
+ var palette = CodexPalette.Dark;
+ var theme = new Theme();
+
+ // Defaults applied to every Control unless overridden.
+ if (_serifBody is not null) theme.DefaultFont = _serifBody;
+ theme.DefaultFontSize = CodexType.BodySize;
+
+ ApplyPanel(theme, palette);
+ ApplyLabel(theme, palette);
+ ApplyButton(theme, palette);
+ ApplyLineEdit(theme, palette);
+ ApplyCheckBox(theme, palette);
+ ApplyScrollContainer(theme, palette);
+
+ return theme;
+ }
+
+ private static void ApplyPanel(Theme theme, CodexPalette p)
+ {
+ // Default Panel (used as background frame). Mirrors .app-frame's
+ // border + bg-with-overlay treatment, simplified to a single
+ // StyleBoxFlat with border.
+ var box = new StyleBoxFlat
+ {
+ BgColor = p.Bg,
+ BorderColor = p.Rule,
+ CornerRadiusTopLeft = CodexSpacing.Radius,
+ CornerRadiusTopRight = CodexSpacing.Radius,
+ CornerRadiusBottomLeft = CodexSpacing.Radius,
+ CornerRadiusBottomRight = CodexSpacing.Radius,
+ };
+ box.SetBorderWidthAll(1);
+ theme.SetStylebox("panel", "Panel", box);
+
+ // Card variant — slightly raised against the page background.
+ // Used by character-creation grid cards (Calling, History, etc.).
+ var card = new StyleBoxFlat
+ {
+ BgColor = p.Bg2,
+ BorderColor = p.Rule,
+ CornerRadiusTopLeft = CodexSpacing.Radius,
+ CornerRadiusTopRight = CodexSpacing.Radius,
+ CornerRadiusBottomLeft = CodexSpacing.Radius,
+ CornerRadiusBottomRight = CodexSpacing.Radius,
+ ContentMarginLeft = 18,
+ ContentMarginRight = 18,
+ ContentMarginTop = 18,
+ ContentMarginBottom = 16,
+ };
+ card.SetBorderWidthAll(1);
+ theme.SetStylebox("panel", "Card", card);
+
+ var cardSelected = (StyleBoxFlat)card.Duplicate();
+ cardSelected.BorderColor = p.Seal;
+ cardSelected.SetBorderWidthAll(1);
+ cardSelected.ShadowColor = WithAlpha(p.Seal, 0.6f);
+ cardSelected.ShadowSize = 14;
+ cardSelected.ShadowOffset = new Vector2(0, 14);
+ theme.SetStylebox("panel_selected", "Card", cardSelected);
+
+ // Popover frame — gild border + soft shadow. Matches .trait-hint.
+ var popover = new StyleBoxFlat
+ {
+ BgColor = p.Bg2,
+ BorderColor = p.Gild,
+ ContentMarginLeft = 16,
+ ContentMarginRight = 16,
+ ContentMarginTop = 14,
+ ContentMarginBottom = 12,
+ ShadowColor = new Color(0, 0, 0, 0.45f),
+ ShadowSize = 18,
+ ShadowOffset = new Vector2(0, 12),
+ };
+ popover.SetBorderWidthAll(1);
+ theme.SetStylebox("panel", "CodexPopover", popover);
+
+ var popoverDetriment = (StyleBoxFlat)popover.Duplicate();
+ popoverDetriment.BorderColor = p.Seal;
+ theme.SetStylebox("panel_detriment", "CodexPopover", popoverDetriment);
+ }
+
+ private static void ApplyLabel(Theme theme, CodexPalette p)
+ {
+ // Default Label — body type, soft ink.
+ theme.SetColor("font_color", "Label", p.Ink);
+ theme.SetFontSize("font_size", "Label", CodexType.BodySize);
+ if (_serifBody is not null) theme.SetFont("font", "Label", _serifBody);
+
+ // h1..h4 variations applied via SetTypeVariation in code (Godot 4
+ // type variations work by setting a control's ThemeTypeVariation).
+ AddLabelVariation(theme, "H1", p.Ink, CodexType.H1Size, _serifDisplay);
+ AddLabelVariation(theme, "H2", p.Ink, CodexType.H2Size, _serifDisplay);
+ AddLabelVariation(theme, "H3", p.Ink, CodexType.H3Size, _serifDisplay);
+ AddLabelVariation(theme, "H4", p.InkMute, CodexType.H4Size, _serifDisplay);
+
+ // .codex-title — uppercased, wide tracking.
+ AddLabelVariation(theme, "CodexTitle", p.Ink, CodexType.CodexTitleSize, _serifDisplay);
+
+ // .codex-sub / .eyebrow / .meta — mono micro-text in muted ink.
+ AddLabelVariation(theme, "Eyebrow", p.InkMute, CodexType.MonoEyebrowSize, _mono);
+
+ // .nav-progress — same mono micro-text style.
+ AddLabelVariation(theme, "Meta", p.InkMute, CodexType.MonoSmallSize, _mono);
+
+ // .validation — italicised seal-red when error, mono-mute when ok.
+ AddLabelVariation(theme, "ValidationError", p.Seal, CodexType.BodySize, _serifDisplay);
+ AddLabelVariation(theme, "ValidationOk", p.InkMute, CodexType.MonoEyebrowSize, _mono);
+
+ // Card name + body
+ AddLabelVariation(theme, "CardName", p.Ink, CodexType.CardNameSize, _serifDisplay);
+ AddLabelVariation(theme, "CardBody", p.InkSoft, CodexType.CardBodySize, _serifBody);
+ AddLabelVariation(theme, "CardMeta", p.InkMute, CodexType.MonoSmallSize, _mono);
+
+ // Stepper labels
+ AddLabelVariation(theme, "StepperNum", p.InkMute, CodexType.StepperNumSize, _serifDisplay);
+ AddLabelVariation(theme, "StepperName", p.InkMute, CodexType.StepperNameSize, _mono);
+ }
+
+ private static void AddLabelVariation(Theme theme, string variation, Color color, int size, FontFile? font)
+ {
+ theme.SetTypeVariation(variation, "Label");
+ theme.SetColor("font_color", variation, color);
+ theme.SetFontSize("font_size", variation, size);
+ if (font is not null) theme.SetFont("font", variation, font);
+ }
+
+ private static void ApplyButton(Theme theme, CodexPalette p)
+ {
+ if (_serifDisplay is not null) theme.SetFont("font", "Button", _serifDisplay);
+ theme.SetFontSize("font_size", "Button", CodexType.BtnSize);
+ theme.SetColor("font_color", "Button", p.Ink);
+ theme.SetColor("font_hover_color", "Button", p.Bg);
+ theme.SetColor("font_pressed_color", "Button", p.Bg);
+ theme.SetColor("font_disabled_color","Button", WithAlpha(p.Ink, 0.4f));
+
+ var normal = MakeButtonBox(p.Bg, p.Ink);
+ var hover = MakeButtonBox(p.Ink, p.Ink);
+ var pressed = MakeButtonBox(p.InkSoft, p.Ink);
+ var disabled = MakeButtonBox(p.Bg, WithAlpha(p.Ink, 0.4f));
+ theme.SetStylebox("normal", "Button", normal);
+ theme.SetStylebox("hover", "Button", hover);
+ theme.SetStylebox("pressed", "Button", pressed);
+ theme.SetStylebox("disabled", "Button", disabled);
+
+ // Primary variant — seal-red fill.
+ theme.SetTypeVariation("PrimaryButton", "Button");
+ theme.SetColor("font_color", "PrimaryButton", p.Bg);
+ theme.SetColor("font_hover_color", "PrimaryButton", p.Bg);
+ theme.SetStylebox("normal", "PrimaryButton", MakeButtonBox(p.Seal, p.Seal));
+ theme.SetStylebox("hover", "PrimaryButton", MakeButtonBox(p.Seal2, p.Seal2));
+ theme.SetStylebox("pressed", "PrimaryButton", MakeButtonBox(p.Seal2, p.Seal2));
+
+ // Ghost variant — transparent background, rule-coloured border.
+ theme.SetTypeVariation("GhostButton", "Button");
+ theme.SetColor("font_color", "GhostButton", p.InkSoft);
+ theme.SetColor("font_hover_color", "GhostButton", p.Ink);
+ theme.SetStylebox("normal", "GhostButton", MakeButtonBox(new Color(0, 0, 0, 0), p.Rule));
+ theme.SetStylebox("hover", "GhostButton", MakeButtonBox(WithAlpha(p.Ink, 0.06f), p.Rule));
+ theme.SetStylebox("pressed", "GhostButton", MakeButtonBox(WithAlpha(p.Ink, 0.12f), p.Rule));
+ }
+
+ private static StyleBoxFlat MakeButtonBox(Color bg, Color border)
+ {
+ var box = new StyleBoxFlat
+ {
+ BgColor = bg,
+ BorderColor = border,
+ CornerRadiusTopLeft = CodexSpacing.Radius,
+ CornerRadiusTopRight = CodexSpacing.Radius,
+ CornerRadiusBottomLeft = CodexSpacing.Radius,
+ CornerRadiusBottomRight = CodexSpacing.Radius,
+ ContentMarginLeft = 22,
+ ContentMarginRight = 22,
+ ContentMarginTop = 10,
+ ContentMarginBottom = 10,
+ };
+ box.SetBorderWidthAll(1);
+ return box;
+ }
+
+ private static void ApplyLineEdit(Theme theme, CodexPalette p)
+ {
+ if (_serifBody is not null) theme.SetFont("font", "LineEdit", _serifBody);
+ theme.SetFontSize("font_size", "LineEdit", CodexType.BodySize);
+ theme.SetColor("font_color", "LineEdit", p.Ink);
+ theme.SetColor("caret_color", "LineEdit", p.Seal);
+ theme.SetColor("selection_color", "LineEdit", WithAlpha(p.Gild, 0.5f));
+
+ var normal = new StyleBoxFlat
+ {
+ BgColor = p.Bg,
+ BorderColor = p.Rule,
+ ContentMarginLeft = 12,
+ ContentMarginRight = 12,
+ ContentMarginTop = 8,
+ ContentMarginBottom = 8,
+ };
+ normal.SetBorderWidthAll(1);
+ theme.SetStylebox("normal", "LineEdit", normal);
+
+ var focus = (StyleBoxFlat)normal.Duplicate();
+ focus.BorderColor = p.Gild;
+ focus.SetBorderWidthAll(1);
+ theme.SetStylebox("focus", "LineEdit", focus);
+ }
+
+ private static void ApplyCheckBox(Theme theme, CodexPalette p)
+ {
+ if (_serifBody is not null) theme.SetFont("font", "CheckBox", _serifBody);
+ theme.SetFontSize("font_size", "CheckBox", CodexType.BodySize);
+ theme.SetColor("font_color", "CheckBox", p.Ink);
+ }
+
+ private static void ApplyScrollContainer(Theme theme, CodexPalette p)
+ {
+ // Scroll bar pieces — keep subtle so they don't dominate the codex feel.
+ var bg = new StyleBoxFlat { BgColor = WithAlpha(p.Rule, 0.15f) };
+ var grabber = new StyleBoxFlat
+ {
+ BgColor = WithAlpha(p.InkMute, 0.7f),
+ CornerRadiusTopLeft = CodexSpacing.Radius,
+ CornerRadiusTopRight = CodexSpacing.Radius,
+ CornerRadiusBottomLeft = CodexSpacing.Radius,
+ CornerRadiusBottomRight = CodexSpacing.Radius,
+ };
+ theme.SetStylebox("scroll", "VScrollBar", bg);
+ theme.SetStylebox("grabber", "VScrollBar", grabber);
+ theme.SetStylebox("grabber_highlight", "VScrollBar",
+ (StyleBoxFlat)grabber.Duplicate(true));
+ theme.SetStylebox("scroll", "HScrollBar", bg);
+ theme.SetStylebox("grabber", "HScrollBar", grabber);
+ }
+
+ // ──────────────────────────────────────────────────────────────────────
+
+ private static void EnsureFonts()
+ {
+ if (_fontsLoaded) return;
+ _fontsLoaded = true;
+
+ _serifDisplay = LoadFontFromFonts("CormorantGaramond-Regular.ttf");
+ _serifDisplayItalic = LoadFontFromFonts("CormorantGaramond-Italic.ttf");
+ _serifBody = LoadFontFromFonts("CrimsonPro-Regular.ttf");
+ _mono = LoadFontFromFonts("JetBrainsMono-Regular.ttf");
+
+ if (_serifDisplay is null && _serifBody is null && _mono is null)
+ {
+ GD.Print("[codex-theme] No fonts in res://Fonts/. Using Godot defaults. " +
+ "Drop CormorantGaramond, CrimsonPro, JetBrainsMono TTFs into " +
+ "res://Fonts/ for full design parity.");
+ }
+ }
+
+ private static FontFile? LoadFontFromFonts(string filename)
+ {
+ // Try res://Fonts/ first (Godot-managed).
+ string resPath = $"res://Fonts/{filename}";
+ if (ResourceLoader.Exists(resPath))
+ return ResourceLoader.Load(resPath);
+
+ // Fall back to Content/Fonts/ via filesystem load (sibling of Gfx,
+ // mirrors how MonoGame's CodexFonts loader walks).
+ string fsPath = Path.Combine(ContentPaths.ContentRoot, "Fonts", filename);
+ if (File.Exists(fsPath))
+ {
+ var font = new FontFile();
+ font.LoadDynamicFont(fsPath);
+ return font;
+ }
+ return null;
+ }
+
+ // ──────────────────────────────────────────────────────────────────────
+
+ public static Color WithAlpha(Color c, float a) => new(c.R, c.G, c.B, a);
+}
diff --git a/Theriapolis.Godot/UI/KitchenSink.cs b/Theriapolis.Godot/UI/KitchenSink.cs
new file mode 100644
index 0000000..df0562d
--- /dev/null
+++ b/Theriapolis.Godot/UI/KitchenSink.cs
@@ -0,0 +1,229 @@
+using Godot;
+using Theriapolis.GodotHost.UI.Widgets;
+
+namespace Theriapolis.GodotHost.UI;
+
+///
+/// M5 kitchen-sink. Renders every primitive from the codex design system
+/// (single Dark theme — Parchment and Blood dropped from scope per port
+/// plan §10) so we can eyeball parity against screenshots of the React
+/// prototype. Launch via:
+/// godot --path Theriapolis.Godot --codex-test
+///
+public partial class KitchenSink : Control
+{
+ private Panel _root = null!;
+ private CodexStepper _stepper = null!;
+
+ public override void _Ready()
+ {
+ AnchorRight = 1f;
+ AnchorBottom = 1f;
+ OffsetRight = 0;
+ OffsetBottom = 0;
+
+ _root = new Panel { ThemeTypeVariation = "Panel" };
+ _root.AnchorRight = 1f;
+ _root.AnchorBottom = 1f;
+ AddChild(_root);
+
+ ApplyTheme();
+ BuildContent();
+ }
+
+ private void ApplyTheme()
+ {
+ _root.Theme = CodexTheme.Build();
+
+ // The root Panel's stylebox covers its rect; fill the *outer*
+ // viewport (which Godot draws with its default clear colour) so
+ // screenshots don't show engine grey along any edge.
+ RenderingServer.SetDefaultClearColor(CodexPalette.Dark.BgDeep);
+ }
+
+ private void BuildContent()
+ {
+ var margin = new MarginContainer();
+ margin.AddThemeConstantOverride("margin_left", 36);
+ margin.AddThemeConstantOverride("margin_right", 36);
+ margin.AddThemeConstantOverride("margin_top", 28);
+ margin.AddThemeConstantOverride("margin_bottom", 28);
+ margin.AnchorRight = 1f;
+ margin.AnchorBottom = 1f;
+ _root.AddChild(margin);
+
+ var scroll = new ScrollContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
+ margin.AddChild(scroll);
+
+ var col = new VBoxContainer
+ {
+ SizeFlagsHorizontal = SizeFlags.ExpandFill,
+ };
+ col.AddThemeConstantOverride("separation", 24);
+ scroll.AddChild(col);
+
+ BuildHeader(col);
+ BuildStepperSection(col);
+ BuildButtonsSection(col);
+ BuildInputsSection(col);
+ BuildCardSection(col);
+ BuildPopoverSection(col);
+ }
+
+ private void BuildHeader(VBoxContainer col)
+ {
+ var h = new HBoxContainer();
+ col.AddChild(h);
+
+ var titleCol = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
+ h.AddChild(titleCol);
+
+ titleCol.AddChild(new Label { Text = "THERIAPOLIS · CODEX OF BECOMING", ThemeTypeVariation = "CodexTitle" });
+ titleCol.AddChild(new Label { Text = "Kitchen sink — every primitive, three themes", ThemeTypeVariation = "Eyebrow" });
+
+ var meta = new Label { Text = "M5 · DESIGN AUDIT", ThemeTypeVariation = "Meta" };
+ h.AddChild(meta);
+ }
+
+ private void BuildStepperSection(VBoxContainer col)
+ {
+ var section = MakeSection(col, "STEPPER");
+ _stepper = new CodexStepper();
+ _stepper.SizeFlagsHorizontal = SizeFlags.ExpandFill;
+ section.AddChild(_stepper);
+
+ _stepper.SetSteps(
+ new[] { "Clade", "Species", "Calling", "Subclass", "History", "Abilities", "Skills", "Sign" },
+ new[]
+ {
+ CodexStepper.StepState.Complete,
+ CodexStepper.StepState.Complete,
+ CodexStepper.StepState.Complete,
+ CodexStepper.StepState.Active,
+ CodexStepper.StepState.Pending,
+ CodexStepper.StepState.Locked,
+ CodexStepper.StepState.Locked,
+ CodexStepper.StepState.Locked,
+ });
+
+ _stepper.StepClicked += (int idx) => GD.Print($"[kitchen-sink] Stepper clicked index={idx}");
+ }
+
+ private void BuildButtonsSection(VBoxContainer col)
+ {
+ var section = MakeSection(col, "BUTTONS");
+ var row = new HBoxContainer();
+ row.AddThemeConstantOverride("separation", 12);
+ section.AddChild(row);
+
+ row.AddChild(new Button { Text = "Default" });
+ row.AddChild(new Button { Text = "Primary", ThemeTypeVariation = "PrimaryButton" });
+ row.AddChild(new Button { Text = "Ghost", ThemeTypeVariation = "GhostButton" });
+ var disabled = new Button { Text = "Disabled", Disabled = true };
+ row.AddChild(disabled);
+ }
+
+ private void BuildInputsSection(VBoxContainer col)
+ {
+ var section = MakeSection(col, "INPUTS");
+ var row = new HBoxContainer();
+ row.AddThemeConstantOverride("separation", 12);
+ section.AddChild(row);
+
+ var le = new LineEdit
+ {
+ PlaceholderText = "Enter your name...",
+ CustomMinimumSize = new Vector2(280, 0),
+ };
+ row.AddChild(le);
+
+ row.AddChild(new CheckBox { Text = "Toggle option" });
+ }
+
+ private void BuildCardSection(VBoxContainer col)
+ {
+ var section = MakeSection(col, "CARDS");
+ var grid = new HBoxContainer();
+ grid.AddThemeConstantOverride("separation", CodexSpacing.Gap);
+ section.AddChild(grid);
+
+ AddCard(grid, "Canidae", "Predator · medium", "Strong-jawed pack hunters of the inland forest.", selected: false);
+ AddCard(grid, "Felidae", "Predator · medium", "Solitary stalkers, claw-tipped and sharp of ear.", selected: true);
+ AddCard(grid, "Mustelidae", "Predator · small", "Burrowers and threadkillers of the river edges.", selected: false);
+ }
+
+ private void AddCard(HBoxContainer parent, string name, string meta, string body, bool selected)
+ {
+ var panel = new PanelContainer
+ {
+ ThemeTypeVariation = "Card",
+ CustomMinimumSize = new Vector2(240, 0),
+ };
+ if (selected && _root.Theme is not null && _root.Theme.HasStylebox("panel_selected", "Card"))
+ {
+ var box = _root.Theme.GetStylebox("panel_selected", "Card");
+ panel.AddThemeStyleboxOverride("panel", box);
+ }
+ var v = new VBoxContainer();
+ v.AddThemeConstantOverride("separation", 6);
+ panel.AddChild(v);
+ v.AddChild(new Label { Text = name, ThemeTypeVariation = "CardName" });
+ v.AddChild(new Label { Text = meta.ToUpperInvariant(), ThemeTypeVariation = "CardMeta" });
+ v.AddChild(new Label
+ {
+ Text = body,
+ ThemeTypeVariation = "CardBody",
+ AutowrapMode = TextServer.AutowrapMode.WordSmart,
+ CustomMinimumSize = new Vector2(220, 0),
+ });
+ parent.AddChild(panel);
+ }
+
+ private void BuildPopoverSection(VBoxContainer col)
+ {
+ var section = MakeSection(col, "TRAIT POPOVERS · HOVER");
+ var row = new HBoxContainer();
+ row.AddThemeConstantOverride("separation", 18);
+ section.AddChild(row);
+
+ row.AddChild(new CodexPopover
+ {
+ TriggerText = "Pack Tactics",
+ Title = "Pack Tactics",
+ Tag = "active",
+ Description = "Once per turn, when an ally is adjacent to your target, gain advantage on the attack.",
+ });
+ row.AddChild(new CodexPopover
+ {
+ TriggerText = "Glass Bones",
+ Title = "Glass Bones",
+ Description = "Critical hits against you deal an extra 1d6 damage.",
+ Detriment = true,
+ });
+ row.AddChild(new CodexPopover
+ {
+ TriggerText = "Athletics",
+ Title = "Athletics",
+ Tag = "STR",
+ Description = "Climbs, jumps, swims, and brute physical contests.",
+ });
+ row.AddChild(new CodexPopover
+ {
+ TriggerText = "+2",
+ Title = "STR modifier",
+ Description = "+1 from Canidae · +1 from Wolf",
+ });
+ }
+
+ private static VBoxContainer MakeSection(VBoxContainer col, string title)
+ {
+ var box = new VBoxContainer();
+ box.AddThemeConstantOverride("separation", 8);
+ col.AddChild(box);
+ box.AddChild(new Label { Text = title, ThemeTypeVariation = "Eyebrow" });
+ var inner = new VBoxContainer();
+ inner.AddThemeConstantOverride("separation", 8);
+ box.AddChild(inner);
+ return inner;
+ }
+}
diff --git a/Theriapolis.Godot/UI/Widgets/CodexPopover.cs b/Theriapolis.Godot/UI/Widgets/CodexPopover.cs
new file mode 100644
index 0000000..7f60048
--- /dev/null
+++ b/Theriapolis.Godot/UI/Widgets/CodexPopover.cs
@@ -0,0 +1,214 @@
+using Godot;
+
+namespace Theriapolis.GodotHost.UI.Widgets;
+
+///
+/// Hoverable text trigger that shows a floating popover with name + optional
+/// tag + description. Mirrors src/trait-hint.jsx from the React
+/// prototype — viewport-clamp horizontally and vertically, flip above/below
+/// based on available space, 80ms grace period when moving from trigger to
+/// popover so the popover stays open across the gap.
+///
+/// Used by future TraitName, SkillChip, BonusPill widgets — the React
+/// version layers className differences on top of this same primitive.
+///
+public partial class CodexPopover : Control
+{
+ [Export] public string TriggerText { get; set; } = "trait";
+ [Export] public string Title { get; set; } = "";
+ [Export] public string Tag { get; set; } = "";
+ [Export] public string Description { get; set; } = "";
+ [Export] public bool Detriment { get; set; }
+
+ private const float GracePeriodSec = 0.08f; // ~80 ms — matches trait-hint.jsx
+ private const float ArrowOffset = 6f;
+ private const int ViewportPad = 8;
+
+ private Label _trigger = null!;
+ private PanelContainer? _popover;
+ private Timer _closeTimer = null!;
+ private bool _hovering;
+
+ public override void _Ready()
+ {
+ _trigger = new Label
+ {
+ Text = string.IsNullOrEmpty(Title) ? TriggerText : Title,
+ ThemeTypeVariation = "Label",
+ MouseFilter = MouseFilterEnum.Stop,
+ };
+ _trigger.MouseEntered += OnTriggerEntered;
+ _trigger.MouseExited += OnTriggerExited;
+ AddChild(_trigger);
+
+ _closeTimer = new Timer { OneShot = true, WaitTime = GracePeriodSec };
+ _closeTimer.Timeout += HidePopover;
+ AddChild(_closeTimer);
+
+ CustomMinimumSize = _trigger.GetMinimumSize();
+ }
+
+ public override void _Notification(int what)
+ {
+ // Make sure the popover is freed if the trigger is removed.
+ if (what == NotificationExitTree && _popover is not null)
+ {
+ _popover.QueueFree();
+ _popover = null;
+ }
+ }
+
+ private void OnTriggerEntered()
+ {
+ _hovering = true;
+ _closeTimer.Stop();
+ ShowPopover();
+ }
+
+ private void OnTriggerExited()
+ {
+ _hovering = false;
+ _closeTimer.Start();
+ }
+
+ private void OnPopoverEntered()
+ {
+ _closeTimer.Stop();
+ }
+
+ private void OnPopoverExited()
+ {
+ _closeTimer.Start();
+ }
+
+ private void ShowPopover()
+ {
+ if (_popover is not null && IsInstanceValid(_popover)) { Reposition(); return; }
+
+ _popover = BuildPopoverPanel();
+ _popover.MouseEntered += OnPopoverEntered;
+ _popover.MouseExited += OnPopoverExited;
+
+ var canvas = new CanvasLayer { Layer = 100 };
+ canvas.Name = "CodexPopoverCanvas";
+ canvas.AddChild(_popover);
+ // Attach the canvas layer to the scene's root viewport so the popover
+ // floats above every other UI element regardless of where the trigger
+ // lives in the tree.
+ GetTree().Root.AddChild(canvas);
+
+ // One frame later, the panel has its laid-out size; reposition with
+ // viewport clamp + flip-above logic.
+ CallDeferred(MethodName.Reposition);
+ }
+
+ private void HidePopover()
+ {
+ if (_popover is null) return;
+ // Free the canvas layer parent (which owns the popover).
+ _popover.GetParent()?.QueueFree();
+ _popover = null;
+ }
+
+ private PanelContainer BuildPopoverPanel()
+ {
+ var panel = new PanelContainer
+ {
+ ThemeTypeVariation = "CodexPopover",
+ MouseFilter = MouseFilterEnum.Pass,
+ ZIndex = 100,
+ // Detriment popovers swap to the seal-coloured stylebox.
+ // Theme stylebox names live under "panel" for the default and
+ // "panel_detriment" for the variant; we set whichever via override.
+ };
+ if (Detriment && panel.HasThemeStylebox("panel_detriment", "CodexPopover"))
+ {
+ var box = panel.GetThemeStylebox("panel_detriment", "CodexPopover");
+ panel.AddThemeStyleboxOverride("panel", box);
+ }
+
+ var vbox = new VBoxContainer { CustomMinimumSize = new Vector2(220, 0) };
+ panel.AddChild(vbox);
+
+ var nameRow = new HBoxContainer();
+ nameRow.AddChild(new Label
+ {
+ Text = string.IsNullOrEmpty(Title) ? TriggerText : Title,
+ ThemeTypeVariation = "H3",
+ });
+ if (!string.IsNullOrEmpty(Tag))
+ {
+ var tagPill = new Label
+ {
+ Text = Tag.ToUpperInvariant(),
+ ThemeTypeVariation = "ValidationOk",
+ };
+ nameRow.AddChild(tagPill);
+ }
+ if (Detriment)
+ {
+ var detPill = new Label
+ {
+ Text = "DETRIMENT",
+ ThemeTypeVariation = "ValidationOk",
+ };
+ nameRow.AddChild(detPill);
+ }
+ vbox.AddChild(nameRow);
+
+ if (!string.IsNullOrEmpty(Description))
+ {
+ var desc = new Label
+ {
+ Text = Description,
+ AutowrapMode = TextServer.AutowrapMode.WordSmart,
+ ThemeTypeVariation = "CardBody",
+ };
+ vbox.AddChild(desc);
+ }
+
+ return panel;
+ }
+
+ private void Reposition()
+ {
+ if (_popover is null || !IsInstanceValid(_popover)) return;
+
+ var viewport = GetViewport().GetVisibleRect();
+ var trig = _trigger.GetGlobalRect();
+ var size = _popover.GetCombinedMinimumSize();
+
+ float left = trig.Position.X;
+ float top = trig.Position.Y + trig.Size.Y + ArrowOffset;
+ bool flippedAbove = false;
+
+ if (top + size.Y + ViewportPad > viewport.Size.Y &&
+ trig.Position.Y - ArrowOffset - size.Y >= ViewportPad)
+ {
+ top = trig.Position.Y - ArrowOffset - size.Y;
+ flippedAbove = true;
+ }
+
+ if (left + size.X + ViewportPad > viewport.Size.X)
+ left = viewport.Size.X - size.X - ViewportPad;
+ if (left < ViewportPad) left = ViewportPad;
+
+ if (top + size.Y + ViewportPad > viewport.Size.Y)
+ top = viewport.Size.Y - size.Y - ViewportPad;
+ if (top < ViewportPad) top = ViewportPad;
+
+ _popover.Position = new Vector2(left, top);
+ _popover.Size = size;
+ // flippedAbove can drive a future arrow image; we don't render an
+ // arrow in M5 — the React version's CSS pseudo-element doesn't
+ // map cleanly onto Godot's StyleBox. Border + shadow is sufficient.
+ _ = flippedAbove;
+ }
+
+ public override void _Process(double delta)
+ {
+ // If the trigger is still mounted but the popover got orphaned for any
+ // reason, drop our reference so a fresh hover re-creates it.
+ if (_popover is not null && !IsInstanceValid(_popover)) _popover = null;
+ }
+}
diff --git a/Theriapolis.Godot/UI/Widgets/CodexStepper.cs b/Theriapolis.Godot/UI/Widgets/CodexStepper.cs
new file mode 100644
index 0000000..d5eb1db
--- /dev/null
+++ b/Theriapolis.Godot/UI/Widgets/CodexStepper.cs
@@ -0,0 +1,168 @@
+using System;
+using System.Collections.Generic;
+using Godot;
+
+namespace Theriapolis.GodotHost.UI.Widgets;
+
+///
+/// Codex stepper. Mirrors .stepper / .step in the React prototype:
+/// horizontal grid of N steps, each with a Roman numeral and an uppercased
+/// name. Per-step state drives colour and the gild-coloured underline:
+/// Active (current), Complete (✓ + seal), Locked (✕ + dimmed). Click on
+/// any non-locked step emits .
+///
+/// Lock semantics mirror app.jsx: a step is locked iff some
+/// earlier step has unmet validation. The owning screen passes a per-step
+/// array; this widget just renders.
+///
+public partial class CodexStepper : HBoxContainer
+{
+ public enum StepState { Pending, Active, Complete, Locked }
+
+ [Signal] public delegate void StepClickedEventHandler(int index);
+
+ private readonly List _entries = new();
+
+ public void SetSteps(IReadOnlyList names, IReadOnlyList states)
+ {
+ if (names.Count != states.Count)
+ throw new ArgumentException("names and states must be the same length");
+
+ // Rebuild children from scratch — N is small (<=8) so this is cheap.
+ foreach (var child in GetChildren())
+ child.QueueFree();
+ _entries.Clear();
+
+ for (int i = 0; i < names.Count; i++)
+ {
+ var entry = BuildStep(i, names[i], states[i], isLast: i == names.Count - 1);
+ AddChild(entry.Container);
+ _entries.Add(entry);
+ }
+ }
+
+ private StepEntry BuildStep(int index, string name, StepState state, bool isLast)
+ {
+ // Outer cell — VBoxContainer with mouse handling on a button child
+ // so we get press events for click-to-jump. SizeFlagsHorizontal
+ // expand makes each cell take an equal share of the width.
+ var btn = new Button
+ {
+ ThemeTypeVariation = "GhostButton",
+ Flat = true,
+ FocusMode = FocusModeEnum.None,
+ CustomMinimumSize = new Vector2(0, 64),
+ SizeFlagsHorizontal = SizeFlags.ExpandFill,
+ ToggleMode = false,
+ };
+ // Build a vbox child for two stacked labels.
+ var vbox = new VBoxContainer
+ {
+ MouseFilter = MouseFilterEnum.Ignore,
+ SizeFlagsHorizontal = SizeFlags.ExpandFill,
+ SizeFlagsVertical = SizeFlags.ExpandFill,
+ Alignment = BoxContainer.AlignmentMode.Center,
+ };
+ btn.AddChild(vbox);
+
+ var num = new Label
+ {
+ Text = state == StepState.Locked ? "✕" : Roman(index + 1),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ ThemeTypeVariation = "StepperNum",
+ MouseFilter = MouseFilterEnum.Ignore,
+ };
+ vbox.AddChild(num);
+
+ var lbl = new Label
+ {
+ Text = name.ToUpperInvariant(),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ ThemeTypeVariation = "StepperName",
+ MouseFilter = MouseFilterEnum.Ignore,
+ };
+ vbox.AddChild(lbl);
+
+ ApplyStateColors(num, lbl, state);
+
+ if (state != StepState.Locked)
+ btn.Pressed += () => EmitSignal(SignalName.StepClicked, index);
+ else
+ btn.Disabled = true;
+
+ // The active step gets a 2-px gild underline. Implement as a
+ // ColorRect bottom-anchored at -1 within the button.
+ if (state == StepState.Active)
+ {
+ var underline = new ColorRect
+ {
+ Color = TryGetThemeColor("font_color", "Gild") ?? new Color("#b48a3c"),
+ MouseFilter = MouseFilterEnum.Ignore,
+ };
+ underline.AnchorTop = 1.0f;
+ underline.AnchorBottom = 1.0f;
+ underline.AnchorLeft = 0.14f;
+ underline.AnchorRight = 0.86f;
+ underline.OffsetTop = -2;
+ underline.OffsetBottom = 0;
+ btn.AddChild(underline);
+ }
+
+ return new StepEntry(btn, num, lbl);
+ }
+
+ private static void ApplyStateColors(Label num, Label name, StepState state)
+ {
+ // Default theme colours come from the StepperNum/StepperName variations
+ // (ink-mute). State overrides bring active steps to ink and complete
+ // to seal-red. Locked uses the dim default plus reduced opacity.
+ Color? numColor = state switch
+ {
+ StepState.Active => TryGetGlobalThemeColor("Ink", new Color("#2b1d10")),
+ StepState.Complete => TryGetGlobalThemeColor("Seal", new Color("#7a1f12")),
+ _ => null,
+ };
+ Color? nameColor = state switch
+ {
+ StepState.Active => TryGetGlobalThemeColor("Ink", new Color("#2b1d10")),
+ _ => null,
+ };
+
+ if (numColor.HasValue) num.AddThemeColorOverride("font_color", numColor.Value);
+ if (nameColor.HasValue) name.AddThemeColorOverride("font_color", nameColor.Value);
+
+ if (state == StepState.Complete)
+ num.Text = "✓ " + num.Text;
+
+ if (state == StepState.Locked)
+ {
+ num.Modulate = new Color(1, 1, 1, 0.45f);
+ name.Modulate = new Color(1, 1, 1, 0.45f);
+ }
+ }
+
+ private static Color? TryGetGlobalThemeColor(string name, Color fallback) => fallback;
+
+ private Color? TryGetThemeColor(string property, string variation)
+ {
+ if (HasThemeColor(property, variation)) return GetThemeColor(property, variation);
+ return null;
+ }
+
+ private static string Roman(int n) => n switch
+ {
+ 1 => "I",
+ 2 => "II",
+ 3 => "III",
+ 4 => "IV",
+ 5 => "V",
+ 6 => "VI",
+ 7 => "VII",
+ 8 => "VIII",
+ 9 => "IX",
+ 10 => "X",
+ _ => n.ToString(),
+ };
+
+ private readonly record struct StepEntry(Button Container, Label Num, Label Name);
+}
diff --git a/theriapolis-rpg-implementation-plan-godot-port.md b/theriapolis-rpg-implementation-plan-godot-port.md
index 281220e..c07b4e8 100644
--- a/theriapolis-rpg-implementation-plan-godot-port.md
+++ b/theriapolis-rpg-implementation-plan-godot-port.md
@@ -55,10 +55,10 @@ architecture test gets a one-line update (the forbidden namespace becomes
dungeon, plus the Phase-7 dialogue→combat overlay. *Visual* parity with
today's CodexUI is **not** a goal — the React prototype is the new
baseline. Behavioural parity (what each screen *does*) is the goal.
-5. **Ship all three themes.** Parchment (default), dark (leather &
- candlelight), blood (warm crimson). The React prototype already designed
- them; Godot's Theme + ThemeVariation system makes shipping all three
- approximately free. Theme is a runtime player setting, not a dev tweak.
+5. **Ship the dark theme only.** The React prototype designed Parchment,
+ Dark, and Blood; the user's call during M5 is that only Dark (leather +
+ candlelight) is wanted for this game. Parchment and Blood are dropped
+ from scope. No runtime theme switcher — there is one theme.
6. **Tests still green.** All ~700 Core/worldgen/determinism/save tests pass
unchanged. Project builds and runs `dotnet test` and the headless
`Theriapolis.Tools` CLI exactly as before.
@@ -270,8 +270,7 @@ only; do not preserve its visual choices.
| React prototype (CharacterCreator.zip) | Godot equivalent |
|-----------------------------------------------------|--------------------------------------------------------|
-| CSS custom properties (`--bg`, `--ink`, `--gild`, ...) | `Theme` resource type variations (one per theme) |
-| `[data-theme="dark"]` / `[data-theme="blood"]` | `ThemeVariation` resources, switched via player setting|
+| CSS custom properties (`--bg`, `--ink`, `--gild`, ...) | Single `Theme` resource constructed in `CodexTheme.cs`|
| `.codex-header`, `.codex-title`, `.codex-sub` | `PanelContainer` + `Label` with display font theme |
| `.stepper` / `.step` / `.step.active/.complete/.locked` | Custom `HBoxContainer` script with state-driven theme |
| `.page` (two-column main + aside) | `HSplitContainer` or `HBoxContainer` (fixed ratio) |
@@ -384,8 +383,8 @@ foundation every subsequent screen builds on.
`--bg` / `--ink` / `--gild` / `--seal` / `--rule` mapped to Theme constants;
display + body fonts as Theme default fonts; standard spacing/radius
constants.
-- `Theme/Variations/dark.tres` and `Theme/Variations/blood.tres`: ThemeVariations
- for the other two themes. Player setting selects the active variation.
+- ~~Theme variations for Parchment + Blood~~ — dropped during M5 per
+ user decision; Dark only.
- Fonts: copy Cormorant Garamond, Crimson Pro, Spectral, EB Garamond, Cinzel,
Uncial Antiqua, JetBrains Mono into `res://Fonts/`. Bundle as `FontFile`
resources at the sizes the prototype uses (verify by spot-rendering each).
@@ -712,8 +711,8 @@ The bright line: **anything that touches `WorldState`, `PlayerCharacter`,
default. The Theme must scale legibly across 1920×1080 / 2560×1440 /
3840×2160. F11 (or `--windowed`) toggles to a windowed mode for
iteration. Configured in M9.
-4. **Default theme.** Parchment. Dark and blood ship as player-selectable
- variations in the existing settings screen.
+4. **Theme.** Dark only (leather + candlelight palette). Parchment and
+ Blood dropped from scope during M5; no runtime theme switcher.
### Open questions for the user / project lead