Template Reference
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:
- Rendering template strings and files
- Reading values from theme token maps
- Applying color and string filters
- Executing loops and conditionals
- Processing TOML template configs
- Computing custom colors and
closest_color
It is not responsible for image loading or palette extraction.
Syntax
Section titled “Syntax”Expressions
Section titled “Expressions”Inline expressions use:
{{ ... }}Examples:
{{ colors.primary.default.hex }}{{ colors.surface.dark.rgb }}{{ mode }}{{ image }}Blocks
Section titled “Blocks”Control blocks use:
<* ... *>Supported block tags:
forifelseendifendfor
Examples:
<* for name, value in colors *>{{ name }}={{ value.default.hex }}<* endfor *><* if {{ loop.first }} *>first<* else *>not first<* endif *>Standalone Block Trimming
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.
Data Model
Section titled “Data Model”Palette Colors
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 }}Available Color Tokens
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:
darklightdefault
default resolves to the configured default mode.
Special Values
Section titled “Special Values”The following values are available directly:
modeimageclosest_color
mode 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.
Color Aliases
Section titled “Color Aliases”Supported aliases:
hover->surface_container_highon_hover->on_surface
Formats
Section titled “Formats”Supported output formats:
hexhex_strippedrgbrgb_csvrgbahslhslaredgreenbluealphahuesaturationlightness
Format 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 percentages
Filters
Section titled “Filters”Filters 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: arg
Numeric filter arguments are locale-invariant and use . as the decimal separator, for example set_alpha 0.5.
Color Filters
Section titled “Color Filters”These operate on colors:
grayscale- no argument; converts to gray using luminance weightinginvert- no argument; inverts each RGB channelset_alpha- alpha0–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)
Color-Argument Filters
Section titled “Color-Argument Filters”These accept another color:
blend- interpolates hue toward the given color by an amount0–1(default1), 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 follow
Examples:
{{ colors.primary.default.hex | blend: "#ff0000", 0.5 }}{{ colors.primary.default.hex | harmonize: "#00ff88" }}{{ "#ffaa00" | to_color | darken 10 }}String Filters
Section titled “String Filters”These operate on strings:
replacelower_casecamel_casepascal_casesnake_casekebab_case
Examples:
{{ mode | replace: "dark", "night" }}{{ "surface container high" | snake_case }}Control Flow
Section titled “Control Flow”For Loops
Section titled “For Loops”Supported forms:
<* for item in iterable *>...<* endfor *><* for key, value in colors *>...<* endfor *>Conditionals
Section titled “Conditionals”Supported forms:
<* if {{ expr }} *>...<* endif *><* if not {{ expr }} *>...<* else *>...<* endif *>Truthiness rules:
falseis false- numeric
0is false - empty strings are false
- case-insensitive
"false","0", and"none"are false - empty arrays and maps are false
- everything else is true
Iterables
Section titled “Iterables”Supported iterable expressions:
colors— all palette color tokenspalettes.primary,palettes.secondary,palettes.tertiary,palettes.error,palettes.neutral,palettes.neutral_variant— tonal palette steps- numeric ranges like
0..10and-5..5(end-exclusive) - arrays and maps from the current scope (e.g. TOML data bound via
[config])
colors
Section titled “colors”colors iterates all available token names and their mode maps. Use two loop variables for key-value destructuring:
<* for name, value in colors *>{{ name }}={{ value.default.hex }}<* endfor *>With a single variable, only the name is bound:
<* for name in colors *>{{ name }}<* endfor *>palettes.*
Section titled “palettes.*”Palette tones can be accessed directly or iterated.
Direct access
Section titled “Direct access”{{ palettes.<family>.<tone>.<format> }}The tone can be a plain integer or underscore-prefixed (for matugen compatibility):
bg0 = '{{ palettes.primary.0.hex }}'bg40 = '{{ palettes.primary.40.hex }}'bg99 = '{{ palettes.primary._99.hex }}'fg = '{{ palettes.neutral.90.rgb }}'Any integer tone from 0 to 100 is accepted, not just the 18 standard steps. All color formats and filters work on palette tones.
Iteration
Section titled “Iteration”As an iterable, palettes.* yields the 18 standard tone steps (0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100). Each entry has .default, .dark, and .light accessors that return hex strings.
<* for tone in palettes.primary *>primary-tone-{{ loop.index }}={{ tone.default }}<* endfor *>Supported families: primary, secondary, tertiary, error, neutral, neutral_variant.
Numeric ranges
Section titled “Numeric ranges”start..end generates integers from start up to (but not including) end. Negative values are supported.
<* for i in 0..16 *>color{{ i }}=...<* endfor *><* for i in -2..3 *>offset={{ i }}<* endfor *>Scope arrays and maps
Section titled “Scope arrays and maps”Any array or map in the current variable scope can be iterated. This includes data from TOML config sections or values set in a parent loop. Maps are iterated as key-value pairs, just like colors.
Loop Metadata
Section titled “Loop Metadata”Inside for loops, the loop object provides:
loop.index— zero-based positionloop.first—trueon the first iterationloop.last—trueon the last iteration
<* for name, value in colors *><* if not {{ loop.first }} *>,<* endif *>"{{ name }}": "{{ value.default.hex }}"<* endfor *>File Rendering
Section titled “File Rendering”When rendering files, TemplateEngine:
- reads the template file
- renders the output
- creates parent directories when needed
- skips rewriting unchanged files
- reports render failures through the file render result
Skipping unchanged output avoids unnecessary timestamp updates.
Config Processing
Section titled “Config Processing”TemplateEngine supports TOML-driven template processing.
Top-level sections:
[config][templates]
Custom Colors
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 = trueUse 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.
Separate dark and light colors
Section titled “Separate dark and light colors”The table form also accepts color_dark and color_light to use a different source hue per mode, instead of deriving the non-active mode from a single color:
[config.custom_colors.warning]color_dark = "#f97316"color_light = "#c2410c"blend = trueEach mode resolves its source hue with this precedence: its own color_dark / color_light if set, otherwise the shared color, otherwise the other mode’s value. So:
coloronly: both modes usecolor(default behavior).color_darkonly (orcolor_lightonly): that single value is used for both modes.color_dark+color_light: each mode uses its own value.color+ one ofcolor_dark/color_light: the override applies to its mode,colorapplies to the other.
{name}_source and {name}_value reflect the per-mode source, so {{ colors.warning_source.dark.hex }} and {{ colors.warning_source.light.hex }} return the dark and light source hues respectively.
Generated tokens per mode:
{name}_source{name}_value{name}on_{name}{name}_containeron_{name}_container
Template Entries
Section titled “Template Entries”Each template entry supports:
input_pathoutput_pathoutput_path_dynamiccolors_to_comparecompare_topre_hookpost_hookpost_actioninput_path_modesindexrequires_path
output_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.
Closest Color
Section titled “Closest Color”compare_to and colors_to_compare can be used to compute closest_color.
Behavior:
compare_tois rendered firstcolors_to_compareprovides named comparison candidates- the closest candidate becomes available as
{{ closest_color }}
Supported hooks:
pre_hookpost_hook
Behavior:
- hook commands are rendered through the template engine first
pre_hookruns before any output files are writtenpost_hookruns after a template is processed successfully, even when rendered output was unchangedclosest_coloris available inside hooks
post_action selects a Noctalia-owned native operation that runs after all outputs are written successfully. The
canonical kde-color-scheme action requires exactly one output, merges that generated KDE color scheme into
$XDG_CONFIG_HOME/kdeglobals, and notifies running KDE applications over the session bus. Unknown action names fail
the template entry.
Additional variables available inside hooks and templates:
{{ mode }}{{ image }}{{ closest_color }}{{ config_dir }}{{ config_file }}
Terminal Tokens
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_white
These are accessed like any other color token:
{{ colors.terminal_background.default.hex }}{{ colors.terminal_normal_red.default.rgb }}CLI Usage
Section titled “CLI Usage”Template rendering is available through noctalia theme.
Render one file:
noctalia theme <image> -r input.txt:output.txtProcess a TOML config:
noctalia theme <image> -c templates.tomlList available templates:
noctalia theme --list-templatesPass -c <file> with --list-templates to include entries from a standalone template TOML config.
Process the shipped built-in template catalog:
noctalia theme <image> --builtin-configThe shipped built-in catalog lives at assets/templates/builtin.toml.
Render from a precomputed theme JSON:
noctalia theme --theme-json theme.json -r input.txt:output.txtCombine both: -c and -r can be used in the same command. The config’s
[config.custom_colors] are loaded first, so the -r templates can reference them:
noctalia theme <image> -c templates.toml -r input.txt:output.txtThe -r files are rendered first, then the templates declared in the config.
Set the default template mode:
noctalia theme <image> --default-mode light -r input.txt:output.txt