Documentation

Authoring a Wallmosphere theme

This is the human reference for building a Wallmosphere theme: the folder layout, the complete theme.toml schema, the shader contract, cursors, the bar, and a checklist before you call a theme done. If you plan to have an AI write most of a theme for you, read this first anyway — it is the contract docs/AI_THEME_GUIDE.md and docs/PROMPT_TEMPLATE.md both point back to.

Everything here is read from the real schema in crates/wallmosphere-core/src/theme.rs and the real shader API in crates/wallmosphere-wallpaper/src/shaders/prelude.wgsl. If this document and the code ever disagree, the code wins — but the intent is that they never do.

1. What a theme is

A theme is a folder. themes/bluecat and themes/newcrest in this repository are the two reference themes; your own theme is any folder with the same shape:

my-theme/
  theme.toml          # required — everything else has a default, this file can even be empty
  wallpaper.png        # the wallpaper image (PNG or JPEG); referenced by [wallpaper] image
  scene.wgsl            # optional — a fragment shader; omit it and the engine draws a plain
                         # image with subtle built-in grain instead
  icons/
    start.png            # optional — custom Start-button glyph; referenced by [bar] start_icon
  cursors/
    Arrow.cur, Wait.ani, ... # optional — hand-made .cur/.ani set; see section 4

Only theme.toml is required, and even that can be an empty file: every field in every section has a default, so a minimal theme falls back to sensible built-ins everywhere. This is deliberate — you can start a theme with three lines and grow it.

Running and iterating

wallmosphere.exe --theme <dir>

<dir> can be relative or absolute. While Wallmosphere is running against your theme, three things hot-reload without a restart:

  • theme.toml — saving it re-reads [wallpaper].params (and anything the settings panel derives from them) live. Fields read once at startup (fps, pause_on_fullscreen, pause_on_battery, image, the cursor set, AppBar reservation) do not hot-reload; those need a restart.
  • scene.wgsl — saving it recompiles and swaps the shader on the next poll (about every 500 ms). A shader that fails to compile is logged and the engine falls back to its default scene rather than crashing — so a typo costs you your effects for a few seconds, never your desktop.
  • User prefs (the sliders/toggles a person adjusts in the settings panel) — these live in %LOCALAPPDATA%\Wallmosphere\prefs\<theme-slug>.toml, never in your theme.toml. They hot-reload the same way.

The settings panel also has an explicit "Reload theme" button (footer of the panel) that forces a full reload of theme.toml and scene.wgsl — useful if you changed something the 500 ms poll would eventually pick up anyway but you don't want to wait, or if you want to confirm a save actually landed.

For fast iteration on the shader and the image without running the whole desktop engine at all, see section 3 — snapshot renders one frame to a PNG with no window, no Progman, and no single-instance lock to fight with.

2. theme.toml reference

Colours are "#RRGGBB" (opaque) or "#RRGGBBAA" (straight, non-premultiplied alpha), case-insensitive. Every field below has the listed default and is optional; a section can be omitted entirely.

[meta] — informational only, never read by the engine's behaviour

Field Type Default Description
name string "untitled" Shown in the settings panel header and the theme switcher.
author string "" Shown next to the name in the panel header.
version string "0.0.0" Shown as a pill in the panel header. Free-form.

[wallpaper]

Field Type Default Description
image string "wallpaper.png" PNG/JPEG, relative to the theme dir.
shader string | omitted (none) scene.wgsl, relative to the theme dir. Omit it to use the engine's built-in default scene (image + light grain).
fps integer 60 Frame cap; the engine sleeps between frames. Clamped to 1..=480. Read once at startup.
pause_on_fullscreen bool true Stop rendering while a fullscreen app has focus. Read once at startup.
pause_on_battery bool true Stop rendering while on battery power. Read once at startup.
set_static_wallpaper bool true Also install image as the ordinary Windows wallpaper, so the desktop stays coherent while the engine is paused or the session is locked.
params array of 16 floats 16 zeros Forwarded to the shader as u.params[0..3].xyzw (see section 3). Meaning is entirely up to your scene.wgsl — the engine does not interpret them. The all-zero default is deliberately neutral: an omitted params never smuggles an effect in.

