57 KiB
EZPPLauncher Theme API 2.0
API 2.0 is a declarative theme format. Instead of shipping a JavaScript bundle that
mounts its own UI, a theme describes its background, effects, audio, fonts and
freeform layers in theme.json. The launcher renders it, and the built-in advanced
editor can create and edit these themes without writing code. Themes can opt into a
custom Svelte layer when they need behavior that data alone cannot express; that
layer runs theme-authored JavaScript and therefore needs an explicit trust prompt.
API 1.0 themes continue to work unchanged. Pick the API that fits the theme:
| API 1.0 | API 2.0 | |
|---|---|---|
| Rendering | Svelte bundle (entry) |
Launcher-rendered |
| Files | theme.json, bundle.js, style.css, assets/ |
theme.json, assets/ |
| Created with | create-ezpplauncher-theme |
Launcher editor or create-ezpplauncher-theme |
| Scripts / arbitrary DOM | Yes | Optional custom Svelte layer (trust prompt) |
| Cross-platform | Depends on the bundle | Yes |
Package layout
A packaged .ezpplauncher-theme file is a zip with this structure:
theme.json
assets/
background.webm
layer.png
music.mp3
font.woff2
preview.png # optional thumbnail
An installed theme lives in:
# Windows
C:\Users\<user>\.ezpplauncher\themes\<folder>\
# macOS / Linux
~/.ezpplauncher/themes/<folder>/
theme.json
{
"name": "Midnight",
"version": "1.0.0",
"apiVersion": "2.0",
"author": "Your Name",
"description": "A dark gradient with drifting stars.",
"preview": "preview.png",
"background": {
"type": "gradient",
"from": "#181825",
"to": "#3b1d4f",
"angle": 135,
"opacity": 1
},
"overlay": {
"color": "#000000",
"opacity": 0.2,
"vignette": 0.35
},
"effects": {
"type": "stars",
"density": 40,
"speed": 1,
"color": "#ffd6f2"
},
"typography": {
"body": "Sora Variable",
"heading": "Bebas Neue",
"mono": "JetBrains Mono Variable"
},
"fonts": [
{
"family": "My Display Font",
"src": "display.woff2",
"weight": "400",
"style": "normal"
}
],
"groups": [
{
"id": "branding",
"name": "Branding",
"opacity": 1
}
],
"elements": [
{
"id": "tagline",
"groupId": "branding",
"type": "text",
"text": "MIDNIGHT",
"x": 640,
"y": 84,
"width": 300,
"height": 72,
"fontFamily": "Bebas Neue",
"fontSize": 46,
"fontWeight": 400,
"color": "#ffffff",
"align": "right",
"verticalAlign": "center",
"letterSpacing": 2
}
],
"audio": {
"src": "music.mp3",
"loop": true
},
"variables": {
"player": "Guest",
"clicks": 0
},
"accentColor": "#ff006e",
"iconSet": "lucide",
"css": ""
}
Metadata
| Field | Type | Required | Notes |
|---|---|---|---|
name |
string | yes | Display name; also used for the install folder |
version |
string | yes | Semver, used for update checks |
apiVersion |
string | yes | Must start with 2 (use "2.0") |
author |
string | yes | Shown in the theme manager |
description |
string | no | Shown on the theme card |
nsfw |
bool | no | Marks the theme as adult content (18+ badge) |
preview |
string | no | Card thumbnail; the editor generates preview.png |
accentColor |
string | no | Hex color used for focus rings and active UI states |
iconSet |
string | no | Launcher icon family: lucide, phosphor or tabler |
css |
string | no | Extra CSS injected while the theme is active |
behaviors |
array | no | Declarative WHEN / IF / THEN rules; see Theme logic |
variables |
object | no | Starting values for placeholders and rules; see Dynamic text |
Background
background.type selects the layer:
{ "type": "color", "color": "#181825", "opacity": 1 }
{ "type": "gradient", "from": "#181825", "to": "#3b1d4f", "angle": 135, "opacity": 1 }
{ "type": "image", "src": "background.png", "fit": "cover", "opacity": 1, "blur": 0 }
{
"type": "video",
"src": "background.webm",
"fit": "cover",
"opacity": 1,
"blur": 0,
"muted": true
}
srcis relative to the theme'sassets/folder.fitiscover(default) orcontain.opacityis clamped to0–1,blurto0–40pixels.- Background videos autoplay and loop. They are muted by default; set
mutedtofalseto play their audio. - Theme music (
audio) cannot be combined with a video background; the editor disables music and the launcher ignores theaudioentry in that case. - Prefer WebM for video backgrounds and video layers. It is supported by the launcher and keeps theme packages much smaller than MP4. MP4, MOV and M4V also work.
Overlay
{ "color": "#000000", "opacity": 0.2, "vignette": 0.35 }
opacity controls the flat dim layer and vignette darkens the edges. Both are
clamped to 0–1.
Effects
{ "type": "stars", "density": 40, "speed": 1, "color": "#ffd6f2" }
Supported types: none, stars, snow, petals, bubbles.
densityis clamped to0–120particles.speedis clamped to0.2–3.- Effects are disabled when the user turns on Reduce animations.
Audio
{ "src": "music.mp3", "loop": true }
src is relative to assets/. Music uses the launcher's volume control and is
silent in the theme editor preview.
Theme music cannot be combined with a video background. The editor disables
the music controls while a video background is selected, and themes that contain
both have their audio entry ignored.
Typography
typography.body changes the font used by the launcher UI. typography.heading
changes headings such as the launcher title. typography.mono changes the
monospace font used for paths, versions and telemetry labels; leave it out to
keep the built-in Space Mono stack.
"typography": {
"body": "Montserrat Variable",
"heading": "Playfair Display Variable",
"mono": "JetBrains Mono Variable"
}
These families ship with the launcher through @fontsource and can be used
without adding any files:
| Family | Style |
|---|---|
Sora Variable |
Sans |
Montserrat Variable |
Sans |
Bebas Neue |
Display |
Playfair Display Variable |
Serif |
Space Mono |
Mono |
JetBrains Mono Variable |
Mono |
Custom fonts can be packaged with the theme. The launcher generates the
@font-face rules from fonts:
"fonts": [
{ "family": "My Display Font", "src": "display.woff2", "weight": "400", "style": "normal" }
]
srcis relative toassets/.- Supported formats: WOFF2 (recommended), WOFF, TTF and OTF.
weightdefaults to400;styledefaults tonormal.- Use the
familyvalue intypographyand in text layers.
The editor's Fonts tab browses the full Fontsource catalog (2,000+ families)
and downloads the selected family, weight, style and subset as a WOFF2 into the
theme's assets/ folder. It adds the matching fonts entry automatically, so
saved and exported themes stay self-contained.
Icon set
iconSet swaps the launcher's navigation, status, settings and theme-browser
icons. The default is Lucide; Phosphor and Tabler are also bundled.
"iconSet": "phosphor"
| Value | Family | Character |
|---|---|---|
lucide |
Lucide | Clean geometric outlines (default) |
phosphor |
Phosphor | Softer, rounded shapes |
tabler |
Tabler | Crisp, technical outlines |
Icon bodies ship with the launcher, so the setting never downloads anything at runtime. Unknown values fall back to Lucide, which keeps older launchers and hand-written themes compatible. The editor shows a live preview in Theme → Icon set, and the selected family is used across the launcher and its live home canvas.
Freeform layers
elements are rendered above the launcher UI, but only on the Home screen.
The launcher canvas is a fixed 1000 × 650 coordinate space. Elements can extend
the home screen with text, images, videos, shapes, icons, data readouts, gauges,
tickers, Lottie animations and buttons. They are pointer-transparent by default so
they never block buttons; set interactive: true to let a layer receive clicks and
hovers from theme logic. Interactive layers sit above the UI and can block clicks
inside their rectangle.
The order of the elements array controls stacking: the last element is drawn on
top. Themes can contain up to 100 layers.
Every layer supports:
| Field | Type | Notes |
|---|---|---|
id |
string | Unique within the theme |
name |
string? | Optional editor label; does not affect rendering |
type |
string | text, data, image, video, shape, icon, repeater, gauge, ticker, lottie, button, path, particles, clock, countdown or svelte |
x, y |
number | Position in the 1000 × 650 stage |
width |
number | 24–1000 |
height |
number | 24–650 |
rotation |
number | Degrees, -360–360 |
scale |
number | Legacy uniform scale fallback, 0.05–10 |
scaleX |
number | Horizontal scale, 0.05–10 |
scaleY |
number | Vertical scale, 0.05–10 |
skewX |
number | Horizontal skew in degrees, -89–89 |
skewY |
number | Vertical skew in degrees, -89–89 |
flipX |
boolean | Mirror the layer horizontally |
flipY |
boolean | Mirror the layer vertically |
opacity |
number | 0–1 |
hidden |
boolean | Keep the layer in the file but do not render it |
locked |
boolean | Prevent moving/resizing the layer in the editor |
interactive |
boolean | Opts into clicks and hovers from theme logic |
groupId |
string? | Group that contains this layer |
filters |
object? | Per-layer CSS filters; see Groups and filters |
mask |
object? | Shape or image clip; see Masks |
cssClasses |
string? | Space-separated classes for the layer wrapper |
Text
{
"id": "greeting",
"type": "text",
"text": "Welcome back!",
"x": 360,
"y": 220,
"width": 420,
"height": 90,
"fontFamily": "Bebas Neue",
"fontSize": 42,
"fontWeight": 600,
"fontStyle": "normal",
"color": "#ffffff",
"align": "center",
"verticalAlign": "center",
"lineHeight": 1.2,
"letterSpacing": 0,
"textDecoration": "none",
"textTransform": "none",
"fillMode": "solid",
"outlineWidth": 0,
"outlineColor": "#000000",
"shadowColor": "#000000",
"shadowBlur": 0,
"shadowOffsetX": 0,
"shadowOffsetY": 0
}
align is left, center or right; verticalAlign is top, center or
bottom. Font size is clamped to 6–240.
Text layers support the full visual styling set:
| Field | Type | Notes |
|---|---|---|
fontStyle |
string | normal or italic |
textDecoration |
string | none, underline, line-through or underline line-through |
textTransform |
string | none, uppercase, lowercase or capitalize |
fillMode |
string | solid or gradient |
gradientFrom |
string | Gradient start color |
gradientTo |
string | Gradient end color |
gradientAngle |
number | Degrees, 0–360; 90 is left to right |
outlineWidth |
number | Pixels, 0–20; 0 disables the outline |
outlineColor |
string | Outline color |
shadowColor |
string | Text shadow color |
shadowBlur |
number | Pixels, 0–40 |
shadowOffsetX |
number | Pixels, -40–40 |
shadowOffsetY |
number | Pixels, -40–40 |
Gradient fills use gradientFrom and gradientTo; color remains the fallback
for solid fills and is used for text decoration when no gradient is active. Text
outlines are drawn behind the fill, so gradients and outlines can be combined.
Dynamic text
Text layers can show live rule variables with {name} placeholders. Variables
are shared by every rule and text layer in the theme.
{
"type": "text",
"text": "Clicks: {clicks}\nPlayer: {player}",
"id": "counter"
}
clicks += 1;
if (clicks >= 3) {
player = 'Ready!';
}
The theme can declare starting values for its variables. This is optional; any
variable without an entry starts at 0.
"variables": {
"clicks": 0,
"player": "Guest",
"ready": false
}
Every valid placeholder starts with its declared value when the theme loads,
then updates whenever a script or builder action changes that variable. Values
may be numbers, strings or booleans, and variables that are only read by rules
or scripts can be seeded here as well. Numbers, strings and booleans are
inserted as plain text. Reserved names such as {screen} and malformed
placeholders stay visible instead of rendering a value. In the editor, the text
inspector lists known variables and inserts the placeholder at the cursor, and
Theme → Variables sets each variable's starting value; clearing a field
falls back to 0.
Built-in clock
The engine also exposes read-only local clock values. They refresh once per second, so a text layer or script can display the live time without declaring any variables. Conditions also read the current clock whenever a rule fires.
| Placeholder | Value |
|---|---|
{time} |
24-hour time, e.g. 21:05 |
{time12} |
12-hour time with AM/PM, e.g. 9:05 PM |
{timeSeconds} |
24-hour time with seconds, e.g. 21:05:07 |
{hour} |
Hour from 0 to 23 |
{minute} |
Minute from 0 to 59 |
{second} |
Second from 0 to 59 |
{dayPeriod} |
AM or PM |
<p class="clock">{time12}</p>
<p class="tiny">LOCAL // {timeSeconds}</p>
They are available to conditions and scripts too, which makes day/night themes simple:
if (hour >= 18 || hour < 6) {
showGroup('night');
hideGroup('day');
}
Conditions are evaluated when their rule's trigger fires, so pair a time-based
condition with a timer trigger to re-check it while the launcher is open.
For example, a rule with trigger: { "type": "timer", "interval": 10 } and a
condition on hour switches the theme at sunset or sunrise without restarting.
The helper actions Show group / Hide group make this easy to build in
the rule editor; the Clock quick-pick in a variable condition fills in
time, hour, minute, second or dayPeriod for you.
The clock names are reserved. Assigning to time, hour or any other clock
name is a script error, and they are not listed in Theme → Variables. They
appear in the text inspector's insert row and in script completion.
Image and video
{
"id": "logo",
"type": "image",
"src": "logo.png",
"x": 700,
"y": 420,
"width": 220,
"height": 140,
"fit": "contain",
"radius": 18,
"tint": "",
"opacity": 1
}
tint repaints the image's alpha silhouette with a solid color. It is the
simplest way to make a monochrome SVG logo follow the theme accent; leave it out
or set it to an empty string to keep the original colors.
{
"id": "ambient",
"type": "video",
"src": "ambient.webm",
"x": 0,
"y": 0,
"width": 1000,
"height": 650,
"fit": "cover",
"opacity": 0.35
}
Video layers autoplay and loop. They are muted by default; set muted to false to
play their audio. WebM is recommended for size. Use an opacity below 1 if the video
should blend into the launcher UI.
Shape
{
"id": "glow",
"type": "shape",
"shape": "ellipse",
"x": 680,
"y": 60,
"width": 260,
"height": 260,
"fill": "#ff006e",
"opacity": 0.18,
"borderWidth": 0,
"borderColor": "#ffffff"
}
shape is rectangle, ellipse, line, polygon, star or arc. radius
rounds rectangle corners and cornerRadii (top-left, top-right, bottom-right,
bottom-left) overrides it per corner. points sets polygon sides and star points
(3–24), and innerRadius sets the star's inner radius (0.1–0.95).
An arc draws a ring segment instead of a filled shape. Angles start at 12
o'clock and run clockwise; endAngle may wrap past 360 and a full 360 span
becomes a ring. thickness is the stroke width in stage pixels and rounded
controls the end caps.
{
"id": "dial",
"type": "shape",
"shape": "arc",
"x": 700,
"y": 80,
"width": 180,
"height": 180,
"startAngle": -120,
"endAngle": 120,
"thickness": 10,
"rounded": true,
"fillMode": "gradient",
"gradientFrom": "#ff006e",
"gradientTo": "#7c3aed"
}
Fill can be a solid color or a gradient:
{
"id": "sunburst",
"type": "shape",
"shape": "star",
"x": 120,
"y": 120,
"width": 220,
"height": 220,
"points": 8,
"innerRadius": 0.42,
"fillMode": "gradient",
"gradientFrom": "#ffd166",
"gradientTo": "#ff006e",
"gradientAngle": 135,
"opacity": 0.9
}
borderStyle accepts solid, dashed or dotted, and backdropBlur adds a
frosted-glass blur behind the shape (0–60 pixels). Keep the fill partially
transparent to see the blur.
Icon
An icon layer draws a semantic icon from the theme's icon set, so switching
iconSet changes every icon layer with the rest of the launcher.
{
"id": "star",
"type": "icon",
"icon": "music",
"x": 720,
"y": 80,
"width": 64,
"height": 64,
"color": "#ffd6f2",
"scale": 100
}
scale is a percentage of the layer's shortest side (10–100). The available
names are the ones listed by Theme → Icon in the editor.
Data layer
A data layer is a text layer whose content comes from the launcher instead of
static copy. Use {value} in text as the placeholder.
{
"id": "player",
"type": "data",
"field": "user.name",
"text": "Welcome, {value}!",
"x": 360,
"y": 180,
"width": 420,
"height": 60,
"fontSize": 26,
"color": "#ffffff"
}
Fields: user.name, user.country, user.pp, user.rank, user.plays,
user.accuracy, user.playtime, launcher.version, osu.build, osu.stream,
server.ping, server.friends, theme.name, theme.author, theme.version,
screen, clock.time, clock.time12 and clock.date. Player stat fields accept
mode: osu, taiko, catch or mania. Unknown or signed-out values render as
an empty string. A data layer accepts every text styling field (fontFamily,
fontSize, fillMode, outlineWidth, shadows and so on).
Repeater
A repeater tiles an image or an icon across the layer bounds.
{
"id": "sparkles",
"type": "repeater",
"source": "icon",
"icon": "music",
"x": 120,
"y": 480,
"width": 300,
"height": 120,
"tileWidth": 28,
"tileHeight": 28,
"gapX": 14,
"gapY": 14,
"color": "#ffffff"
}
source is image or icon. Image repeaters use src, radius and fit
(cover inside each tile). Leave columns and rows at 0 to fill the bounds;
explicit counts are clamped to 60. At most 240 tiles are drawn.
Gauge
A gauge renders a bar or a ring from a 0–1 value. Bind it to a theme variable
so logic rules can animate it, or leave variable out and use the static value.
{
"id": "progress",
"type": "gauge",
"mode": "ring",
"variable": "sync",
"x": 760,
"y": 420,
"width": 120,
"height": 120,
"trackColor": "#000000",
"fillColor": "#ff006e",
"thickness": 10,
"rounded": true,
"label": "{percent}%"
}
label supports {percent}, {value} and any rule variable. thickness is the
ring stroke width in pixels; the bar fills the layer height.
Ticker
A ticker scrolls a single line of text horizontally.
{
"id": "now-playing",
"type": "ticker",
"text": "Now playing · Your favourite track · ",
"x": 120,
"y": 560,
"width": 420,
"height": 48,
"fontSize": 20,
"color": "#ffffff",
"speed": 18,
"gap": 48
}
speed is the number of seconds for one loop. The track is duplicated so the
loop is seamless, and animation stops when the system asks for reduced motion.
Lottie animation
A Lottie layer plays a JSON animation from the theme's assets folder. It scales
crisply and is usually far smaller than a WebM.
{
"id": "crest",
"type": "lottie",
"src": "crest.json",
"x": 720,
"y": 60,
"width": 200,
"height": 200,
"loop": true,
"autoplay": true,
"speed": 1
}
speed is clamped to 0.1–4. The exported preview image renders the first
frame; the launcher renders the live animation.
Vector path
A path layer draws custom SVG path data as a scalable shape. Author the path
inside a 0–100 coordinate box; it is stretched to the layer rectangle. M, L,
H, V, C, S, Q, T, A and Z commands are supported, and anything outside path
grammar is stripped when the theme loads.
{
"id": "wave",
"type": "path",
"path": "M 0 70 C 20 20 35 95 55 55 C 72 22 86 40 100 10 L 100 100 L 0 100 Z",
"x": 0,
"y": 470,
"width": 700,
"height": 180,
"fillMode": "gradient",
"gradientFrom": "#ff006e",
"gradientTo": "#7c3aed",
"gradientAngle": 90,
"stroke": "#ffffff",
"strokeWidth": 2,
"strokeStyle": "solid",
"strokeLinecap": "round"
}
Set stroke to an empty string for a fill-only path. strokeStyle accepts
solid, dashed or dotted, and strokeLinecap accepts butt, round or
square.
Particles
A particle field animates inside its rectangle without any assets. The presets cover the most common ambient effects, and the layer respects the system's reduced-motion setting.
{
"id": "sparkle-field",
"type": "particles",
"preset": "sparkles",
"x": 520,
"y": 90,
"width": 420,
"height": 360,
"density": 42,
"size": 7,
"speed": 1,
"color": "#ffe9a8",
"color2": "#ff006e",
"wind": 0.2,
"fade": true
}
| Field | Type | Notes |
|---|---|---|
preset |
string | sparkles, snow, petals, bubbles, embers, confetti |
density |
number | Particle count, 4–120 |
speed |
number | Animation speed multiplier, 0.1–3 |
size |
number | Base particle size in stage pixels, 1–24 |
color |
string | Primary color |
color2 |
string | Secondary color; particles alternate between both |
wind |
number | Horizontal drift, -1 (left) to 1 (right) |
fade |
boolean | Fade particles at the top and bottom edges (default true) |
Clock
A clock layer shows the viewer's local time, either as text or as an analog dial. It ticks once per second while it is visible.
{
"id": "local-time",
"type": "clock",
"mode": "digital",
"x": 40,
"y": 500,
"width": 300,
"height": 110,
"hour12": false,
"showSeconds": true,
"showDate": true,
"label": "Local time",
"fontFamily": "Sora Variable",
"fontSize": 42,
"fontWeight": 700,
"color": "#ffffff",
"accentColor": "#ff006e",
"align": "center"
}
mode is digital or analog. hour12, showSeconds and showDate affect the
digital clock; the analog dial always uses local hours and shows the second hand
unless showSeconds is false. accentColor colors the second hand, dial
details and the date.
Countdown
A countdown layer ticks towards an ISO date-time using a template. It shares the clock tick and needs no logic rule.
{
"id": "release",
"type": "countdown",
"target": "2027-01-01T00:00:00Z",
"text": "{days}d {hours}h {minutes}m {seconds}s",
"finishedText": "Happy new year!",
"x": 60,
"y": 420,
"width": 380,
"height": 80,
"fontSize": 34,
"color": "#ffffff",
"align": "left"
}
The template supports {days}, {hours}, {minutes} and {seconds}. Hours,
minutes and seconds are zero-padded. Once the target passes, finishedText is
shown instead. An empty or invalid target displays zeroes.
Button
A button layer is a real launcher control. It receives clicks even though the rest of the layer stack is pointer-transparent, and it is above the built-in UI.
{
"id": "open-themes",
"type": "button",
"label": "Browse themes",
"icon": "themes",
"x": 360,
"y": 480,
"width": 240,
"height": 52,
"action": { "type": "navigate", "screen": "themes" },
"background": "#ff006e",
"color": "#ffffff",
"hoverBackground": "#ff4d94",
"radius": 12
}
action is one of:
| Action | Shape |
|---|---|
navigate |
{ "type": "navigate", "screen": "home" } |
launch |
{ "type": "launch" } |
url |
{ "type": "url", "url": "https://…" } |
External URLs always open through the launcher's confirmation dialog, which shows
the full address first. A url action must use http or https; the theme check
reports anything else as an error.
Custom Svelte layer
A svelte layer is a self-contained Svelte 5 component compiled by the launcher at
runtime. It fills the layer rectangle and can render any markup, SVG or canvas
content, use runes, and import from Svelte's runtime modules.
{
"id": "welcome",
"type": "svelte",
"x": 360,
"y": 200,
"width": 360,
"height": 190,
"interactive": true,
"source": "<script>\n let { variables = {}, asset } = $props();\n</script>\n\n<div class=\"card\">Score: {variables.score}</div>\n\n<style>\n .card { height: 100%; color: white; }\n</style>\n"
}
The component receives these reactive props:
| Prop | Value |
|---|---|
variables |
Live theme variable values. Logic rule changes update the component. |
screen |
The current launcher screen (custom layers render on Home today). |
layer |
{ id, name, width, height } for this layer. |
theme |
{ name, author, accentColor }. |
asset |
asset('image.png') resolves a file inside the theme assets/ folder. |
<script>
let { variables = {}, asset } = $props();
const logo = $derived(asset('logo.png'));
</script>
<img src={logo} alt="" /><p>Score: {variables.score ?? 0}</p>
The editor provides a Monaco source pane with Svelte/JavaScript highlighting,
completions for props, runes and theme variables, and inline compiler errors. The
source is plain JavaScript; TypeScript in <script lang="ts"> is not
preprocessed. Imports are limited to Svelte's runtime (svelte, svelte/store,
svelte/transition, svelte/easing, svelte/motion, svelte/animate,
svelte/reactivity and svelte/legacy). Dynamic import() and import.meta are
not available.
Security: a Svelte layer is executable theme code. It can read the launcher DOM and call APIs available to the page, so only run themes from authors you trust. The launcher asks for confirmation before activating or opening the editor for any API 2.0 theme that contains a Svelte layer, just like an API 1.0 theme bundle. The editor shows a warning in the layer inspector as a reminder.
Groups and filters
groups are optional layer groups for the layer list. Assign a layer to a group with
groupId; groups can nest through parentId:
"groups": [
{
"id": "branding",
"name": "Branding",
"collapsed": false,
"opacity": 1,
"hidden": false,
"locked": false,
"offsetX": 12,
"offsetY": -8,
"scaleX": 1.1,
"scaleY": 1.1,
"rotation": 0,
"skewX": 0,
"skewY": 0,
"flipX": false,
"flipY": false,
"filters": { "brightness": 1.1, "saturate": 1.2 }
},
{ "id": "branding_badge", "name": "Badge", "parentId": "branding" }
]
| Field | Type | Notes |
|---|---|---|
id |
string | Unique within the theme |
name |
string? | Group label in the editor |
parentId |
string? | Parent group for nested groups |
hidden |
boolean | Hides every layer inside the group |
locked |
boolean | Prevents dragging, resizing and transforming the group |
collapsed |
boolean | Folds the group in the editor; ignored by the launcher |
opacity |
number | 0–1, multiplied with the layer opacity |
offsetX |
number | Horizontal translation in stage pixels, -2000–2000 |
offsetY |
number | Vertical translation in stage pixels, -2000–2000 |
scale |
number | Legacy uniform scale fallback, 0.05–10 |
scaleX |
number | Horizontal scale, 0.05–10 |
scaleY |
number | Vertical scale, 0.05–10 |
rotation |
number | Degrees around the group's bounds, -360–360 |
skewX |
number | Horizontal skew in degrees, -89–89 |
skewY |
number | Vertical skew in degrees, -89–89 |
flipX |
boolean | Mirror the group horizontally |
flipY |
boolean | Mirror the group vertically |
filters |
object? | Filters applied to the whole group |
mask |
object? | Shape or image clip; see Masks |
cssClasses |
string? | Classes inherited by every layer in the group |
Masks
Layers and groups can be clipped by a shape or by an image's alpha channel:
"mask": {
"shape": "polygon",
"sides": 6,
"radius": 0,
"innerRadius": 0.5,
"src": "mask.png",
"fit": "cover"
}
| Shape | Behavior |
|---|---|
rectangle |
Rounded rectangle; radius in pixels |
ellipse |
Inscribed ellipse |
polygon |
Regular polygon with sides (3–24) |
star |
Star with sides points and innerRadius (0.1–0.95) |
image |
Alpha mask from src; fit is cover or contain |
A group mask clips every layer in the group and follows the group's transform. A layer mask clips only that layer; selection outlines in the editor stay outside the mask so the layer remains easy to move. Image masks need an actual alpha channel; opaque PNG or JPEG files clip the layer away completely.
Both groups and layers accept the same filters object:
"filters": {
"blur": 4,
"brightness": 1.1,
"contrast": 1.05,
"saturate": 1.2,
"hueRotate": 12,
"grayscale": 0.2,
"sepia": 0,
"invert": 0
}
| Field | Default | Range | Notes |
|---|---|---|---|
blur |
0 |
0–40 | Pixels |
brightness |
1 |
0–3 | Multiplier |
contrast |
1 |
0–3 | Multiplier |
saturate |
1 |
0–4 | Multiplier |
hueRotate |
0 |
-360–360 | Degrees |
grayscale |
0 |
0–1 | |
sepia |
0 |
0–1 | |
invert |
0 |
0–1 |
Filters compose down the tree: a layer's own filters run first, followed by its
group, then outer groups. Group opacity multiplies the same way. Group transforms
are anchored to the bounds of the layers inside the group and compose through
nested groups. A layer's own rotation, skewX, skewY, scaleX, scaleY,
flipX and flipY apply before its ancestor groups' transforms. Themes can
contain up to 50 groups; nesting is limited only by that count.
The full 2D transform set is supported: translate, rotate, independent X/Y scale
and skew. Layers use x/y for translation and groups use offsetX/offsetY
(shown as Translate in the editor). The remaining transforms compose in CSS
order: rotate, skew, then scale and flips, around the layer or group bounds
centre. scaleX and scaleY take precedence over the legacy uniform scale
field; when they are absent, scale is used for both axes. Raw matrix(),
matrix3d() and 3D transforms such as perspective and rotateX/rotateY are
not exposed because the editor's hit-testing and group handles use an invertible
2D matrix.
In the editor, hover any layer on the canvas to reveal its group outline and a
breadcrumb of the group path from the outermost group to the innermost one.
Click any crumb to select that group, including an outer ancestor. You can also
double-click the layer or use Select parent group in its context menu. The
group frame has a center label for moving, corner handles for proportional scaling
and a top handle for rotation. A selected single layer gets a top rotate handle of
its own; rotating a layer inside a group respects the group's transform. Hold Alt
while moving to skip snapping and Shift while rotating or scaling to snap to 15°
or 5% steps. Group handles are hidden while the group is locked.
Theme logic
API 2.0 themes can include declarative interaction rules. Think of them as Scratch-style WHEN / IF / THEN blocks: no JavaScript is executed, and the editor can show exactly what a rule will do. Rules can also opt into a safe advanced script with expressions, branches and loops; see Advanced scripts below.
"behaviors": [
{
"id": "open_themes",
"name": "Open themes",
"enabled": true,
"once": true,
"target": { "type": "element", "id": "themes_button" },
"trigger": { "type": "click", "elementId": "themes_button" },
"conditionMode": "all",
"conditions": [
{ "type": "variable", "variable": "opened", "operator": "equals", "value": 0 }
],
"actions": [
{ "type": "setVariable", "variable": "opened", "value": 1 },
{ "type": "navigate", "screen": "themes" }
]
}
]
A layer only receives pointer events when it sets "interactive": true. Use the
Interactive toggle in the layer inspector, then pick that layer in a click or
hover trigger.
target is optional and links a rule to a layer or group:
"target": { "type": "group", "id": "home_actions" }
Linked rules appear directly in that layer or group inspector, where they can be
viewed, added and edited without leaving the tab. For click, hover and leave
triggers, a group target matches any interactive layer inside the group (including
nested groups); an element target matches only that layer. load, screen and
timer triggers are theme-wide regardless of the link.
| Trigger | Fields | When it runs |
|---|---|---|
load |
— | The theme becomes active |
screen |
screen |
The launcher switches to home, themes, settings or login |
click |
elementId? |
An interactive layer (or a layer inside a linked group) is clicked |
hover |
elementId? |
The pointer enters an interactive layer or linked group |
leave |
elementId? |
The pointer leaves an interactive layer or linked group |
timer |
interval (seconds) |
Repeatedly, with a minimum of 0.5 seconds |
Conditions can be combined with conditionMode: "all" (default) or "any".
An empty condition list always matches.
| Condition | Fields | Checks |
|---|---|---|
variable |
variable, operator, value |
Compares a variable using equals, notEquals, greaterThan, lessThan, greaterOrEqual or lessOrEqual |
visible |
elementId, value |
Whether a layer is currently visible |
groupVisible |
groupId, value |
Whether a group is currently visible |
screen |
screen |
The current launcher screen |
chance |
percent |
A random roll from 0–100 |
| Action | Fields | Effect |
|---|---|---|
setVariable |
variable, value |
Stores a string, number or boolean |
changeVariable |
variable, amount |
Adds a number to a variable |
toggleVariable |
variable |
Flips a boolean |
showElement / hideElement / toggleElement |
elementId |
Overrides a layer's visible state |
showGroup / hideGroup / toggleGroup |
groupId |
Overrides a group's visibility, including its descendants |
navigate |
screen |
Switches the launcher to a screen |
launch |
— | Starts the normal osu! launch flow from a click |
openUrl |
url |
Asks first, then opens an http/https URL from a click |
A group-level hide wins over its children. A group-level show reveals the group, including descendants that are hidden in the theme data. Element visibility and nested-group overrides still take priority over a group show.
Rules are evaluated in array order and every matching rule runs. Use conditions
and once: true to gate rules. Rules are limited to 100 per theme, 12 conditions
per rule and 20 actions per rule. Variables are scoped to the active theme
session, start at 0, and are reset when the theme changes. once: true stops a
rule after it matches once.
Rules run in the launcher, not in the editor preview. Use Save theme and
then test the saved theme in the launcher. launch and openUrl are deliberately
restricted to click triggers so loading, timers or screen changes cannot leave
the launcher unexpectedly. Before opening an external URL, the launcher shows a
blocking confirmation dialog with the full destination; links never open on their
own.
Advanced scripts
Every rule can use an advanced script instead of its builder actions. Set script
to a string; when the field is present, it replaces actions for that rule.
The builder actions stay in theme.json so switching back in the editor restores
them.
"behaviors": [
{
"id": "advanced_click",
"trigger": { "type": "click", "elementId": "button" },
"actions": [],
"script": "clicks += 1;\nif (clicks >= 3) {\n showLayer(\"secret\");\n} else {\n hideLayer(\"secret\");\n}"
}
]
Scripts use plain JavaScript syntax, but they are never passed to eval.
The launcher parses each script into a small, safe instruction set and runs only
that subset. There is no DOM, browser API, network access, import or eval, and
unsupported syntax is reported as an error in the editor. The earlier keyword
syntax (set, show layer, repeat) is no longer accepted.
Supported statements:
| JavaScript | Effect |
|---|---|
let name = expression; / const / var |
Stores a number, string or boolean |
name = expression; |
Assigns a value |
name += 1;, -=, *=, /=, %= |
Updates a number |
name++;, name--; |
Increments or decrements |
showLayer("id"); |
Shows a layer |
hideLayer("id"); |
Hides a layer |
toggleLayer("id"); |
Flips a layer's visibility |
showGroup("id"); |
Shows a group and its descendants |
hideGroup("id"); |
Hides a group and its descendants |
toggleGroup("id"); |
Flips a group's visibility |
if (expression) { ... } |
Runs a block when the expression is truthy |
if (...) { ... } else { ... } |
Adds an else branch, including else if |
for (let i = 0; i < 3; i++) { ... } |
Runs a bounded loop |
while (expression) { ... } |
Runs a bounded loop |
goTo("themes"); |
Navigates to home, themes, settings or login |
openUrl("https://..."); |
Opens an http/https URL from a click |
launch(); |
Starts the normal osu! launch flow from a click |
stop(); |
Ends the current script immediately |
Comments use // or /* ... */. Semicolons are optional, but one statement
per line is easiest to read.
Expressions support numbers, strings, template literals, true, false,
variables, screen (the current screen name), read-only clock values (time,
time12, timeSeconds, hour, minute, second, dayPeriod), + - * / %,
== != < <= > >= (=== and !== also work), && || !, the ternary
condition ? a : b, and parentheses. Math.min, Math.max, Math.round,
Math.floor, Math.ceil, Math.abs and Math.random are available; the
shorthand min(), max(), round() and the other built-ins below work too.
Not supported: functions, classes, arrays, objects, destructuring, switch,
try/catch, do...while, break/continue, async, imports, this,
regular expressions and BigInt. Assignments must be their own statement; they
cannot appear inside an expression.
Built-in functions:
| Function | Result |
|---|---|
min(a, b, …) / max(a, b, …) |
Smallest or largest number |
clamp(value, min, max) |
Clamps a number to a range |
round(value), floor(value), ceil(value), abs(value) |
Numeric helpers |
random() |
A random number from 0 to 1 |
chance(percent) |
A random boolean with the given percentage |
visible("elementId") |
Whether a layer is currently visible |
groupVisible("groupId") |
Whether a group is currently visible |
contains(text, search) |
Whether text contains a substring |
length(text) |
String length |
number(value), string(value), boolean(value) |
Explicit conversions |
Scripts have a 20,000 character limit, loops are capped at 1,000 iterations and
each trigger is limited to 10,000 executed statements. launch() and
openUrl() still only run from explicit click triggers. Use a timer trigger
instead of waiting inside a script. Variables changed by a script can be shown
in any text layer with a {name} placeholder, for example Clicks: {clicks}.
Custom CSS
The css field is appended while the theme is active. Theme authors can use it for
small tweaks, but it is not required. The advanced editor applies it live to the
canvas, with selectors scoped to the 1000 × 650 preview so the editor window itself
keeps its own styling. Example:
:root {
--radius: 1rem;
}
Layers and groups can expose stable hooks through cssClasses:
{ "id": "hero", "type": "text", "cssClasses": "hero-title glow", "text": "Welcome" }
.hero-title {
letter-spacing: 0.04em;
}
.hero-title.glow {
text-shadow: 0 0 24px #ffd37a;
}
A group's classes are applied to every layer inside it, including nested groups.
Class names are space-separated and limited to letters, numbers, _, - and a
leading -; up to 20 classes per layer or group. Use the CSS classes section
in the layer or group inspector to edit them.
Using the advanced editor
- Open Themes in the launcher.
- Select Create theme, or the pencil button on an installed API 2.0 theme.
- The editor opens in its own resizable window, not a modal. The center is a full launcher home canvas at 1000 × 650.
- Use the + Add menu above the layer list to add text, image, video and shape layers. Drag layers on the canvas, resize with the corner handles, or edit exact values in the right inspector. Shift/Cmd-click a layer or drag a marquee over empty canvas to select several layers. Multi-selections can be dragged together and aligned or distributed from the right panel. Layers snap to canvas edges, the canvas center, and other layers while dragging; hold Alt to disable snapping. Right-click a layer for copy, cut, paste, duplicate, group, ordering, hide/lock and delete actions, or right-click empty canvas to add or paste a layer.
- The layer list supports double-click rename, hide/show, lock/unlock and drag-and-drop. Drag a layer by its grip handle: drop it between two layers to reorder it and adopt that row's group, drop it onto a group to move it inside, or drop it in the move out of all groups zone to return it to the root. Groups are draggable too, so nested groups can be moved into or out of other groups. Select several layers and choose Group selection. Groups can be collapsed, hidden, locked and renamed; right-click a group to copy, cut, paste into it, or use its other quick actions. Hidden layers remain in the package, while locked layers cannot be moved or resized on the canvas. Select a layer or a group to edit its Filters (blur, brightness, contrast, saturation, hue, grayscale, sepia and invert) in the right inspector. Group filters and opacity apply to every layer inside. The Transform section provides translate, independent X/Y scale, rotation, X/Y skew and flips for both layers and groups. Group transforms are anchored to the bounds of their layers. Text layers also expose bold, italic, underline, strikethrough, case, gradient fill, outline and shadow controls in the inspector.
- Use the Theme tab for metadata, background, effects, typography, icon set
and custom CSS. Open the Fonts tab to search and download from every Fontsource
family, or import WOFF2, WOFF, TTF and OTF files. Downloaded fonts are copied
into the theme's
assets/folder when you save. - Open the Logic tab to add WHEN / IF / THEN rules and search them by name or
target. Mark the layers that should react to the pointer as Interactive,
then pick them in a click, hover or leave trigger. Link a rule to a layer or
group with Linked to; linked rules also appear in that layer or group
inspector with an inline editor and an Add rule shortcut. In Then, use
the Builder / Script switch for a safe advanced script with expressions,
if/else,repeat, variables and all the normal actions. The script opens in a Monaco editor with line numbers, syntax highlighting, variable/layer/group completion, hover documentation and inline error markers. Use the expand button for a fullscreen editor. Logic badges in the layer list open the linked editor, and the arrows above the list (orAlt/Option+ArrowUp/Down) jump between layers and groups that have rules. Rules run in the launcher, so save the theme before testing them. - Select Save theme to write
theme.jsonand copy new assets into~/.ezpplauncher/themes. A freshassets/preview.pngthumbnail is rendered from the theme and the launcher applies the saved theme immediately. Unsaved edits are autosaved as a local draft and offered for recovery after a crash. - Select Export to create a shareable
.ezpplauncher-themepackage. A theme check runs first and reports missing assets, oversized media, missing fonts and unused imports. Export saves and refreshes the preview, then reports the final package size.
Keyboard shortcuts:
Cmd/Ctrl+Zundoes the last change.Cmd/Ctrl+Shift+ZorCtrl+Yredoes it.Cmd/Ctrl+Ssaves.Cmd/Ctrl+C,Cmd/Ctrl+XandCmd/Ctrl+Vcopy, cut and paste the selected layer(s) or group. Pasting a group keeps its nested groups, layers and filters.Cmd/Ctrl+Dduplicates the selected layer or multi-selection.Cmd/Ctrl+Ggroups the selected layers, or ungroups the selected group.Cmd/Ctrl+Aselects every visible layer.- Shift/Cmd-click toggles a layer in the current selection.
- Arrow keys nudge the selected layer(s) by 1 px; hold Shift for 10 px.
Alt/Option+ArrowDownandAlt/Option+ArrowUpjump to the next or previous layer or group with linked logic, expanding its inspector section.Deleteremoves the selected layer(s).- Hold Alt while dragging to skip snapping.
Escclears the selection or closes an open editor dialog.
Creating API 2.0 themes with the CLI
The create-ezpplauncher-theme CLI includes a declarative (API 2.0) template:
npx git+https://git.ez-pp.farm/EZPPFarm/create-ezpplauncher-theme.git my-theme
Choose declarative (API 2.0), edit theme.json, add files to assets/, then run:
npm run build
This produces dist/my_theme.ezpplauncher-theme. The template also includes
preview.html for a standalone browser preview:
npx serve .
Compatibility and limits
- API 2.0 packages must include
theme.json;entryandstyleare not used. - Asset paths are resolved inside
assets/; path traversal is rejected by the import commands. - Exports include only
assets/files referenced bytheme.json(background, audio, fonts, media layers and preview). Asset paths mentioned by custom Svelte layers throughasset('...')are kept as well. Orphaned files left in the folder are omitted, which keeps packages small. API 1.0 themes are exported whole because their bundles can load files dynamically. - The editor writes
preview.pngwhenever you save or export. The generated thumbnail renders the background, effects, launcher UI, theme fonts, freeform layers, group visibility/opacity and filters; custom CSS is applied to the live editor canvas but not to the generated thumbnail. - The theme editor only writes inside
~/.ezpplauncher/themes. - API 1.0 themes are still loaded through their existing
mountThemebundle API. - Advanced scripts use JavaScript syntax but never run as JavaScript. Theme logic
is compiled into the same restricted instruction set as the declarative
triggers, conditions and actions above. There is no
eval, DOM access or network API;launch()andopenUrl()only run from explicit click triggers, and external URLs require a second confirmation in the launcher. - Custom Svelte layers are the exception: they are real JavaScript and are not
sandboxed. They require a trust confirmation before activation. Keep the source
self-contained and use
asset()for packaged files so exports can keep the right assets. - Older launchers ignore unknown fields such as
behaviors, so a theme with logic still loads, but its rules will not run until the launcher is updated. - Freeform layers are home-screen-only and pointer-transparent by default. Marking
a layer
interactiveopts it into logic clicks/hovers; it still cannot add arbitrary controls or execute code.