diff --git a/README.md b/README.md index 14200dd..6645e98 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ It enhances the osu! experience with quality-of-life features and integration sp - [x] Discord Rich Presence - [x] Performance Display Overlay (currently in experimental patcher release stream) - [x] Launcher Theming (API 1.0 bundles and API 2.0 declarative themes) -- [x] Advanced theme editor in its own window with freeform text/image/video/shape layers, multi-select and snapping, layer locks, draft recovery, validation and export -- [x] Theme update backups with one-click rollback, plus a trust prompt before running executable API 1.0 themes +- [x] Advanced theme editor in its own window with freeform text/image/video/shape layers, custom Svelte 5 component layers with Monaco editing and inline compiler diagnostics, gradient/outline/shadow text effects, multi-select and snapping, a drag-and-drop layer tree with moves into and out of nested groups, layer/group transforms with independent X/Y scale and skew, hover group breadcrumbs, on-canvas layer/group rotate handles plus group move and scale handles, layer/group-linked WHEN/IF/THEN logic with quick navigation, safe advanced rule scripts with expressions, branches and loops, draft recovery, validation and export +- [x] Theme update backups with one-click rollback, plus a trust prompt before running executable API 1.0 themes or API 2.0 themes with custom Svelte layers - [x] In-app diagnostics for osu!, Wine, the patcher, and the experimental stream's .NET 8 runtime, plus a log-bundle viewer --- @@ -141,8 +141,23 @@ in two ways: - Use the built-in **advanced editor** on the Themes screen. It opens in its own resizable window with a full launcher home canvas, freeform text/image/video/shape - layers, WebM support, `@fontsource` fonts and custom font imports. It creates API 2.0 - themes and can export them as shareable `.ezpplauncher-theme` files. + layers, custom Svelte 5 components with a Monaco editor, rich text effects + (gradient fills, outlines, shadows, decoration and case), WebM support, + `@fontsource` fonts and custom font imports. Themes can switch the launcher + chrome between the bundled Lucide, Phosphor and Tabler icon families. Hover a + layer and click a crumb in its group path (or double-click it), then use the + on-canvas frame or the inspector to move, rotate, scale or skew the group. + Logic rules can be linked to layers or groups and edited from their inspectors. + Any rule can switch from the visual builder to a sandboxed JavaScript script + with variables, expressions, `if/else`, `for`/`while`, visibility actions and + navigation; scripts open in a Monaco editor with syntax highlighting, + completion, hover docs and inline errors, and can expand to fullscreen. Text + layers can display live variables with `{name}` placeholders and theme-defined + starting values, plus read-only clock values such as `{time}` and `{hour}` that + refresh every second. Scripts are compiled into a safe instruction set and run by + the launcher without `eval`. + It creates API 2.0 themes and can export them as shareable + `.ezpplauncher-theme` files. - Use the [create-ezpplauncher-theme](https://git.ez-pp.farm/EZPPFarm/create-ezpplauncher-theme) CLI. It supports both API 1.0 Svelte themes and API 2.0 declarative themes. diff --git a/THEME_API_V2.md b/THEME_API_V2.md index 2204289..c8663e2 100644 --- a/THEME_API_V2.md +++ b/THEME_API_V2.md @@ -3,7 +3,9 @@ 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. +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: @@ -12,7 +14,7 @@ API 1.0 themes continue to work unchanged. Pick the API that fits the theme: | 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 | No (data only, optional CSS) | +| Scripts / arbitrary DOM | Yes | Optional custom Svelte layer (trust prompt) | | Cross-platform | Depends on the bundle | Yes | --- @@ -73,7 +75,8 @@ C:\Users\\.ezpplauncher\themes\\ }, "typography": { "body": "Sora Variable", - "heading": "Bebas Neue" + "heading": "Bebas Neue", + "mono": "JetBrains Mono Variable" }, "fonts": [ { @@ -83,9 +86,17 @@ C:\Users\\.ezpplauncher\themes\\ "style": "normal" } ], + "groups": [ + { + "id": "branding", + "name": "Branding", + "opacity": 1 + } + ], "elements": [ { "id": "tagline", + "groupId": "branding", "type": "text", "text": "MIDNIGHT", "x": 640, @@ -105,24 +116,32 @@ C:\Users\\.ezpplauncher\themes\\ "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 | -| `css` | string | no | Extra CSS injected while the theme is active | +| 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 @@ -156,6 +175,8 @@ C:\Users\\.ezpplauncher\themes\\ - `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. @@ -190,15 +211,22 @@ Supported types: `none`, `stars`, `snow`, `petals`, `bubbles`. `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. +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" + "heading": "Playfair Display Variable", + "mono": "JetBrains Mono Variable" } ``` @@ -233,30 +261,66 @@ 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 and shapes, and they are -pointer-transparent in the running launcher so they never block buttons. +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`, `image`, `video` or `shape` | -| `x`, `y` | number | Position in the 1000 × 650 stage | -| `width` | number | 24–1000 | -| `height` | number | 24–650 | -| `rotation` | number | Degrees, `-360`–`360` | -| `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 | +| 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 @@ -272,17 +336,133 @@ Every layer supports: "fontFamily": "Bebas Neue", "fontSize": 42, "fontWeight": 600, + "fontStyle": "normal", "color": "#ffffff", "align": "center", "verticalAlign": "center", "lineHeight": 1.2, - "letterSpacing": 0 + "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 +

{time12}

+

LOCAL // {timeSeconds}