[bar]

Field Type Default Description
position "bottom" | "top" "bottom" Which screen edge the bar docks to.
height integer px 44 Logical pixels; scaled by the monitor's DPI.
margin integer px 10 Gap to the screen edges. 0 docks the bar full-width with square corners on the sides.
corner_radius integer px 14
width "auto" | integer px "auto" "auto" is the full monitor width minus the margins; an integer is a fixed, centred pixel width.
blur bool true Best-effort acrylic blur behind the bar. On this project's reference machine (Windows 10 19045) DWM does not honour a rounded window region on the layered window the bar draws, so blur is currently a no-op kept only for schema compatibility — the bar draws its own translucency with premultiplied Direct2D alpha instead. Set it however you like; it does not currently change anything visible.
reserve_space bool true Register as a Windows AppBar so maximised windows don't go under the bar.
hide_on_fullscreen bool true Hide the bar while a fullscreen app has focus.
font string "Segoe UI" Any installed font family name.
font_size float px 13.0
icon_size integer px 22 Taskbar app icon size.
clock_format string "%H:%M" chrono strftime format for the primary clock segment.
clock_secondary string "%a %d %b" A second, muted clock segment/line. "" hides it.
label string "" Muted, tracked-uppercase label at the far right of the bar (a tagline). "" hides it.
show_start bool true Show the Start button.
start_icon string "" PNG relative to the theme dir, shown as the Start button glyph. "" draws the engine's built-in hexagon glyph.
pinned array of strings [] Executable paths shown as launchers before the running-apps list. v0.1: launch only (no state tracking beyond "is it running").
group_windows bool true Stack multiple windows of the same app into one button with a live-thumbnail popup.
show_search bool true Show the search field at the top of the Wallmosphere menu (apps, Settings pages, Run).

[bar.colors]

All Color ("#RRGGBB" or "#RRGGBBAA").

Field Default Description
background #0B0B0DD9 The bar's fill.
border #FFFFFF1F Hairline stroke around the bar.
foreground #EDEDED Primary text/icon colour.
muted #8C8C8C Secondary text (secondary clock, label, tooltips).
accent #FFFFFF Selection/emphasis colour — also the settings panel's tab indicator and slider fill, and (by default) the seed for a derived click-particle palette.
active #FFFFFF22 Background of the currently-active app button.
hover #FFFFFF14 Hover background for bar buttons.
indicator #FFFFFF The 2 px line drawn under a running app (full opacity when active, ~40% otherwise).
warning #E6B450 Warning state: network without internet / disconnected, low battery.

[cursor]

set names a folder (relative to the theme dir) holding .cur/.ani files under the fixed names Windows expects (Arrow.cur, Wait.ani, …; see section 4). A missing individual file is simply skipped, so a partial set is fine.

