Files
Christopher Wiebe 83c6343783 M6.21: Dark theme parity + card shadow polish + hover/selection fixes
--dark command-line flag swaps CodexTheme.DefaultPalette to Dark
before any UI mounts; both TitleScreen and Wizard pick it up via
the no-arg Build() overload.

Stepper colours track the active palette. ApplyStateColors and the
Active step's gild underline previously read from a stub that
hardcoded parchment values, so the Active label rendered as
brown-black ink against the dark bg (invisible). Both sites now
read CodexTheme.DefaultPalette directly.

Card hover stays applied while the cursor is over an inner Button.
PanelContainer.MouseExited fires when the cursor crosses onto a
child that captures input (Sire/Dam toggles, Sheep/Goat toggles,
trait pickers); the recheck defers and uses GetGlobalRect.HasPoint
on the cursor position so hover only drops when the cursor truly
leaves the card area.

Selection stylebox lands on first refresh. SetSelected was
previously called inside BuildCard before AddChild, so
HasThemeStylebox returned false (theme cascade unreachable) and
the override silently dropped — it only re-attached when
MouseEntered later re-ran Apply. Refactored SetSelected/SetHover
through a new ApplyOrDefer helper that uses CallDeferred when the
card isn't in tree yet, so the seal border + drop shadow appear
immediately on selection rather than only after the first hover.

Selection drop shadow refined. Was a 14px shadow at offset (0,14)
which overlapped the next card by 16px in the v_separation:12
grid. Now offset (4,4) + size 6 — diagonal "light from upper-left"
direction, total reach 10px, leaves a 2px clearance before the
next card so the shadow reads as a shadow on the surface below.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:55:40 -07:00

430 lines
19 KiB
C#
Raw Permalink 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;
/// <summary>
/// Palette used by the no-arg <see cref="Build()"/>. Set this before any
/// UI mounts to swap the active codex palette globally — e.g. Main reads
/// the <c>--dark</c> command-line flag and assigns <see cref="CodexPalette.Dark"/>
/// here. Defaults to <see cref="CodexPalette.Parchment"/>.
/// </summary>
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<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);
}