ZOA UI Theming
The design-token contract every ZOA surface renders through, owned by nobody and importable from anywhere.
UI Theming exists because a HUD needs colours and the package that authored those colours needed the HUD. Equipment hosted the theme assets, Nucleon drew the HUD, and the assembly definitions closed a cycle that no amount of careful using-statements could open. The fix was to lift the contract out of both: put IHudTheme and IHudThemeProvider in a package that depends on nothing, and let the consumer resolve the provider at runtime instead of referencing the host at compile time.
A test enforces that constraint. UiThemingPackageTests asserts the package declares no com.zoa.* dependency at all, including messaging: the package cannot call FoundryServiceRegistry, so it cannot self-register, and a consuming bootstrap has to publish the implementation. A contract with no dependencies is safe to import from a gameplay package, a rendering package, an editor window, and a diagnostics overlay at the same time.
Around that contract sits the actual theme pipeline. ZOAUiTheme is a large ScriptableObject holding every --zoa-* design token the project renders through, an editor generator turns it into a real USS variables sheet, and ZOAThemeService attaches that sheet to every VisualElement root it tracks. Change a colour on the asset, save, and every open panel repaints against the new value with no code edit anywhere.
Depends on (0)
How it works
Concepts
Contract-only, and tested to stay that way
The package ships interfaces and one plain implementation, and takes zero ZOA dependencies. UiThemingPackageTests.UiThemingPackage_NoReverseZoaDeps reads the package manifest back through PackageInfo and fails if a com.zoa.* dependency ever appears, because a single one would reopen the Nucleon to Equipment cycle the package was created to break.
The absence of com.zoa.messaging is the interesting half of that rule. ZOAThemeService does not register itself: a bootstrap in a consuming package constructs the singleton and registers it against both IZOAThemeService and IHudThemeProvider. In edit mode ZOAUiThemeEditorServiceBootstrap keeps an editor-session instance alive and installs it through VisualElementExtensions.SetThemeServiceResolver, so Workbench windows and wizards are themed before any runtime bootstrap has run.
A HUD component compiled against IHudTheme knows six colours and nothing else. It never learns which package authored the theme, and swapping the host is a registration change rather than an asmdef change.
Two interfaces pointed in opposite directions
IHudThemeProvider is the narrow surface: ActiveTheme plus a ThemeChanged event, where the theme is six colours with no UI Toolkit types anywhere in the signature. A HUD bridge can compile against it without pulling UnityEngine.UIElements into its assembly, and it may legitimately see a null ActiveTheme before a theme loads.
IZOAThemeService is the wide surface, and it is explicitly about UI Toolkit. Attach takes a VisualElement root and wires the generated variables stylesheet into it; ResolvedTheme hands back a fallback-resolving projection for sprites, textures, cursors and HUD metrics. Splitting the two keeps the HUD path free of UI Toolkit and keeps the panel path honest about depending on it.
Attach is idempotent per root, tracks roots through weak references so it never keeps a dead visual tree alive, and re-attaches automatically when the active theme changes. Callers do not need their own bookkeeping for hot reload.
Tokens are generated from the asset
A designer edits a ZOAUiTheme asset and saves. ZOAThemeAssetPostprocessor notices the import and calls ZOAThemeStyleSheetGenerator.EnsureGenerated, which writes a single :root block of --zoa-* declarations mirroring the checked-in ZOASwarms.tokens.uss structure token for token, imports it, and returns the path. ZOAThemeService then re-attaches every tracked root, and every existing var(--zoa-*) usage resolves against the new values with no source edit.
The generator imports the sheet it writes, which re-enters the postprocessor. That second pass carries only the generated .uss path, no theme and no settings asset, so the filter classifies the batch as irrelevant and the chain terminates. EnsureGenerated also diffs content before writing, so an unchanged theme costs nothing.
ZOAUiTheme implements IHudTheme directly rather than through an adapter. WindowBackgroundColor maps to SurfaceBase, PanelBackgroundColor to SurfacePanel, SlotBackgroundColor to SurfaceEmbedded, BorderColor to BorderDefault, TextColor to TextPrimary, and AccentColor to Yellow, the canonical primary accent. A ZOAUiTheme reference can be handed to a HUD bridge as-is.
A panel needs two separate things
A runtime UIDocument paints nothing without a PanelSettings asset, and a PanelSettings without a base ThemeStyleSheet renders Labels with no glyphs at all. That is the panel-level concern, and ZOAUiPanelSettings.Ensure owns it: it loads the canonical PanelSettings from the Resources path UI/ZOA UI Panel Settings and guarantees it carries the runtime theme sheet at UI/ZOADefaultRuntimeTheme.
Attaching the design tokens is the element-level concern, and that is root.ApplyZOATheme(). Both are required and neither substitutes for the other. Skipping the first gives an invisible panel; skipping the second gives a visible panel painted in fallback colours.
The canonical panel assets live in another package's Resources folder and are loaded here by global Resources path, because Resources folders merge across packages at runtime. That is how the theming package supplies them without taking an asmdef dependency on their owner.
Players can pick a theme too
ZOAUiThemeSettings is the project-level answer to which theme is active, loaded from Resources/ZOA/ThemeSettings so any package can resolve it with a single Resources.Load. ZOAUiThemeRuntimeSelection layers a player-facing choice on top, persisting a theme id under the PlayerPrefs key ZOA.UI.Theme.ActiveThemeId and scanning the ZOA/ThemeS catalog folders for what is available.
ZOAUiThemeSelectorWidget builds the picker as a plain VisualElement, independent of the frontend options descriptor model. An options screen wants a rich preset picker with live apply; the scalar-valued options service is the wrong shape for that, so the widget owns the interaction and writes back through the theme service itself.
In the editor
Screens
Screenshot pending
/screenshots/theme-asset-inspector.png
The theme ScriptableObject open in the Inspector with the Identity and Palette headers expanded, showing the nine canonical colour tokens and the surface roles beneath them.
Screenshot pending
/screenshots/theme-swap-comparison.png
Side-by-side captures of one in-game HUD, identical layout and content, rendered under two sample themes such as Modern Warfare and Cyberpunk, to show that only tokens changed.
Screenshot pending
/screenshots/theme-selector-widget.png
The ZOAUiThemeSelectorWidget rendered inside an in-game options screen, dropdown open, showing several installed sample themes by display name.
Screenshot pending
/screenshots/generated-variables-uss.png
The generated .variables.uss file open in an editor, showing the single :root block of --zoa-* declarations, beside the theme asset it was generated from.
Setup
Workflow
- 01
Let the editor bootstrap seed the project
On first editor load ZOAUiThemeBootstrap materialises the canonical ZOA Swarms default theme and a ZOAUiThemeSettings pointing at it, at the paths the runtime loaders expect. Nothing needs running by hand; the menu entries under Tools > ZOA > Advanced > Generate > UI > Themes exist for repair rather than setup.
- 02
Author or import a theme
Create a ZOAUiTheme through ZOA > UI > UI Theme, or import the UI Themes sample, which ships more than thirty pre-configured themes spanning Modern Warfare, Sci Fi, Cyberpunk, Dieselpunk, Fantasy, Wild West, Post-Apocalypse, Ancient World, and Cosmic Horror aesthetics, one folder per theme, each with a ZOAUiTheme asset. Point the settings asset's ActiveTheme at the one you want.
- 03
Save, and let the generator run
Saving the theme triggers ZOAThemeAssetPostprocessor, which regenerates the theme's variables stylesheet and prompts the service to re-attach every tracked root. There is no manual regenerate step in the normal loop.
- 04
Register a provider from your own bootstrap
The theming package cannot reach FoundryServiceRegistry. Construct a ZOAThemeService in a consuming package's bootstrap and register it against both IZOAThemeService and IHudThemeProvider, then install the resolver with VisualElementExtensions.SetThemeServiceResolver so ApplyZOATheme routes to the same instance.
- 05
Make each panel render-ready, then theme its root
In a UIDocument host's Awake or OnEnable, call document.EnsureZOAPanelSettings() for the panel-level requirement, then root.ApplyZOATheme() for the element-level one. Call both, in that order, on every panel.
- 06
Consume tokens from USS, not from literals
Write rules against var(--zoa-surface-panel), var(--zoa-border-default) and friends. The token-coverage guardrail fails the build on raw colour literals in rule bodies, so a theme swap changes the whole surface.
Surface
Key types
IHudTheme
interface
Six colours and an id. The single cross-package contract a HUD component consumes so it never imports the package that authored the theme.
- string ThemeId { get; }
- Color WindowBackgroundColor { get; }
- Color PanelBackgroundColor { get; }
- Color SlotBackgroundColor { get; }
- Color BorderColor { get; }
- Color TextColor { get; }
- Color AccentColor { get; }
IHudThemeProvider
interface
Publishes the active IHudTheme and raises an event when it changes. Resolved from the service registry by consumers; ActiveTheme may be null before a theme loads.
- IHudTheme ActiveTheme { get; }
- event Action<IHudTheme> ThemeChanged
IZOAThemeService
interface
The UI Toolkit side of theming. Attach the generated variables sheet to a panel root, read the fallback-resolved theme view, and react to theme changes.
- ZOAUiTheme ActiveTheme { get; }
- ZOAUiThemeResolvedView ResolvedTheme { get; }
- event Action<ZOAUiTheme> ThemeChanged
- void Attach(VisualElement root)
- void Detach(VisualElement root)
- void RefreshAttachedRoots()
- void ReloadFromSettings()
ZOAThemeService
service
The default implementation of both interfaces. Resolves the active theme from settings, loads the generated stylesheet from Resources, and re-attaches every tracked root on change. It never self-registers.
- ZOAThemeService()
- ZOAThemeService(ZOAUiTheme fallbackTheme)
- void SetActiveTheme(ZOAUiTheme theme)
- void ReloadFromSettings()
VisualElementExtensions
class
The extension methods most call sites use. ApplyZOATheme resolves the service through an installed resolver, falling back to an in-process instance so an unbootstrapped editor panel still renders themed.
- static void ApplyZOATheme(this VisualElement root)
- static void RemoveZOATheme(this VisualElement root)
- static void SetThemeServiceResolver(Func<IZOAThemeService> resolver)
ZOAUiPanelSettings
class
Makes a runtime UIDocument render-ready: canonical PanelSettings plus the base ThemeStyleSheet that UI Toolkit needs before a Label draws any glyphs.
- const string PanelSettingsResourcePath = "UI/ZOA UI Panel Settings"
- const string RuntimeThemeResourcePath = "UI/ZOADefaultRuntimeTheme"
- static PanelSettings LoadCanonical()
- static PanelSettings Ensure(UIDocument document)
- static void EnsureRuntimeTheme(PanelSettings settings)
- static PanelSettings EnsureZOAPanelSettings(this UIDocument document)
ZOAUiThemeResolvedView
class
Active theme projected over a fallback theme. Reach for this instead of reading ActiveTheme directly when you need sprites, textures, cursors, HUD metrics, or scalars, so every UI stack shares one set of fallback rules.
- ZOAUiTheme ActiveTheme / FallbackTheme / EffectiveTheme { get; }
- static ZOAUiThemeResolvedView FromActiveTheme(ZOAUiTheme activeTheme)
- static ZOAUiTheme LoadDefaultTheme()
- Sprite CrosshairSprite { get; } float CrosshairSize { get; }
- float HudBarHeight { get; } float HudBarSpacing { get; }
- Sprite ResolveArchetypeIcon(string archetypeId)
- bool TryResolveSlotLayout(string slotId, out Rect position)
ZOAUiThemeRuntimeSelection
class
Player-facing theme selection. Enumerates the theme catalog out of Resources and persists the chosen theme id in PlayerPrefs, separate from the project default in the settings asset.
- const string PlayerPrefsThemeIdKey = "ZOA.UI.Theme.ActiveThemeId"
- static List<ZOAUiTheme> LoadAvailableThemes()
- static ZOAUiTheme ResolveThemeForService(ZOAUiTheme projectDefault)
- static void PersistThemeSelection(ZOAUiTheme theme)
- static bool TryFindThemeById(string themeId, out ZOAUiTheme theme)
- static void ClearPersistedThemeSelection()
ZOAUiThemeSelectorWidget
class
Builds a runtime-safe theme picker as a VisualElement. Options control whether a pick is persisted, written back to the settings asset, and applied immediately.
- static VisualElement Build(Options options = null)
- class Options { Themes, ActiveTheme, ThemeService, PersistSelection, WriteThemeSettings, ApplyOnSelection, ThemeSelected }
Surface
Authoring assets
ZOAUiTheme
asset
The theme asset. One ScriptableObject carrying every --zoa-* token the project renders through: palette and tints, typography, spacing, radii, borders, surfaces, text roles, controls, transitions, shadows, focus, sprites, cursors, accessibility values, and HUD bar and notification values. It implements IHudTheme directly. Created via ZOA > UI > UI Theme.
- string ThemeId / DisplayName / Description, int Version, string CacheKey
- Color Yellow, Black, Dark, Slate, Red, Cyan, White, Dim (the nine canonical palette tokens)
- Color SurfaceBase / SurfacePanel / SurfaceEmbedded / SurfaceElevated / SurfaceHover
- Color TextPrimary / TextSecondary / TextAccent, BorderSubtle / BorderDefault / BorderStrong
- float UiScale, bool UseColorBlindMode, Color ColorBlindAccent
- Sprite CrosshairSprite, float CrosshairSize, Texture2D CursorDefault / CursorInteract
- Sprite GetArchetypeIcon(string archetypeId)
- bool TryGetSlotLayout(string slotId, out Rect position)
ZOAUiThemeSettings
asset
Project-level active-theme selector, resolved through a static Instance that loads from Resources/ZOA/ThemeSettings. In the editor it falls back to a project-wide asset search and then to the editor bootstrap, so a fresh clone is never themeless. Created via ZOA > UI > Theme Settings.
- static ZOAUiThemeSettings Instance { get; }
- ZOAUiTheme ActiveTheme { get; set; }
- bool RequireThemeInResources { get; }
- float WorkbenchThemeBackgroundOpacity / ChromeOpacity / ContentOpacity
- ZOAUiThemePaths.DefaultSettingsAssetPath = "Assets/Resources/ZOA/ThemeSettings.asset"
Usage
Examples
using System;
using UnityEngine;
using UnityEngine.UIElements;
using ZOA.Messaging;
using ZOA.UI.Theming;
public sealed class VitalsPanel : MonoBehaviour
{
private IHudThemeProvider _themes;
private VisualElement _panel;
private void OnEnable()
{
if (!FoundryServiceRegistry.TryResolve<IHudThemeProvider>(out _themes))
return;
_themes.ThemeChanged += Repaint;
// ActiveTheme is legitimately null before a theme loads.
Repaint(_themes.ActiveTheme);
}
private void OnDisable()
{
if (_themes != null) _themes.ThemeChanged -= Repaint;
}
private void Repaint(IHudTheme theme)
{
if (theme == null || _panel == null) return;
_panel.style.backgroundColor = theme.PanelBackgroundColor;
_panel.style.borderBottomColor = theme.BorderColor;
_panel.style.color = theme.TextColor;
}
}using UnityEngine;
using UnityEngine.UIElements;
using ZOA.UI.Theming;
[RequireComponent(typeof(UIDocument))]
public sealed class ObjectiveHud : MonoBehaviour
{
private void Awake()
{
var document = GetComponent<UIDocument>();
// 1. Panel-level: PanelSettings + base ThemeStyleSheet, or the
// panel paints nothing and Labels render no glyphs.
document.EnsureZOAPanelSettings();
}
private void OnEnable()
{
var root = GetComponent<UIDocument>().rootVisualElement;
// 2. Element-level: attach the generated --zoa-* variables sheet
// so this tree's USS resolves against the active theme.
root.ApplyZOATheme();
}
}using ZOA.Messaging;
using ZOA.UI.Theming;
public static class ProjectThemeBootstrap
{
public static void Install()
{
var service = new ZOAThemeService();
service.ReloadFromSettings();
// Register against both faces: HUD bridges resolve the narrow
// provider, panel hosts resolve the UI Toolkit service.
FoundryServiceRegistry.Register<IZOAThemeService>(service);
FoundryServiceRegistry.Register<IHudThemeProvider>(service);
// Route ApplyZOATheme at the same instance instead of the
// in-process fallback.
VisualElementExtensions.SetThemeServiceResolver(
() => FoundryServiceRegistry.TryResolve<IZOAThemeService>(out var s) ? s : null);
}
}using UnityEngine.UIElements;
using ZOA.UI.Theming;
VisualElement BuildThemeSection(IZOAThemeService themeService)
{
return ZOAUiThemeSelectorWidget.Build(new ZOAUiThemeSelectorWidget.Options
{
Themes = ZOAUiThemeRuntimeSelection.LoadAvailableThemes(),
ActiveTheme = themeService.ActiveTheme,
ThemeService = themeService,
PersistSelection = true, // writes ZOA.UI.Theme.ActiveThemeId
WriteThemeSettings = false, // leave the project default alone
ApplyOnSelection = true,
ThemeSelected = theme => Debug.Log("Theme now " + theme.DisplayName),
});
}Tooling
Editor tools
Regenerate Active Theme Stylesheet
Tools > ZOA > Advanced > Generate > UI > Themes > Regenerate Active Theme Stylesheet
Forces ZOAThemeStyleSheetGenerator to rewrite the active theme's variables sheet. The postprocessor normally does this on save; reach for the menu after an out-of-band edit.
Ensure Default Theme / Ensure Theme Settings
Tools > ZOA > Advanced > Generate > UI > Themes
Materialises the canonical ZOA Swarms theme and the settings asset at their expected paths. Runs automatically on a fresh project; the menu entries are the repair path.
Reset Default Theme to Canonical Values
Tools > ZOA > Advanced > Generate > UI > Themes > Reset Default Theme to Canonical Values
Rewrites the shipped default theme's fields back to the values that mirror ZOASwarms.tokens.uss, for when a default asset has been hand-edited.
ZOAThemeAssetPostprocessor
Runs on every import. When a ZOAUiTheme or ZOAUiThemeSettings asset is imported, moved, or deleted, it regenerates the affected stylesheet and refreshes attached roots. No menu entry, no manual step.
Read this
Notes and caveats
See also