Field Type Default Description
enabled bool true Master switch for the whole cursor subsystem (custom cursor shapes and the click effect).
set string "cursors" Folder, relative to the theme dir, holding the .cur/.ani files.
size integer px 32 Nominal size the set is generated/authored at. gen_cursors always bakes 24/32/48 px into every file regardless of this value, adding size itself too if it's something else (any value 1..=256); Windows picks whichever matches the user's cursor-size setting. Purely informational at runtime.
style "pointer" | "dot" | "ring" "pointer" Which cursor silhouette gen_cursors draws. Only affects the pointer-like shapes (Arrow, Help, AppStarting, NWPen, UpArrow); precision/utility shapes (crosshair, I-beam, resize handles, hand, wait, …) keep their own geometry regardless and only pick up your colours. Irrelevant if you bring your own .cur/.ani files instead of generating them.
fill Color #0B0B0D Body fill of every generated cursor shape.
outline Color #FFFFFF Thin stroke around every shape.
glow Color #00000000 Soft halo just outside the outline. Alpha 0 (the default) disables it outright; give it alpha to opt in.
dot_size integer px 10 Diameter of the dot/ring style's ball, measured at a fixed 24 px reference size.
click_effect bool true Master switch for whatever a click draws (the ring/particles below). Independent of the wallpaper's own click ripple, which is not gated on this at all — see section 3.
click_color Color #FFFFFFB3 Colour of the ring, and the seed a derived particle palette is built from when click_colors is empty.
click_radius integer px 28 Final radius of a click ring, in physical pixels.
click_ms integer ms 320 Duration of one ring animation (also the ring half of shockwave).
click_style "ring" | "confetti" | "sparkle" | "shockwave" | "none" "ring" What the left mouse button paints.
right_click_style same enum "ring" What the right mouse button paints. Independent of click_style — a theme can give the left button confetti and keep the ring on the right.
click_colors array of Color [] Palette for confetti/sparkle/shockwave. Empty means "derive one" from click_color and [bar.colors] accent.
click_count integer 60 Particles per confetti/sparkle burst. Clamped to 300 by the renderer (each particle is software-rasterised on the mouse-hook thread).
click_gravity float 1.0 Downward pull on confetti, as a multiple of 900 px/s². 0 lets pieces float on their initial velocity and air drag alone; 1 is a natural fall. Unused by shockwave.
click_life_ms integer ms 1400 How long a particle burst lives. Clamped to 5000 by the renderer. Separate from click_ms because a ring that should read as instant at 320 ms would look wrong if the confetti it's paired with vanished at the same time.
click_max_bursts integer 6 How many click bursts can be on screen at once (clamped to 1..=12 by the renderer). One layered window and one bitmap per slot, so this is the click effect's whole memory footprint.

The right button's right_click_style and the two-buttons-are-independent behaviour, and the particle knobs (click_colors, click_count, click_gravity, click_life_ms, click_max_bursts), are the newer half of this section — a theme written before they existed still loads and still gets the plain white ring on both buttons, unchanged.

[system]

Field Type Default Description
hide_windows_taskbar bool true Hide the real Windows taskbar while Wallmosphere's bar is running (restored automatically on exit).

[[controls]]

An array of tables — zero or more user-facing knobs the settings panel draws over [wallpaper].params. A theme with none exposes nothing to adjust and renders exactly as you drew it; this section is entirely additive.

Field Type Required for Description
id string always Stable identifier. Must be unique across all [[controls]] in the file — load_theme rejects a duplicate. Also the key the user's saved override is stored under in their prefs file.
label string always Shown to the user in the panel.
kind "slider" | "toggle" always Which widget the panel draws.
param integer always Index 0..16 into [wallpaper].params this control drives. load_theme rejects anything outside that range.
min float slider Lower bound. Must be finite and less than max.
max float slider Upper bound. Must be finite.
step float slider Increment for the scroll-wheel/arrow-key nudge. Must be finite and greater than 0.
on float toggle The value written to params[param] when the toggle is on.
off float toggle The value written to params[param] when the toggle is off.
default float | omitted optional, both kinds Initial value (same units as the slider, or on/off for a toggle — >= 0.5 reads as on). Omit it to mean "leave whatever [wallpaper].params[param] is already tuned to" rather than forcing an arbitrary starting point — this is why the reference themes give effect sliders no default (they inherit the value already in params above) but always give toggles an explicit one.
group string optional Default "Wallpaper". Groups controls into cards on the panel's Wallpaper tab; group = "Bar" and group = "Cursor" instead route the control into those tabs. Presentation only.
hint string optional One short sentence, shown as a tooltip on the control's label. Default "" (no tooltip). Presentation only.

