Files
TheriapolisV3/Theriapolis.Godot/UI/CodexTheme.cs
T
Christopher Wiebe 0ab4715aee M6.16: Unified hybrid species grid + codex-styled hover popover
StepSpecies hybrid mode now uses one grid combining sire-clade species
(under a "SIRE — <Clade>" eyebrow) and dam-clade species (under
"DAM — <Clade>"). Cards are click-to-select like the purebred path —
since clades are guaranteed disjoint by StepClade's parent-conflict
rule, the lineage is implicit from the species' clade and no per-card
toggles are needed.

Hover popover now picks up the codex theme: parchment Bg2 panel with a
gild border, rounded 14px corners, and soft drop shadow; H3 display
serif title, mono Eyebrow tag, CardBody description. Detriment popovers
swap to a 3px seal-red border via the panel_detriment stylebox override
(replaces the old red Modulate hack).

Theme propagation fix: CanvasLayer breaks Godot's Control theme
inheritance, so the popup was rendering on Godot defaults. _Ready
defers a lookup of the parent Control's theme and assigns it directly
to the popup so the codex parchment + Cormorant/CrimsonPro fonts
actually resolve.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 21:26:38 -07:00

416 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Godot;
using System.IO;
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. 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.
/// </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() => Build(CodexPalette.Parchment);
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);
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 + 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<FontFile>(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);
}