Add Forms Theme Import

SkillAI & models

Add Forms dialog's tool-content theme system (Templates/FormsThemes/*). Triggers: FormsFileService, AddFormsViewModel, ThemeRequirements, theme.txt, GetSourceDestinations, Bubblegum/Hazard theme content, ThemeRecolorHelper, porting a new Forms theme.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Add Forms Theme Import skill

What this skill tells your AI

The instructions your AI receives, as published by vchelaru/gum in .claude/skills/gum-forms-theme-import/SKILL.md and read by ahel’s review.

Distinct from code-only themes (gum-theming skill — C# *Visual subclasses, NuGet packages). The two are alternative delivery paths, not layers: a theme imported here renders from the user's own .gumx, so a Gum.Themes.* package is never needed for the styled look. KernSmith and the runtime's shapes package are still recommended (fonts, shape-backed visuals) but bypassable — the tool never edits the user's game project.

This is the tool-content side: a theme is a self-contained Gum project under Tools/Gum.ProjectServices/Templates/FormsThemes/<Name>/ (its own GumProject.gumx, Components/, Behaviors/, Screens/, Standards/) that Add Forms copies into the user's project. Bubblegum, Hazard, DarkPro, Retro95, ForestGlade, Neon, and Meadow have parity today (staged per-theme in Gum/GumFormsPlugin/GumFormsPlugin.csproj's postbuild, alongside the default Standard theme — 8 entries in the tool's theme dropdown). Editor exists as a code-only theme (Themes/Gum.Themes.Editor.*) but was investigated and intentionally skipped: it doesn't follow the full-chrome-rebuild pattern the tool-content model assumes (it recolors V3's stock NineSlice visuals in place rather than replacing them), restyles only 9 of ~21 Forms controls, and introduces Expander — a control with no Add-Forms behavior infrastructure. Porting it would mean inventing a look the theme's own author chose not to build.

Porting a new theme (#3527): do the landmines up front, not reactively

Each gotcha below is cheap to prevent up front and expensive to discover one at a time via manual testing after the fact. Sequence a new theme port like this:

  1. Clone an existing theme's whole tree (fastest correct starting point — layout, state set, and structure are almost all theme-agnostic) rather than hand-authoring controls from scratch; namespace it and scan physical files on disk (not just the .gumx's reference list) for orphans, before anything else.
  2. Build Styles.gucx matching the code-only *Colors/*Text class names first — every control wires against it, so getting names right early avoids a rename pass later.
  3. Recolor per "Recoloring after a clone" below instead of hand-placing literals control-by-control.
  4. Grep the code-only theme's *Visual.cs files for CornerRadius and HasDropshadow/ Dropshadow* constants — a separate axis from color, untouched by any Styles-swatch rename. A theme whose corner radius or shadow use differs uniformly from the clone's needs a project-wide mechanical fix (rewrite every CornerRadius, flip every HasDropshadow) that nothing catches automatically. Do this sweep across Screens/ too, not just Components/ — a demo screen's own instances (e.g. a panel background) are just as likely to still carry the cloned theme's raw values, and nothing propagates a Components-only fix to them.
  5. Run AllColorVariables_ShouldBeStylesWired from the start, not as a final audit — see "Wire the controls to Styles" below for what it does and doesn't catch.
  6. After every batch of edits: gumcli check/check-references/diff-standards, and a rebuild plus fresh-project Add Forms import in the actual tool — gumcli never exercises the postbuild-copy path where duplicate-import bugs live. gumcli check's summary line reads "N error(s) found" even when every listed line is warning: severity, so a pre-existing warning inherited from the clone (e.g. a Category state illegally selecting its own category) is easy to mistake for a pass — read the lines, not just the count.
  7. A swatch-name recolor pass never touches geometry, and geometry is where the worst-looking bugs hide. Ring/indicator X/Y/Width/Height/CornerRadius literals are carried over from the clone verbatim by every rename/recolor script — nothing about "fix the colors" touches them. Diff each focus-ring/indicator explicitly against the code-only theme's actual constants: a ring built RelativeToParent + Center-anchored (X=Y=0) can only be wrong in size (its Width/Height delta must equal the code's FocusRingInset*2, its CornerRadius must equal body-radius+inset — not whatever the clone had); one built Absolute + edge-anchored (a Left/Bottom-anchored circle or box — CheckBox/RadioButton's pattern) can also be positionally asymmetric, since the clone's edge offset doesn't automatically rescale to a different inset — diff its X/Y against -Inset and its Width/Height against bodySize + 2*Inset by hand.
  8. Don't cut a visual feature (a glow, a border, a state-specific effect) as a silent scope-reduction call. If the code-only theme's *Visual.cs sets it (HasDropshadow, a stroke, anything state-conditional), port it or flag the omission explicitly to the user — folding "skipped for time" into a summary aside is not the same as getting a decision. Missing a whole control this way (not just a wrong value) is the most visually obvious kind of incomplete port.
  9. The rebuild in step 6 can silently no-op. GumFormsPlugin's postbuild xcopy only runs when MSBuild actually re-executes that project's build — and theme .gucx/.gusx content isn't a tracked input of GumFormsPlugin.csproj (it lives in a different project entirely), so an IDE-driven incremental "Build" can decide the project is up to date and skip the postbuild even though the theme source changed. The result: every content-only fix looks correct on disk and in every automated check, but the staged output the running tool actually reads from (Gum/bin/<Config>/Content/FormsThemes/<Name>/) is still the old content, and a fresh Add Forms import reproduces the exact bug you just fixed. After any content-only change, diff a file directly from the staged output (not just the source template) before calling the fix verified — a full dotnet build GumFull.sln from the command line has reliably re-triggered the postbuild in practice; an IDE "Build" may not.

Key files

FileRole
Tools/Gum.Presentation/GumForms/Services/FormsFileService.csGetAvailableThemes, GetThemeDirectory, GetSourceDestinations — computes what gets copied where
Tools/Gum.Presentation/GumForms/ViewModels/AddFormsViewModel.csAdd Forms dialog: theme selection, save/import
Tools/Gum.Presentation/GumForms/Services/ThemeRequirements.csParses optional theme.txt (font generator, Skia shapes) — project-level prerequisites, not content
Gum/GumFormsPlugin/GumFormsPlugin.csprojPostbuild <Exec> stages each theme into the built Content/FormsThemes/<Name>/one hand-written xcopy + stage-forms-behaviors block per theme, not a loop over the folder. A new theme is invisible to FormsFileService.GetAvailableThemes() in a built tool until its own block is added here, mirroring the existing per-theme blocks exactly.

A theme's qualified names must be self-prefixed — there's no folder-nesting shortcut

Components/, Screens/, Standards/ are hardcoded literal subfolders appended directly to the project root (GumDataTypes/ElementReference.cs Subfolder; Behaviors/ the same way in BehaviorReference.cs) — an element's file path is always <projectRoot>/<Subfolder>/<Name>.<ext>. So a theme can't be namespaced by nesting its whole tree one level deeper (e.g. under <Theme>/Components/...) — that path segment never resolves.

Instead, prefix the theme name into every qualified Name itself, the same way Controls/ and Elements/ already subfolder within Components/: Controls/ButtonBubblegum/Controls/Button, landing on disk at Components/Bubblegum/Controls/Button.gucx. Apply this to every Component/Screen reference surface: .gumx ComponentReference/ScreenReference Name attributes, each file's own <Name> tag, BaseType="..." instance attributes, VariableReferences strings (Components/Styles.XComponents/Bubblegum/Styles.X), and a .behx's <DefaultImplementation> (points at a themed component, so it gets the prefix even though the behavior file itself doesn't — see below). StandardElementReference and BehaviorReference stay unprefixed. Without this, importing a second theme into the same project silently overwrites the first theme's entire Components/Screens tree (GetSourceDestinations maps every theme file to an identical unnamespaced destination with overwrite: true).

A file that exists on disk but isn't referenced in the theme's own .gumx still gets copied and importedGetSourceDestinations walks every .gucx/.behx file under the theme folder, not just the ones listed as a ComponentReference/BehaviorReference. A rename pass built only from the .gumx's reference list misses these orphans, leaving a stale un-prefixed <Name> that duplicates or collides with the correctly-renamed content. Verify by scanning physical files directly (Directory.GetFiles(..., SearchOption.AllDirectories)), not project.AllElements, which only reflects what's registered.

Behaviors are shared infrastructure, not per-theme content

Unlike Components (a theme's look) and Screens (a theme's demo content), Behaviors declare the generic category-state/FormsProperty contract every theme's visuals plug into — the same ButtonBehavior, CheckBoxBehavior, etc. every theme and project needs. They live flat at Behaviors/*.behx, unprefixed, matching how Standards/ is shared rather than namespaced. A .behx's own <Name> and a .gucx's <ElementBehaviorReference><BehaviorName> stay unprefixed; only <DefaultImplementation> (which names a themed component) gets the theme prefix. Each theme still ships its own copy of every behavior file (same reason as Standards, above) — those copies are expected to be identical to every other theme's, so importing one theme after another just re-overwrites the shared file rather than duplicating it. There's no mechanism yet to guarantee the copies actually stay identical as Forms controls gain new properties — see #4070.

Standard elements: only the default theme may touch them

GetSourceDestinations skips .gutx files for any theme except DefaultThemeName ("Standard") — a theme's own styling must live entirely in its Components, never in shared Standards, or importing it stomps the destination project's real defaults (and any other theme already imported). A theme's Standards/ folder still needs to exist on disk with real content, though — without it the theme's GumProject.gumx isn't a loadable, standalone Gum project, which is how a theme is meant to be opened/edited/previewed directly in the tool.

A theme's Styles component must mirror its code-only palette by name, not just value

A theme's centralized styling component (e.g. Components/Bubblegum/Styles.gucx) should expose exactly the color/text tokens its code-only counterpart defines (Themes/Gum.Themes.<Name>.*'s *Colors/*Text classes — see gum-theming), under the same names, with no extra swatch that class doesn't define. A swatch can hold the correct value under the wrong name — verify by diffing the tool content's swatch/text-instance names against the code-only class's property names directly, not by eyeballing rendered colors. "Exactly" is scoped to swatches something actually references, not the full C# property list: a get-only alias (returns another property's value unchanged, e.g. HoverFill => Surface2) needs no swatch of its own — point references at the underlying token; a get-only property with a genuinely computed value (e.g. AccentHover => Accent.Adjust(+15f)) has no equivalent in static XML and must be precomputed (same lighten/darken math as ColorExtensions.Adjust — see gum-theming) and baked in as an ordinary swatch. A theme's own StylesPalette_ShouldExactlyMatch... test in Tests/Gum.ProjectServices.Tests/ pins the resulting (non-exhaustive) swatch list once decided.

"Wire the controls to Styles" means every state, not just Default

A control can look fully wired at a glance — its Default state references Styles.* — while every one of its categorized states (Enabled/Disabled/Highlighted/Pushed/...) still hardcodes the same colors directly, since Default and category states are separate StateSaves with independent VariableReferences. Tests/Gum.ProjectServices.Tests/BubblegumTemplateTests.cs's AllColorVariables_ShouldBeStylesWired is the authoritative check-all for this — a fully transparent fill (FillAlpha set to 0) is exempted, since its RGB is never visible. It only proves some Styles swatch is wired, not the right one — a state referencing the wrong swatch (right structure, wrong concept) passes silently. Spot-check a control's trickiest states (the ones whose interaction language most diverges between the cloned theme and the new one) against the code-only theme's state-wiring method directly.

Recoloring after a clone

A cloned theme's controls already reference the old theme's swatch names throughout.

A swatch name surviving into the new palette does not mean the reference is still correct. Two themes can share a whole vocabulary and still assign it differently per control and per state — one fills a resting Button with Accent, another with Surface1 and reserves Accent for the focus stroke. Derive the mapping from the code-only theme's *Visual.cs States.<X>.Apply bodies as a function of (instance, property, state name); a global old-name → new-name rename yields a theme that passes every automated check and looks nothing like its counterpart. Where a swatch's concept really does carry over, the reference string needs no edit — only the value differs, and that's handled below. Rename only the reference strings whose target swatch name no longer exists under the new palette (e.g. old White → new Ink); grep each renamed swatch's actual usage sites first; a single old swatch can legitimately split across several new ones by role, and the local left-hand property prefix on each VariableReferences line reliably signals which — bare Red=/Green=/Blue= is a Text-type instance, Stroke*= is a border, Fill*= is a fill — so this is a small, closed decision per swatch, not per site.

Once every reference string names the correct swatch, every state's redundant hardcoded scalar (FillRed, etc.) is still the old theme's value until something re-runs ApplyVariableReferences and re-saves — gumcli check-references --fix will not do this: it only materializes a scalar that's entirely missing, never corrects one that exists but no longer matches what its reference currently resolves to (confirmed empirically — running it after a rename with the values already stale made zero changes). Tests/Gum.ProjectServices.Tests/ThemeRecolorHelper.cs is a reusable, [Fact(Skip=...)]-guarded migration step that loads the theme project, calls GumProjectSave.ApplyAllVariableReferences() (from GumRuntime), and re-saves every component and screen (see the previous section for why screens need the same pass) — point it at the new theme and run it once via dotnet test --filter. When re-saving any ElementSave programmatically, Save(path, useCompactFormat: true) is load-bearing: the default false serializes VariableSave/InstanceSave as child elements (the legacy v1 shape) instead of the attribute-based shape every theme file on disk actually uses — both round-trip through ProjectLoader fine, so passing the default silently produces a valid-but-inconsistent file.

A bundled custom font must be referenced by its .ttf filename, not its family name

Setting a Text/Styles instance's Font value to a human-readable family name (e.g. "Saira Condensed") makes Gum look that name up as a Windows-installed system font — both at runtime (CustomSetPropertyOnRenderable.UpdateToFontValuesBmfcSave.ResolveTtfSourcePath) and in the tool's headless generator. A theme's own bundled Fonts/*.ttf is invisible to that lookup. If the family isn't actually installed on the machine (a Google Font bundled as project content almost never is), generation fails — logged as KernSmith error: System font '<name>' not found in the tool's Output panel, easy to miss — and the text silently falls back to the embedded default (Font18Arial), which looks like a rendering/alpha bug at a glance, not a missing-font one.

The fix is to set Font to the literal bundled path instead — IsFontFilePath/ResolveTtfSourcePath treat any Font value ending in .ttf as a direct file reference, bypassing system-font lookup entirely. The value must include the Fonts/ folder segment ("Fonts/SairaCondensed-Regular.ttf", not the bare filename) — ResolveFontFilePath resolves a non-rooted path against the project root (the .gumx's own directory), not the Fonts/ folder specifically, so a bare filename looks for the ttf directly in the project root and silently fails to find it there. That failure is swallowed by a bare catch { } around the font-creation call (CustomSetPropertyOnRenderable. GetOrCreateBakedFont) with no Output-panel message at all — quieter than the system-font-lookup failure above, and easy to mistake for "the fix didn't take" after already fixing the family-name half of this. There's no separate "pick the bold weight" mechanism on this path — a Strong/bold style must point Font at the bold-weight file directly ("Fonts/SairaCondensed-SemiBold.ttf"); IsBold alone does nothing for a .ttf-valued Font. This has no color-style parallel to check against: AllColorVariables_ShouldBeStylesWired only inspects color-channel suffixes, so a family-name Font value passes every existing automated check. After fixing Styles.gucx, rerun ThemeRecolorHelper (above) to cascade to every control and screen that references Styles.Normal.Font/Styles.Strong.Font — then grep the whole theme for the bare family-name string, since a demo screen's own Text instances can hardcode Font directly instead of routing through Styles, and a hardcoded value has no reference for the reapply pass to cascade into.

Verifying theme content changes

Structural checks prove the XML is well-formed; they prove nothing about whether the theme actually works. gumcli check/check-references/diff-standards and the Gum.ProjectServices.Tests suite all validate shape — types match, references resolve to some materialized scalar, Standards match canonical defaults. None of them generate a font, render a color, or read the built tool's output. A theme can pass every one of these while still being visibly broken — a family-name Font value, a missing Fonts/ path segment, and an un-recolored screen all do. Treat a clean run of these as "no regression," never as "the theme works." Three checks actually exercise real behavior and catch what structural checks can't:

  • Font generation, for real. GumProjectFontGenerator (GumProjectFontGenerator/Program.cs) runs the same headless pipeline (HeadlessFontGenerationService) the tool's own "Checking N font files..." step uses. Copy the theme to a scratch folder, empty its FontCache/, and run dotnet GumProjectFontGenerator.dll <path>\GumProject.gumx — a real .fnt/.png pair appearing in FontCache/ for every custom font is the only proof font resolution actually succeeds; a clean gumcli check proves nothing about it.
  • Render it — and every state that changes appearance, not just the one you get for free. gumcli screenshot <theme>/GumProject.gumx <element> --background <hex> (see gum-cli) renders the theme's own demo screen or any single control to a PNG, but only ever the first category state in the .gucx — almost always the unfocused/unchecked/resting look. A focus ring, a selected-row tint, a checked-state fill can be badly broken (wrong size, wrong position, missing entirely) and invisible in every render you take by default; the resting-state screenshot proves nothing about them. To actually see a specific state, temporarily flip its own Visible/color <Variable> values in place (the state that's actually rendered, not Default — category-state values override Default at render/instantiation time), screenshot, then revert before moving on — do this per control for every state whose geometry or color meaningfully diverges from resting.
  • The staged build, not the source. Add Forms reads from Gum/bin/<Config>/Content/FormsThemes/<Theme>/, not from Templates/FormsThemes/<Theme>/ — and per the postbuild-can-no-op landmine above, those two can silently diverge even after a correct, committed fix. Diff a file straight from the staged path after rebuilding, not just the source template, before calling anything verified.

Beyond those two, still run gumcli diff-standards (theme's Standards vs. StandardElementsManager canonical defaults — should always read "No drift found" for a non-default theme), gumcli check (structural errors), and gumcli check-references (unmaterialized VariableReferences) — see the gum-cli skill. Run before and after a change; identical output (same pre-existing warnings, nothing new) is the proof a mechanical edit didn't regress anything.

Hand-editing a VariableReferences block needs the correct LHS property name for the target instance's type, not the swatch's. Text instances use Red/Green/Blue; Rectangle/ Circle fills use FillRed/FillGreen/FillBlue; strokes use StrokeRed/StrokeGreen/StrokeBlue regardless of instance type. Getting this wrong doesn't error — if the pre-existing hardcoded <Variable> value already happens to match the swatch, gumcli check stays clean and the visual is unchanged, so the mistake is invisible until gumcli check-references reports "has VariableReferences but missing materialized scalars" (it matches by property name, not value). Run gumcli check-references --fix after any hand-authored reference to materialize it for real — propagation is never automatic (not on project load, not on opening a screen in the tool), so authoring the reference row is the whole job; don't also hand-copy the resolved value into instances yourself, --fix already writes it into every state. This only works when the scalar is missing outright, though; see "Recoloring after a clone" above for the stale-but-present case it can't touch.

A Red/Green/Blue triple collapses to one *Color line whenever both sides decompose to the same composite token — the two names need not match, so cross-named forms like DropshadowColor = X.FillColor and Color = X.FillColor are valid. All three lines must target the same object, since channels pair positionally. The hard requirement is on the left side only: the owning instance's type must declare every channel (ExpandCompositeReferenceLine/OwnerHasAllCompositeChannels), otherwise the line silently fails to expand at apply time and gumcli check-references reports the unmaterialized *Color scalar.

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
620
Forks
80
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
gum-forms-theme-import
Source
github.com/vchelaru/gum