Validation, all enforced by load_theme at load time (a broken theme fails loudly and specifically rather than misbehaving later): param must be < 16; every id must be unique; a slider must carry finite min/max/step with min < max and step > 0; a toggle must carry both on and off.

Precedence when the engine resolves the actual value handed to the shader for a given control: the user's saved value in their prefs file, else the control's own default, else whatever is already sitting in [wallpaper].params[param] in your theme.toml — in that order. In other words: theme default < your explicit default < the user's own choice. Your theme.toml is never written to by the app; the user's overrides live in their own small file per theme.

3. Shader contract

The contract

The engine builds one WGSL module by concatenating prelude.wgsl (built into the engine, not something you write) with your scene.wgsl. Your file must define exactly one entry point:

@fragment
fn fs_main(in: VSOut) -> @location(0) vec4<f32>

and nothing that clashes with a name the prelude already declares (see the list below). If your shader fails to compile, the engine logs the error and falls back to its own default scene — your desktop never goes black over a shader bug, but your effects silently vanish until you fix it, so check the log (or better, snapshot/wallmosphere-theme lint — see below) rather than assuming silence means success.

Colour space: tex is rgba8unorm-srgb and the surface is bgra8unorm-srgb, so textureSample returns linear light and the hardware re-encodes on write. If you want perceptually-even brightness math (additive glows, grain, vignettes), work in to_display(colour) space and convert back with to_linear(...) before you return — both reference themes do this for their entire composite and only convert back on the final line.

Uniforms — u: Uniforms

