From 953bb985ad754dc6fc99e3781ac7ce5837c59b40 Mon Sep 17 00:00:00 2001 From: Christopher Wiebe Date: Fri, 1 May 2026 20:29:22 -0700 Subject: [PATCH] M5: Codex design system (dark theme) + kitchen-sink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Theriapolis.Godot/Main.cs | 15 + Theriapolis.Godot/UI/CodexPalette.cs | 87 +++++ Theriapolis.Godot/UI/CodexTheme.cs | 311 ++++++++++++++++++ Theriapolis.Godot/UI/KitchenSink.cs | 229 +++++++++++++ Theriapolis.Godot/UI/Widgets/CodexPopover.cs | 214 ++++++++++++ Theriapolis.Godot/UI/Widgets/CodexStepper.cs | 168 ++++++++++ ...olis-rpg-implementation-plan-godot-port.md | 19 +- 7 files changed, 1033 insertions(+), 10 deletions(-) create mode 100644 Theriapolis.Godot/UI/CodexPalette.cs create mode 100644 Theriapolis.Godot/UI/CodexTheme.cs create mode 100644 Theriapolis.Godot/UI/KitchenSink.cs create mode 100644 Theriapolis.Godot/UI/Widgets/CodexPopover.cs create mode 100644 Theriapolis.Godot/UI/Widgets/CodexStepper.cs 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