M5: Codex design system (dark theme) + kitchen-sink

Programmatic Theme builder + reusable popover and stepper widgets,
ported from CharacterCreator.zip's :root design tokens. Kitchen-sink
scene exercises every primitive for visual eyeballing.

CodexPalette.cs:
  Color tokens lifted verbatim from the React prototype's `:root`
  block (--bg, --ink, --gild, --seal, etc.). Variable names mirror
  the CSS so the audit trail stays readable. Spacing locked at the
  prototype's normal density (--gap=24, --pad=28, --radius=2).

  Scope cut: only the Dark theme ships. The React prototype designed
  Parchment, Dark, and Blood as switchable variations — user direction
  during M5 is that only Dark (leather + candlelight) is wanted for
  this game. Parchment/Blood code dropped, plan doc updated to match
  (§1 goal #5, §4.5 UI map, §5 M5 scope, §10 resolved decisions #4).
  No runtime theme switcher.

CodexTheme.Build():
  Programmatically constructs a Godot Theme from CodexPalette.Dark
  plus CodexSpacing/CodexType tokens. Configures Panel, Card,
  CodexPopover styleboxes; Label variations for H1..H4, CodexTitle,
  Eyebrow, Meta, ValidationOk/Error, CardName/Body/Meta, StepperNum/
  Name; Button + PrimaryButton + GhostButton variants; LineEdit,
  CheckBox, scrollbar styling.

  Fonts: looks for CormorantGaramond / CrimsonPro / JetBrainsMono
  TTFs in res://Fonts/ (or Content/Fonts/) and graceful-falls-back to
  Godot defaults if missing. M5 ships with no fonts in repo; user can
  drop them in later for typography parity with the React prototype.

CodexPopover.cs:
  Hoverable text trigger + floating PanelContainer, mirrors
  src/trait-hint.jsx. Viewport-clamps horizontally and vertically;
  flips above the trigger if there's no room below; 80 ms grace
  period when moving cursor from trigger to popover. Detriment
  variant uses the seal-coloured stylebox. Future TraitName /
  SkillChip / BonusPill widgets layer className differences on top.

CodexStepper.cs:
  Roman-numeral horizontal stepper with Pending / Active / Complete /
  Locked states. Active step gets a 2-px gild underline, Complete
  shows a ✓ in seal-red, Locked shows ✕ + 0.45 modulate. Emits
  StepClicked(int) for non-locked rows. M5 is decorative — M6 wires
  the signal to the character-creation state machine.

KitchenSink.cs + Main.cs --codex-test:
  Verification scene rendering every primitive (header, stepper,
  buttons, inputs, cards, trait popovers). Clicks log to console.
  Fonts default to Godot's Noto Sans until res://Fonts/ is populated.

Closes M5 of theriapolis-rpg-implementation-plan-godot-port.md.
Next: M6 (title + character creation).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Christopher Wiebe
2026-05-01 20:29:22 -07:00
parent 42d66c00c3
commit 953bb985ad
7 changed files with 1033 additions and 10 deletions
+15
View File
@@ -1,6 +1,7 @@
using Godot; using Godot;
using Theriapolis.GodotHost.Platform; using Theriapolis.GodotHost.Platform;
using Theriapolis.GodotHost.Rendering; using Theriapolis.GodotHost.Rendering;
using Theriapolis.GodotHost.UI;
namespace Theriapolis.GodotHost; namespace Theriapolis.GodotHost;
@@ -20,9 +21,15 @@ public partial class Main : Node
ulong? smokeTestSeed = null; ulong? smokeTestSeed = null;
ulong? worldMapSeed = null; ulong? worldMapSeed = null;
bool runAssetTest = false; bool runAssetTest = false;
bool runCodexTest = false;
(ulong seed, int tx, int ty)? tacticalArgs = null; (ulong seed, int tx, int ty)? tacticalArgs = null;
for (int i = 0; i < args.Length; i++) for (int i = 0; i < args.Length; i++)
{ {
if (args[i] == "--codex-test")
{
runCodexTest = true;
break;
}
if (args[i] == "--smoke-test") if (args[i] == "--smoke-test")
{ {
ulong seed = 12345UL; ulong seed = 12345UL;
@@ -94,6 +101,14 @@ public partial class Main : Node
return; return;
} }
if (runCodexTest)
{
foreach (Node child in GetChildren())
child.QueueFree();
AddChild(new KitchenSink());
return;
}
GD.Print("Theriapolis.Godot host ready (M0 hello-world)."); GD.Print("Theriapolis.Godot host ready (M0 hello-world).");
} }
+87
View File
@@ -0,0 +1,87 @@
using Godot;
namespace Theriapolis.GodotHost.UI;
/// <summary>
/// Color, font, and spacing tokens lifted from
/// <c>CharacterCreator.zip/index.html</c>'s <c>:root</c> 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).
/// </summary>
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);
}
/// <summary>
/// Spacing + radius tokens. Density is locked at "normal" per port plan
/// §10 (compact mode dropped from scope).
/// </summary>
public static class CodexSpacing
{
public const int Gap = 24; // --gap
public const int Pad = 28; // --pad
public const int Radius = 2; // --radius
}
/// <summary>
/// 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).
/// </summary>
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;
}
+311
View File
@@ -0,0 +1,311 @@
using Godot;
using System.IO;
using Theriapolis.GodotHost.Platform;
namespace Theriapolis.GodotHost.UI;
/// <summary>
/// Programmatic Theme builder for the codex design system. Builds a Godot
/// <see cref="Theme"/> from a <see cref="CodexPalette"/> 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 <c>res://Fonts/</c> 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.
/// </summary>
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<FontFile>(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);
}
+229
View File
@@ -0,0 +1,229 @@
using Godot;
using Theriapolis.GodotHost.UI.Widgets;
namespace Theriapolis.GodotHost.UI;
/// <summary>
/// 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
/// </summary>
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;
}
}
@@ -0,0 +1,214 @@
using Godot;
namespace Theriapolis.GodotHost.UI.Widgets;
/// <summary>
/// Hoverable text trigger that shows a floating popover with name + optional
/// tag + description. Mirrors <c>src/trait-hint.jsx</c> 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.
/// </summary>
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;
}
}
@@ -0,0 +1,168 @@
using System;
using System.Collections.Generic;
using Godot;
namespace Theriapolis.GodotHost.UI.Widgets;
/// <summary>
/// Codex stepper. Mirrors <c>.stepper / .step</c> 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 <see cref="StepClicked"/>.
///
/// Lock semantics mirror <c>app.jsx</c>: a step is locked iff some
/// earlier step has unmet validation. The owning screen passes a per-step
/// <see cref="StepState"/> array; this widget just renders.
/// </summary>
public partial class CodexStepper : HBoxContainer
{
public enum StepState { Pending, Active, Complete, Locked }
[Signal] public delegate void StepClickedEventHandler(int index);
private readonly List<StepEntry> _entries = new();
public void SetSteps(IReadOnlyList<string> names, IReadOnlyList<StepState> 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);
}
@@ -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 dungeon, plus the Phase-7 dialogue→combat overlay. *Visual* parity with
today's CodexUI is **not** a goal — the React prototype is the new today's CodexUI is **not** a goal — the React prototype is the new
baseline. Behavioural parity (what each screen *does*) is the goal. baseline. Behavioural parity (what each screen *does*) is the goal.
5. **Ship all three themes.** Parchment (default), dark (leather & 5. **Ship the dark theme only.** The React prototype designed Parchment,
candlelight), blood (warm crimson). The React prototype already designed Dark, and Blood; the user's call during M5 is that only Dark (leather +
them; Godot's Theme + ThemeVariation system makes shipping all three candlelight) is wanted for this game. Parchment and Blood are dropped
approximately free. Theme is a runtime player setting, not a dev tweak. from scope. No runtime theme switcher — there is one theme.
6. **Tests still green.** All ~700 Core/worldgen/determinism/save tests pass 6. **Tests still green.** All ~700 Core/worldgen/determinism/save tests pass
unchanged. Project builds and runs `dotnet test` and the headless unchanged. Project builds and runs `dotnet test` and the headless
`Theriapolis.Tools` CLI exactly as before. `Theriapolis.Tools` CLI exactly as before.
@@ -270,8 +270,7 @@ only; do not preserve its visual choices.
| React prototype (CharacterCreator.zip) | Godot equivalent | | React prototype (CharacterCreator.zip) | Godot equivalent |
|-----------------------------------------------------|--------------------------------------------------------| |-----------------------------------------------------|--------------------------------------------------------|
| CSS custom properties (`--bg`, `--ink`, `--gild`, ...) | `Theme` resource type variations (one per theme) | | CSS custom properties (`--bg`, `--ink`, `--gild`, ...) | Single `Theme` resource constructed in `CodexTheme.cs`|
| `[data-theme="dark"]` / `[data-theme="blood"]` | `ThemeVariation` resources, switched via player setting|
| `.codex-header`, `.codex-title`, `.codex-sub` | `PanelContainer` + `Label` with display font theme | | `.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 | | `.stepper` / `.step` / `.step.active/.complete/.locked` | Custom `HBoxContainer` script with state-driven theme |
| `.page` (two-column main + aside) | `HSplitContainer` or `HBoxContainer` (fixed ratio) | | `.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; `--bg` / `--ink` / `--gild` / `--seal` / `--rule` mapped to Theme constants;
display + body fonts as Theme default fonts; standard spacing/radius display + body fonts as Theme default fonts; standard spacing/radius
constants. constants.
- `Theme/Variations/dark.tres` and `Theme/Variations/blood.tres`: ThemeVariations - ~~Theme variations for Parchment + Blood~~ — dropped during M5 per
for the other two themes. Player setting selects the active variation. user decision; Dark only.
- Fonts: copy Cormorant Garamond, Crimson Pro, Spectral, EB Garamond, Cinzel, - Fonts: copy Cormorant Garamond, Crimson Pro, Spectral, EB Garamond, Cinzel,
Uncial Antiqua, JetBrains Mono into `res://Fonts/`. Bundle as `FontFile` Uncial Antiqua, JetBrains Mono into `res://Fonts/`. Bundle as `FontFile`
resources at the sizes the prototype uses (verify by spot-rendering each). 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 / default. The Theme must scale legibly across 1920×1080 / 2560×1440 /
3840×2160. F11 (or `--windowed`) toggles to a windowed mode for 3840×2160. F11 (or `--windowed`) toggles to a windowed mode for
iteration. Configured in M9. iteration. Configured in M9.
4. **Default theme.** Parchment. Dark and blood ship as player-selectable 4. **Theme.** Dark only (leather + candlelight palette). Parchment and
variations in the existing settings screen. Blood dropped from scope during M5; no runtime theme switcher.
### Open questions for the user / project lead ### Open questions for the user / project lead