Files
TheriapolisV3/Theriapolis.Godot/UI/CodexTheme.cs
T
Christopher Wiebe e3f0296e6f M6.7: Parchment theme pass
Lights up the M5 codex design system across the wizard. Default
palette swaps from dark leather to aged-parchment cream with
sealing-wax red selection emphasis, matching the React prototype's
default theme variant. CodexTheme.Build() is applied at the wizard
root so every step + Aside + popover cascades through it.

Theme additions:
- Parchment palette in CodexPalette (Dark retained as alt)
- Type variations registered for Card, CodexPopover, Pill,
  PillDetriment, AbilityToken, AbilitySlot, SkillRow — without
  SetTypeVariation, panel-stylebox lookup falls through to Godot's
  default dark slate, which is what was happening to every bare
  PanelContainer before this pass.
- panel_hover stylebox on Card (gild border) wired via CodexCard's
  MouseEntered/Exited helper; panel_selected bumped to 3px seal-red
  border + soft shadow so selection reads at a glance.

Card selection refactor:
- Replaced the warm-cream Modulate hint on cards with stylebox swaps
  via the new CodexCard.SetSelected helper. The Modulate approach
  was a no-op on cream-on-cream parchment; the stylebox swap looks
  the same on either palette.
- Step intros + Aside section headers now use the existing Eyebrow /
  H2 / H3 / CardName / CardMeta / CardBody label variations.
- Confirm button on Step VIII uses the PrimaryButton variation.

Popover + chip behaviour:
- PopoverLayer is now MouseFilter=Ignore so clicks/scroll/hover all
  pass through. Adjacent chips fire reliably even when the previous
  popover overlaps them spatially.
- Dropped the 80ms grace timer; chip MouseExited closes immediately.
- TraitChip MouseFilter Stop → Pass so clicks bubble up to the
  parent card's GuiInput (selecting the card).

Misc:
- Wizard._Ready inserts a backing Panel so the parchment Bg fills
  the canvas — Wizard root is a plain Control, which paints nothing.
- CodexTheme font lookup tries Cormorant-Medium before -Regular and
  globalizes res://Fonts/ for runtime FontFile load (the previous
  fallback used ContentPaths which points at a sibling data tree).
- StepStats final-score Label rendered at font_size 22 to match the
  AbilityToken die.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 22:04:24 -07:00

406 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. Matches .trait-hint.
theme.SetTypeVariation("CodexPopover", "PanelContainer");
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);
// 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);
}