UI Skins
What This System Is
UI skins map authored semantic keys to runtime image entries. Authors reference keys such as menu-button-primary, slot-frame-filled, or close-icon; runtime profiles decide which built-in skin manifest supplies the pixels for each key.
How Authors Use It
Built-in skins live under assets/ui/*/skin.json. The current built-ins are mana-soul (default), dark-dwellers, and dragon-regalia. The manifest registry requires metadata, a non-empty id and label, a matching asset directory, unique ids and manifest paths, and exactly one default skin.
The loader prepares a manifest by reading each entry, expanding generated entries, validating assignments, and indexing resolved textures. Prepared manifests are cached by asset dependency revision so editor and runtime surfaces can reuse the same resolved data until a manifest, texture, registry, or catalog dependency changes.
Rules And Invariants
assets/ui/skin-key-catalog.json owns the canonical key order and author-facing grouping. Current groups include Editor, Chat, Status, Menu, Inventory, Window Controls, Navigation, and Pointers. Keys that appear in a manifest but not in the catalog are still accepted, then appended under an Other group.
The catalog is strict where it needs to be strict:
- Duplicate ordered keys fail validation.
- Catalog keys that are missing from every manifest fail validation.
- Profile assignments to unknown keys fail validation.
- A profile assignment fails if it points a key at a skin manifest that does not define that key or a compatible fallback.
Add new UI art by adding or generating the manifest entry first, then adding the key to the catalog when authors should see it as a supported semantic target.
Entry Kinds
Each manifest entry has a kind, texture, and optional source_rect, borders, content_padding, and paint_center.
nine_slice: a framed surface with four borders and a scalable center. Bevy builds aTextureSlicerfrom the borders.three_slice_h: a horizontally sliced surface, usually for rows, tabs, or button-like strips. Bevy builds a horizontal slicer from the entry borders.bar: a slice-capable status or fill surface. Bevy uses the same slicer path as other sliced entries.icon: an image-only entry. It does not receive a slicer.frame: an unsliced frame/static image entry. It does not receive a slicer.
source_rect is converted to the runtime texture rectangle. Bevy skin entries use nearest sampling, so atlas coordinates and border values should be exact rather than relying on filtering.
Generated Entries
Manifests can define generated_entries for families that reuse the same style rules with different texture slices. A generated entry names a new key, a template_key, a replacement texture, and an optional replacement source_rect. The loader clones the template entry, applies the replacement fields, and inserts the generated key into the manifest.
Generated entries are validated like authored entries. Empty generated keys, duplicate generated keys, missing templates, and empty textures are load errors. This is how the built-ins provide expanded families such as cursor variants, alternate panel frames, equipment slot frames, map-frame, and slot-frame-filled without repeating all style fields by hand.
Legacy Aliases
Legacy compatibility exists at two levels:
- Built-in skin ids still accept old ids such as
mana-soul,dark-dwellers, anddragon-regalia. - Legacy profile wire with
{ base, key_overrides }expands into explicit per-key assignments.
Missing keys may also resolve through compatible fallbacks. Examples include cursor-target-icon-alt falling back to cursor-target-icon, slot-frame-filled falling back to slot-frame, map-frame falling back to portrait-frame, and compass-icon falling back to close-icon. Equipment pants and generic *-variant-* keys have family-aware fallback chains.
Keep aliases in the resolver rather than scattering one-off substitutions through layout rendering. That keeps Egui, Bevy, validation, and standalone preview on the same compatibility path.
Runtime Profiles
UiSkinProfile can be uniform, where one built-in skin supplies every key, or per-key, where selected keys are assigned to different built-ins. Profiles normalize into explicit assignments and expose a stable signature shaped like:
assignments=key=skin|key=skin
Profile dependency paths include the skin registry, key catalog, assigned manifests, and resolved textures. This lets bake, preview, and runtime cache invalidation respond to skin changes without treating every UI document as changed.
Egui Cache
GameUiSkinCache is the Egui-side cache. It resolves the active profile through the shared resolved-profile cache and stores egui::TextureId values with the underlying Bevy image handles. Cache refresh uses the egui_ui_skin cache name and a small LRU profile cache.
Refresh failures become EngineReportSource::UiSkins reports. Typical causes are missing textures, invalid manifest JSON, generated-entry errors, unknown profile keys, or a profile assigning a key to a skin that does not define it.
Bevy Cache
BevyUiSkinCache is the runtime/standalone Bevy UI cache. It stores BevyUiManifestSkin entries containing the entry kind, image handle, optional source rectangle, and optional slicer. NineSlice, ThreeSliceH, and Bar entries get slicers; Icon and Frame entries do not.
The cache refreshes through the same profile dependency path as Egui. Refresh errors are logged and the old cache remains in place, which prevents one bad manifest edit from immediately blanking every existing UI surface.
Validation And Debugging
When a skin edit fails to appear, check these layers in order:
- The manifest JSON parses and its metadata matches the built-in registry path.
- Every generated entry has a valid key, template, and texture.
- The key exists in at least one manifest and, if listed in
skin-key-catalog.json, appears only once. - The active
UiSkinProfileassigns the key to a built-in that can resolve it directly or through a supported fallback. - The referenced texture path exists under the asset root.
Use cache reset helpers only after fixing the source data. Resetting the manifest/profile caches clears stale prepared Arc values, but it does not make invalid data valid.
Layout Node References
.aetherui nodes expose an optional skin string. Empty means no skin. Authored aliases resolve to canonical keys before validation and runtime rendering; examples include button-primary to menu-button-primary, button-secondary to menu-button-secondary, button-danger to menu-button-danger, tab-selected to menu-tab-active, and selected text input to menu-button-primary.
Skin support depends on node role:
- Frames, containers, panels, and text inputs accept frame or button-like skins.
- Buttons accept button skins.
- Image nodes accept image-only skins.
- Labels ignore skins and validation warns when a label tries to use one.
Standalone Bevy UI, gameplay Bevy UI, and editor preview resolve layout node skins through the same authored resolver, so a key that passes validation should render consistently across those surfaces.
Examples
To add a new button skin, add the entry to the built-in manifest under assets/ui/<skin>/skin.json, add or confirm the semantic key in assets/ui/skin-key-catalog.json, reference that key from a .aetherui node that supports button skins, then run bake diagnostics before checking preview or runtime.