Files
EZPPLauncher/THEME_API_V2.md
T

1414 lines
57 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`
```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:
```json
{ "type": "color", "color": "#181825", "opacity": 1 }
```
```json
{ "type": "gradient", "from": "#181825", "to": "#3b1d4f", "angle": 135, "opacity": 1 }
```
```json
{ "type": "image", "src": "background.png", "fit": "cover", "opacity": 1, "blur": 0 }
```
```json
{
"type": "video",
"src": "background.webm",
"fit": "cover",
"opacity": 1,
"blur": 0,
"muted": true
}
```
- `src` is relative to the theme's `assets/` folder.
- `fit` is `cover` (default) or `contain`.
- `opacity` is clamped to `0`–`1`, `blur` to `0`–`40` pixels.
- Background videos autoplay and loop. They are muted by default; set `muted` to `false`
to play their audio.
- Theme music (`audio`) cannot be combined with a video background; the editor
disables music and the launcher ignores the `audio` entry 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
```json
{ "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
```json
{ "type": "stars", "density": 40, "speed": 1, "color": "#ffd6f2" }
```
Supported types: `none`, `stars`, `snow`, `petals`, `bubbles`.
- `density` is clamped to `0`–`120` particles.
- `speed` is clamped to `0.2`–`3`.
- Effects are disabled when the user turns on **Reduce animations**.
### Audio
```json
{ "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.
```json
"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`:
```json
"fonts": [
{ "family": "My Display Font", "src": "display.woff2", "weight": "400", "style": "normal" }
]
```
- `src` is relative to `assets/`.
- Supported formats: WOFF2 (recommended), WOFF, TTF and OTF.
- `weight` defaults to `400`; `style` defaults to `normal`.
- Use the `family` value in `typography` and 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.
```json
"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
```json
{
"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.
```json
{
"type": "text",
"text": "Clicks: {clicks}\nPlayer: {player}",
"id": "counter"
}
```
```js
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`.
```json
"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` |
```html
<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:
```js
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
```json
{
"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.
```json
{
"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
```json
{
"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.
```json
{
"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:
```json
{
"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.
```json
{
"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.
```json
{
"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.
```json
{
"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.
```json
{
"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.
```json
{
"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.
```json
{
"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.
```json
{
"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.
```json
{
"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.
```json
{
"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.
```json
{
"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.
```json
{
"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.
```json
{
"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. |
```svelte
<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`:
```json
"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:
```json
"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:
```json
"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.
```json
"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:
```json
"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.
```json
"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:
```css
:root {
--radius: 1rem;
}
```
Layers and groups can expose stable hooks through `cssClasses`:
```json
{ "id": "hero", "type": "text", "cssClasses": "hero-title glow", "text": "Welcome" }
```
```css
.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
1. Open **Themes** in the launcher.
2. Select **Create theme**, or the pencil button on an installed API 2.0 theme.
3. The editor opens in its **own resizable window**, not a modal. The center is a
full launcher home canvas at 1000 × 650.
4. 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.
5. 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.
6. 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.
7. 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
(or `Alt/Option+ArrowUp/Down`) jump between layers and groups that have rules.
Rules run in the launcher, so save the theme before testing them.
8. Select **Save theme** to write `theme.json` and copy new assets into
`~/.ezpplauncher/themes`. A fresh `assets/preview.png` thumbnail 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.
9. Select **Export** to create a shareable `.ezpplauncher-theme` package. 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+Z` undoes the last change.
- `Cmd/Ctrl+Shift+Z` or `Ctrl+Y` redoes it.
- `Cmd/Ctrl+S` saves.
- `Cmd/Ctrl+C`, `Cmd/Ctrl+X` and `Cmd/Ctrl+V` copy, cut and paste the selected
layer(s) or group. Pasting a group keeps its nested groups, layers and filters.
- `Cmd/Ctrl+D` duplicates the selected layer or multi-selection.
- `Cmd/Ctrl+G` groups the selected layers, or ungroups the selected group.
- `Cmd/Ctrl+A` selects 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+ArrowDown` and `Alt/Option+ArrowUp` jump to the next or previous
layer or group with linked logic, expanding its inspector section.
- `Delete` removes the selected layer(s).
- Hold Alt while dragging to skip snapping.
- `Esc` clears 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:
```bash
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:
```bash
npm run build
```
This produces `dist/my_theme.ezpplauncher-theme`. The template also includes
`preview.html` for a standalone browser preview:
```bash
npx serve .
```
---
## Compatibility and limits
- API 2.0 packages must include `theme.json`; `entry` and `style` are not used.
- Asset paths are resolved inside `assets/`; path traversal is rejected by the
import commands.
- Exports include only `assets/` files referenced by `theme.json` (background,
audio, fonts, media layers and preview). Asset paths mentioned by custom Svelte
layers through `asset('...')` 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.png` whenever 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 `mountTheme` bundle 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()` and `openUrl()` 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 `interactive` opts it into logic clicks/hovers; it still cannot add
arbitrary controls or execute code.