+``` + +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 @@ -296,10 +476,15 @@ Every layer supports: "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", @@ -336,13 +521,736 @@ should blend into the launcher UI. } ``` -`shape` is `rectangle`, `ellipse` or `line`. `radius` rounds rectangle corners. -`borderWidth` is clamped to `0`–`40`. +`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": "\n\n
Score: {variables.score}
\n\n\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 + + +

Score: {variables.score ?? 0}

+``` + +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 ` + +
+
+ (behavior.name = (event.currentTarget as HTMLInputElement).value)} + /> + +
+ +
+
+ (behavior.enabled = checked === true)} + /> + +
+
+ (behavior.once = checked === true)} + /> + +
+
+ +
+ + setTarget(String(value))} + > + + {targetLabel} + + + + {#each groupOptions as option (option.value)} + + {/each} + {#each elements as element (element.id)} + + {/each} + + +
+ + {#if themeVariables.length > 0} +
+ + Variables + + {#each themeVariables as variable (variable)} + + {variable} + + {/each} +
+ {/if} + +
+ + + +
+ + {#if activeSection === 'when'} +
+ setTriggerType(String(value))} + > + + {triggerOptions.find((option) => option.value === behavior.trigger.type)?.label ?? + 'Theme loads'} + + + {#each triggerOptions as option (option.value)} + + {/each} + + + + {#if behavior.trigger.type === 'screen'} +
+ + updateTrigger({ screen: value as ThemeV2ScriptScreen })} + > + + {screens.find((screen) => screen.value === triggerScreen)?.label ?? 'Home'} + + + {#each screens as screen (screen.value)} + + {/each} + + +
+ {:else if pointerTrigger} +
+ {#if behavior.target} +
+ +

+ {#if behavior.target.type === 'group'} + Fires for any interactive layer inside + {targetName}. + {:else} + Fires for {targetName}. + {/if} +

+
+ {#if targetNeedsInteractive} +

+ {#if behavior.target.type === 'group'} + Mark at least one layer inside this group as + Interactive so this trigger can fire. + {:else} + This linked layer is not Interactive, so this trigger cannot + fire. + {/if} +

+ {/if} + {:else} + + + updateTrigger({ elementId: value === '_any' ? undefined : String(value) })} + > + + {elementOptions.find((option) => option.value === triggerElementId)?.label ?? + 'Any interactive layer'} + + + + {#each elementOptions as option (option.value)} + + {/each} + + +

+ Enable Interactive in the layer inspector to make + this layer receive pointer events. +

+ {/if} +
+ {:else if behavior.trigger.type === 'timer'} +
+ + + updateTrigger({ + interval: Math.max( + 0.5, + Number((event.currentTarget as HTMLInputElement).value) || 5 + ), + })} + /> +
+ {:else} +

+ Runs once when the theme is applied to the launcher. +

+ {/if} +
+ {:else if activeSection === 'if'} +
+
+

Match

+ (behavior.conditionMode = value === 'any' ? 'any' : 'all')} + > + + {behavior.conditionMode === 'any' ? 'Any' : 'All'} + + + + + + +
+ + {#if (behavior.conditions ?? []).length === 0} +

+ No conditions — this rule always runs when the trigger fires. +

+ {:else} + {#each behavior.conditions ?? [] as condition, index (index)} +
+
+ + replaceCondition(index, String(value) as ThemeV2BehaviorCondition['type'])} + > + + {conditionOptions.find((option) => option.value === condition.type)?.label ?? + 'Condition'} + + + {#each conditionOptions as option (option.value)} + + {/each} + + + +
+ + {#if condition.type === 'variable'} +
+ + (condition.variable = (event.currentTarget as HTMLInputElement).value)} + /> + (condition.operator = value as ThemeV2BehaviorOperator)} + > + + {operators.find((operator) => operator.value === condition.operator)?.label ?? + 'equals'} + + + {#each operators as operator (operator.value)} + + {/each} + + +
+ + (condition.value = parseValue((event.currentTarget as HTMLInputElement).value))} + /> +
+ + Clock + + {#each clockConditionNames as clock (clock)} + + {/each} +
+ {:else if condition.type === 'visible'} + setElementField(condition, String(value))} + > + + {elementOptions.find((option) => option.value === condition.elementId)?.label ?? + 'Choose a layer'} + + + {#each elementOptions as option (option.value)} + + {/each} + + + (condition.value = value === 'visible')} + > + + {condition.value ? 'Is visible' : 'Is hidden'} + + + + + + + {:else if condition.type === 'groupVisible'} + setGroupField(condition, String(value))} + > + + {groupOptions.find((option) => option.value === condition.groupId)?.label ?? + 'Choose a group'} + + + {#each groupOptions as option (option.value)} + + {/each} + + + (condition.value = value === 'visible')} + > + + {condition.value ? 'Is visible' : 'Is hidden'} + + + + + + + {:else if condition.type === 'screen'} + (condition.screen = value as ThemeV2ScriptScreen)} + > + + {screens.find((screen) => screen.value === condition.screen)?.label ?? 'Home'} + + + {#each screens as screen (screen.value)} + + {/each} + + + {:else} +
+
+ Chance + {Math.round(condition.percent)}% +
+ + (condition.percent = Math.min( + 100, + Math.max(0, Number((event.currentTarget as HTMLInputElement).value) || 0) + ))} + /> +
+ {/if} +
+ {/each} + {/if} + + + + {#snippet child({ props })} + + {/snippet} + + + {#each availableConditionOptions as option (option.value)} + addCondition(option.value as ThemeV2BehaviorCondition['type'])} + > + {option.label} + + {/each} + + +
+ {:else} +
+
+

+ {scriptMode ? 'Script' : 'Actions'} +

+
+ + +
+
+ + {#if scriptMode} +
+ (behavior.script = next)} + /> +
+ Builder actions are kept but ignored. +
+ {#if scriptError} +

+ {formatThemeScriptError(scriptError)} +

+ {:else if !(behavior.script ?? '').trim()} +

+ This script is empty, so the rule does nothing. +

+ {:else} +

+ Sandboxed JavaScript syntax — no browser APIs or eval. Try + clicks += 1;, + if (clicks >= 3) { ... }, + for (let i = 0; i < 3; i++) { ... }, + showLayer("id");, goTo("themes");. +

+ {/if} +
+ {:else} + {#if behavior.actions.length === 0} +

+ Add at least one action for this rule to do something. +

+ {:else} + {#each behavior.actions as action, index (index)} +
+
+ + replaceAction(index, String(value) as ThemeV2BehaviorAction['type'])} + > + + {actionOptions.find((option) => option.value === action.type)?.label ?? + 'Action'} + + + {#each actionOptions as option (option.value)} + + {/each} + + + +
+ + {#if action.type === 'setVariable'} +
+ + (action.variable = (event.currentTarget as HTMLInputElement).value)} + /> + + (action.value = parseValue((event.currentTarget as HTMLInputElement).value))} + /> +
+ {:else if action.type === 'changeVariable'} +
+ + (action.variable = (event.currentTarget as HTMLInputElement).value)} + /> + + (action.amount = + Number((event.currentTarget as HTMLInputElement).value) || 0)} + /> +
+ {:else if action.type === 'toggleVariable'} + + (action.variable = (event.currentTarget as HTMLInputElement).value)} + /> + {:else if action.type === 'showElement' || action.type === 'hideElement' || action.type === 'toggleElement'} + setElementField(action, String(value))} + > + + {elementOptions.find((option) => option.value === action.elementId)?.label ?? + 'Choose a layer'} + + + {#each elementOptions as option (option.value)} + + {/each} + + + {:else if action.type === 'showGroup' || action.type === 'hideGroup' || action.type === 'toggleGroup'} + setGroupField(action, String(value))} + > + + {groupOptions.find((option) => option.value === action.groupId)?.label ?? + 'Choose a group'} + + + {#each groupOptions as option (option.value)} + + {/each} + + + {:else if action.type === 'navigate'} + (action.screen = value as ThemeV2ScriptScreen)} + > + + {screens.find((screen) => screen.value === action.screen)?.label ?? 'Home'} + + + {#each screens as screen (screen.value)} + + {/each} + + + {:else if action.type === 'openUrl'} + + (action.url = (event.currentTarget as HTMLInputElement).value)} + /> + {:else} +

+ Starts the normal osu! launch flow. Only use this on an explicit click. +

+ {/if} +
+ {/each} + {/if} + + + + {#snippet child({ props })} + + {/snippet} + + + {#each availableActionOptions as option (option.value)} + addAction(option.value as ThemeV2BehaviorAction['type'])} + > + {option.label} + + {/each} + + + {/if} +
+ {/if} + + {#if scriptMode ? scriptCanLeave : behavior.actions.some((action) => action.type === 'launch' || action.type === 'openUrl')} +

+ This rule can leave the launcher. URL actions show the full destination and ask for + confirmation first; launch actions start the normal osu! flow. Prefer a click trigger and tell + users what will happen. +

+ {/if} +
diff --git a/src/lib/components/theme/ThemeComponentEditor.svelte b/src/lib/components/theme/ThemeComponentEditor.svelte new file mode 100644 index 0000000..9ce61c6 --- /dev/null +++ b/src/lib/components/theme/ThemeComponentEditor.svelte @@ -0,0 +1,764 @@ + + + { + if (event.key === 'Escape' && expanded) { + event.preventDefault(); + expanded = false; + } + }} +/> + +{#if expanded} + +{/if} + +
+
+ + + Svelte component + + + {#if loadError} + Editor unavailable + {:else if checking} + Checking… + {:else if errorCount > 0} + + + {errorCount} + + {:else} + Ready + {/if} + + {#if expanded} + + {/if} + +
+ + {#if loadError} + +

+ Monaco could not load: {loadError} +

+ {:else} +
+
+ {#if !ready} +
+ Loading editor… +
+ {/if} +
+ {/if} + + {#if diagnostics.length > 0} +
+ {#each diagnostics.slice(0, 8) as diagnostic, index (index)} +
+ +

+ {diagnostic.message} +

+ +
+ {/each} +
+ {/if} + +
+ {lineCount} line{lineCount === 1 ? '' : 's'} + Ln {cursorLine}, Col {cursorColumn} + + {#if checking} + Checking… + {:else if errorCount > 0} + {errorCount} error{errorCount === 1 ? '' : 's'} + {#if warningCount > 0} + + · {warningCount} warning{warningCount === 1 ? '' : 's'} + {/if} + {:else if warningCount > 0} + {warningCount} warning{warningCount === 1 ? '' : 's'} + {:else} + No errors + {/if} + +
+
diff --git a/src/lib/components/theme/ThemeCssClasses.svelte b/src/lib/components/theme/ThemeCssClasses.svelte new file mode 100644 index 0000000..7abd392 --- /dev/null +++ b/src/lib/components/theme/ThemeCssClasses.svelte @@ -0,0 +1,79 @@ + + +{#snippet resetAction()} + +{/snippet} + + +
+ + setValue(event.currentTarget.value)} + /> +
+ +

+ Separate multiple classes with spaces and style them from the theme's Custom CSS, for example + .glow-card. +

+ + {#if invalidTokens.length > 0} +

+ Ignored: {invalidTokens.join(' ')}. Use letters, numbers, + dashes and underscores. +

+ {/if} +
diff --git a/src/lib/components/theme/ThemeCustomLayer.svelte b/src/lib/components/theme/ThemeCustomLayer.svelte new file mode 100644 index 0000000..793a40c --- /dev/null +++ b/src/lib/components/theme/ThemeCustomLayer.svelte @@ -0,0 +1,132 @@ + + +
+
+ + {#if interactive && loading} +
+ Compiling Svelte… +
+ {/if} + + {#if interactive && error} +
+ +

Svelte layer failed

+

{error}

+

Fix the error in the layer inspector.

+
+ {/if} +
diff --git a/src/lib/components/theme/ThemeEditorWindow.svelte b/src/lib/components/theme/ThemeEditorWindow.svelte index 4761c4c..f970d74 100644 --- a/src/lib/components/theme/ThemeEditorWindow.svelte +++ b/src/lib/components/theme/ThemeEditorWindow.svelte @@ -1,16 +1,22 @@ - + { + closeContextMenu(); + panning = false; + }} +/> + +{#snippet contextMenuItems()} + + {#if contextMenuGroup} + {groupLabel(contextMenuGroup)} + {:else if contextMenuTargetId} + {contextMenuSelection.length > 1 + ? `${contextMenuSelection.length} layers selected` + : contextMenuSelection[0] + ? elementLabel(contextMenuSelection[0]) + : 'Layer'} + {:else} + Canvas + {/if} + + + {#if contextMenuGroup} + + + Copy group + + duplicateGroup(contextMenuGroup?.id)} + > + + Duplicate group + + + + Cut group + + pasteClipboard(contextMenuGroup.id)} + > + + Paste into group + + + ungroupGroup(contextMenuGroup.id)} + > + + Ungroup group + + requestGroupDelete(contextMenuGroup.id)} + > + + Delete group + + + toggleGroupFlag(contextMenuGroup, 'hidden')} + > + {#if contextMenuGroup.hidden} + + Show group + {:else} + + Hide group + {/if} + + toggleGroupFlag(contextMenuGroup, 'locked')} + > + {#if contextMenuGroup.locked} + + Unlock group + {:else} + + Lock group + {/if} + + + selectGroup(contextMenuGroup.id)} + > + + Edit group + + {:else if contextMenuTargetId} + + + Copy + + + + Cut + + + pasteClipboard( + contextMenuSelection.length === 1 ? contextMenuSelection[0].groupId : undefined + )} + > + + Paste + + + + + Duplicate + + {#if contextMenuSelection.length === 1 && contextMenuSelection[0].groupId} + { + const groupId = contextMenuSelection[0]?.groupId; + if (groupId) selectGroup(groupId); + }} + > + + Select parent group + + {/if} + + + Group selection + + {#if contextMenuSelection.some((element) => element.groupId)} + + + Remove from group + + {/if} + {#if contextMenuSelection.length === 1} + = elements.length - 1} + onclick={() => moveSelected(1)} + > + + Bring forward + + moveSelected(-1)} + > + + Send backward + + {/if} + + toggleSelectedFlag('hidden')}> + {#if contextMenuAllHidden} + + Show {contextMenuSelection.length === 1 ? 'layer' : 'layers'} + {:else} + + Hide {contextMenuSelection.length === 1 ? 'layer' : 'layers'} + {/if} + + toggleSelectedFlag('locked')}> + {#if contextMenuAllLocked} + + Unlock {contextMenuSelection.length === 1 ? 'layer' : 'layers'} + {:else} + + Lock {contextMenuSelection.length === 1 ? 'layer' : 'layers'} + {/if} + + + + + Delete {contextMenuSelection.length === 1 ? 'layer' : 'layers'} + + {:else} + pasteClipboard()} + > + + Paste + + + + + Add text + + addMedia('image')}> + + Add image + + addMedia('video')}> + + + + Add shape + + + + Add Svelte component + + + addLayer('data')}> + + Add data layer + + addLayer('icon')}> + + Add icon + + addLayer('repeater')}> + + Add repeater + + addLayer('gauge')}> + + Add gauge + + addLayer('ticker')}> + + Add ticker + + addLayer('button')}> + + Add button + + addLayer('path')}> + + Add vector path + + addLayer('particles')}> + + Add particles + + addLayer('clock')}> + + Add clock + + addLayer('countdown')}> + + Add countdown + + + + Add Lottie animation + + + { + selectedIds = elements.filter((element) => !element.hidden).map((element) => element.id); + }} + > + + Select all layers + + {/if} +{/snippet} {#if selectedFontPreviewCss} @@ -1493,7 +3234,7 @@
-

Advanced theme editor

+

Theme editor

- {definition.name || 'Untitled theme'} · freeform home screen canvas + {definition.name || 'Untitled theme'}

- + + - - - - - - - -
-

Canvas

-

1000×650

-
- - +
@@ -139,15 +161,15 @@ >
- + 1.2k
- + 42
- + 19ms
@@ -172,7 +194,11 @@
- +
@@ -185,15 +211,26 @@
diff --git a/src/lib/components/theme/ThemeTextStyleControls.svelte b/src/lib/components/theme/ThemeTextStyleControls.svelte new file mode 100644 index 0000000..67767d8 --- /dev/null +++ b/src/lib/components/theme/ThemeTextStyleControls.svelte @@ -0,0 +1,364 @@ + + +{#snippet textStyleActions()} + +{/snippet} + + +
+ + + + +
+ +
+
+ + { + if (value) onchange({ textTransform: value as ThemeV2TextTransform }); + }} + > + + {transformOptions.find((option) => option.value === (element.textTransform ?? 'none')) + ?.label ?? 'None'} + + + {#each transformOptions as option (option.value)} + + {/each} + + +
+
+ + setFillMode(value as ThemeV2TextStyle['fillMode'])} + > + + {element.fillMode === 'gradient' ? 'Gradient' : 'Solid'} + + + + + + +
+
+ + {#if gradient} +
+
+
+ + {element.gradientFrom ?? '#ff006e'} +
+
+ onchange({ gradientFrom: inputValue(event) })} + /> + onchange({ gradientFrom: inputValue(event) })} + /> +
+
+
+
+ + {element.gradientTo ?? '#7c3aed'} +
+
+ onchange({ gradientTo: inputValue(event) })} + /> + onchange({ gradientTo: inputValue(event) })} + /> +
+
+
+ + + onchange({ + gradientAngle: Math.min(360, Math.max(0, Number(inputValue(event)) || 0)), + })} + /> +
+
+ {:else} +
+
+ + {element.color ?? '#ffffff'} +
+
+ onchange({ color: inputValue(event) })} + /> + onchange({ color: inputValue(event) })} + /> +
+
+ {/if} + +
+ +
+ + onchange({ outlineWidth: Math.min(20, Math.max(0, Number(inputValue(event)) || 0)) })} + /> + onchange({ outlineColor: inputValue(event) })} + /> + onchange({ outlineColor: inputValue(event) })} + /> +
+
+ +
+ +
+ + onchange({ shadowBlur: Math.min(40, Math.max(0, Number(inputValue(event)) || 0)) })} + /> + + onchange({ + shadowOffsetX: Math.min(40, Math.max(-40, Number(inputValue(event)) || 0)), + })} + /> + + onchange({ + shadowOffsetY: Math.min(40, Math.max(-40, Number(inputValue(event)) || 0)), + })} + /> +
+
+ onchange({ shadowColor: inputValue(event) })} + /> + onchange({ shadowColor: inputValue(event) })} + /> +
+
+
diff --git a/src/lib/components/theme/ThemeTransformControls.svelte b/src/lib/components/theme/ThemeTransformControls.svelte new file mode 100644 index 0000000..0a42f8c --- /dev/null +++ b/src/lib/components/theme/ThemeTransformControls.svelte @@ -0,0 +1,260 @@ + + +{#snippet transformActions()} + +{/snippet} + + + {#if includeTranslate} +
+
+ + setOffset('offsetX', event.currentTarget.value)} + /> +
+
+ + setOffset('offsetY', event.currentTarget.value)} + /> +
+
+ {/if} + +
+
+ + setScale('scaleX', event.currentTarget.value)} + /> +
+
+ + setScale('scaleY', event.currentTarget.value)} + /> +
+
+ + setSkew('skewX', event.currentTarget.value)} + /> +
+
+ + setSkew('skewY', event.currentTarget.value)} + /> +
+
+ +
+
+ + setRotation(event.currentTarget.value)} + /> +
+
+ + + + +
+
+
diff --git a/src/lib/components/theme/ThemeV2Elements.svelte b/src/lib/components/theme/ThemeV2Elements.svelte index 04a2f22..5ab3a91 100644 --- a/src/lib/components/theme/ThemeV2Elements.svelte +++ b/src/lib/components/theme/ThemeV2Elements.svelte @@ -1,36 +1,282 @@ + + diff --git a/src/lib/components/theme/ThemeVariableDefaults.svelte b/src/lib/components/theme/ThemeVariableDefaults.svelte new file mode 100644 index 0000000..efb71b7 --- /dev/null +++ b/src/lib/components/theme/ThemeVariableDefaults.svelte @@ -0,0 +1,94 @@ + + + +

+ Starting values for + {'{name}'} + placeholders and logic variables. Leave a field empty to start at + 0. +

+ + {#if variables.length === 0} +

+ Add a + {'{placeholder}'} + to a text layer or use a variable in a rule to see it here. +

+ {:else} +
+ {#each variables as name (name)} +
+
+
+ {name} +
+
+ {hasDefault(name) ? valueLabel(defaults[name]) : 'no default · starts at 0'} +
+
+ onchange(name, parseValue(event.currentTarget.value))} + /> + +
+ {/each} +
+ {/if} +
diff --git a/src/lib/components/ui/download-button/DownloadButton.svelte b/src/lib/components/ui/download-button/DownloadButton.svelte index 77bac59..cba63c1 100644 --- a/src/lib/components/ui/download-button/DownloadButton.svelte +++ b/src/lib/components/ui/download-button/DownloadButton.svelte @@ -1,6 +1,6 @@ + +
+ +

CUSTOM SVELTE LAYER

+

Hello, {player}

+

{Math.round(layer.width ?? 0)} × {Math.round(layer.height ?? 0)} · {clicks} clicks

+
+ + +`; diff --git a/src/lib/themeSvelteSnippets.ts b/src/lib/themeSvelteSnippets.ts new file mode 100644 index 0000000..85b4560 --- /dev/null +++ b/src/lib/themeSvelteSnippets.ts @@ -0,0 +1,33 @@ +/** Editor-only snippets for custom Svelte layers. */ +export const THEME_SVELTE_COMPONENT_SKELETON = ` + +
+

Score: {score}

+

{Math.round(layer.width)} × {Math.round(layer.height)}

+
+ + +`; + +export const THEME_SVELTE_COMPONENT_PLACEHOLDER = ` + +
{variables.score}
`; diff --git a/src/lib/themeTextVariables.ts b/src/lib/themeTextVariables.ts new file mode 100644 index 0000000..dd937df --- /dev/null +++ b/src/lib/themeTextVariables.ts @@ -0,0 +1,43 @@ +import type { ThemeV2ScriptValue } from './themeV2'; + +/** + * Text layers can display rule variables with `{name}` placeholders. The + * pattern intentionally matches script identifiers only, so ordinary braces in + * copy are left alone and every reference can also be offered in the editor. + */ +const VARIABLE_PATTERN = /\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}/g; +const BLOCKED_NAMES = new Set(['__proto__', 'prototype', 'constructor', 'screen']); + +const variableName = (value: string): string | undefined => { + if (!value || BLOCKED_NAMES.has(value)) return undefined; + return value; +}; + +/** Variable names referenced by a text layer, in first-use order. */ +export const themeTextVariableNames = (text: string): string[] => { + const names = new Set(); + for (const match of text.matchAll(VARIABLE_PATTERN)) { + const name = variableName(match[1]); + if (name) names.add(name); + } + return [...names]; +}; + +/** + * Replace `{variable}` placeholders with their current rule values. Unknown + * names stay visible instead of silently disappearing, which makes typos easy + * to spot in the editor and in the launcher. + */ +export const interpolateThemeText = ( + text: string, + variables: Record +): string => { + if (!text.includes('{')) return text; + return text.replace(VARIABLE_PATTERN, (placeholder, rawName: string) => { + const name = variableName(rawName); + if (!name || !Object.prototype.hasOwnProperty.call(variables, name)) return placeholder; + const value = variables[name]; + if (typeof value === 'number') return Number.isFinite(value) ? String(value) : ''; + return typeof value === 'boolean' ? String(value) : String(value ?? ''); + }); +}; diff --git a/src/lib/themeTrust.ts b/src/lib/themeTrust.ts index 01bc9f9..bf13a3e 100644 --- a/src/lib/themeTrust.ts +++ b/src/lib/themeTrust.ts @@ -2,13 +2,26 @@ import type { Theme } from '$lib/themes'; /** * API 1.0 themes are executable bundles: loading one imports its entry script - * into the launcher window. API 2.0 themes are declarative data and do not - * need a trust decision. + * into the launcher window. API 2.0 themes are declarative by default, but an + * opt-in custom Svelte layer compiles and runs theme-authored JavaScript too. */ const TRUSTED_THEMES_KEY = 'ezpplauncher.trusted-v1-themes'; -export const themeRunsCode = (theme: Pick) => - theme.apiVersion.split('.')[0] === '1' && theme.scriptUrl.length > 0; +export type ThemeCodeKind = 'none' | 'v1' | 'v2-svelte'; + +export const themeCodeKind = ( + theme: Pick +): ThemeCodeKind => { + const major = theme.apiVersion.split('.')[0]; + if (major === '1') return theme.scriptUrl.length > 0 ? 'v1' : 'none'; + if (major !== '2') return 'none'; + return theme.definition?.elements?.some((element) => element.type === 'svelte') + ? 'v2-svelte' + : 'none'; +}; + +export const themeRunsCode = (theme: Pick) => + themeCodeKind(theme) !== 'none'; export const themeTrustKey = (theme: Pick) => `${theme.folder_name}@${theme.version}`; diff --git a/src/lib/themeV2.ts b/src/lib/themeV2.ts index 5ca402a..4195394 100644 --- a/src/lib/themeV2.ts +++ b/src/lib/themeV2.ts @@ -7,6 +7,14 @@ * any code. A v2 theme package is a zip containing `theme.json` and an * `assets/` folder. */ +import { + DEFAULT_THEME_ICON_SET, + isThemeIconName, + isThemeIconSet, + type ThemeIconName, + type ThemeIconSet, +} from './themeIcons'; +import { DEFAULT_THEME_SVELTE_SOURCE } from './themeSvelteDefaults'; export type ThemeV2BackgroundType = 'color' | 'gradient' | 'image' | 'video'; @@ -74,12 +82,87 @@ export type ThemeV2Typography = { body?: string; /** Font used by headings such as the launcher title. */ heading?: string; + /** + * Font used by monospace details such as paths, versions and telemetry. + * Omit it to keep the launcher's built-in Space Mono stack. + */ + mono?: string; +}; + +/** CSS-like filters shared by groups and individual elements. */ +export type ThemeV2Filters = { + /** Pixels, 0-40. */ + blur?: number; + /** Multiplier, 1 is unchanged. */ + brightness?: number; + contrast?: number; + saturate?: number; + /** Degrees, -360 to 360. */ + hueRotate?: number; + /** 0-1. */ + grayscale?: number; + sepia?: number; + invert?: number; +}; + +/** 2D CSS transform fields shared by layers and groups. */ +export type ThemeV2Transform = { + /** Degrees around the layer or group bounds centre. */ + rotation?: number; + /** Legacy uniform scale fallback; `scaleX` and `scaleY` take precedence. */ + scale?: number; + /** Horizontal scale around the bounds centre; 1 is unchanged. */ + scaleX?: number; + /** Vertical scale around the bounds centre; 1 is unchanged. */ + scaleY?: number; + /** Horizontal skew in degrees; 0 is unchanged. */ + skewX?: number; + /** Vertical skew in degrees; 0 is unchanged. */ + skewY?: number; + flipX?: boolean; + flipY?: boolean; +}; + +/** A group in the layer list. Groups may be nested through `parentId`. */ +export type ThemeV2Group = ThemeV2Transform & { + id: string; + name?: string; + parentId?: string; + hidden?: boolean; + locked?: boolean; + /** Editor-only fold state; stored so the group stays folded on reopen. */ + collapsed?: boolean; + opacity?: number; + /** Visual translation applied to the whole group in stage pixels. */ + offsetX?: number; + offsetY?: number; + filters?: ThemeV2Filters; + /** Clips every layer in the group to a shape or image alpha. */ + mask?: ThemeV2Mask; + /** Space-separated CSS classes inherited by every layer in the group. */ + cssClasses?: string; +}; + +/** A shape or image used to clip a layer or group. */ +export type ThemeV2MaskShape = 'rectangle' | 'ellipse' | 'polygon' | 'star' | 'image'; + +export type ThemeV2Mask = { + shape: ThemeV2MaskShape; + /** Corner radius for rectangle masks. */ + radius?: number; + /** Sides for polygon masks and points for star masks. */ + sides?: number; + /** Inner radius of a star mask, as a fraction of the outer radius. */ + innerRadius?: number; + /** Asset used for image masks; its alpha channel clips the layer. */ + src?: string; + fit?: 'cover' | 'contain'; }; export type ThemeV2ElementAlign = 'left' | 'center' | 'right'; export type ThemeV2ElementVerticalAlign = 'top' | 'center' | 'bottom'; -export type ThemeV2ElementBase = { +export type ThemeV2ElementBase = ThemeV2Transform & { id: string; /** Optional editor-facing name; does not affect rendering. */ name?: string; @@ -88,32 +171,107 @@ export type ThemeV2ElementBase = { y: number; width: number; height: number; - rotation?: number; opacity?: number; /** Hidden layers stay in the package but are not rendered. */ hidden?: boolean; /** Locked layers can be selected in the layer list but not dragged on canvas. */ locked?: boolean; + /** Opt in to clicks and hovers from theme logic. */ + interactive?: boolean; + /** Group this layer belongs to. */ + groupId?: string; + filters?: ThemeV2Filters; + /** Clips this layer to a shape or image alpha. */ + mask?: ThemeV2Mask; + /** Space-separated CSS classes added to the rendered layer wrapper. */ + cssClasses?: string; }; -export type ThemeV2TextElement = ThemeV2ElementBase & { - type: 'text'; - text: string; +export type ThemeV2TextDecoration = + | 'none' + | 'underline' + | 'line-through' + | 'underline line-through'; +export type ThemeV2TextTransform = 'none' | 'uppercase' | 'lowercase' | 'capitalize'; +export type ThemeV2TextFillMode = 'solid' | 'gradient'; + +export type ThemeV2TextStyle = { fontFamily?: string; fontSize?: number; fontWeight?: number; + fontStyle?: 'normal' | 'italic'; color?: string; align?: ThemeV2ElementAlign; verticalAlign?: ThemeV2ElementVerticalAlign; lineHeight?: number; letterSpacing?: number; + textDecoration?: ThemeV2TextDecoration; + textTransform?: ThemeV2TextTransform; + fillMode?: ThemeV2TextFillMode; + gradientFrom?: string; + gradientTo?: string; + /** CSS gradient angle in degrees; 90 is left to right. */ + gradientAngle?: number; + /** Text stroke width in pixels; 0 disables the outline. */ + outlineWidth?: number; + outlineColor?: string; + shadowColor?: string; + shadowBlur?: number; + shadowOffsetX?: number; + shadowOffsetY?: number; }; +export type ThemeV2TextElement = ThemeV2ElementBase & + ThemeV2TextStyle & { + type: 'text'; + text: string; + }; + +/** Read-only launcher values a data layer can display. */ +export const THEME_DATA_FIELDS = [ + { value: 'user.name', label: 'Player name', group: 'Player' }, + { value: 'user.country', label: 'Country code', group: 'Player' }, + { value: 'user.pp', label: 'Performance points', group: 'Player' }, + { value: 'user.rank', label: 'Global rank', group: 'Player' }, + { value: 'user.plays', label: 'Play count', group: 'Player' }, + { value: 'user.accuracy', label: 'Accuracy', group: 'Player' }, + { value: 'user.playtime', label: 'Playtime', group: 'Player' }, + { value: 'launcher.version', label: 'Launcher version', group: 'Launcher' }, + { value: 'osu.build', label: 'osu! build', group: 'osu!' }, + { value: 'osu.stream', label: 'osu! stream', group: 'osu!' }, + { value: 'server.ping', label: 'Server ping', group: 'Launcher' }, + { value: 'server.friends', label: 'Online friends', group: 'Launcher' }, + { value: 'theme.name', label: 'Theme name', group: 'Theme' }, + { value: 'theme.author', label: 'Theme author', group: 'Theme' }, + { value: 'theme.version', label: 'Theme version', group: 'Theme' }, + { value: 'screen', label: 'Current screen', group: 'Launcher' }, + { value: 'clock.time', label: 'Clock (24h)', group: 'Clock' }, + { value: 'clock.time12', label: 'Clock (12h)', group: 'Clock' }, + { value: 'clock.date', label: 'Date', group: 'Clock' }, +] as const; + +export type ThemeV2DataField = (typeof THEME_DATA_FIELDS)[number]['value']; + +export type ThemeV2DataMode = 'osu' | 'taiko' | 'catch' | 'mania'; + +export type ThemeV2DataElement = ThemeV2ElementBase & + ThemeV2TextStyle & { + type: 'data'; + /** Built-in launcher value, for example `user.pp` or `clock.time`. */ + field: ThemeV2DataField; + /** Which gamemode the player fields read from. */ + mode?: ThemeV2DataMode; + /** Template for the value; `{value}` is replaced. Defaults to `{value}`. */ + text: string; + }; + export type ThemeV2ImageElement = ThemeV2ElementBase & { type: 'image'; src: string; fit?: 'cover' | 'contain'; radius?: number; + /** Repaints the image's alpha silhouette with this color; empty keeps it. */ + tint?: string; }; export type ThemeV2VideoElement = ThemeV2ElementBase & { @@ -125,20 +283,303 @@ export type ThemeV2VideoElement = ThemeV2ElementBase & { muted?: boolean; }; +export type ThemeV2ShapeKind = 'rectangle' | 'ellipse' | 'line' | 'polygon' | 'star' | 'arc'; +export type ThemeV2ShapeFillMode = 'solid' | 'gradient'; + export type ThemeV2ShapeElement = ThemeV2ElementBase & { type: 'shape'; - shape: 'rectangle' | 'ellipse' | 'line'; + shape: ThemeV2ShapeKind; fill?: string; + fillMode?: 'solid' | 'gradient'; + gradientFrom?: string; + gradientTo?: string; + gradientAngle?: number; radius?: number; + /** Top-left, top-right, bottom-right, bottom-left radii. */ + cornerRadii?: [number, number, number, number]; borderWidth?: number; borderColor?: string; + borderStyle?: 'solid' | 'dashed' | 'dotted'; + /** Sides for polygon shapes and points for star shapes. */ + points?: number; + /** Star inner radius, as a fraction of the outer radius. */ + innerRadius?: number; + /** Frosted-glass blur behind the shape, in pixels. */ + backdropBlur?: number; + /** Arc start angle in degrees; 0 points up and angles run clockwise. */ + startAngle?: number; + /** Arc end angle in degrees; wraps past 360 when smaller than `startAngle`. */ + endAngle?: number; + /** Arc stroke width in stage pixels. */ + thickness?: number; + /** Whether arc ends use round caps. */ + rounded?: boolean; +}; + +export type ThemeV2PathElement = ThemeV2ElementBase & { + type: 'path'; + /** SVG path data authored in a 0–100 box, stretched to the layer bounds. */ + path: string; + fill?: string; + fillMode?: ThemeV2ShapeFillMode; + gradientFrom?: string; + gradientTo?: string; + gradientAngle?: number; + /** Outline color; an empty value disables the outline. */ + stroke?: string; + /** Outline width in stage pixels. */ + strokeWidth?: number; + strokeStyle?: 'solid' | 'dashed' | 'dotted'; + strokeLinecap?: 'butt' | 'round' | 'square'; +}; + +export type ThemeV2ParticlePreset = + | 'sparkles' + | 'snow' + | 'petals' + | 'bubbles' + | 'embers' + | 'confetti'; + +export const THEME_PARTICLE_PRESETS = [ + { value: 'sparkles', label: 'Sparkles' }, + { value: 'snow', label: 'Snow' }, + { value: 'petals', label: 'Petals' }, + { value: 'bubbles', label: 'Bubbles' }, + { value: 'embers', label: 'Embers' }, + { value: 'confetti', label: 'Confetti' }, +] as const satisfies readonly { value: ThemeV2ParticlePreset; label: string }[]; + +export type ThemeV2ParticlesElement = ThemeV2ElementBase & { + type: 'particles'; + preset: ThemeV2ParticlePreset; + /** Particle count, 4–120. */ + density?: number; + /** Animation speed multiplier, 0.1–3. */ + speed?: number; + /** Particle size in stage pixels, 1–24. */ + size?: number; + color?: string; + /** Secondary color; presets blend between both. */ + color2?: string; + /** Horizontal drift, -1 (left) to 1 (right). */ + wind?: number; + /** Fade each particle towards the layer edges. */ + fade?: boolean; +}; + +export type ThemeV2ClockElement = ThemeV2ElementBase & { + type: 'clock'; + mode?: 'digital' | 'analog'; + /** Digital clock uses 12-hour time. Analog clocks always use the local hour. */ + hour12?: boolean; + showSeconds?: boolean; + /** Digital clock adds the local weekday and date below the time. */ + showDate?: boolean; + /** Optional caption shown above the time. */ + label?: string; + color?: string; + /** Second hand, dial accents and the date text. */ + accentColor?: string; + fontFamily?: string; + fontSize?: number; + fontWeight?: number; + align?: ThemeV2ElementAlign; +}; + +export type ThemeV2CountdownElement = ThemeV2ElementBase & { + type: 'countdown'; + /** ISO date-time the layer counts down to. */ + target: string; + /** Template with `{days}`, `{hours}`, `{minutes}` and `{seconds}`. */ + text: string; + /** Shown once the target has passed. */ + finishedText?: string; + fontFamily?: string; + fontSize?: number; + fontWeight?: number; + color?: string; + align?: ThemeV2ElementAlign; +}; + +export type ThemeV2IconElement = ThemeV2ElementBase & { + type: 'icon'; + icon: ThemeIconName; + color?: string; + /** Icon size as a percentage of the layer's shortest side. */ + scale?: number; +}; + +export type ThemeV2RepeaterElement = ThemeV2ElementBase & { + type: 'repeater'; + source: 'image' | 'icon'; + src?: string; + icon?: ThemeIconName; + tileWidth?: number; + tileHeight?: number; + gapX?: number; + gapY?: number; + /** Tile counts; 0 or omitted fills the layer bounds. */ + columns?: number; + rows?: number; + radius?: number; + color?: string; +}; + +export type ThemeV2GaugeElement = ThemeV2ElementBase & { + type: 'gauge'; + mode?: 'bar' | 'ring'; + /** Fallback value from 0 to 1 when no variable is bound. */ + value?: number; + /** Theme variable whose 0-1 numeric value drives the gauge. */ + variable?: string; + trackColor?: string; + fillColor?: string; + thickness?: number; + rounded?: boolean; + /** Template shown in the middle; `{percent}`, `{value}` and `{variable}` work. */ + label?: string; + labelColor?: string; + fontSize?: number; + fontFamily?: string; +}; + +export type ThemeV2TickerElement = ThemeV2ElementBase & { + type: 'ticker'; + text: string; + fontFamily?: string; + fontSize?: number; + fontWeight?: number; + color?: string; + background?: string; + /** Seconds for one full loop. */ + speed?: number; + gap?: number; + textTransform?: ThemeV2TextTransform; +}; + +export type ThemeV2LottieElement = ThemeV2ElementBase & { + type: 'lottie'; + /** Lottie JSON file relative to the theme's assets folder. */ + src: string; + loop?: boolean; + autoplay?: boolean; + speed?: number; +}; + +export type ThemeV2ButtonAction = + | { type: 'navigate'; screen: ThemeV2ScriptScreen } + | { type: 'launch' } + | { type: 'url'; url: string }; + +export type ThemeV2ButtonElement = ThemeV2ElementBase & { + type: 'button'; + label: string; + icon?: ThemeIconName; + action: ThemeV2ButtonAction; + background?: string; + color?: string; + hoverBackground?: string; + borderColor?: string; + borderWidth?: number; + radius?: number; + fontSize?: number; + fontWeight?: number; + fontFamily?: string; +}; + +/** + * A self-contained Svelte 5 component authored in the theme editor. The source + * is compiled in the WebView and mounted inside the layer bounds. Because this + * executes theme JavaScript, API 2.0 themes with a Svelte layer require the + * same explicit trust confirmation as API 1.0 themes. + */ +export type ThemeV2SvelteElement = ThemeV2ElementBase & { + type: 'svelte'; + /** Plain-JavaScript Svelte 5 component source (no TypeScript preprocessor). */ + source: string; }; export type ThemeV2Element = | ThemeV2TextElement + | ThemeV2DataElement | ThemeV2ImageElement | ThemeV2VideoElement - | ThemeV2ShapeElement; + | ThemeV2ShapeElement + | ThemeV2IconElement + | ThemeV2RepeaterElement + | ThemeV2GaugeElement + | ThemeV2TickerElement + | ThemeV2LottieElement + | ThemeV2ButtonElement + | ThemeV2PathElement + | ThemeV2ParticlesElement + | ThemeV2ClockElement + | ThemeV2CountdownElement + | ThemeV2SvelteElement; + +export type ThemeV2ScriptScreen = 'home' | 'themes' | 'settings' | 'login'; +export type ThemeV2ScriptValue = string | number | boolean; +export type ThemeV2BehaviorOperator = + | 'equals' + | 'notEquals' + | 'greaterThan' + | 'lessThan' + | 'greaterOrEqual' + | 'lessOrEqual'; + +export type ThemeV2BehaviorTrigger = + | { type: 'load' } + | { type: 'screen'; screen: ThemeV2ScriptScreen } + | { type: 'click' | 'hover' | 'leave'; elementId?: string } + | { type: 'timer'; interval: number }; + +/** Attach a rule to a layer or group so it can be edited from that inspector. */ +export type ThemeV2BehaviorTarget = { type: 'element' | 'group'; id: string }; + +export type ThemeV2BehaviorCondition = + | { + type: 'variable'; + variable: string; + operator: ThemeV2BehaviorOperator; + value: ThemeV2ScriptValue; + } + | { type: 'visible'; elementId: string; value: boolean } + | { type: 'groupVisible'; groupId: string; value: boolean } + | { type: 'screen'; screen: ThemeV2ScriptScreen } + | { type: 'chance'; percent: number }; + +export type ThemeV2BehaviorAction = + | { type: 'setVariable'; variable: string; value: ThemeV2ScriptValue } + | { type: 'changeVariable'; variable: string; amount: number } + | { type: 'toggleVariable'; variable: string } + | { type: 'showElement' | 'hideElement' | 'toggleElement'; elementId: string } + | { type: 'showGroup' | 'hideGroup' | 'toggleGroup'; groupId: string } + | { type: 'navigate'; screen: ThemeV2ScriptScreen } + | { type: 'launch' } + | { type: 'openUrl'; url: string }; + +/** A Scratch-style WHEN / IF / THEN rule. */ +export type ThemeV2Behavior = { + id: string; + name?: string; + enabled?: boolean; + /** Fire at most once per theme session. */ + once?: boolean; + /** Optional layer or group this rule belongs to. */ + target?: ThemeV2BehaviorTarget; + trigger: ThemeV2BehaviorTrigger; + conditions?: ThemeV2BehaviorCondition[]; + /** Whether every condition or at least one must match. */ + conditionMode?: 'all' | 'any'; + actions: ThemeV2BehaviorAction[]; + /** + * Optional advanced script body written in JavaScript syntax. When defined, + * it replaces `actions` for this rule. The script is compiled into a safe + * instruction set and executed without eval(). + */ + script?: string; +}; export type ThemeV2Definition = { name: string; @@ -156,8 +597,19 @@ export type ThemeV2Definition = { audio?: ThemeV2Audio; /** Theme-wide fonts and freeform layers rendered over the launcher UI. */ typography: ThemeV2Typography; + /** Built-in icon family used by launcher chrome. Defaults to Lucide. */ + iconSet?: ThemeIconSet; fonts: ThemeV2Font[]; elements: ThemeV2Element[]; + /** Optional layer groups. Elements reference them through `groupId`. */ + groups: ThemeV2Group[]; + /** Declarative interaction rules; API 2.0 themes never run arbitrary JS. */ + behaviors?: ThemeV2Behavior[]; + /** + * Starting values for `{name}` text placeholders and rule variables. Any + * variable without an entry starts at `0`. + */ + variables?: Record; /** Used for focus rings, active states and effect accents. */ accentColor?: string; /** Optional CSS appended after the launcher's own styles. */ @@ -186,6 +638,29 @@ const color = (value: unknown, fallback: string) => { const oneOf = (value: unknown, options: readonly T[], fallback: T): T => options.includes(value as T) ? (value as T) : fallback; +const CSS_CLASS_TOKEN = /^-?[_a-zA-Z][_a-zA-Z0-9-]*$/; + +/** + * Split a space-separated class list into safe, unique tokens. Theme CSS is + * injected globally, so this keeps selectors predictable and prevents a layer + * from carrying malformed attribute content. + */ +export const themeClassTokens = (value: unknown): string[] => { + if (typeof value !== 'string') return []; + const seen = new Set(); + const tokens: string[] = []; + for (const token of value.trim().split(/\s+/)) { + if (!token || token.length > 64 || !CSS_CLASS_TOKEN.test(token) || seen.has(token)) continue; + seen.add(token); + tokens.push(token); + if (tokens.length === 20) break; + } + return tokens; +}; + +/** Sanitized class attribute value for a layer or group. */ +export const themeClassNames = (value: unknown): string => themeClassTokens(value).join(' '); + const clampInteger = (value: unknown, fallback: number, min: number, max: number) => Math.round(clamp(value, fallback, min, max)); @@ -222,10 +697,208 @@ const normalizeFonts = (raw: unknown): ThemeV2Font[] => { return fonts; }; +const normalizeFilters = (raw: unknown): ThemeV2Filters | undefined => { + if (!raw || typeof raw !== 'object') return undefined; + const source = raw as Record; + const filters: ThemeV2Filters = {}; + let hasValue = false; + const add = ( + key: keyof ThemeV2Filters, + min: number, + max: number, + fallback: number, + present: boolean + ) => { + if (!present) return; + filters[key] = clamp(source[key], fallback, min, max); + hasValue = true; + }; + + add('blur', 0, 40, 0, source.blur !== undefined); + add('brightness', 0, 3, 1, source.brightness !== undefined); + add('contrast', 0, 3, 1, source.contrast !== undefined); + add('saturate', 0, 4, 1, source.saturate !== undefined); + add('hueRotate', -360, 360, 0, source.hueRotate !== undefined); + add('grayscale', 0, 1, 0, source.grayscale !== undefined); + add('sepia', 0, 1, 0, source.sepia !== undefined); + add('invert', 0, 1, 0, source.invert !== undefined); + + return hasValue ? filters : undefined; +}; + +const normalizeTransform = (source: Record): ThemeV2Transform => { + const scale = clamp(source.scale, 1, 0.05, 10); + return { + rotation: clamp(source.rotation, 0, -360, 360), + scale, + scaleX: clamp(source.scaleX, scale, 0.05, 10), + scaleY: clamp(source.scaleY, scale, 0.05, 10), + skewX: clamp(source.skewX, 0, -89, 89), + skewY: clamp(source.skewY, 0, -89, 89), + flipX: source.flipX === true, + flipY: source.flipY === true, + }; +}; + +const normalizeGroups = (raw: unknown): ThemeV2Group[] => { + if (!Array.isArray(raw)) return []; + const seen = new Set(); + const groups: ThemeV2Group[] = []; + + raw.slice(0, 50).forEach((candidate, index) => { + if (!candidate || typeof candidate !== 'object') return; + const source = candidate as Record; + let id = text(source.id, `group_${index + 1}`).slice(0, 64); + let suffix = 2; + while (seen.has(id)) id = `${id}_${suffix++}`; + seen.add(id); + const filters = normalizeFilters(source.filters); + const cssClasses = themeClassNames(source.cssClasses); + const mask = normalizeMask(source.mask); + const group: ThemeV2Group = { + id, + name: typeof source.name === 'string' ? source.name.slice(0, 80) : undefined, + parentId: typeof source.parentId === 'string' ? source.parentId.slice(0, 64) : undefined, + hidden: source.hidden === true, + locked: source.locked === true, + collapsed: source.collapsed === true, + opacity: clamp(source.opacity, 1, 0, 1), + offsetX: clamp(source.offsetX, 0, -2000, 2000), + offsetY: clamp(source.offsetY, 0, -2000, 2000), + ...normalizeTransform(source), + ...(filters ? { filters } : {}), + ...(mask ? { mask } : {}), + ...(cssClasses ? { cssClasses } : {}), + }; + groups.push(group); + }); + + // Drop missing/self parents and break accidental cycles so rendering and + // the layer tree can safely walk upwards. + const ids = new Set(groups.map((group) => group.id)); + for (const group of groups) { + if (!group.parentId || !ids.has(group.parentId) || group.parentId === group.id) { + group.parentId = undefined; + } + } + for (const group of groups) { + const seenParents = new Set([group.id]); + let parent = group.parentId + ? groups.find((candidate) => candidate.id === group.parentId) + : undefined; + while (parent) { + if (seenParents.has(parent.id)) { + group.parentId = undefined; + break; + } + seenParents.add(parent.id); + parent = parent.parentId + ? groups.find((candidate) => candidate.id === parent?.parentId) + : undefined; + } + } + + return groups; +}; + +const normalizeMask = (raw: unknown): ThemeV2Mask | undefined => { + if (!raw || typeof raw !== 'object') return undefined; + const source = raw as Record; + const shape = oneOf( + source.shape, + ['rectangle', 'ellipse', 'polygon', 'star', 'image'] as const, + 'rectangle' + ); + const src = text(source.src).slice(0, 500); + if (shape === 'image' && !src) return undefined; + return { + shape, + radius: clamp(source.radius, 0, 0, 999), + sides: clampInteger(source.sides, shape === 'star' ? 5 : 6, 3, 24), + innerRadius: clamp(source.innerRadius, 0.5, 0.1, 0.95), + ...(src ? { src } : {}), + fit: source.fit === 'contain' ? 'contain' : 'cover', + }; +}; + +const DATA_FIELD_VALUES = THEME_DATA_FIELDS.map((field) => field.value); + +const normalizeTextStyle = (source: Record): ThemeV2TextStyle => ({ + fontFamily: text(source.fontFamily, 'Sora Variable'), + fontSize: clamp(source.fontSize, 28, 6, 240), + fontWeight: clampInteger(source.fontWeight, 600, 100, 900), + fontStyle: source.fontStyle === 'italic' ? 'italic' : 'normal', + color: color(source.color, '#ffffff'), + align: oneOf(source.align, ['left', 'center', 'right'] as const, 'left'), + verticalAlign: oneOf(source.verticalAlign, ['top', 'center', 'bottom'] as const, 'top'), + lineHeight: clamp(source.lineHeight, 1.2, 0.7, 3), + letterSpacing: clamp(source.letterSpacing, 0, -20, 40), + textDecoration: oneOf( + source.textDecoration, + ['none', 'underline', 'line-through', 'underline line-through'] as const, + 'none' + ), + textTransform: oneOf( + source.textTransform, + ['none', 'uppercase', 'lowercase', 'capitalize'] as const, + 'none' + ), + fillMode: source.fillMode === 'gradient' ? 'gradient' : 'solid', + gradientFrom: color(source.gradientFrom, '#ff006e'), + gradientTo: color(source.gradientTo, '#7c3aed'), + gradientAngle: clamp(source.gradientAngle, 90, 0, 360), + outlineWidth: clamp(source.outlineWidth, 0, 0, 20), + outlineColor: color(source.outlineColor, '#000000'), + shadowColor: color(source.shadowColor, '#000000'), + shadowBlur: clamp(source.shadowBlur, 0, 0, 40), + shadowOffsetX: clamp(source.shadowOffsetX, 0, -40, 40), + shadowOffsetY: clamp(source.shadowOffsetY, 0, -40, 40), +}); + +const normalizeButtonAction = (raw: unknown): ThemeV2ButtonAction => { + if (!raw || typeof raw !== 'object') return { type: 'navigate', screen: 'home' }; + const source = raw as Record; + const type = oneOf(source.type, ['navigate', 'launch', 'url'] as const, 'navigate'); + if (type === 'launch') return { type: 'launch' }; + if (type === 'url') { + return { type: 'url', url: text(source.url).slice(0, 2000) }; + } + return { type: 'navigate', screen: oneOf(source.screen, SCRIPT_SCREENS, 'home') }; +}; + +const PATH_DATA_PATTERN = /^[MmZzLlHhVvCcSsQqTtAa0-9eE.,+\-\s]*$/; + +const normalizePathData = (value: unknown) => { + const raw = text(value).slice(0, 4000); + return raw && PATH_DATA_PATTERN.test(raw) ? raw : ''; +}; + const normalizeElement = (raw: unknown, index: number): ThemeV2Element | undefined => { if (!raw || typeof raw !== 'object') return undefined; const source = raw as Record; - const type = oneOf(source.type, ['text', 'image', 'video', 'shape'] as const, 'text'); + const type = oneOf( + source.type, + [ + 'text', + 'data', + 'image', + 'video', + 'shape', + 'icon', + 'repeater', + 'gauge', + 'ticker', + 'lottie', + 'button', + 'path', + 'particles', + 'clock', + 'countdown', + 'svelte', + ] as const, + 'text' + ); + const cssClasses = themeClassNames(source.cssClasses); const base = { id: text(source.id, `element_${index + 1}`).slice(0, 64), name: typeof source.name === 'string' ? source.name.slice(0, 80) : undefined, @@ -233,10 +906,15 @@ const normalizeElement = (raw: unknown, index: number): ThemeV2Element | undefin y: clamp(source.y, 0, 0, 650), width: clamp(source.width, 320, 24, 1000), height: clamp(source.height, 120, 24, 650), - rotation: clamp(source.rotation, 0, -360, 360), + ...normalizeTransform(source), opacity: clamp(source.opacity, 1, 0, 1), hidden: source.hidden === true, locked: source.locked === true, + interactive: source.interactive === true, + groupId: typeof source.groupId === 'string' ? source.groupId.slice(0, 64) : undefined, + filters: normalizeFilters(source.filters), + mask: normalizeMask(source.mask), + ...(cssClasses ? { cssClasses } : {}), }; if (type === 'text') { @@ -244,26 +922,244 @@ const normalizeElement = (raw: unknown, index: number): ThemeV2Element | undefin ...base, type, text: typeof source.text === 'string' ? source.text.slice(0, 2000) : 'Your text', - fontFamily: text(source.fontFamily, 'Sora Variable'), - fontSize: clamp(source.fontSize, 28, 6, 240), - fontWeight: clampInteger(source.fontWeight, 600, 100, 900), - color: color(source.color, '#ffffff'), - align: oneOf(source.align, ['left', 'center', 'right'] as const, 'left'), - verticalAlign: oneOf(source.verticalAlign, ['top', 'center', 'bottom'] as const, 'top'), - lineHeight: clamp(source.lineHeight, 1.2, 0.7, 3), - letterSpacing: clamp(source.letterSpacing, 0, -20, 40), + ...normalizeTextStyle(source), + }; + } + + if (type === 'data') { + return { + ...base, + type, + field: oneOf(source.field, DATA_FIELD_VALUES, 'user.name' as ThemeV2DataField), + mode: oneOf(source.mode, ['osu', 'taiko', 'catch', 'mania'] as const, 'osu'), + text: typeof source.text === 'string' ? source.text.slice(0, 400) : '{value}', + ...normalizeTextStyle(source), }; } if (type === 'shape') { + const cornerRadii = Array.isArray(source.cornerRadii) ? source.cornerRadii.slice(0, 4) : []; + const radius = clamp(source.radius, 16, 0, 999); return { ...base, type, - shape: oneOf(source.shape, ['rectangle', 'ellipse', 'line'] as const, 'rectangle'), + shape: oneOf( + source.shape, + ['rectangle', 'ellipse', 'line', 'polygon', 'star', 'arc'] as const, + 'rectangle' + ), fill: color(source.fill, '#ff006e'), - radius: clamp(source.radius, 16, 0, 999), + fillMode: source.fillMode === 'gradient' ? 'gradient' : 'solid', + gradientFrom: color(source.gradientFrom, '#ff006e'), + gradientTo: color(source.gradientTo, '#7c3aed'), + gradientAngle: clamp(source.gradientAngle, 135, 0, 360), + radius, + ...(cornerRadii.length > 0 + ? { + cornerRadii: [ + clamp(cornerRadii[0], radius, 0, 999), + clamp(cornerRadii[1], radius, 0, 999), + clamp(cornerRadii[2], radius, 0, 999), + clamp(cornerRadii[3], radius, 0, 999), + ] as [number, number, number, number], + } + : {}), borderWidth: clamp(source.borderWidth, 0, 0, 40), borderColor: color(source.borderColor, '#ffffff'), + borderStyle: oneOf(source.borderStyle, ['solid', 'dashed', 'dotted'] as const, 'solid'), + points: clampInteger(source.points, 6, 3, 24), + innerRadius: clamp(source.innerRadius, 0.5, 0.1, 0.95), + backdropBlur: clamp(source.backdropBlur, 0, 0, 60), + startAngle: clamp(source.startAngle, 0, -360, 360), + endAngle: clamp(source.endAngle, 270, -360, 720), + thickness: clamp(source.thickness, 12, 1, 120), + rounded: source.rounded !== false, + }; + } + + if (type === 'icon') { + return { + ...base, + type, + icon: isThemeIconName(source.icon) ? source.icon : 'brush', + color: color(source.color, '#ffffff'), + scale: clamp(source.scale, 100, 10, 100), + }; + } + + if (type === 'repeater') { + return { + ...base, + type, + source: oneOf(source.source, ['image', 'icon'] as const, 'icon'), + ...(text(source.src) ? { src: text(source.src).slice(0, 500) } : {}), + icon: isThemeIconName(source.icon) ? source.icon : 'music', + tileWidth: clamp(source.tileWidth, 28, 4, 400), + tileHeight: clamp(source.tileHeight, 28, 4, 400), + gapX: clamp(source.gapX, 8, 0, 200), + gapY: clamp(source.gapY, 8, 0, 200), + columns: clampInteger(source.columns, 0, 0, 60), + rows: clampInteger(source.rows, 0, 0, 60), + radius: clamp(source.radius, 0, 0, 999), + color: color(source.color, '#ffffff'), + }; + } + + if (type === 'gauge') { + return { + ...base, + type, + mode: oneOf(source.mode, ['bar', 'ring'] as const, 'bar'), + value: clamp(source.value, 0.5, 0, 1), + ...(text(source.variable) ? { variable: text(source.variable).slice(0, 64) } : {}), + trackColor: color(source.trackColor, '#000000'), + fillColor: color(source.fillColor, '#ff006e'), + thickness: clamp(source.thickness, 12, 2, 80), + rounded: source.rounded !== false, + label: typeof source.label === 'string' ? source.label.slice(0, 200) : '', + labelColor: color(source.labelColor, '#ffffff'), + fontSize: clamp(source.fontSize, 20, 6, 200), + fontFamily: text(source.fontFamily, 'Sora Variable'), + }; + } + + if (type === 'ticker') { + return { + ...base, + type, + text: typeof source.text === 'string' ? source.text.slice(0, 500) : 'Now playing', + fontFamily: text(source.fontFamily, 'Sora Variable'), + fontSize: clamp(source.fontSize, 24, 6, 200), + fontWeight: clampInteger(source.fontWeight, 600, 100, 900), + color: color(source.color, '#ffffff'), + background: /^#(?:[0-9a-f]{3,8})$/i.test(text(source.background)) + ? text(source.background) + : '', + speed: clamp(source.speed, 18, 2, 240), + gap: clamp(source.gap, 48, 0, 400), + textTransform: oneOf( + source.textTransform, + ['none', 'uppercase', 'lowercase', 'capitalize'] as const, + 'none' + ), + }; + } + + if (type === 'lottie') { + const src = text(source.src).slice(0, 500); + if (!src) return undefined; + return { + ...base, + type, + src, + loop: source.loop !== false, + autoplay: source.autoplay !== false, + speed: clamp(source.speed, 1, 0.1, 4), + }; + } + + if (type === 'button') { + return { + ...base, + type, + label: typeof source.label === 'string' ? source.label.slice(0, 80) : 'Click me', + ...(isThemeIconName(source.icon) ? { icon: source.icon } : {}), + action: normalizeButtonAction(source.action), + background: color(source.background, '#ff006e'), + color: color(source.color, '#ffffff'), + hoverBackground: color(source.hoverBackground, '#ff4d94'), + borderColor: color(source.borderColor, '#ffffff'), + borderWidth: clamp(source.borderWidth, 0, 0, 20), + radius: clamp(source.radius, 10, 0, 999), + fontSize: clamp(source.fontSize, 16, 6, 120), + fontWeight: clampInteger(source.fontWeight, 600, 100, 900), + fontFamily: text(source.fontFamily, 'Sora Variable'), + }; + } + + if (type === 'path') { + return { + ...base, + type, + path: normalizePathData(source.path) || 'M 5 95 L 50 5 L 95 95 Z', + fill: color(source.fill, '#ff006e'), + fillMode: source.fillMode === 'gradient' ? 'gradient' : 'solid', + gradientFrom: color(source.gradientFrom, '#ff006e'), + gradientTo: color(source.gradientTo, '#7c3aed'), + gradientAngle: clamp(source.gradientAngle, 135, 0, 360), + stroke: text(source.stroke) ? color(source.stroke, '#ffffff') : '', + strokeWidth: clamp(source.strokeWidth, 0, 0, 60), + strokeStyle: oneOf(source.strokeStyle, ['solid', 'dashed', 'dotted'] as const, 'solid'), + strokeLinecap: oneOf(source.strokeLinecap, ['butt', 'round', 'square'] as const, 'round'), + }; + } + + if (type === 'particles') { + return { + ...base, + type, + preset: oneOf( + source.preset, + ['sparkles', 'snow', 'petals', 'bubbles', 'embers', 'confetti'] as const, + 'sparkles' + ), + density: clampInteger(source.density, 28, 4, 120), + speed: clamp(source.speed, 1, 0.1, 3), + size: clamp(source.size, 6, 1, 24), + color: color(source.color, '#ffffff'), + color2: color(source.color2, '#ff006e'), + wind: clamp(source.wind, 0, -1, 1), + fade: source.fade !== false, + }; + } + + if (type === 'clock') { + return { + ...base, + type, + mode: oneOf(source.mode, ['digital', 'analog'] as const, 'digital'), + hour12: source.hour12 === true, + showSeconds: source.showSeconds !== false, + showDate: source.showDate === true, + label: typeof source.label === 'string' ? source.label.slice(0, 80) : '', + color: color(source.color, '#ffffff'), + accentColor: color(source.accentColor, '#ff006e'), + fontFamily: text(source.fontFamily, 'Sora Variable'), + fontSize: clamp(source.fontSize, 40, 6, 240), + fontWeight: clampInteger(source.fontWeight, 700, 100, 900), + align: oneOf(source.align, ['left', 'center', 'right'] as const, 'center'), + }; + } + + if (type === 'countdown') { + return { + ...base, + type, + target: text(source.target).slice(0, 64), + text: + typeof source.text === 'string' + ? source.text.slice(0, 200) + : '{days}d {hours}h {minutes}m {seconds}s', + finishedText: + typeof source.finishedText === 'string' + ? source.finishedText.slice(0, 200) + : 'The wait is over', + fontFamily: text(source.fontFamily, 'Sora Variable'), + fontSize: clamp(source.fontSize, 40, 6, 240), + fontWeight: clampInteger(source.fontWeight, 700, 100, 900), + color: color(source.color, '#ffffff'), + align: oneOf(source.align, ['left', 'center', 'right'] as const, 'center'), + }; + } + + if (type === 'svelte') { + return { + ...base, + type, + source: + typeof source.source === 'string' + ? source.source.slice(0, 100_000) + : DEFAULT_THEME_SVELTE_SOURCE, }; } @@ -276,6 +1172,7 @@ const normalizeElement = (raw: unknown, index: number): ThemeV2Element | undefin fit: source.fit === 'contain' ? 'contain' : 'cover', radius: clamp(source.radius, 0, 0, 999), ...(type === 'video' ? { muted: source.muted !== false } : {}), + ...(type === 'image' ? { tint: text(source.tint).slice(0, 9) } : {}), }; }; @@ -295,6 +1192,236 @@ const normalizeElements = (raw: unknown): ThemeV2Element[] => { return elements; }; +const SCRIPT_SCREENS = ['home', 'themes', 'settings', 'login'] as const; +const SCRIPT_OPERATORS = [ + 'equals', + 'notEquals', + 'greaterThan', + 'lessThan', + 'greaterOrEqual', + 'lessOrEqual', +] as const; + +const normalizeScriptValue = ( + value: unknown, + fallback: ThemeV2ScriptValue = 0 +): ThemeV2ScriptValue => { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'boolean') return value; + if (typeof value === 'string') return value.slice(0, 200); + return fallback; +}; + +const VARIABLE_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; +const RESERVED_VARIABLE_NAMES = new Set(['__proto__', 'prototype', 'constructor', 'screen']); + +/** Keep theme-declared variable defaults safe and bounded. */ +const normalizeVariableDefaults = (value: unknown): Record => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const defaults: Record = {}; + for (const [rawName, rawValue] of Object.entries(value as Record).slice(0, 64)) { + const name = rawName.trim().slice(0, 64); + if (!VARIABLE_NAME_PATTERN.test(name) || RESERVED_VARIABLE_NAMES.has(name)) continue; + defaults[name] = normalizeScriptValue(rawValue); + } + return defaults; +}; + +const normalizeBehaviorTarget = (raw: unknown): ThemeV2BehaviorTarget | undefined => { + if (!raw || typeof raw !== 'object') return undefined; + const source = raw as Record; + const type = oneOf(source.type, ['element', 'group'] as const, 'element'); + const id = text(source.id).slice(0, 64); + return id ? { type, id } : undefined; +}; + +const normalizeBehaviorTrigger = (raw: unknown): ThemeV2BehaviorTrigger | undefined => { + if (!raw || typeof raw !== 'object') return undefined; + const source = raw as Record; + const type = oneOf( + source.type, + ['load', 'screen', 'click', 'hover', 'leave', 'timer'] as const, + 'load' + ); + if (type === 'screen') { + return { type, screen: oneOf(source.screen, SCRIPT_SCREENS, 'home') }; + } + if (type === 'click' || type === 'hover' || type === 'leave') { + const elementId = typeof source.elementId === 'string' ? source.elementId.slice(0, 64) : ''; + return elementId ? { type, elementId } : { type }; + } + if (type === 'timer') { + return { type, interval: clamp(source.interval, 5, 0.5, 3600) }; + } + return { type: 'load' }; +}; + +const normalizeBehaviorCondition = (raw: unknown): ThemeV2BehaviorCondition | undefined => { + if (!raw || typeof raw !== 'object') return undefined; + const source = raw as Record; + const type = oneOf( + source.type, + ['variable', 'visible', 'groupVisible', 'screen', 'chance'] as const, + 'variable' + ); + if (type === 'variable') { + const variable = text(source.variable).slice(0, 64); + if (!variable) return undefined; + return { + type, + variable, + operator: oneOf(source.operator, SCRIPT_OPERATORS, 'equals'), + value: normalizeScriptValue(source.value), + }; + } + if (type === 'visible') { + const elementId = text(source.elementId).slice(0, 64); + if (!elementId) return undefined; + return { type, elementId, value: source.value !== false }; + } + if (type === 'groupVisible') { + const groupId = text(source.groupId).slice(0, 64); + if (!groupId) return undefined; + return { type, groupId, value: source.value !== false }; + } + if (type === 'screen') { + return { type, screen: oneOf(source.screen, SCRIPT_SCREENS, 'home') }; + } + return { type, percent: clamp(source.percent, 25, 0, 100) }; +}; + +const normalizeBehaviorAction = (raw: unknown): ThemeV2BehaviorAction | undefined => { + if (!raw || typeof raw !== 'object') return undefined; + const source = raw as Record; + const type = oneOf( + source.type, + [ + 'setVariable', + 'changeVariable', + 'toggleVariable', + 'showElement', + 'hideElement', + 'toggleElement', + 'showGroup', + 'hideGroup', + 'toggleGroup', + 'navigate', + 'launch', + 'openUrl', + ] as const, + 'setVariable' + ); + if (type === 'setVariable') { + const variable = text(source.variable).slice(0, 64); + if (!variable) return undefined; + return { type, variable, value: normalizeScriptValue(source.value) }; + } + if (type === 'changeVariable') { + const variable = text(source.variable).slice(0, 64); + if (!variable) return undefined; + return { type, variable, amount: clamp(source.amount, 1, -1000000, 1000000) }; + } + if (type === 'toggleVariable') { + const variable = text(source.variable).slice(0, 64); + return variable ? { type, variable } : undefined; + } + if (type === 'showElement' || type === 'hideElement' || type === 'toggleElement') { + const elementId = text(source.elementId).slice(0, 64); + return elementId ? { type, elementId } : undefined; + } + if (type === 'showGroup' || type === 'hideGroup' || type === 'toggleGroup') { + const groupId = text(source.groupId).slice(0, 64); + return groupId ? { type, groupId } : undefined; + } + if (type === 'navigate') { + return { type, screen: oneOf(source.screen, SCRIPT_SCREENS, 'home') }; + } + if (type === 'openUrl') { + const url = text(source.url).slice(0, 2000); + return /^https?:\/\//i.test(url) ? { type, url } : undefined; + } + return { type: 'launch' }; +}; + +const normalizeBehaviors = (raw: unknown): ThemeV2Behavior[] => { + if (!Array.isArray(raw)) return []; + const seen = new Set(); + const behaviors: ThemeV2Behavior[] = []; + + raw.slice(0, 100).forEach((candidate, index) => { + if (!candidate || typeof candidate !== 'object') return; + const source = candidate as Record; + const trigger = normalizeBehaviorTrigger(source.trigger); + if (!trigger) return; + + let id = text(source.id, `rule_${index + 1}`).slice(0, 64); + let suffix = 2; + while (seen.has(id)) id = `${id}_${suffix++}`; + seen.add(id); + + const conditions = Array.isArray(source.conditions) + ? source.conditions + .slice(0, 12) + .map(normalizeBehaviorCondition) + .filter((condition): condition is ThemeV2BehaviorCondition => Boolean(condition)) + : []; + const actions = Array.isArray(source.actions) + ? source.actions + .slice(0, 20) + .map(normalizeBehaviorAction) + .filter((action): action is ThemeV2BehaviorAction => Boolean(action)) + : []; + + behaviors.push({ + id, + name: typeof source.name === 'string' ? source.name.slice(0, 80) : `Rule ${index + 1}`, + enabled: source.enabled !== false, + once: source.once === true, + target: normalizeBehaviorTarget(source.target), + trigger, + conditions, + conditionMode: source.conditionMode === 'any' ? 'any' : 'all', + actions, + ...(typeof source.script === 'string' ? { script: source.script.slice(0, 20000) } : {}), + }); + }); + + return behaviors; +}; + +/** Create a blank rule, optionally linked to a layer or group. */ +export const createThemeV2Behavior = ( + index = 0, + target?: ThemeV2BehaviorTarget +): ThemeV2Behavior => { + const base = { + id: `rule_${Date.now().toString(36)}_${index}`, + name: `Rule ${index + 1}`, + enabled: true, + once: false, + conditions: [], + conditionMode: 'all' as const, + }; + + if (target?.type === 'element') { + return { + ...base, + target, + trigger: { type: 'click', elementId: target.id }, + actions: [{ type: 'toggleElement', elementId: target.id }], + }; + } + if (target?.type === 'group') { + return { + ...base, + target, + trigger: { type: 'click' }, + actions: [{ type: 'toggleGroup', groupId: target.id }], + }; + } + return { ...base, trigger: { type: 'load' }, actions: [] }; +}; + /** Create a sensible element for the advanced editor's add buttons. */ export const createThemeV2Element = (type: ThemeV2Element['type'], index = 0): ThemeV2Element => { const id = `element_${Date.now().toString(36)}_${index}`; @@ -311,12 +1438,112 @@ export const createThemeV2Element = (type: ThemeV2Element['type'], index = 0): T index )! as ThemeV2VideoElement; } + if (type === 'data') { + return normalizeElement( + { + ...base, + type, + field: 'user.name', + text: 'Welcome, {value}!', + fontSize: 26, + width: 420, + height: 60, + }, + index + )! as ThemeV2DataElement; + } if (type === 'shape') { return normalizeElement( { ...base, type, shape: 'rectangle', width: 220, height: 120 }, index )! as ThemeV2ShapeElement; } + if (type === 'icon') { + return normalizeElement( + { ...base, type, icon: 'brush', color: '#ffffff', width: 96, height: 96, x: 450, y: 280 }, + index + )! as ThemeV2IconElement; + } + if (type === 'repeater') { + return normalizeElement( + { ...base, type, source: 'icon', icon: 'music', width: 320, height: 180 }, + index + )! as ThemeV2RepeaterElement; + } + if (type === 'gauge') { + return normalizeElement( + { ...base, type, mode: 'bar', label: '{percent}%', width: 320, height: 44, y: 300 }, + index + )! as ThemeV2GaugeElement; + } + if (type === 'ticker') { + return normalizeElement( + { ...base, type, text: 'Now playing · Your favourite track · ', width: 420, height: 48 }, + index + )! as ThemeV2TickerElement; + } + if (type === 'lottie') { + return normalizeElement( + { ...base, type, src: 'animation.json', width: 320, height: 320 }, + index + )! as ThemeV2LottieElement; + } + if (type === 'button') { + return normalizeElement( + { ...base, type, label: 'Open themes', width: 220, height: 52, y: 320 }, + index + )! as ThemeV2ButtonElement; + } + if (type === 'path') { + return normalizeElement( + { + ...base, + type, + path: 'M 5 95 L 50 5 L 95 95 Z', + fillMode: 'gradient', + width: 220, + height: 200, + }, + index + )! as ThemeV2PathElement; + } + if (type === 'particles') { + return normalizeElement( + { ...base, type, preset: 'sparkles', width: 520, height: 360, density: 36 }, + index + )! as ThemeV2ParticlesElement; + } + if (type === 'clock') { + return normalizeElement( + { ...base, type, mode: 'digital', width: 300, height: 110, fontSize: 48 }, + index + )! as ThemeV2ClockElement; + } + if (type === 'countdown') { + return normalizeElement( + { + ...base, + type, + target: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), + width: 380, + height: 90, + fontSize: 36, + }, + index + )! as ThemeV2CountdownElement; + } + if (type === 'svelte') { + return normalizeElement( + { + ...base, + type, + source: DEFAULT_THEME_SVELTE_SOURCE, + width: 360, + height: 190, + }, + index + )! as ThemeV2SvelteElement; + } return normalizeElement( { ...base, type, text: 'Your text', fontSize: 40, width: 420, height: 90 }, index @@ -355,8 +1582,11 @@ export const createDefaultThemeV2 = (): ThemeV2Definition => ({ body: 'Sora Variable', heading: 'Sora Variable', }, + iconSet: DEFAULT_THEME_ICON_SET, fonts: [], elements: [], + groups: [], + behaviors: [], accentColor: '#ff006e', css: '', }); @@ -424,6 +1654,12 @@ export const normalizeThemeV2 = (raw: Partial): ThemeV2Defini const sourceTypography = (raw.typography ?? {}) as Record; const bodyFont = text(sourceTypography.body).slice(0, 80); const headingFont = text(sourceTypography.heading).slice(0, 80); + const monoFont = text(sourceTypography.mono).slice(0, 80); + const groups = normalizeGroups(raw.groups); + const groupIds = new Set(groups.map((group) => group.id)); + const elements = normalizeElements(raw.elements).map((element) => + element.groupId && !groupIds.has(element.groupId) ? { ...element, groupId: undefined } : element + ); return { name: text(raw.name, 'Untitled theme'), @@ -445,8 +1681,10 @@ export const normalizeThemeV2 = (raw: Partial): ThemeV2Defini speed: clamp(sourceEffects.speed, 1, 0.2, 3), color: color(sourceEffects.color, '#ffffff'), }, + // A video background owns the soundtrack; combining it with theme music + // would play two sources at once. audio: - sourceAudio && text(sourceAudio.src) + background.type !== 'video' && sourceAudio && text(sourceAudio.src) ? { src: text(sourceAudio.src), loop: sourceAudio.loop !== false, @@ -455,9 +1693,14 @@ export const normalizeThemeV2 = (raw: Partial): ThemeV2Defini typography: { body: bodyFont || defaults.typography?.body || 'Sora Variable', heading: headingFont || bodyFont || defaults.typography?.heading || 'Sora Variable', + ...(monoFont ? { mono: monoFont } : {}), }, + iconSet: isThemeIconSet(raw.iconSet) ? raw.iconSet : DEFAULT_THEME_ICON_SET, fonts: normalizeFonts(raw.fonts), - elements: normalizeElements(raw.elements), + elements, + groups, + behaviors: normalizeBehaviors(raw.behaviors), + variables: normalizeVariableDefaults(raw.variables), accentColor: color(raw.accentColor, '#ff006e'), css: typeof raw.css === 'string' ? raw.css : '', }; @@ -473,7 +1716,12 @@ export const themeAssetUrl = (base: string, path: string | undefined) => { .filter(Boolean) .map((segment) => encodeURIComponent(segment)) .join('/'); - return base ? `${base.replace(/\/+$/, '')}/${relative}` : relative; + // Keep a cache-busting query (used when reloading a theme from disk) on the + // base URL, after the asset path rather than inside it. + const match = /^([^?#]*)([?#].*)?$/.exec(base); + const basePath = (match?.[1] ?? base).replace(/\/+$/, ''); + const suffix = match?.[2] ?? ''; + return basePath ? `${basePath}/${relative}${suffix}` : `${relative}${suffix}`; }; /** A CSS background value used for theme cards in the manager. */ @@ -528,6 +1776,481 @@ export const themeFontFaceCss = ( ) .join('\n'); +/** Compose CSS filter functions for a layer and its ancestor groups. */ +export const themeFilterCss = (...filters: (ThemeV2Filters | undefined)[]) => { + const value = (candidate: unknown, fallback: number) => + typeof candidate === 'number' && Number.isFinite(candidate) ? candidate : fallback; + const parts: string[] = []; + + for (const filter of filters) { + if (!filter) continue; + const blur = value(filter.blur, 0); + const brightness = value(filter.brightness, 1); + const contrast = value(filter.contrast, 1); + const saturate = value(filter.saturate, 1); + const hueRotate = value(filter.hueRotate, 0); + const grayscale = value(filter.grayscale, 0); + const sepia = value(filter.sepia, 0); + const invert = value(filter.invert, 0); + + if (blur > 0) parts.push(`blur(${blur}px)`); + if (brightness !== 1) parts.push(`brightness(${brightness})`); + if (contrast !== 1) parts.push(`contrast(${contrast})`); + if (saturate !== 1) parts.push(`saturate(${saturate})`); + if (hueRotate !== 0) parts.push(`hue-rotate(${hueRotate}deg)`); + if (grayscale > 0) parts.push(`grayscale(${grayscale})`); + if (sepia > 0) parts.push(`sepia(${sepia})`); + if (invert > 0) parts.push(`invert(${invert})`); + } + + return parts.join(' '); +}; + +/** Groups from the element's immediate parent up to the root. */ +export const themeGroupAncestors = ( + groups: ThemeV2Group[] | undefined, + element: Pick +): ThemeV2Group[] => { + const byId = new Map((groups ?? []).map((group) => [group.id, group])); + const ancestors: ThemeV2Group[] = []; + const seen = new Set(); + let current = element.groupId ? byId.get(element.groupId) : undefined; + + while (current && !seen.has(current.id)) { + seen.add(current.id); + ancestors.push(current); + current = current.parentId ? byId.get(current.parentId) : undefined; + } + + return ancestors; +}; + +/** Group path from the outermost ancestor down to the given group. */ +export const themeGroupPath = ( + groups: ThemeV2Group[] | undefined, + groupId: string +): ThemeV2Group[] => themeGroupAncestors(groups, { groupId }).slice().reverse(); + +export type ThemeV2TransformMatrix = { + a: number; + b: number; + c: number; + d: number; + e: number; + f: number; +}; + +const IDENTITY_MATRIX: ThemeV2TransformMatrix = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; + +const finite = (value: number | undefined, fallback: number) => + typeof value === 'number' && Number.isFinite(value) ? value : fallback; + +const multiplyMatrices = ( + left: ThemeV2TransformMatrix, + right: ThemeV2TransformMatrix +): ThemeV2TransformMatrix => ({ + a: left.a * right.a + left.c * right.b, + b: left.b * right.a + left.d * right.b, + c: left.a * right.c + left.c * right.d, + d: left.b * right.c + left.d * right.d, + e: left.a * right.e + left.c * right.f + left.e, + f: left.b * right.e + left.d * right.f + left.f, +}); + +const translateMatrix = (x: number, y: number): ThemeV2TransformMatrix => ({ + a: 1, + b: 0, + c: 0, + d: 1, + e: x, + f: y, +}); + +const rotateMatrix = (degrees: number): ThemeV2TransformMatrix => { + const radians = (degrees * Math.PI) / 180; + return { + a: Math.cos(radians), + b: Math.sin(radians), + c: -Math.sin(radians), + d: Math.cos(radians), + e: 0, + f: 0, + }; +}; + +const scaleMatrix = (x: number, y: number): ThemeV2TransformMatrix => ({ + a: x, + b: 0, + c: 0, + d: y, + e: 0, + f: 0, +}); + +const aroundPoint = ( + x: number, + y: number, + matrix: ThemeV2TransformMatrix +): ThemeV2TransformMatrix => + multiplyMatrices(translateMatrix(x, y), multiplyMatrices(matrix, translateMatrix(-x, -y))); + +const cleanMatrix = (matrix: ThemeV2TransformMatrix): ThemeV2TransformMatrix => { + const clean = (value: number) => { + const rounded = Math.round(value * 100000) / 100000; + return Object.is(rounded, -0) ? 0 : rounded; + }; + return { + a: clean(matrix.a), + b: clean(matrix.b), + c: clean(matrix.c), + d: clean(matrix.d), + e: clean(matrix.e), + f: clean(matrix.f), + }; +}; + +/** Bounds of every layer inside a group, including nested groups. */ +export const themeGroupBounds = ( + groups: ThemeV2Group[] | undefined, + elements: ThemeV2Element[], + groupId: string +) => { + const members = elements.filter((element) => + themeGroupAncestors(groups, element).some((group) => group.id === groupId) + ); + if (members.length === 0) return undefined; + + const left = Math.min(...members.map((element) => element.x)); + const top = Math.min(...members.map((element) => element.y)); + const right = Math.max(...members.map((element) => element.x + element.width)); + const bottom = Math.max(...members.map((element) => element.y + element.height)); + return { + left, + top, + width: right - left, + height: bottom - top, + centerX: (left + right) / 2, + centerY: (top + bottom) / 2, + }; +}; + +export type ThemeV2Point = { x: number; y: number }; + +/** Applies an affine transform to a point in stage coordinates. */ +export const themeTransformPoint = ( + matrix: ThemeV2TransformMatrix, + x: number, + y: number +): ThemeV2Point => ({ + x: matrix.a * x + matrix.c * y + matrix.e, + y: matrix.b * x + matrix.d * y + matrix.f, +}); + +const transformScaleX = (transform: ThemeV2Transform) => + Math.max(0.05, finite(transform.scaleX, finite(transform.scale, 1))); + +const transformScaleY = (transform: ThemeV2Transform) => + Math.max(0.05, finite(transform.scaleY, finite(transform.scale, 1))); + +const skewMatrix = (skewX: number, skewY: number): ThemeV2TransformMatrix => { + const x = Math.tan((finite(skewX, 0) * Math.PI) / 180); + const y = Math.tan((finite(skewY, 0) * Math.PI) / 180); + return { a: 1, b: y, c: x, d: 1, e: 0, f: 0 }; +}; + +/** Skew and scale, without rotation; useful for gesture math. */ +export const themeTransformScaleMatrix = (transform: ThemeV2Transform): ThemeV2TransformMatrix => + multiplyMatrices( + skewMatrix(finite(transform.skewX, 0), finite(transform.skewY, 0)), + scaleMatrix( + transformScaleX(transform) * (transform.flipX ? -1 : 1), + transformScaleY(transform) * (transform.flipY ? -1 : 1) + ) + ); + +/** Complete 2D linear transform: rotate, then skew, then scale and flips. */ +export const themeTransformLinearMatrix = (transform: ThemeV2Transform): ThemeV2TransformMatrix => + multiplyMatrices( + rotateMatrix(finite(transform.rotation, 0)), + themeTransformScaleMatrix(transform) + ); + +const themeGroupOwnMatrix = ( + group: ThemeV2Group, + bounds: { centerX: number; centerY: number } +): ThemeV2TransformMatrix => { + return multiplyMatrices( + translateMatrix( + finite(group.offsetX, 0) + bounds.centerX, + finite(group.offsetY, 0) + bounds.centerY + ), + multiplyMatrices( + themeTransformLinearMatrix(group), + translateMatrix(-bounds.centerX, -bounds.centerY) + ) + ); +}; + +const themeGroupMatrix = ( + group: ThemeV2Group, + elements: ThemeV2Element[], + groups: ThemeV2Group[], + seen: Set +): ThemeV2TransformMatrix => { + if (seen.has(group.id)) return IDENTITY_MATRIX; + seen.add(group.id); + + const bounds = themeGroupBounds(groups, elements, group.id); + const own = bounds ? themeGroupOwnMatrix(group, bounds) : IDENTITY_MATRIX; + const parent = group.parentId + ? groups.find((candidate) => candidate.id === group.parentId) + : undefined; + const parentMatrix = parent ? themeGroupMatrix(parent, elements, groups, seen) : IDENTITY_MATRIX; + return cleanMatrix(multiplyMatrices(parentMatrix, own)); +}; + +/** + * Stage transform for a group itself, including nested ancestor groups. Use + * this to draw a group's selection frame; layer matrices include the layer's + * local transform as well. + */ +export const themeGroupTransformMatrix = ( + group: ThemeV2Group, + elements: ThemeV2Element[], + groups: ThemeV2Group[] = [] +): ThemeV2TransformMatrix => themeGroupMatrix(group, elements, groups, new Set()); + +/** + * Transform applied by a group's ancestors. Offsets are expressed in this + * parent space, so screen-space drag deltas must be mapped through its inverse. + */ +export const themeGroupParentMatrix = ( + group: ThemeV2Group, + elements: ThemeV2Element[], + groups: ThemeV2Group[] = [] +): ThemeV2TransformMatrix => { + const parent = group.parentId + ? groups.find((candidate) => candidate.id === group.parentId) + : undefined; + return parent ? themeGroupTransformMatrix(parent, elements, groups) : IDENTITY_MATRIX; +}; + +/** + * Local affine transform for a layer, including its own rotation, skew, + * non-uniform scale and flips, plus every ancestor group's transform around + * that group's bounds. + */ +export const themeElementTransformMatrix = ( + element: ThemeV2Element, + elements: ThemeV2Element[], + groups: ThemeV2Group[] = [] +): ThemeV2TransformMatrix => { + const elementTransform = aroundPoint( + element.width / 2, + element.height / 2, + themeTransformLinearMatrix(element) + ); + + let matrix = elementTransform; + for (const group of themeGroupAncestors(groups, element)) { + const bounds = themeGroupBounds(groups, elements, group.id); + if (!bounds) continue; + + const centerX = bounds.centerX - element.x; + const centerY = bounds.centerY - element.y; + const local = multiplyMatrices( + translateMatrix(finite(group.offsetX, 0) + centerX, finite(group.offsetY, 0) + centerY), + multiplyMatrices(themeTransformLinearMatrix(group), translateMatrix(-centerX, -centerY)) + ); + matrix = multiplyMatrices(local, matrix); + } + + return cleanMatrix(matrix); +}; + +/** CSS transform value for a layer, using the matrix computed above. */ +export const themeElementTransformCss = ( + element: ThemeV2Element, + elements: ThemeV2Element[], + groups: ThemeV2Group[] = [] +) => { + const matrix = themeElementTransformMatrix(element, elements, groups) ?? IDENTITY_MATRIX; + return `matrix(${matrix.a}, ${matrix.b}, ${matrix.c}, ${matrix.d}, ${matrix.e}, ${matrix.f})`; +}; + +/** CSS declarations for text decoration, fill and visual effects. */ +export const themeTextStyleCss = (element: ThemeV2TextStyle) => { + const color = element.color ?? '#ffffff'; + const gradient = element.fillMode === 'gradient'; + const from = element.gradientFrom ?? '#ff006e'; + const to = element.gradientTo ?? '#7c3aed'; + const decoration = element.textDecoration ?? 'none'; + const fontSize = element.fontSize ?? 28; + const parts = [ + `font-style:${element.fontStyle === 'italic' ? 'italic' : 'normal'}`, + `text-transform:${element.textTransform ?? 'none'}`, + `text-decoration-line:${decoration}`, + ]; + + if (gradient) { + parts.push( + `background-image:linear-gradient(${element.gradientAngle ?? 90}deg, ${from}, ${to})`, + 'background-clip:text', + '-webkit-background-clip:text', + '-webkit-text-fill-color:transparent', + 'color:transparent' + ); + } else { + parts.push(`color:${color}`); + } + + if (decoration !== 'none') { + parts.push( + `text-decoration-color:${gradient ? from : color}`, + `text-decoration-thickness:${Math.max(1, Math.round(fontSize * 0.06))}px`, + `text-underline-offset:${Math.max(1, Math.round(fontSize * 0.08))}px` + ); + } + + const outlineWidth = element.outlineWidth ?? 0; + if (outlineWidth > 0) { + parts.push( + `-webkit-text-stroke:${outlineWidth}px ${element.outlineColor ?? '#000000'}`, + 'paint-order:stroke fill' + ); + } + + const shadowBlur = element.shadowBlur ?? 0; + const shadowX = element.shadowOffsetX ?? 0; + const shadowY = element.shadowOffsetY ?? 0; + if (shadowBlur > 0 || shadowX !== 0 || shadowY !== 0) { + parts.push( + `text-shadow:${shadowX}px ${shadowY}px ${shadowBlur}px ${element.shadowColor ?? '#000000'}` + ); + } + + return parts.join(';'); +}; + +/** Regular polygon or star points, in percentages of the layer bounds. */ +export const themeMaskPolygonPoints = ( + sides = 6, + star = false, + innerRadius = 0.5 +): ThemeV2Point[] => { + const count = Math.max(3, Math.min(24, Math.round(sides))) * (star ? 2 : 1); + const points: ThemeV2Point[] = []; + for (let index = 0; index < count; index += 1) { + const angle = -Math.PI / 2 + (index * Math.PI * 2) / count; + const radius = star && index % 2 === 1 ? Math.min(0.95, Math.max(0.1, innerRadius)) : 1; + points.push({ x: 50 + Math.cos(angle) * 50 * radius, y: 50 + Math.sin(angle) * 50 * radius }); + } + return points; +}; + +/** CSS `clip-path` for a shape mask, or an empty string for image masks. */ +export const themeMaskClipPath = (mask: ThemeV2Mask | undefined) => { + if (!mask) return ''; + if (mask.shape === 'rectangle') { + return `inset(0 round ${Math.max(0, Math.round(mask.radius ?? 0))}px)`; + } + if (mask.shape === 'ellipse') return 'ellipse(50% 50% at 50% 50%)'; + if (mask.shape === 'image') return ''; + const points = themeMaskPolygonPoints( + mask.sides ?? 6, + mask.shape === 'star', + mask.innerRadius ?? 0.5 + ); + return `polygon(${points.map((point) => `${point.x}% ${point.y}%`).join(', ')})`; +}; + +/** CSS declarations for an image mask. */ +export const themeMaskImageCss = (mask: ThemeV2Mask | undefined, url: string | undefined) => { + if (!mask || mask.shape !== 'image' || !url) return ''; + const safeUrl = url.replace(/["\\)]/g, ''); + return [ + `-webkit-mask-image:url("${safeUrl}")`, + `mask-image:url("${safeUrl}")`, + `-webkit-mask-size:${mask.fit === 'contain' ? 'contain' : '100% 100%'}`, + `mask-size:${mask.fit === 'contain' ? 'contain' : '100% 100%'}`, + '-webkit-mask-position:center', + 'mask-position:center', + '-webkit-mask-repeat:no-repeat', + 'mask-repeat:no-repeat', + ].join(';'); +}; + +/** + * Clip/mask CSS for a group, expressed in stage coordinates. Groups render + * their layers in a stage-sized wrapper, so the clip path is transformed with + * the group's own matrix and the members keep their absolute positions. + */ +export const themeGroupMaskCss = ( + group: ThemeV2Group, + elements: ThemeV2Element[], + groups: ThemeV2Group[], + assetUrl: (path: string) => string +) => { + const mask = group.mask; + if (!mask) return ''; + const bounds = themeGroupBounds(groups, elements, group.id); + if (!bounds) return ''; + + if (mask.shape === 'image') { + const url = mask.src ? assetUrl(mask.src) : ''; + if (!url) return ''; + const safeUrl = url.replace(/["\\)]/g, ''); + return [ + `-webkit-mask-image:url("${safeUrl}")`, + `mask-image:url("${safeUrl}")`, + `-webkit-mask-size:${mask.fit === 'contain' ? 'contain' : `${bounds.width}px ${bounds.height}px`}`, + `mask-size:${mask.fit === 'contain' ? 'contain' : `${bounds.width}px ${bounds.height}px`}`, + `-webkit-mask-position:${bounds.left}px ${bounds.top}px`, + `mask-position:${bounds.left}px ${bounds.top}px`, + '-webkit-mask-repeat:no-repeat', + 'mask-repeat:no-repeat', + ].join(';'); + } + + const matrix = themeGroupTransformMatrix(group, elements, groups); + const toStage = (x: number, y: number) => + themeTransformPoint(matrix, bounds.left + x, bounds.top + y); + const points: ThemeV2Point[] = []; + + if (mask.shape === 'rectangle') { + points.push( + toStage(0, 0), + toStage(bounds.width, 0), + toStage(bounds.width, bounds.height), + toStage(0, bounds.height) + ); + } else if (mask.shape === 'ellipse') { + const steps = 48; + for (let index = 0; index < steps; index += 1) { + const angle = (index / steps) * Math.PI * 2; + points.push( + toStage( + bounds.width / 2 + Math.cos(angle) * (bounds.width / 2), + bounds.height / 2 + Math.sin(angle) * (bounds.height / 2) + ) + ); + } + } else { + const shapePoints = themeMaskPolygonPoints( + mask.sides ?? 6, + mask.shape === 'star', + mask.innerRadius ?? 0.5 + ); + for (const point of shapePoints) { + points.push(toStage((point.x / 100) * bounds.width, (point.y / 100) * bounds.height)); + } + } + + return `clip-path:polygon(${points + .map((point) => `${Math.round(point.x * 100) / 100}px ${Math.round(point.y * 100) / 100}px`) + .join(', ')})`; +}; + /** CSS injected while a v2 theme is active. */ export const themeV2Css = ( definition: ThemeV2Definition, @@ -542,6 +2265,7 @@ export const themeV2Css = ( : ''; const body = definition.typography?.body?.trim(); const heading = definition.typography?.heading?.trim(); + const mono = definition.typography?.mono?.trim(); const fontFaces = themeFontFaceCss(definition, assets, assetOverrides); const typographyCss = body ? `:root,:root.dark{--font-sans:${fontFamilyCss(body)},ui-sans-serif,system-ui,sans-serif;}body{font-family:${fontFamilyCss(body)},ui-sans-serif,system-ui,sans-serif;}` @@ -549,7 +2273,10 @@ export const themeV2Css = ( const headingCss = heading ? `:root,:root.dark{--font-heading:${fontFamilyCss(heading)},ui-sans-serif,system-ui,sans-serif;}h1,h2,h3,h4,h5,h6,.theme-heading{font-family:${fontFamilyCss(heading)},ui-sans-serif,system-ui,sans-serif;}` : ''; - return [accentCss, fontFaces, typographyCss, headingCss, definition.css?.trim()] + const monoCss = mono + ? `:root,:root.dark{--font-mono:${fontFamilyCss(mono)},ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;}` + : ''; + return [accentCss, fontFaces, typographyCss, headingCss, monoCss, definition.css?.trim()] .filter(Boolean) .join('\n'); }; diff --git a/src/lib/themes.ts b/src/lib/themes.ts index a120c9a..70634fb 100644 --- a/src/lib/themes.ts +++ b/src/lib/themes.ts @@ -457,18 +457,18 @@ export const deleteTheme = async (themeToUninstall: Theme) => { return false; } patchTheme(themeToUninstall.name, (current) => ({ ...current, status: 'deleting' })); - await fs.remove(themeFolder, { recursive: true }); + try { + await fs.remove(themeFolder, { recursive: true }); + } catch (error) { + patchTheme(themeToUninstall.name, (current) => ({ ...current, status: 'installed' })); + throw error; + } - patchTheme(themeToUninstall.name, (current) => ({ - ...current, - scriptUrl: '', - cssStyle: '', - assets: '', - definition: undefined, - preview: `${THEME_REPOSITORY}/raw/branch/main/previews/${toSafeName(themeToUninstall.name)}.png`, - status: 'not-installed', - updateAvailable: false, - })); + // Remove the local card entirely, then let reloadThemes add it back as a + // downloadable theme only when the repository actually offers it. Marking + // local-only themes as "not-installed" left them stuck in the gallery with + // an unusable Download Theme button. + custom_themes.update((themes) => themes.filter((theme) => theme.name !== themeToUninstall.name)); await reloadThemes(); return true; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 7f6372f..2552d69 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -25,12 +25,19 @@ platform, presenceLoading, setupValues, + theme_reload_token, trackingEnabled, } from '$lib/global'; import { initLogging } from '$lib/logging'; import { exit, getLauncherVersion, getPlatform, setLoggingEnabled } from '$lib/osuUtil'; import * as presence from '$lib/presence'; - import { smoothThemeMedia, themeMediaPreparing, themeMediaStatus } from '$lib/themeMedia'; + import { + smoothThemeMedia, + themeMediaPreparing, + themeMediaStatus, + themeMediaSuspended, + themeMediaSuspension, + } from '$lib/themeMedia'; import { isThemeTrusted } from '$lib/themeTrust'; import { themeV2Css } from '$lib/themeV2'; import { getDownloadableThemes, getThemes, loadTheme } from '$lib/themes'; @@ -43,15 +50,20 @@ } from '$lib/userSettings'; import { trackMediaContainer } from '$lib/utils'; import '../app.css'; + import { listen } from '@tauri-apps/api/event'; + import { WebviewWindow } from '@tauri-apps/api/webviewWindow'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { Buffer } from 'buffer'; import { SemVer } from 'semver'; import { sileo } from 'sileo'; - import { onMount } from 'svelte'; + import { onDestroy, onMount } from 'svelte'; import { fade } from 'svelte/transition'; let { children } = $props(); + let unlistenEditorState: (() => void) | undefined; + onDestroy(() => unlistenEditorState?.()); + if (browser) initLogging(); const editorWindow = (() => { @@ -110,6 +122,19 @@ onMount(async () => { if (editorWindow) return; + // The editor is a separate WebView, so the launcher cannot observe its + // focus directly. Native code emits this state when the window is created + // or destroyed, and the launcher pauses its own theme media while it is + // open so both previews cannot play over each other. + try { + unlistenEditorState = await listen('theme-editor-state', (event) => { + themeMediaSuspended.set(event.payload === true); + }); + if (await WebviewWindow.getByLabel('theme-editor')) themeMediaSuspended.set(true); + } catch (error) { + console.warn('Could not sync the theme editor media state', error); + } + window.Buffer = Buffer; disableReload(); @@ -325,7 +350,7 @@ -
+
@@ -345,10 +370,12 @@
{#if $active_custom_theme?.definition}
- + {#key $theme_reload_token} + + {/key}
{/if} {@render children()} diff --git a/src/screens/Launch.svelte b/src/screens/Launch.svelte index dccbca3..6a6218e 100644 --- a/src/screens/Launch.svelte +++ b/src/screens/Launch.svelte @@ -4,6 +4,7 @@ import { ezppfarm } from '$lib/api/ezpp'; import DiagnosticsPanel from '$lib/components/diagnostics/DiagnosticsPanel.svelte'; import LogViewer from '$lib/components/logs/LogViewer.svelte'; + import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte'; import ThemeV2Elements from '$lib/components/theme/ThemeV2Elements.svelte'; import * as AlertDialog from '$lib/components/ui/alert-dialog'; import * as Avatar from '$lib/components/ui/avatar'; @@ -51,6 +52,7 @@ serverConnectionFails, serverPing, skinsCount, + theme_reload_token, trackingEnabled, } from '$lib/global'; import { @@ -84,8 +86,24 @@ stopOpenTabletDriver, } from '$lib/osuUtil'; import * as presence from '$lib/presence'; - import { isThemeTrusted, trustTheme } from '$lib/themeTrust'; - import { themeAssetUrl, themeV2ThumbnailStyle } from '$lib/themeV2'; + import { DEFAULT_THEME_ICON_SET } from '$lib/themeIcons'; + import { themeMediaSuspended } from '$lib/themeMedia'; + import { + loadThemeScript, + setThemeScriptHandlers, + setThemeScriptScreen, + stopThemeScript, + themeScriptState, + triggerThemeScriptElement, + } from '$lib/themeScript'; + import { isThemeTrusted, themeCodeKind, trustTheme } from '$lib/themeTrust'; + import { + themeAssetUrl, + themeV2ThumbnailStyle, + type ThemeV2ButtonAction, + type ThemeV2Definition, + type ThemeV2ScriptScreen, + } from '$lib/themeV2'; import { checkThemeFromFile, deleteTheme, @@ -124,44 +142,6 @@ setCurrentTimeGlobalMedia, urlIsValidImage, } from '$lib/utils'; - import { - AlertTriangle, - Eye, - EyeOff, - History, - Info, - LoaderCircle, - Music, - Wifi, - WifiOff, - Drum, - Cherry, - Piano, - Circle, - LogOut, - LogIn, - Brush, - ArrowRight, - Settings, - House, - LayoutGrid, - Paintbrush, - Trash, - CloudDownload, - Import, - Plus, - Pencil, - RefreshCw, - FolderOpen, - Search, - Gamepad2, - HardDrive, - Palette, - Plug, - ScrollText, - ShieldCheck, - Wrench, - } from '@lucide/svelte'; import NumberFlow from '@number-flow/svelte'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; @@ -176,59 +156,67 @@ import { TextMorph } from 'torph/svelte'; let selectedView = $state('HOME'); + let pendingExternalUrl = $state(undefined); + const iconSet = $derived($active_custom_theme?.definition?.iconSet ?? DEFAULT_THEME_ICON_SET); const settingsTabs = [ { id: 'gameplay', label: 'Gameplay', description: 'Patching behavior and the patcher release stream.', - icon: Gamepad2, + icon: 'gameplay', }, { id: 'appearance', label: 'Appearance', description: 'Cursor, motion and animation preferences.', - icon: Palette, + icon: 'themes', }, { id: 'integrations', label: 'Integrations', description: 'Discord presence and anonymous usage tracking.', - icon: Plug, + icon: 'integrations', }, { id: 'installation', label: 'Installation', description: 'osu! location and platform-specific tools.', - icon: HardDrive, + icon: 'installation', }, { id: 'diagnostics', label: 'Diagnostics', description: 'Installation health, Wine, FFmpeg and dependencies.', - icon: ShieldCheck, + icon: 'diagnostics', }, { id: 'logging', label: 'Logging', description: 'Log capture, rotation and support bundles.', - icon: ScrollText, + icon: 'logs', }, ] as const; type SettingsTab = (typeof settingsTabs)[number]['id']; let activeSettingsTab = $state('gameplay'); + const sidebarButtonClass = (view: LaunchView) => + `flex size-12 items-center gap-2 rounded-[0.85rem] p-3 mt-3 ring-1 ring-inset ring-white/15 transition-colors ${ + selectedView === view + ? 'bg-primary text-primary-foreground' + : 'border-black/20 bg-black/20 text-theme-200 hover:bg-primary/20 hover:text-theme-50' + }`; + const settingsTabClass = (id: SettingsTab) => - `group relative flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-xs font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 ${ + `flex h-8 items-center gap-1.5 whitespace-nowrap rounded-md px-2.5 text-[11px] font-medium transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 ${ activeSettingsTab === id - ? 'bg-theme-800/70 text-theme-50' - : 'text-muted-foreground hover:bg-theme-900/50 hover:text-theme-100' + ? 'bg-theme-800/80 text-theme-50' + : 'text-muted-foreground hover:bg-theme-900/60 hover:text-theme-100' }`; const activeSettings = $derived( settingsTabs.find((tab) => tab.id === activeSettingsTab) ?? settingsTabs[0] ); - const ActiveSettingsIcon = $derived(activeSettings.icon); let progress = $state(-1); let launchInfo = $state(''); @@ -276,42 +264,40 @@ let themeSearch = $state(''); let themeSavedUnlisten: (() => void) | undefined; let refreshingThemes = $state(false); + let reloadingActiveTheme = $state(false); let revealedNsfw = $state([]); let trustDialogTheme = $state(undefined); + let trustDialogEditor = $state(false); + const trustDialogCode = $derived(trustDialogTheme ? themeCodeKind(trustDialogTheme) : 'none'); + const requestThemeTrust = (theme: Theme, forEditor = false) => { + trustDialogEditor = forEditor; + trustDialogTheme = theme; + }; + const dismissThemeTrust = () => { + trustDialogTheme = undefined; + trustDialogEditor = false; + }; let restoreCandidate = $state<{ theme: Theme; backup: ThemeBackup } | undefined>(undefined); let restoringTheme = $state(false); let logViewerOpen = $state(false); const themeFilterOptions = [ - { id: 'all', label: 'All themes', icon: LayoutGrid }, - { id: 'installed', label: 'Installed', icon: HardDrive }, - { id: 'available', label: 'Available', icon: CloudDownload }, - { id: 'updates', label: 'Updates', icon: RefreshCw }, + { id: 'all', label: 'All themes', icon: 'all-themes' }, + { id: 'installed', label: 'Installed', icon: 'installed' }, + { id: 'available', label: 'Available', icon: 'available' }, + { id: 'updates', label: 'Updates', icon: 'updates' }, ] as const; type ThemeFilter = (typeof themeFilterOptions)[number]['id']; let themeFilter = $state('all'); const themeFilterClass = (id: ThemeFilter) => - `group relative flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[11px] font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 ${ + `flex h-8 items-center gap-1.5 rounded-md px-2.5 text-[11px] font-medium transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 ${ themeFilter === id - ? 'bg-theme-800/70 text-theme-50' - : 'text-muted-foreground hover:bg-theme-900/50 hover:text-theme-100' + ? 'bg-theme-800/80 text-theme-50' + : 'text-muted-foreground hover:bg-theme-900/60 hover:text-theme-100' }`; - const activeThemeFilter = $derived( - themeFilterOptions.find((option) => option.id === themeFilter) ?? themeFilterOptions[0] - ); - - const themeFilterDescription = $derived( - { - all: 'Everything installed or ready to download.', - installed: 'The themes saved on this device.', - available: 'Gallery themes you have not installed yet.', - updates: 'Installed themes with a newer release waiting.', - }[themeFilter] - ); - /** A theme is local while installed, being removed, or updating in place. */ const themeIsInstalled = (theme: Theme) => theme.status === 'installed' || @@ -372,12 +358,23 @@ }; const openThemeEditor = async (theme?: Theme) => { + // Opening the editor mounts the theme's layers, so an untrusted theme + // must pass the same approval gate as activating it. + if (theme && !isThemeTrusted(theme)) { + requestThemeTrust(theme, true); + return; + } + + // Pause immediately instead of waiting for the native window event, so + // the launcher track cannot bleed into the editor while its window opens. + themeMediaSuspended.set(true); try { const themeFolder = theme ? await path.join(await themesDirectory(), theme.folder_name) : null; await invoke('open_theme_editor', { themeFolder }); } catch (error) { + themeMediaSuspended.set(false); console.error(error); sileo.error({ title: 'Could not open the theme editor', @@ -414,7 +411,7 @@ const updatedTheme = get(custom_themes).find((candidate) => candidate.name === theme.name); if (updatedTheme) { if (!isThemeTrusted(updatedTheme)) { - trustDialogTheme = updatedTheme; + requestThemeTrust(updatedTheme); } else { await applyInstalledTheme(updatedTheme); } @@ -448,7 +445,7 @@ } if (!isThemeTrusted(theme)) { - trustDialogTheme = theme; + requestThemeTrust(theme); return; } @@ -458,22 +455,95 @@ const confirmThemeTrust = async () => { const theme = trustDialogTheme; if (!theme) return; + const openEditorAfterTrust = trustDialogEditor; trustTheme(theme); - trustDialogTheme = undefined; + dismissThemeTrust(); + if (openEditorAfterTrust) { + await openThemeEditor(theme); + return; + } await applyInstalledTheme(theme); }; const applySavedTheme = async (folderName: string) => { + // Saving an edit must not activate the theme. Only refresh the launcher's + // copy when the saved theme was already the active one. + const wasActive = $active_custom_theme?.folder_name === folderName; await reloadThemes(); + if (!wasActive) return; + const savedTheme = get(custom_themes).find((theme) => theme.folder_name === folderName); if (!savedTheme) return; - if (!isThemeTrusted(savedTheme)) { - trustDialogTheme = savedTheme; - return; - } + const reloadVersion = Date.now(); + const themeToApply = savedTheme.definition + ? { + ...savedTheme, + assets: savedTheme.assets + ? `${savedTheme.assets}${savedTheme.assets.includes('?') ? '&' : '?'}v=${reloadVersion}` + : savedTheme.assets, + } + : savedTheme; - await applyInstalledTheme(savedTheme); + await applyInstalledTheme(themeToApply); + theme_reload_token.update((value) => value + 1); + }; + + const reloadActiveTheme = async () => { + if (reloadingActiveTheme) return; + const active = $active_custom_theme; + if (!active) return; + + reloadingActiveTheme = true; + try { + await reloadThemes(); + // The user may have switched themes while the theme list was reloading. + if ($active_custom_theme?.folder_name !== active.folder_name) return; + + const refreshed = get(custom_themes).find( + (theme) => theme.folder_name === active.folder_name + ); + if (!refreshed) { + sileo.error({ + title: 'Theme not found', + description: `"${active.name}" is no longer installed.`, + }); + return; + } + + if (!isThemeTrusted(refreshed)) { + requestThemeTrust(refreshed); + return; + } + + // Re-request v2 theme assets under a fresh URL so the WebView cannot + // keep serving a file that changed on disk. API 1.0 bundles use their + // own container and must not receive a query string in the assets path. + const reloadVersion = Date.now(); + const themeToApply = refreshed.definition + ? { + ...refreshed, + assets: refreshed.assets + ? `${refreshed.assets}${refreshed.assets.includes('?') ? '&' : '?'}v=${reloadVersion}` + : refreshed.assets, + } + : refreshed; + + await applyInstalledTheme(themeToApply); + theme_reload_token.update((value) => value + 1); + sileo.success({ + title: 'Theme reloaded', + description: `"${refreshed.name}" was reloaded from disk.`, + }); + } catch (error) { + console.error(error); + sileo.error({ + title: 'Could not reload the theme', + description: error instanceof Error ? error.message : String(error), + }); + } finally { + reloadingActiveTheme = false; + } }; const requestThemeRestore = (theme: Theme, backup: ThemeBackup) => { @@ -494,7 +564,7 @@ const restoredTheme = get(custom_themes).find((candidate) => candidate.name === theme.name); if (wasActive && restoredTheme) { if (!isThemeTrusted(restoredTheme)) { - trustDialogTheme = restoredTheme; + requestThemeTrust(restoredTheme); } else { await applyInstalledTheme(restoredTheme); } @@ -1050,6 +1120,74 @@ } }; + setThemeScriptHandlers({ + navigate: (screen) => { + selectedView = screen.toUpperCase() as LaunchView; + }, + launch: () => { + void launch(); + }, + openUrl: (url) => { + if (pendingExternalUrl) return; + try { + const parsed = new URL(url); + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + pendingExternalUrl = parsed.toString(); + } + } catch { + // The rule normalizer already drops malformed and non-web URLs. + } + }, + }); + + const handleThemeButtonAction = (action: ThemeV2ButtonAction) => { + if (action.type === 'navigate') { + selectedView = action.screen.toUpperCase() as LaunchView; + return; + } + if (action.type === 'launch') { + void launch(); + return; + } + if (pendingExternalUrl) return; + try { + const parsed = new URL(action.url); + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + pendingExternalUrl = parsed.toString(); + } + } catch { + // Theme actions are normalized to http(s) when the theme loads. + } + }; + + let scriptDefinition: ThemeV2Definition | undefined; + + $effect(() => { + const next = $active_custom_theme?.definition; + if (next === scriptDefinition) return; + scriptDefinition = next; + pendingExternalUrl = undefined; + loadThemeScript(next); + }); + + $effect(() => { + setThemeScriptScreen(selectedView.toLowerCase() as ThemeV2ScriptScreen); + }); + + async function confirmExternalUrl() { + const url = pendingExternalUrl; + pendingExternalUrl = undefined; + if (!url) return; + try { + const parsed = new URL(url); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return; + await openURL(parsed.toString()); + } catch (error) { + console.error(error); + sileo.error({ title: 'Could not open the website' }); + } + } + let username = $state(''); let password = $state(''); let loginIsLoading = $state(false); @@ -1239,6 +1377,7 @@ return () => { window.clearInterval(animateInterval); themeSavedUnlisten?.(); + stopThemeScript(); }; }); @@ -1294,7 +1433,7 @@ {$launcherVersion}
- +
New Version @@ -1404,7 +1543,7 @@
- + Update check failed
@@ -1445,7 +1584,7 @@
- + Import Theme
@@ -1517,7 +1656,7 @@ disabled={themeInstalling} > {#if themeInstalling} - + {:else} Yes, import {/if} @@ -1545,23 +1684,38 @@
- - Run this theme's code? + + + {trustDialogEditor ? 'Edit this theme?' : "Run this theme's code?"} +
- {trustDialogTheme?.name} is an API 1.0 theme, so the launcher will execute - its bundled JavaScript. Only continue if you trust {trustDialogTheme?.author} and - got the theme from a source you trust. API 2.0 themes are data-only and do not need this prompt. + {#if trustDialogCode === 'v2-svelte'} + {trustDialogTheme?.name} contains a custom Svelte layer, so the launcher + will execute JavaScript authored by the theme. Only continue if you trust + {trustDialogTheme?.author} + and got the theme from a source you trust. Other API 2.0 themes are data-only and do not need + this prompt. + {:else} + {trustDialogTheme?.name} is an API 1.0 theme, so the launcher will execute + its bundled JavaScript. Only continue if you trust + {trustDialogTheme?.author} + and got the theme from a source you trust. + {/if} + {#if trustDialogEditor} +

+ The editor renders custom layers immediately, so this code starts running as soon as the + editor opens. +

+ {/if}
- +
@@ -1576,7 +1730,7 @@
- + Restore previous version?
@@ -1615,6 +1769,48 @@ + + +
+ + Open external website? +
+
+

+ {$active_custom_theme?.name ?? 'The active theme'} + wants to open a website in your browser. Check the full address before continuing. +

+
+

+ Full URL +

+

+ {pendingExternalUrl} +

+
+

+ Only continue if you recognize this address. Theme links never open without this + confirmation. +

+
+
+ + +
+
+
+
{#if dragAndDrop.isDraggingOverApp && !droppedTheme}
- +

Drag and Drop a .ezpplauncher-theme file here to import

@@ -1638,40 +1834,31 @@
{$launcherVersion || 'dev'}
@@ -1679,7 +1866,7 @@ - + @@ -1702,12 +1889,12 @@ selectedView = 'HOME'; }} > - + Logout {:else} (selectedView = 'LOGIN')}> - + Login {/if} @@ -1733,20 +1920,20 @@
{#if $osuInstallationPath !== ''}
- + {#if !$beatmapSets && $beatmapSets !== 0} - + {:else} {numberHumanReadable($beatmapSets ?? 0)} {/if}
- + {#if !$skinsCount && $skinsCount !== 0} - + {:else} {numberHumanReadable($skinsCount ?? 0)} {/if} @@ -1755,9 +1942,9 @@ {/if}
{#if $serverConnectionFails > 1} - + {:else} - + {/if}
- +
+ {/if} + {/if} + {/if} {/if} @@ -1841,6 +2028,7 @@ ? 'No connection...' : undefined} disabled={$osuInstallationPath === '' || $serverConnectionFails > 1} + {iconSet} onClick={async () => await launch()} />
@@ -1861,13 +2049,18 @@ >
{#if selectedMode === 0} - + {:else if selectedMode === 1} - + {:else if selectedMode === 2} - + {:else if selectedMode === 3} - + {/if} {getGamemodeName(modeIntToStr(selectedMode), typeIntToStr(selectedType))}
@@ -1878,13 +2071,13 @@
{#if gamemod.mode === 0} - + {:else if gamemod.mode === 1} - + {:else if gamemod.mode === 2} - + {:else if gamemod.mode === 3} - + {/if} {getGamemodeName(modeIntToStr(gamemod.mode), typeIntToStr(gamemod.type))}
@@ -1914,7 +2107,7 @@
{:else}
- +
{/if}
@@ -1940,7 +2133,7 @@
{:else}
- +
{/if}
@@ -1964,7 +2157,7 @@
{:else}
- +
{/if}
@@ -1989,7 +2182,7 @@
{:else}
- +
{/if}
@@ -2046,583 +2239,535 @@ >
- + -
-
- {#key activeSettingsTab} -
- - - -
-
-

{activeSettings.label}

- {#if activeSettingsTab === 'logging'} - - {$loggingEnabled ? 'Enabled' : 'Disabled'} - - {/if} -
-

- {activeSettings.description} -

-
-
- {/key} -
+
+ {#key activeSettingsTab} +
+ +
+
+
+ {#if activeSettingsTab === 'gameplay'} +
+
+
+
+ +

+ Shows misses in Relax and Autopilot. + {#if $platform === 'macos'} + Runs through your osu! Wine prefix. Only the experimental + patcher stream needs .NET 8 installed there. + {/if} +

+
+ { + patch.set(e); + }} + class="mt-0.5" + /> +
-
- {#key activeSettingsTab} -
- -
-
-
- {#if activeSettingsTab === 'gameplay'} -
-
-
+ {#if $platform === 'windows'} +
+ +

+ Test different versions of the patcher. The experimental + stream requires the .NET 8 Desktop Runtime; stable does not. +

+ { + // Only the experimental patcher build targets .NET 8. + if (newStream === 'experimental') { + const isNet8Installed = await hasNet8(); + if (!isNet8Installed) { + launcherStream.set('stable'); + sileo.error({ + title: 'Hmm...', + description: + '.NET 8.0 Desktop Runtime is required for the experimental patcher stream.', + button: { + title: 'Download .NET 8.0', + onClick: async () => + await openURL( + 'https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-desktop-8.0.22-windows-x64-installer' + ), + }, + }); + return; + } + } + await config.value('patcherStream').set(newStream); + launcherStream.set(newStream); + }} + > + +
+ {$launcherStream} +
+
+ + {#each $launcherStreams as stream (stream)} + +
+ {stream} +
+
+ {/each} +
+
+
+ {/if} + + {#if $platform !== 'windows'} +
- +

- Shows misses in Relax and Autopilot. - {#if $platform === 'macos'} - Runs through your osu! Wine prefix. Only the experimental - patcher stream needs .NET 8 installed there. - {/if} + The experimental stream is Windows-only, so + {$platform === 'macos' ? 'macOS' : 'Linux'} uses stable.

+ stable +
+ {/if} +
+
+ {:else if activeSettingsTab === 'appearance'} +
+
+
+
+ +

+ Enable a custom cursor in the launcher, like the lazer build + of osu!. +

+
+ { + if (!e) { + cursorSmoothening.set(false); + } + customCursor.set(e); + }} + class="mt-0.5" + /> +
+ +
+
+ +

+ Makes the custom cursor movement smoother. +

+
+ { + if (!$customCursor) return; + cursorSmoothening.set(e); + }} + disabled={!$customCursor} + class="mt-0.5" + /> +
+ +
+
+ +

+ Disables some launcher animations to improve performance on + low-end devices. +

+
+ { + reduceAnimations.set(e); + }} + class="mt-0.5" + /> +
+
+
+ {:else if activeSettingsTab === 'integrations'} +
+
+
+
+ +

+ Let other Discord users see what you are doing right now 👀 +

+
+
+ {#if $presenceLoading} + + {/if} { - patch.set(e); - }} - class="mt-0.5" + id="setting-rich-presence" + bind:checked={$discordPresence} + disabled={$presenceLoading} />
+
- {#if $platform === 'windows'} -
- -

- Test different versions of the patcher. The experimental - stream requires the .NET 8 Desktop Runtime; stable does not. -

- { - // Only the experimental patcher build targets .NET 8. - if (newStream === 'experimental') { - const isNet8Installed = await hasNet8(); - if (!isNet8Installed) { - launcherStream.set('stable'); - sileo.error({ - title: 'Hmm...', - description: - '.NET 8.0 Desktop Runtime is required for the experimental patcher stream.', - button: { - title: 'Download .NET 8.0', - onClick: async () => - await openURL( - 'https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-desktop-8.0.22-windows-x64-installer' - ), - }, - }); - return; - } - } - await config - .value('patcherStream') - .set(newStream); - launcherStream.set(newStream); - }} - > - -
- {$launcherStream} -
-
- - {#each $launcherStreams as stream (stream)} - -
- {stream} -
-
- {/each} -
-
-
- {/if} +
+
+ +

+ Allow anonymous usage data to be collected to help improve the + application. +

+
+ { + trackingEnabled.set(e); + await config.value('tracking_consent').set(e); + }} + class="mt-0.5" + /> +
+
+
+ {:else if activeSettingsTab === 'installation'} +
+
+
+ +

+ The path to your osu! installation. + {#if $platform === 'macos'} + Select your osu!.app bundle directly. The launcher + will locate osu! inside the Wineskin wrapper for you. + {/if} +

+
+ + +
+
- {#if $platform !== 'windows'} -
+ {#if $platform === 'windows'} +
+
- +

- The experimental stream is Windows-only, so - {$platform === 'macos' ? 'macOS' : 'Linux'} uses stable. + Automatically start OpenTabletDriver when launching osu!.

- stable -
- {/if} -
-
- {:else if activeSettingsTab === 'appearance'} -
-
-
-
- -

- Enable a custom cursor in the launcher, like the lazer build - of osu!. -

-
- { - if (!e) { - cursorSmoothening.set(false); - } - customCursor.set(e); - }} - class="mt-0.5" - /> -
- -
-
- -

- Makes the custom cursor movement smoother. -

-
- { - if (!$customCursor) return; - cursorSmoothening.set(e); - }} - disabled={!$customCursor} - class="mt-0.5" - /> -
- -
-
- -

- Disables some launcher animations to improve performance on - low-end devices. -

-
- { - reduceAnimations.set(e); - }} - class="mt-0.5" - /> -
-
-
- {:else if activeSettingsTab === 'integrations'} -
-
-
-
- -

- Let other Discord users see what you are doing right now 👀 -

-
-
- {#if $presenceLoading} - - {/if}
-
- -
-
- -

- Allow anonymous usage data to be collected to help improve - the application. -

-
- { - trackingEnabled.set(e); - await config.value('tracking_consent').set(e); - }} - class="mt-0.5" - /> -
-
-
- {:else if activeSettingsTab === 'installation'} -
-
-
- -

- The path to your osu! installation. - {#if $platform === 'macos'} - Select your osu!.app bundle directly. The launcher - will locate osu! inside the Wineskin wrapper for you. - {/if} -

- - {#if $platform === 'windows'} -
-
-
- -

- Automatically start OpenTabletDriver when launching - osu!. -

-
- -
-
- - -
-
- {/if} -
-
- {:else if activeSettingsTab === 'diagnostics'} -
- -
- {:else if activeSettingsTab === 'logging'} -
-
-
-
- -

- Capture launcher, osu! and patcher output. Rotated logs are - bundled into one ZIP per session, and the last five bundles - are kept. -

-
- loggingEnabled.set(enabled)} - class="mt-0.5" - /> -
- -
-
- -

- Stored in - {$platform === 'windows' - ? '%USERPROFILE%\\.ezpplauncher\\logs' - : '~/.ezpplauncher/logs'}. -

-
-
- - -
-
-
-
- {/if} -
- - {#if activeSettingsTab !== 'diagnostics'} -
+ {:else if activeSettingsTab === 'diagnostics'} +
+ +
+ {:else if activeSettingsTab === 'logging'} +
-

- Quick actions -

-
- - - +
+
+ +

+ Capture launcher, osu! and patcher output. Rotated logs are + bundled into one ZIP per session, and the last five bundles + are kept. +

+
+ loggingEnabled.set(enabled)} + class="mt-0.5" + /> +
+ +
+
+ +

+ Stored in + {$platform === 'windows' + ? '%USERPROFILE%\\.ezpplauncher\\logs' + : '~/.ezpplauncher/logs'}. +

+
+
+ + +
- +
{/if}
+ + {#if activeSettingsTab !== 'diagnostics'} + + {/if}
- -
- {/key} -
+
+ +
+ {/key}
@@ -2671,7 +2816,7 @@ - {/each} - - -
- {#if $active_custom_theme} -
-

- Current theme -

-
- - - {$active_custom_theme.name} - -
+ {$custom_themes.length} +
- {/if} - -
+

Make it feel like yours.

+
+
+ {#if $active_custom_theme} + + {/if} +
- -
-
-
- {#key themeFilter} -
+
- {/key} -
- - -
+ + {option.label} + + {themeCounts[option.id]} + + + {/each} + +
+ +
-
+
+ -
- - {#if filteredThemes.length > 0} - {#key themeFilter} -
- {#each filteredThemes as theme, index (theme.folder_name)} - {@const isActive = - $active_custom_theme?.folder_name === theme.folder_name} +
+ + {#if filteredThemes.length > 0} + {#key themeFilter} +
+ {#each filteredThemes as theme, index (theme.folder_name)} + {@const isActive = $active_custom_theme?.folder_name === theme.folder_name} +
-
- {#if theme.preview} - Preview of {theme.name} - {:else if themeBackgroundVideo(theme)} - - {:else if theme.definition} -
- {:else} - Preview of {theme.name} - {/if} -
-
- {#if isActive} - In use - {/if} - - API {theme.apiVersion.startsWith('2') ? '2.0' : '1.0'} - - {#if theme.nsfw && !revealedNsfw.includes(theme.name)} - - {:else if theme.nsfw} - - {/if} - {#if theme.nsfw} - - 18+ - + {#if theme.preview} + Preview of {theme.name} + {:else if themeBackgroundVideo(theme)} + + {:else if theme.definition} +
+ {:else} + Preview of {theme.name} {/if}
-
-
-
- - {theme.name} - - - by {theme.author} - -
+
+ {#if isActive} + In use + {/if} + + API {theme.apiVersion.startsWith('2') ? '2.0' : '1.0'} + + {#if theme.nsfw && !revealedNsfw.includes(theme.name)} + + {:else if theme.nsfw} + + {/if} + {#if theme.nsfw} + + 18+ + + {/if} +
+
+
+
- {#if theme.updateAvailable && theme.availableVersion} - v{theme.version} → - v{theme.availableVersion} - {:else} - v{theme.version} - {/if} + {theme.name} + + + by {theme.author}
- {#if theme.description} -

- {theme.description} -

- {/if} -
-
+ {#if theme.description} +

+ {theme.description} +

+ {/if} +
+ + {#if theme.status === 'installed' && theme.definition} + - {#if theme.status === 'installed' && theme.definition} - - {/if} - {#if theme.status === 'installed' && (theme.backups?.length ?? 0) > 0} - - {/if} - {#if theme.status === 'installed' && theme.updateAvailable} - - {/if} - {#if (theme.status === 'installed' || theme.status === 'deleting') && !isActive && theme.name !== 'Default'} - + {/if} + {#if theme.status === 'installed' && theme.updateAvailable} + + {/if} + {#if (theme.status === 'installed' || theme.status === 'deleting') && !isActive && theme.name !== 'Default'} + - {/if} -
+ } catch (err) { + console.log(err); + sileo.error({ + title: 'Uhhm..', + description: 'Failed to uninstall theme.', + }); + } + }} + > + {#if theme.status === 'deleting'} + + {:else} + + {/if} + + {/if}
- {/each} -
- {/key} - {:else} -
-
- -
-

- {#if themeSearch} - No themes match "{themeSearch}" - {:else if themeFilter === 'updates'} - Everything is up to date - {:else if themeFilter === 'available'} - No themes left to install - {:else if themeFilter === 'installed'} - No installed themes yet - {:else} - No themes found - {/if} -

-

- {#if themeSearch} - Try a different name, author or API version. - {:else if themeFilter === 'updates'} - You are running the latest version of every installed theme. - {:else if themeFilter === 'available'} - Every gallery theme is already installed. - {:else if themeFilter === 'installed'} - Download a theme from the gallery to get started. - {:else} - Try refreshing the gallery, or create a new API 2.0 theme. - {/if} -

- {#if themeSearch || themeFilter !== 'all'} - - {/if} +
+ {/each}
- {/if} - -
+ {/key} + {:else} +
+
+ +
+

+ {#if themeSearch} + No themes match "{themeSearch}" + {:else if themeFilter === 'updates'} + Everything is up to date + {:else if themeFilter === 'available'} + No themes left to install + {:else if themeFilter === 'installed'} + No installed themes yet + {:else} + No themes found + {/if} +

+

+ {#if themeSearch} + Try a different name, author or API version. + {:else if themeFilter === 'updates'} + You are running the latest version of every installed theme. + {:else if themeFilter === 'available'} + Every gallery theme is already installed. + {:else if themeFilter === 'installed'} + Download a theme from the gallery to get started. + {:else} + Try refreshing the gallery, or create a new API 2.0 theme. + {/if} +

+ {#if themeSearch || themeFilter !== 'all'} + + {/if} +
+ {/if} +
@@ -3136,11 +3262,34 @@
{#if selectedView === 'HOME' && $active_custom_theme?.definition?.elements?.length} -
- +
+ {#key $theme_reload_token} + + {/key}
{/if} diff --git a/src/screens/SetupWizard.svelte b/src/screens/SetupWizard.svelte index c44144c..fb9ad0c 100644 --- a/src/screens/SetupWizard.svelte +++ b/src/screens/SetupWizard.svelte @@ -566,9 +566,9 @@

- Shows misses in Relax and Autopilot.{#if $platform === 'macos'}{' '}Stable - patching needs no extra runtime; the experimental stream needs .NET 8 in - your Wine prefix.{/if} + Shows misses in Relax and Autopilot.{#if $platform === 'macos'} + Stable patching needs no extra runtime; the experimental stream needs + .NET 8 in your Wine prefix.{/if}