Field Type Description
resolution vec2<f32> Surface size, physical pixels.
image_size vec2<f32> Source wallpaper image size, pixels.
time f32 Seconds since the engine started. Wraps at 3600 — design periodic effects around a divisor of 3600 (or at least something that doesn't produce a visible jump at the wrap).
dt f32 Duration of the previous frame, seconds.
mouse vec2<f32> Cursor position, 0..1 surface UV, clamped, smoothed by the engine (~0.12 s time constant).
params array<vec4<f32>, 4> Your [wallpaper].params 16 floats, packed 4-at-a-time — params[i/4][i%4] is index i.
clicks array<vec4<f32>, 8> The 8 most recent mouse clicks, ring-buffer order (not oldest/newest sorted — treat every slot the same). .xy = click position in surface UV (not clamped — a click on another monitor lands outside 0..1 and its wave rolls in from that side). .z = when it happened, on u.time's own clock; an empty/expired slot carries the sentinel -1e9 so u.time - z is enormous and every decay curve is already at zero. .w = button: 1 left, 2 right, 3 middle.

VSOut

struct VSOut {
    @builtin(position) pos: vec4<f32>,
    @location(0) uv: vec2<f32>,   // surface UV, 0..1, y DOWN (0 = top of screen)
};

The vertex stage (vs_main) is provided by the prelude — a single oversized triangle, no buffers needed. You never write it.

Constants

PI, TAU.

Helpers

Function Signature What it does
cover_uv (uv: vec2<f32>) -> vec2<f32> Surface UV → image UV with a "cover" fit: fills the surface without stretching, crops the overflowing axis. Use this to sample your wallpaper image.
hash21 (p: vec2<f32>) -> f32 Deterministic pseudo-random scalar from a 2D input.
hash12 (p: f32) -> vec2<f32> Deterministic pseudo-random 2D vector from a scalar input.
noise2 (p: vec2<f32>) -> f32 Bilinear value noise, one octave.
fbm (p: vec2<f32>) -> f32 5-octave fractional Brownian motion over noise2, roughly 0..1. If your effect only needs 2-3 octaves, write your own smaller loop (both reference themes do, as fbm2/fbm3) rather than paying for 5.
luma (c: vec3<f32>) -> f32 Perceptual luminance (Rec. 709 weights).
to_display (c: vec3<f32>) -> vec3<f32> sqrt(c) — cheap gamma-2.0 stand-in for sRGB, for compositing in perceptual space.
to_linear (c: vec3<f32>) -> vec3<f32> c * c — inverse of to_display.
click_ripple (uv, click: vec4<f32>, speed: f32, width: f32) -> f32 One expanding ring of wave from a single u.clicks[i] entry. Signed, roughly -1..1 (a wallmosphere then a trough). speed is UV/second the ring grows at; width is the wallmosphere's thickness in UV.
click_ripples (uv, speed: f32, width: f32) -> f32 Every live click's click_ripple, summed and soft-capped (soften) so several overlapping clicks never tear the frame. The usual entry point if you just want "the surface reacts to a click".
click_ripples_dir (uv, speed: f32, width: f32) -> vec3<f32> Same sum, plus direction: .xy is a displacement vector pointing away from each click (already amplitude-scaled), .z is the same amplitude click_ripples returns. Use this one — not the bare amplitude — if you're distorting UV, or every wave bends the frame the same way instead of radiating outward.
soften (v: f32) -> f32 Soft ceiling used internally by the two functions above; call it yourself if you sum something else that can blow past 1.
screen_blend (base: vec3<f32>, add: vec3<f32>) -> vec3<f32> Photographic "screen" blend — for beams/glows that must never clip to a hard white edge.

NO_CLICK (-1e9, the empty-slot sentinel) is also in scope if you want to test a slot yourself instead of going through click_ripple.

Patterns worth following

  • Mask regions, don't distort everything. Both reference themes compute one or more masks (a signed distance to a shape, a rectangle with soft edges, …) before any UV displacement, and gate every displacement and every additive effect by them. The thing your image is actually of — a logo, a face, a subject in the foreground — should have a mask that keeps it pixel-perfect regardless of what the sky/background around it is doing.
  • Keep the hero sharp. Corollary of the above: never displace, blur, or otherwise touch the region a viewer's eye is drawn to first. Effects belong in the "weather" around it.
  • Make periodic effects wrap-friendly. Since u.time resets to 0 every 3600 seconds, an effect keyed directly off u.time (a cycle every N seconds) should pick an N that divides 3600 evenly (or is close enough that the seam is imperceptible) — see themes/bluecat's shooting star at a 14.4 s cycle (3600 / 14.4 = 250, exact). effects driven by sin/fract of u.time * frequency don't need this care since they're already continuous across the wrap; it's one-shot/cyclic events (bursts, streaks) that need it.
  • Budget roughly ≤ 12 loop iterations per pixel. This targets integrated GPUs, not just discrete ones. fbm alone is 5; a plain noise2 call is effectively 1. themes/newcrest's scene totals about 8 (a full fbm plus three extra noise2 calls); themes/bluecat's totals about 7 (two 2-3 octave hand-rolled fbms). Texture samples are cheap by comparison but not free — a 4-tap neighbourhood ring (star detection in themes/bluecat) is a reasonable amount to spend, ten would not be.
  • Composite in to_display space, convert back once at the end. See the colour-space note above; skipping this makes small additive effects (a faint veil, grain) look 2-4x stronger on screen than the alpha value you wrote suggests.

A minimal scene.wgsl, commented line by line

Image, a vignette, film grain, and a ripple on click — the smallest scene that demonstrates every part of the contract. (wallmosphere-theme new writes exactly this file when it has no theme to copy from.)

// A minimal scene.wgsl: the wallpaper image, a vignette, film grain, and a ripple on click.
//
// prelude.wgsl is concatenated above this file automatically, so `u`, `tex`, `samp`, `VSOut`,
// `cover_uv`, `hash21`, `click_ripples`, `to_display`, `to_linear` and friends are already in
// scope. This file only has to define `fs_main`.

@fragment
fn fs_main(in: VSOut) -> @location(0) vec4<f32> {
    // `in.uv` is surface UV (0..1, y down). `cover_uv` maps it into the image's own UV so the
    // picture fills the screen without stretching, cropping whichever axis overflows.
    let uv = cover_uv(in.uv);

    // Sample the wallpaper image. `tex`/`samp` return *linear* light (see the prelude's colour
    // space note), so `to_display` (a cheap gamma-2.0 stand-in for sRGB) is applied before any
    // math that assumes perceptual brightness, and undone with `to_linear` at the very end.
    var col = to_display(textureSample(tex, samp, uv).rgb);

    // A click sends one ring of displacement outward from where the mouse was pressed. Here it
    // only lightens the pixel a little: `click_ripples` gives the plain amplitude (roughly
    // -1..1), so `max(x, 0.0)` keeps only the outward wallmosphere and drops the trailing trough.
    let ripple = max(click_ripples(in.uv, 0.35, 0.05), 0.0);
    col += vec3<f32>(0.15) * ripple;

    // Film grain: a fresh hash per pixel per frame, small enough (+-1.5%) to read as texture
    // rather than noise.
    let grain = (hash21(in.pos.xy + vec2<f32>(u.time * 143.0, u.time * 77.0)) - 0.5) * 0.03;
    col += vec3<f32>(grain);

    // Vignette: darken the corners a little, based on distance from the centre of the screen.
    let d = length(in.uv - vec2<f32>(0.5));
    col *= 1.0 - 0.25 * smoothstep(0.4, 0.9, d);

    return vec4<f32>(to_linear(clamp(col, vec3<f32>(0.0), vec3<f32>(1.0))), 1.0);
}

Measuring your work

You do not need a running desktop to see a scene. wallmosphere-wallpaper's snapshot example builds the exact same GPU pipeline the engine does — same prelude, same bindings, same compile-or-fall-back rule — pointed at an offscreen texture instead of a window, and writes one frame to a PNG:

cargo run --release -p wallmosphere-wallpaper --example snapshot -- \
    --theme themes/my-theme --time 9.4 --out preview.png \
    [--size 1920x1080] [--mouse 0.5,0.5] [--click 0.5,0.4,9.1]

--time sets u.time for that one frame. --click x,y,t[,button] fills one u.clicks slot by hand — t is on the same clock --time is, so --time 9.4 --click 0.5,0.4,9.1 renders "300 ms after a click in the middle of the screen"; repeat the flag (up to 8 times) to review several overlapping ripples at once. If the console prints !! THEME SHADER DID NOT COMPILE !!, your scene fell back to the engine default — check the wgpu_hal/wgpu_core log lines above it for the compile error.

wallmosphere-theme lint <dir> (see docs/AI_THEME_GUIDE.md) runs the same shader — prelude concatenated with your scene.wgsl — through a standalone WGSL parser/validator (naga, no GPU needed) and reports parse/validation errors with a line and column, which is usually faster to iterate against than reading engine logs.

There is no formal numeric "score" yet — a --bench flag for measuring per-frame GPU cost is planned but not implemented as of this writing. Until then, "good" is: snapshot renders without falling back to the default scene, the frame looks right at a few different --time/--click values spanning a full cycle of your slowest periodic effect, and the loop-iteration budget above is respected by inspection of your own shader code.

4. Cursors

A theme's cursor set is a folder ([cursor].set, default cursors/) of .cur/.ani files under the fixed names Windows expects in HKCU\Control Panel\Cursors: Arrow.cur, Help.cur, AppStarting.ani, Wait.ani, Crosshair.cur, IBeam.cur, NWPen.cur, No.cur, SizeNS.cur, SizeWE.cur, SizeNWSE.cur, SizeNESW.cur, SizeAll.cur, UpArrow.cur, Hand.cur. A file that's missing is simply skipped, so a partial hand-made set is fine.

Generating one — the usual path, and how both reference themes get theirs:

cargo run --release -p wallmosphere-cursor --bin gen_cursors -- --theme themes/my-theme

Reads [cursor] from your theme.toml (output folder, base size, style, colours) and writes the whole set. Flags override the theme's own values for that one run without ever rewriting theme.toml — handy for previewing a look before you commit it to the file, or for generating a set before theme.toml even exists yet (point --theme at a directory with no theme.toml and the generator falls back to engine defaults plus whatever flags you gave):

--style pointer|dot|ring   --fill #RRGGBB[AA]   --outline #RRGGBB[AA]
--glow #RRGGBB[AA]         --dot-size <px>       --size <px>
--preview <file.png>        # contact sheet of every shape, dark background
--preview-click <file.png>  # filmstrip of the click ring, both buttons
--contact-sheet <file.png>  # every shape at real 24/48 px output size, mid-grey background

The three styles: pointer is the compact rounded-triangle silhouette; dot is a filled ball (diameter set by dot_size); ring is the same footprint as dot but hollow. Only the pointer-like shapes change silhouette with style — precision shapes (crosshair, I-beam, resize handles, hand, wait) always keep their own geometry and only pick up your fill/outline/glow.

Bringing your own files instead: just drop .cur/.ani files under the exact names above into your [cursor].set folder. wallmosphere-theme lint (and the engine itself) don't care how the files were made.

Click effects are configured entirely in [cursor] (see the table in section 2) — they are not part of the cursor set files. click_style/right_click_style choose the visual per button; gen_cursors --preview-click is the fastest way to see one without running the desktop.

5. Bar

Customisable per-theme, all in [bar]/[bar.colors] (full reference in section 2): position, size, corner radius, width mode, whether it registers as an AppBar, font/size/icon size, the two clock segments (clock_format/clock_secondary, both chrono strftime — e.g. "%H:%M", "%a %d %b", "%Y-%m-%d %H:%M:%S"), the tagline label, whether Start is shown, the Start button's icon (start_icon, a PNG — "" draws the engine's built-in hexagon glyph), a list of pinned launcher paths shown before the running-apps list, window grouping, and the search field in the Wallmosphere menu. Every colour in [bar.colors] (including warning, used for network/battery alerts) is themeable.

