using Godot; using System.IO; 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. Each role tries a list of /// candidate filenames in priority order so the project can ship /// Cormorant-Medium or Cormorant-Regular interchangeably. /// Display serif: CormorantGaramond-{Medium,Regular}.ttf /// Body serif: CrimsonPro-Regular.ttf /// Mono: JetBrainsMono-Regular.ttf (optional; falls back to body) /// /// 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; /// /// Palette used by the no-arg . Set this before any /// UI mounts to swap the active codex palette globally — e.g. Main reads /// the --dark command-line flag and assigns /// here. Defaults to . /// public static CodexPalette DefaultPalette { get; set; } = CodexPalette.Parchment; public static Theme Build() => Build(DefaultPalette); public static Theme Build(CodexPalette palette) { EnsureFonts(); 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.). // SetTypeVariation registers the inheritance so a PanelContainer // with ThemeTypeVariation="Card" actually resolves "panel" to the // Card stylebox; without it, Godot's default PanelContainer panel // (dark slate) wins and the parchment colours never land. theme.SetTypeVariation("Card", "PanelContainer"); 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); // Hover — gild border so the affordance pops without committing // the seal-red selection signal yet. CodexCard wires this in via // MouseEntered/MouseExited. var cardHover = (StyleBoxFlat)card.Duplicate(); cardHover.BorderColor = p.Gild; cardHover.SetBorderWidthAll(2); theme.SetStylebox("panel_hover", "Card", cardHover); var cardSelected = (StyleBoxFlat)card.Duplicate(); cardSelected.BorderColor = p.Seal; cardSelected.SetBorderWidthAll(3); // Drop shadow: directional (light from upper-left) and sized so the // shadow's bottom edge stays clear of the next card. Card grids // separate cards by 12px (v_separation in StepClade / StepSpecies / // StepClass / etc.) — offset.y + size ≤ 11 keeps a 1px-minimum gap // before the next card so the shadow reads as a shadow on the // surface below, not as a smudge between cards. cardSelected.ShadowColor = WithAlpha(p.Seal, 0.55f); cardSelected.ShadowSize = 6; cardSelected.ShadowOffset = new Vector2(4, 4); theme.SetStylebox("panel_selected", "Card", cardSelected); // Popover frame — gild border + soft shadow + rounded corners. // Matches .trait-hint, with a softer corner radius than the rest of // the codex (cards/buttons use 2px sharp) so the floating reveal // reads as a friendlier secondary surface. theme.SetTypeVariation("CodexPopover", "PanelContainer"); var popover = new StyleBoxFlat { BgColor = p.Bg2, BorderColor = p.Gild, CornerRadiusTopLeft = 14, CornerRadiusTopRight = 14, CornerRadiusBottomLeft = 14, CornerRadiusBottomRight = 14, 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(2); theme.SetStylebox("panel", "CodexPopover", popover); // Detriment swap — seal-red border drawn at 3px so the warning reads // unambiguously against the parchment bg even at a glance. var popoverDetriment = (StyleBoxFlat)popover.Duplicate(); popoverDetriment.BorderColor = p.Seal; popoverDetriment.SetBorderWidthAll(3); theme.SetStylebox("panel_detriment", "CodexPopover", popoverDetriment); // Pill — small trait/skill chip. Mirrors .trait-chips .t-name from // the React prototype: gild-tinted bg over the page bg with a // translucent gild border. Detriment variant swaps to seal red. // The page bg (p.Bg) is cream on parchment so the pill reads as // a slightly warmer carved-into shape inside a card. theme.SetTypeVariation("Pill", "PanelContainer"); var pill = new StyleBoxFlat { BgColor = p.Bg.Lerp(p.Gild, 0.07f), BorderColor = WithAlpha(p.Gild, 0.55f), CornerRadiusTopLeft = CodexSpacing.Radius, CornerRadiusTopRight = CodexSpacing.Radius, CornerRadiusBottomLeft = CodexSpacing.Radius, CornerRadiusBottomRight = CodexSpacing.Radius, ContentMarginLeft = 9, ContentMarginRight = 9, ContentMarginTop = 3, ContentMarginBottom = 3, }; pill.SetBorderWidthAll(1); theme.SetStylebox("panel", "Pill", pill); theme.SetTypeVariation("PillDetriment", "PanelContainer"); var pillDetriment = (StyleBoxFlat)pill.Duplicate(); pillDetriment.BgColor = p.Bg.Lerp(p.Seal, 0.08f); pillDetriment.BorderColor = WithAlpha(p.Seal, 0.55f); theme.SetStylebox("panel", "PillDetriment", pillDetriment); // Ability tokens + slots — fixed-size 56×56 panels used by Step V. // Token = the draggable die; slot = the drop target. Both use the // page bg with a Rule border so they read as carved-into the page // rather than floating cards. Mirrors .die / .slot in the React // prototype's CSS (parchment block). var dieBox = new StyleBoxFlat { BgColor = p.Bg, BorderColor = p.Rule, CornerRadiusTopLeft = CodexSpacing.Radius, CornerRadiusTopRight = CodexSpacing.Radius, CornerRadiusBottomLeft = CodexSpacing.Radius, CornerRadiusBottomRight = CodexSpacing.Radius, }; dieBox.SetBorderWidthAll(1); theme.SetTypeVariation("AbilityToken", "PanelContainer"); theme.SetStylebox("panel", "AbilityToken", dieBox); theme.SetTypeVariation("AbilitySlot", "PanelContainer"); theme.SetStylebox("panel", "AbilitySlot", dieBox); // Skill row — sits inside an ability-group Card. Bg2 fill (matching // the card so rows blend into the card bg by default); state tints // applied per row via Modulate in StepSkills (background-granted → // warm gild, chosen → pale green, unavailable → reduced alpha). theme.SetTypeVariation("SkillRow", "PanelContainer"); var skillRow = new StyleBoxFlat { BgColor = p.Bg2, CornerRadiusTopLeft = CodexSpacing.Radius, CornerRadiusTopRight = CodexSpacing.Radius, CornerRadiusBottomLeft = CodexSpacing.Radius, CornerRadiusBottomRight = CodexSpacing.Radius, ContentMarginLeft = 8, ContentMarginRight = 8, ContentMarginTop = 4, ContentMarginBottom = 4, }; theme.SetStylebox("panel", "SkillRow", skillRow); } 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-Medium.ttf", "CormorantGaramond-Regular.ttf"); _serifDisplayItalic = LoadFontFromFonts("CormorantGaramond-MediumItalic.ttf", "CormorantGaramond-Italic.ttf"); _serifBody = LoadFontFromFonts("CrimsonPro-Regular.ttf"); _mono = LoadFontFromFonts("JetBrainsMono-Regular.ttf"); // Mono falls back to body so eyebrow/meta labels still render in a // serif rather than collapsing to Godot's default sans. if (_mono is null) _mono = _serifBody; if (_serifDisplay is null && _serifBody is null) { GD.Print("[codex-theme] No fonts in res://Fonts/. Using Godot defaults. " + "Drop CormorantGaramond + CrimsonPro TTFs into res://Fonts/ " + "for full design parity."); } } private static FontFile? LoadFontFromFonts(params string[] candidateFilenames) { foreach (var filename in candidateFilenames) { // Try res://Fonts/ first (Godot-managed import). string resPath = $"res://Fonts/{filename}"; if (ResourceLoader.Exists(resPath)) return ResourceLoader.Load(resPath); // Fall back to globalized res://Fonts/ via runtime FontFile load. // ContentPaths is for game data (Content/Data, Content/Gfx) which // sits next to Theriapolis.Godot, not inside it — so it can't be // used for fonts shipped under res://Fonts/. string globalRes = ProjectSettings.GlobalizePath(resPath); if (File.Exists(globalRes)) { var font = new FontFile(); font.LoadDynamicFont(globalRes); return font; } } return null; } // ────────────────────────────────────────────────────────────────────── public static Color WithAlpha(Color c, float a) => new(c.R, c.G, c.B, a); }