Create / Mod files / UI themes for mods
UI themes for mods
A UiTheme is the look the whole interface paints in: the menus, Settings, the
pause screen, the shipyard, the HUD chrome and every widget on them. The base
game ships two - base/phosphor, the green terminal, and base/hardware, the
moulded casing - and a mod adds a third by declaring a new id.
A theme is DATA. It names colours, three metrics and the paint of every control in every state it has. It runs no code, so a broken theme is a lint finding rather than a crash.
This page is the field-by-field theme reference. For general RON spelling rules
such as double parentheses, Some(...), and asset schemes, see
RON spelling rules.
What a theme can and cannot change
A theme owns PAINT and two shapes:
- foreground, background and border colour;
- border width and corner radius;
- solid, linear and radial fills;
- glows and drop shadows;
- whether a slider meters in blocks or in one continuous fill;
- whether a badge is a
[TAG]or a bordered chip.
A theme owns NO layout. It cannot set a position, a size, padding, a gap, a flex or grid rule, a z order, visibility, pointer behaviour or a font size. Those are the screen's, and a theme that could move them could make a screen unusable rather than ugly.
A theme also cannot move the gameplay colours. A hostile reticle is red and an ally green in every theme, so the threat, ally, nav and objective accents are fixed in the game and are not theme roles. Everything a player would call chrome goes through the theme.
The UiTheme item
A theme is an item in any *.content.ron file, beside your campaigns and
ships:
[
UiTheme((
id: "my-mod/amber",
name: "Amber CRT",
inherit: Some("base/phosphor"),
palette: {
"primary": "#ffb84a",
"secondary": "#c98a28",
"body": "#ffe6bd",
},
metrics: None,
roles: (
badge: Some((
bracketed: false,
padding_x: 8.0,
border_width: 1.0,
border_alpha: 0.4,
fill_alpha: 0.06,
)),
),
)),
]
| field | type | default | meaning |
|---|---|---|---|
id |
string | required | Stable key. The overlay key, the value Settings persists, and what another theme's inherit names. Prefix new ids with your mod id. |
name |
string | required | The label on the Settings row. |
inherit |
Option string |
required | The theme this one derives from, or None for a root theme - see Inheritance. |
palette |
map of string to string | {} |
Named colour variables, six-digit #rrggbb. |
metrics |
Option metrics |
required | Border width and the two radii, or None to take the parent's - see Metrics. |
roles |
roles | () |
The per-control paint tables - see Roles. |
Unknown fields are refused, not ignored. A misspelled field stops the content file from parsing at all, and the message names what you wrote and every name that would have been valid.
Palette
The palette is a map of name to hex. Role tables reference these names, so each tone is stated once and changed once.
Thirteen names are REQUIRED in a root theme, because the screens ask for them directly:
| variable | what it inks |
|---|---|
primary |
Live text, active borders, glyphs - the primary ink. |
secondary |
Secondary text and idle fills. |
label |
Labels, section heads, tags. |
body |
Readable body copy. |
accent |
Selection, keycaps, warnings. |
accent_high |
The bright end of the accent family. |
accent_low |
The deep end of the accent family. |
inverted |
Glyphs sitting on a bright fill. |
surface |
The panel and screen surface. |
void |
The deepest field behind everything. |
danger |
The danger family. |
info |
The info family. |
nominal |
Healthy, online, lit - a passed check, an enabled dependency. |
nominal is separate from primary on purpose. On a phosphor screen the ink
IS green, so the two look like one colour and a screen that wanted "good" can
get away with asking for the ink. On a casing the ink is bone white and the
lamp is still green, and a theme that answers both questions with one colour
draws a white ONLINE badge.
Give these thirteen your OWN tones. A theme that copies another theme's semantic palette cannot differ from it anywhere a screen paints by meaning, whatever its role table says - the labels, the body copy and the preview panes all keep the colours they were copied from.
Extra names are free, and are how a theme gives its role table tones the
screens never ask for by name. The base themes add black, white,
hover_text, primary_high, primary_low, screen_lifted, the case_*
casing family and danger_text_hot this way.
A derived theme's palette entry SHADOWS its parent's, and its extra names join rather than replace. The example above recolours three variables and inherits the rest.
An unknown variable name is a lint error, never a silent black.
Metrics
Three numbers the whole interface is drawn with:
metrics: Some((
border_width: 1.0,
radius: 2.0,
panel_radius: 10.0,
)),
| field | meaning |
|---|---|
border_width |
Hairline border width, in logical pixels. |
radius |
Control corner radius. Phosphor is 2.0 and square-ish, hardware 7.0 and moulded. |
panel_radius |
Panel corner radius, larger than a control's in both shipped looks. |
metrics: None takes the nearest ancestor's three whole. A root theme must
carry them.
Colours
Every colour in a role table is a value plus an alpha:
border: (
value: Variable("primary"),
alpha: 0.4,
),
text: (
value: Literal("#ffd9d5"),
),
| field | meaning |
|---|---|
value |
Variable("name") reads the palette, Literal("#rrggbb") writes a one-off tone. Anything reused belongs in the palette. |
alpha |
0.0 to 1.0. Omitted means opaque. It REPLACES the source colour's alpha rather than multiplying it, so the same variable at 0.12 reads the same in every theme. |
Transparent paint is alpha 0.0, never a missing colour.
Fills
A surface background is one of three:
fill: Solid((value: Variable("surface"))),
fill: Linear(
base: (value: Variable("surface")),
degrees: 180.0,
stops: [
(color: (value: Variable("white"), alpha: 0.06), percent: 0.0),
(color: (value: Variable("black"), alpha: 0.18), percent: 100.0),
],
),
fill: Radial(
base: (value: Variable("surface")),
anchor: Top,
stops: [
(color: (value: Variable("primary"), alpha: 0.05), percent: 0.0),
(color: (value: Variable("black"), alpha: 0.0), percent: 70.0),
],
),
Linear takes an angle in degrees, where 180 runs top to bottom - that is how
a moulded face gets its light. Radial takes an anchor of Top or Center
and always shapes to the farthest side; the phosphor panel blooms from its top
edge this way.
Both gradient forms carry an explicit base, the flat colour painted UNDER the
gradient. It is not derivable from the stops: the phosphor panel is a dark
screen face under a translucent glow, so its stops say nothing about the colour
a player sees. State it.
Effects
A glow or a drop shadow, or None for a flat face:
effect: Some((
color: (value: Variable("primary"), alpha: 0.25),
offset_y: 0.0,
blur: 12.0,
)),
One type serves both. A drop shadow is an offset dark blur, and a glow an un-offset coloured one.
Roles
Sixteen roles name every control the interface paints. Each is an Option:
None INHERITS that role whole from the parent, and a root theme must fill
every one.
| role | what it paints | states |
|---|---|---|
button |
The neutral button. | normal, hovered, pressed, selected, selected_pressed, disabled |
button_primary |
The call-to-action button. | the same six |
button_danger |
The destructive button. | the same six |
button_ghost |
The border-only button. | the same six |
panel |
A bordered panel surface. | one surface |
panel_head |
A panel's header band: border, title, rule, tag. |
one band |
list_row |
A list row. | normal, hovered, selected |
segmented |
A segmented control's recessed container. | one surface |
slider_track |
A slider track - see Shape. | one track |
text_field |
A text field. | normal, hovered, focused, error |
checkbox |
A checkbox, plus its own radius. |
off, on |
toggle |
A pill toggle, plus radius and knob_radius. |
off, on |
badge |
A status badge - see Shape. | shape and alpha only |
scroll_bar |
A scrollbar: track and thumb. |
two colours |
key_chip |
A keycap chip. | one state |
separator |
A rule between sections. | one colour |
A control state is a fill, a border, a text colour and an optional effect:
normal: (
fill: Solid((value: Variable("primary"), alpha: 0.05)),
border: (value: Variable("primary"), alpha: 0.4),
text: (value: Variable("primary")),
effect: None,
),
Every state of a role a theme declares is explicit. A theme cannot leave one out and inherit it from another state, because a button that painted five of its six states would carry a dead state nobody noticed.
A panel, a segmented and a slider's well carry radius: Option<f32> -
None takes the metric, and a number overrides it for that surface alone.
A list row carries no text colour. What a row holds is arbitrary - icons, badges, several spans - and each of those is already themed.
Shape
Two roles carry shape as well as paint, because the shipped looks differ there and the difference IS the look.
A slider meters in discrete blocks or in one continuous fill:
slider_track: Some((
surface: (
fill: Solid((value: Variable("black"), alpha: 0.5)),
border: (value: Variable("primary"), alpha: 0.32),
effect: None,
radius: None,
),
height: 14.0,
meter: Blocks(segments: 24, gap: 2.0),
lit: (value: Variable("primary")),
unlit: (value: Variable("primary"), alpha: 0.16),
)),
meter: Fill draws one bar filled to the value instead, which is what the
hardware look does behind its moulded well. height is authored because the
two meters are not legible at one height.
A badge is the terminal's bracketed tag or a bordered chip:
badge: Some((
bracketed: true,
padding_x: 2.0,
border_width: 0.0,
border_alpha: 0.0,
fill_alpha: 0.0,
)),
bracketed: true renders [TAG] as text with no chrome. bracketed: false
draws the chip, and then padding_x, border_width, border_alpha and
fill_alpha shape it. A badge's HUE comes from what it reports: a nominal
badge reads primary, a warning accent, a fault danger. The theme sets
those variables in its palette, so the role itself carries alphas rather than
colours.
Inheritance
inherit: None marks a ROOT theme. A root theme must define every one of the
sixteen roles, all thirteen required palette variables, and its metrics. Nothing
stands behind it.
inherit: Some("base/phosphor") marks a DERIVED theme. Every role it omits
comes from its parent. This is the normal way to ship a look: recolour the
palette, override the two or three roles that should differ, and inherit the
rest.
A role a derived theme DOES declare replaces the inherited one COMPLETELY. Individual states are never merged, so a theme can never leave a control half-painted in two looks.
Chains can be any depth. The game refuses:
- an
inheritthat names a theme nothing declares; - an inheritance cycle, by the names in it;
- a root theme missing a role, a palette variable or its metrics;
- a hex that does not parse.
Each is one lint finding naming the theme and what is missing.
A theme can only inherit from a theme it can SEE: one the base game ships, one
in the same bundle, or one in a bundle listed in meta.dependencies. base is
an implicit dependency and is never declared, so inheriting from
base/phosphor needs nothing.
Restyling a base theme
Declaring id: "base/phosphor" REPLACES the shipped phosphor theme rather than
adding a theme beside it. The player keeps the theme they chose and it looks
different - which is what a restyle mod is.
The registration order is the Settings picker's order, and an overlay keeps a
theme where it was. A mod that restyles base/phosphor leaves the default in
the first position, where the player expects it.
Two mods that both restyle one id: the later-loaded one wins, as with every other content kind.
What the player sees
Settings lists every theme every ENABLED mod declares, by name, in
registration order. Picking one writes its id; the whole interface repaints
on the same frame, with no restart and no rebuild.
The setting persists as that id and nothing else. A theme id survives a restart, which is the point of the format.
Enabling a mod never changes the player's selection. Disabling the mod whose
theme was selected falls back to base/phosphor and says why, beside the theme
row - so a player can tell a reverted theme from a forgotten setting.
Check it
nix develop --command cargo run content lint --target path/to/your-mod
The lint RESOLVES every theme the bundle declares, against every theme visible to it. It refuses an unknown parent, a cycle, a missing role, a missing palette variable, an unknown variable name and an unparseable hex. A theme that does not resolve is not a theme that looks wrong; it is one the game would refuse and fall back from.
Then run the game and open Settings. Headless output cannot prove appearance.