Preview the whole settings panel — including how your bar section renders — without starting Wallmosphere:

cargo run --release -p wallmosphere-bar --example settings_preview -- themes/my-theme out.png [scale] [tab]

6. Publication checklist

Before you call a theme finished:

  • Performance. Render scene.wgsl through snapshot and eyeball the loop-iteration budget in section 3 (~12/pixel); nothing should feel like it's fighting for GPU time on an integrated chip. pause_on_fullscreen/pause_on_battery should stay true unless you have a specific reason to override them.
  • Image ownership. You must own the rights to wallpaper.png (and any icons//cursor artwork) or have a licence that permits redistribution. Don't ship someone else's wallpaper.
  • File size. Keep wallpaper.png reasonably compressed — a multi-hundred-megabyte PNG loads slowly and serves no visual purpose a well-compressed one doesn't. JPEG is a legitimate choice for photographic sources if PNG's losslessness isn't buying you anything.
  • Resolution and aspect ratio. 1920×1080 (16:9) is the recommended and best-tested target; cover_uv handles other aspect ratios by cropping, so a theme still works off-ratio, but a source significantly narrower than 16:9 crops more than you probably intend. wallmosphere-theme lint warns if your image isn't roughly 16:9 or is smaller than 1280 px wide.
  • [[controls]] hints. If you expose sliders/toggles, give each one a label a stranger would understand and consider a one-line hint — it costs nothing and the panel already has the UI for it.
  • Run the lint. wallmosphere-theme lint <dir> (see docs/AI_THEME_GUIDE.md) catches a broken schema, a missing/wrong-shaped image, a scene.wgsl that won't compile, and a [cursor] set that's enabled but empty — all before you ever launch the real engine.