v5-theming-templates.md
TemplateEngine renders text and files from a theme token map. It supports inline expressions, block syntax for loops and conditionals, color formatting, filter pipelines, and TOML-driven batch processing.
This document describes the supported template syntax and data model.
TemplateEngine is responsible for:
closest_colorIt is not responsible for image loading or palette extraction.
Inline expressions use:
{{ ... }}
Examples:
{{ colors.primary.default.hex }}{{ colors.surface.dark.rgb }}{{ mode }}{{ image }}
Control blocks use:
<* ... *>
Supported block tags:
forifelseendifendforExamples:
<* for name, value in colors *>{{ name }}={{ value.default.hex }}<* endfor *>
<* if {{ loop.first }} *>first<* else *>not first<* endif *>
Section titled “Standalone Block Trimming”
If a block tag appears alone on a line, with only whitespace around it, that whole line is removed from the rendered output. This matters for whitespace-sensitive templates.
Section titled “Palette Colors”
Colors are accessed with:
{{ colors.<name>.<mode>.<format> }}
Examples:
{{ colors.primary.default.hex }}{{ colors.surface.light.hsl }}{{ colors.terminal_foreground.default.hex_stripped }}
Section titled “Available Color Tokens”
The built-in Material color tokens are:
| Group | Tokens |
|---|---|
| Primary | primary, on_primary, primary_container, on_primary_container, primary_fixed, primary_fixed_dim, on_primary_fixed, on_primary_fixed_variant |
| Secondary | secondary, on_secondary, secondary_container, on_secondary_container, secondary_fixed, secondary_fixed_dim, on_secondary_fixed, on_secondary_fixed_variant |
| Tertiary | tertiary, on_tertiary, tertiary_container, on_tertiary_container, tertiary_fixed, tertiary_fixed_dim, on_tertiary_fixed, on_tertiary_fixed_variant |
| Error | error, on_error, error_container, on_error_container |
| Surface | surface, on_surface, surface_variant, on_surface_variant, surface_dim, surface_bright, surface_container_lowest, surface_container_low, surface_container, surface_container_high, surface_container_highest |
| Outline and utility | outline, outline_variant, shadow, scrim |
| Inverse | inverse_surface, inverse_on_surface, inverse_primary |
| Background | background, on_background |
This list covers the 48 Material color tokens exposed through colors. Terminal tokens are listed in Terminal Tokens. Custom colors add their generated token names at render time; see Custom Colors.
Supported modes:
darklightdefaultdefault resolves to the configured default mode.
Section titled “Special Values”
The following values are available directly:
modeimageclosest_colormode is the current default mode.
image is the source image path when rendering from an image-driven theme.
closest_color is populated during config-driven rendering when compare_to and colors_to_compare are used.
Section titled “Color Aliases”
Supported aliases:
hover -> surface_container_highon_hover -> on_surfaceSupported output formats:
hexhex_strippedrgbrgb_csvrgbahslhslaredgreenbluealphahuesaturationlightnessFormat behavior:
hex: #rrggbbhex_stripped: rrggbbrgb: rgb(r, g, b)rgb_csv: r,g,brgba: rgba(r, g, b, a)hsl: hsl(h, s%, l%)hsla: hsla(h, s%, l%, a)red, green, blue: integer channelsalpha: floating-point alphahue: integer huesaturation, lightness: integer percentagesFilters are chained with pipe syntax:
{{ colors.primary.default.hex | grayscale }}{{ colors.primary.default.hex | set_alpha 0.5 }}{{ colors.primary.default.hex | blend: "#ff0000", 0.5 }}
Supported syntaxes:
| filter| filter arg| filter: argNumeric filter arguments are locale-invariant and use . as the decimal separator, for example set_alpha 0.5.
Section titled “Color Filters”
These operate on colors:
grayscale - no argument; converts to gray using luminance weightinginvert - no argument; inverts each RGB channelset_alpha - alpha 0–1, e.g. set_alpha 0.5set_lightness - sets HSL lightness, 0–100set_hue - sets absolute hue in degrees, 0–360rotate_hue - rotates hue by a relative amount in degrees, e.g. rotate_hue 30set_saturation - sets HSL saturation, 0–100set_red - sets the red channel, 0–255set_green - sets the green channel, 0–255set_blue - sets the blue channel, 0–255lighten - adds to lightness in percentage points, e.g. lighten 10darken - subtracts from lightness in percentage points, e.g. darken 10saturate - adds to saturation in percentage points, e.g. saturate 10desaturate - subtracts from saturation in percentage points, e.g. desaturate 10auto_lightness - shifts lightness by the given percentage points away from mid (lightens dark colors, darkens light ones)Section titled “Color-Argument Filters”
These accept another color:
blend - interpolates hue toward the given color by an amount 0–1 (default 1), e.g. blend: "#ff0000", 0.5harmonize - nudges hue toward the given color, capped at 15°to_color - reinterprets a string value as a color so color filters can followExamples:
{{ colors.primary.default.hex | blend: "#ff0000", 0.5 }}{{ colors.primary.default.hex | harmonize: "#00ff88" }}{{ "#ffaa00" | to_color | darken 0.1 }}
Section titled “String Filters”
These operate on strings:
replacelower_casecamel_casepascal_casesnake_casekebab_caseExamples:
{{ mode | replace: "dark", "night" }}{{ "surface container high" | snake_case }}
Supported forms:
<* for item in iterable *>...<* endfor *>
<* for key, value in colors *>...<* endfor *>
Supported forms:
<* if {{ expr }} *>...<* endif *>
<* if not {{ expr }} *>...<* else *>...<* endif *>
Truthiness rules:
false is false0 is false"false", "0", and "none" are falseSupported iterable expressions:
colorspalettes.primarypalettes.secondarypalettes.tertiarypalettes.errorpalettes.neutralpalettes.neutral_variant0..10 and -5..5colorscolors iterates all available token names and their mode maps:
<* for name, value in colors *>{{ name }}={{ value.default.hex }}<* endfor *>
palettes.*palettes.* iterates derived tone steps for the selected palette family.
Supported families:
primarysecondarytertiaryerrorneutralneutral_variantTone values:
05101520253035405060708090959899100Section titled “Loop Metadata”
Inside for loops, the loop object provides:
loop.indexloop.firstloop.lastloop.index is zero-based.
Section titled “File Rendering”
When rendering files, TemplateEngine:
Skipping unchanged output avoids unnecessary timestamp updates.
Section titled “Config Processing”
TemplateEngine supports TOML-driven template processing.
Top-level sections:
[config][templates]Section titled “Custom Colors”
[config.custom_colors] defines named colors that are expanded into Material 3-style roles before templates render. It supports both forms:
[config.custom_colors]warning = "#f97316"
[config.custom_colors.warning]color = "#f97316"blend = true
Use the generated roles through colors.<role>.<mode>.<format> just like built-in palette colors:
.warning-banner { color: {{ colors.on_warning.default.hex }}; background: {{ colors.warning_container.default.hex }}; border-color: {{ colors.warning.default.hex }};}
In Noctalia’s main config, put the same custom color definitions under [theme.templates.custom_colors] and reference them from any enabled user template:
[theme.templates.custom_colors]warning = "#f97316"
[theme.templates.user.my_app]input_path = "$XDG_CONFIG_HOME/noctalia/templates/my-app.css"output_path = "$XDG_CONFIG_HOME/my-app/theme.css"
blend defaults to true, which harmonizes the custom color with the active palette. Set blend = false in the table form when the generated roles should keep the source hue as closely as possible.
Generated tokens per mode:
{name}_source{name}_value{name}on_{name}{name}_containeron_{name}_containerSection titled “Template Entries”
Each template entry supports:
input_pathoutput_pathoutput_path_dynamiccolors_to_comparecompare_topre_hookpost_hookinput_path_modesindexrequires_pathoutput_path accepts either a single string or an array of strings:
[templates.qt]input_path = "./qtct.conf"output_path = ["$XDG_CONFIG_HOME/qt5ct/colors/noctalia.conf", "$XDG_CONFIG_HOME/qt6ct/colors/noctalia.conf",]
output_path_dynamic is optional. It is a shell command string (same templating as hooks: {{ config_dir }}, {{ mode }}, etc.). After rendering, the shell runs it; on exit status 0, each non-empty line of stdout is appended as an output path (after resolveConfigPath). Lines starting with # are ignored. Use this when destinations depend on the machine (for example Emacs config layout) without hard-coding app-specific logic in the engine. Static output_path entries, if any, are kept; dynamic lines are added after them.
input_path_modes selects a different template input for dark and light mode:
[templates.foo]input_path_modes = { dark = "./dark.css", light = "./light.css" }output_path = "$XDG_CONFIG_HOME/foo/theme.css"
Relative input_path and output_path values are resolved from the config file directory.
A leading $XDG_CONFIG_HOME, $XDG_DATA_HOME, $XDG_STATE_HOME, or $XDG_CACHE_HOME token in input_path/output_path is expanded per the XDG Base Directory spec: the environment variable when it is set, otherwise the spec default (~/.config, ~/.local/share, ~/.local/state, ~/.cache). Prefer these tokens over a hardcoded ~/.config so output lands in the right place when a config/data/cache home is relocated. A leading ~ still expands to $HOME only.
index controls processing order and defaults to 0.
requires_path is optional. When set, the entry is skipped unless that path already exists on disk. Use it for explicit install checks (for example a Flatpak data directory).
When several entries share the same input_path but write to different client config roots (for example Discord forks or VS Code vs VSCodium), the engine infers each output’s client root and skips outputs whose root is missing. Single-app templates (one entry per input) still create missing directories as before.
Section titled “Closest Color”
compare_to and colors_to_compare can be used to compute closest_color.
Behavior:
compare_to is rendered firstcolors_to_compare provides named comparison candidates{{ closest_color }}Supported hooks:
pre_hookpost_hookBehavior:
pre_hook runs before any output files are writtenpost_hook runs after a template is processed successfully, even when rendered output was unchangedclosest_color is available inside hooksAdditional variables available inside hooks and templates:
{{ mode }}{{ image }}{{ closest_color }}{{ config_dir }}{{ config_file }}Section titled “Terminal Tokens”
Every resolved theme exposes flattened terminal tokens. Built-in and community palettes provide curated terminal colors, while wallpaper-derived themes synthesize terminal colors from the generated palette.
terminal_foregroundterminal_backgroundterminal_cursorterminal_cursor_textterminal_selection_fgterminal_selection_bgterminal_normal_blackterminal_normal_redterminal_normal_greenterminal_normal_yellowterminal_normal_blueterminal_normal_magentaterminal_normal_cyanterminal_normal_whiteterminal_bright_blackterminal_bright_redterminal_bright_greenterminal_bright_yellowterminal_bright_blueterminal_bright_magentaterminal_bright_cyanterminal_bright_whiteThese are accessed like any other color token:
{{ colors.terminal_background.default.hex }}{{ colors.terminal_normal_red.default.rgb }}
Template rendering is available through noctalia theme.
Render one file:
Terminal window
noctalia theme <image> -r input.txt:output.txt
Process a TOML config:
Terminal window
noctalia theme <image> -c templates.toml
List available templates:
Terminal window
noctalia theme --list-templates
Pass -c <file> with --list-templates to include entries from a standalone template TOML config.
Process the shipped built-in template catalog:
Terminal window
noctalia theme <image> --builtin-config
The shipped built-in catalog lives at assets/templates/builtin.toml.
Render from a precomputed theme JSON:
Terminal window
noctalia theme --theme-json theme.json -r input.txt:output.txt
Process a TOML config:
Terminal window
noctalia theme <image> -c templates.toml
Set the default template mode:
Terminal window
noctalia theme <image> --default-mode light -r input.txt:output.txt