chore: add svelte component layer with monaco editor

This commit is contained in:
HorizonCode committed 2026-10-31 10:28:33 +01:00
1 parent 8071085e94
commit 54f7f81160
55 files changed
+20563 -2043

No files matched your search

+19 -4
View File
@@ -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.
+1025 -52
View File
File diff suppressed because it is too large. Load diff
+29 -1
View File
@@ -19,10 +19,13 @@
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-shell": "^2.3.5",
"@types/node": "^25.9.1",
"acorn": "^8.19.0",
"animejs": "^4.4.1",
"buffer": "^6.0.3",
"crypto-js": "^4.2.0",
"jszip": "^3.10.1",
"lottie-web": "^5.13.0",
"monaco-editor": "^0.57.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"semver": "^7.8.1",
@@ -35,6 +38,9 @@
"@eslint/compat": "^2.1.0",
"@eslint/js": "^10.0.1",
"@iarna/toml": "2.2.5",
"@iconify-json/lucide": "^1.2.149",
"@iconify-json/ph": "^1.2.2",
"@iconify-json/tabler": "^1.2.43",
"@lucide/svelte": "^1.16.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.61.1",
@@ -142,6 +148,14 @@
"@iarna/toml": ["@iarna/[email protected]", "", {}, "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg=="],
"@iconify-json/lucide": ["@iconify-json/[email protected]", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-5NG9YdbtRS03hG1A+EIE6nLhhdAYsf0mKu1ZRi6kLMlCG8DxmDsRM6DAhwlNMLiSiQKFbHpKTJ2VddZdwUkUyw=="],
"@iconify-json/ph": ["@iconify-json/[email protected]", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-PgkEZNtqa8hBGjHXQa4pMwZa93hmfu8FUSjs/nv4oUU6yLsgv+gh9nu28Kqi8Fz9CCVu4hj1MZs9/60J57IzFw=="],
"@iconify-json/tabler": ["@iconify-json/[email protected]", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-EKhyOj6nASi25ZjuRpEhPL1fwiKVtg/e/VX6v6X3FPKpjG9og26ypW8ItnWYSTn6fdNuQe3VPeyuThWlP6QuWw=="],
"@iconify/types": ["@iconify/[email protected]", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
"@internationalized/date": ["@internationalized/[email protected]", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA=="],
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
@@ -346,7 +360,7 @@
"@typescript-eslint/visitor-keys": ["@typescript-eslint/[email protected]", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg=="],
"acorn": ["[email protected]6.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn": ["[email protected]9.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw=="],
"acorn-jsx": ["[email protected]", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@@ -402,6 +416,8 @@
"devalue": ["[email protected]", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="],
"dompurify": ["[email protected]", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw=="],
"enhanced-resolve": ["[email protected]", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="],
"escape-string-regexp": ["[email protected]", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
@@ -544,14 +560,20 @@
"lodash-es": ["[email protected]", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
"lottie-web": ["[email protected]", "", {}, "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ=="],
"lz-string": ["[email protected]", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
"magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"marked": ["[email protected]", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
"micromatch": ["[email protected]", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"monaco-editor": ["[email protected]", "", { "dependencies": { "dompurify": "3.4.15", "marked": "14.0.0" } }, "sha512-5BkI9KGoqrNvBGUe15/QlZq3OooZ8WLg1AxTpaqHRCP3HNpzPPZKE2EDz8M7c+VRmCeUw1Brp4cx/PWm3kI/5A=="],
"motion": ["[email protected]", "", { "dependencies": { "framer-motion": "^12.34.1", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N9RVNGn/NSo85OgHX1wGaUWHvReuQ7dZUwuQRhHyzY2wfVOvY3cEgn0Mw4NXOsXMHL/y7EYuzA+b59PYI6EejA=="],
"motion-dom": ["[email protected]", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-SC7ZC5dRcGwku2g7EsPvI4q/EzHumUbqsDNumBmZTLFg+goBO5LTJvDu9MAxx+0mtX4IA78B2be/A3aRjY0jnw=="],
@@ -778,6 +800,8 @@
"@parcel/watcher/detect-libc": ["[email protected]", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
"@sveltejs/kit/acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/[email protected]", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
@@ -810,10 +834,14 @@
"eslint-plugin-svelte/globals": ["[email protected]", "", {}, "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ=="],
"espree/acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"micromatch/picomatch": ["[email protected]", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"sass/chokidar": ["[email protected]", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
"svelte/acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"svelte-eslint-parser/eslint-scope": ["[email protected]", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
"svelte-eslint-parser/eslint-visitor-keys": ["[email protected]", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
+7
View File
@@ -11,6 +11,7 @@
"tauri:build": "tauri build",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"icons:build": "bun ./scripts/generate-theme-icons.ts",
"tauri": "tauri",
"tauri:update-logo": "bun run tauri icon ./static/logo.png",
"format": "prettier --write .",
@@ -32,10 +33,13 @@
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-shell": "^2.3.5",
"@types/node": "^25.9.1",
"acorn": "^8.19.0",
"animejs": "^4.4.1",
"buffer": "^6.0.3",
"crypto-js": "^4.2.0",
"jszip": "^3.10.1",
"lottie-web": "^5.13.0",
"monaco-editor": "^0.57.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"semver": "^7.8.1",
@@ -48,6 +52,9 @@
"@eslint/compat": "^2.1.0",
"@eslint/js": "^10.0.1",
"@iarna/toml": "2.2.5",
"@iconify-json/lucide": "^1.2.149",
"@iconify-json/ph": "^1.2.2",
"@iconify-json/tabler": "^1.2.43",
"@lucide/svelte": "^1.16.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.61.1",
+142
View File
@@ -0,0 +1,142 @@
import { THEME_ICON_NAMES, type ThemeIconName, type ThemeIconSet } from '../src/lib/themeIcons';
import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
type IconifyCollection = {
width?: number;
height?: number;
icons: Record<string, { body: string; width?: number; height?: number }>;
aliases?: Record<string, { parent: string }>;
};
type IconBody = {
width: number;
height: number;
body: string;
};
/**
* Semantic name -> Iconify icon id per family. Keeping this explicit means a
* family swap can never pick a random similarly named icon from another set.
*/
const ICON_IDS: Record<ThemeIconName, Record<ThemeIconSet, string>> = {
home: { lucide: 'house', phosphor: 'house', tabler: 'home' },
themes: { lucide: 'palette', phosphor: 'palette', tabler: 'palette' },
settings: {
lucide: 'sliders-horizontal',
phosphor: 'sliders-horizontal',
tabler: 'adjustments-horizontal',
},
launch: { lucide: 'play', phosphor: 'play', tabler: 'player-play' },
user: { lucide: 'user', phosphor: 'user', tabler: 'user' },
music: { lucide: 'music', phosphor: 'music-notes', tabler: 'music' },
brush: { lucide: 'brush', phosphor: 'paint-brush', tabler: 'brush' },
wifi: { lucide: 'wifi', phosphor: 'wifi-high', tabler: 'wifi' },
'wifi-off': { lucide: 'wifi-off', phosphor: 'wifi-slash', tabler: 'wifi-off' },
gameplay: { lucide: 'gamepad-2', phosphor: 'game-controller', tabler: 'device-gamepad-2' },
integrations: { lucide: 'plug', phosphor: 'plugs', tabler: 'plug' },
installation: { lucide: 'hard-drive', phosphor: 'hard-drives', tabler: 'server' },
installed: { lucide: 'hard-drive', phosphor: 'hard-drives', tabler: 'server' },
diagnostics: { lucide: 'shield-check', phosphor: 'shield-check', tabler: 'shield-check' },
logs: { lucide: 'scroll-text', phosphor: 'scroll', tabler: 'file-text' },
'all-themes': { lucide: 'layout-grid', phosphor: 'squares-four', tabler: 'layout-grid' },
available: {
lucide: 'cloud-download',
phosphor: 'cloud-arrow-down',
tabler: 'cloud-download',
},
updates: { lucide: 'refresh-cw', phosphor: 'arrows-clockwise', tabler: 'refresh' },
search: { lucide: 'search', phosphor: 'magnifying-glass', tabler: 'search' },
folder: { lucide: 'folder-open', phosphor: 'folder-open', tabler: 'folder-open' },
plus: { lucide: 'plus', phosphor: 'plus', tabler: 'plus' },
'osu-standard': { lucide: 'circle', phosphor: 'circle', tabler: 'circle' },
'osu-taiko': { lucide: 'drum', phosphor: 'music-notes', tabler: 'music' },
'osu-catch': { lucide: 'cherry', phosphor: 'orange-slice', tabler: 'cherry' },
'osu-mania': { lucide: 'piano', phosphor: 'piano-keys', tabler: 'piano' },
login: { lucide: 'log-in', phosphor: 'sign-in', tabler: 'login' },
logout: { lucide: 'log-out', phosphor: 'sign-out', tabler: 'logout' },
warning: { lucide: 'triangle-alert', phosphor: 'warning', tabler: 'alert-triangle' },
info: { lucide: 'info', phosphor: 'info', tabler: 'info-circle' },
loader: { lucide: 'loader-circle', phosphor: 'circle-notch', tabler: 'loader' },
ellipsis: { lucide: 'ellipsis', phosphor: 'dots-three', tabler: 'dots' },
check: { lucide: 'check', phosphor: 'check', tabler: 'check' },
trash: { lucide: 'trash', phosphor: 'trash', tabler: 'trash' },
edit: { lucide: 'pencil', phosphor: 'pencil-simple', tabler: 'pencil' },
history: { lucide: 'history', phosphor: 'clock-counter-clockwise', tabler: 'history' },
refresh: { lucide: 'refresh-cw', phosphor: 'arrows-clockwise', tabler: 'refresh' },
download: { lucide: 'cloud-download', phosphor: 'cloud-arrow-down', tabler: 'cloud-download' },
import: { lucide: 'import', phosphor: 'upload-simple', tabler: 'upload' },
'arrow-right': { lucide: 'arrow-right', phosphor: 'arrow-right', tabler: 'arrow-right' },
eye: { lucide: 'eye', phosphor: 'eye', tabler: 'eye' },
'eye-off': { lucide: 'eye-off', phosphor: 'eye-slash', tabler: 'eye-off' },
wrench: { lucide: 'wrench', phosphor: 'wrench', tabler: 'tool' },
rotate: { lucide: 'rotate-cw', phosphor: 'arrows-clockwise', tabler: 'rotate-clockwise' },
volume: { lucide: 'volume-2', phosphor: 'speaker-high', tabler: 'volume' },
'volume-low': { lucide: 'volume-1', phosphor: 'speaker-low', tabler: 'volume-2' },
'volume-off': { lucide: 'volume-x', phosphor: 'speaker-slash', tabler: 'volume-off' },
};
const collectionPaths: Record<ThemeIconSet, string> = {
lucide: '../node_modules/@iconify-json/lucide/icons.json',
phosphor: '../node_modules/@iconify-json/ph/icons.json',
tabler: '../node_modules/@iconify-json/tabler/icons.json',
};
const loadCollection = (set: ThemeIconSet): IconifyCollection =>
JSON.parse(readFileSync(fileURLToPath(new URL(collectionPaths[set], import.meta.url)), 'utf8'));
const resolveIcon = (collection: IconifyCollection, id: string): IconBody | undefined => {
const direct = collection.icons[id];
if (direct) {
return {
width: direct.width ?? collection.width ?? 24,
height: direct.height ?? collection.height ?? 24,
body: direct.body,
};
}
const alias = collection.aliases?.[id];
if (!alias) return undefined;
const parent = collection.icons[alias.parent];
if (!parent) return undefined;
return {
width: parent.width ?? collection.width ?? 24,
height: parent.height ?? collection.height ?? 24,
body: parent.body,
};
};
const collections = Object.fromEntries(
(Object.keys(collectionPaths) as ThemeIconSet[]).map((set) => [set, loadCollection(set)])
) as Record<ThemeIconSet, IconifyCollection>;
const data = {} as Record<ThemeIconSet, Record<ThemeIconName, IconBody>>;
for (const set of Object.keys(collectionPaths) as ThemeIconSet[]) {
data[set] = {} as Record<ThemeIconName, IconBody>;
for (const name of THEME_ICON_NAMES) {
const id = ICON_IDS[name]?.[set];
if (!id) throw new Error(`No ${set} icon mapped for semantic name "${name}".`);
const icon = resolveIcon(collections[set], id);
if (!icon) throw new Error(`Icon "${id}" from ${set} was not found for "${name}".`);
data[set][name] = icon;
}
}
const output = `// Generated by scripts/generate-theme-icons.ts. Do not edit by hand.
// Icon artwork: Lucide (ISC), Phosphor (MIT), Tabler (MIT).
import type { ThemeIconName, ThemeIconSet } from './themeIcons';
export type ThemeIconBody = { width: number; height: number; body: string };
export const THEME_ICON_BODIES: Record<ThemeIconSet, Record<ThemeIconName, ThemeIconBody>> = ${JSON.stringify(
data,
null,
2
)};
`;
const outputPath = fileURLToPath(new URL('../src/lib/themeIconData.ts', import.meta.url));
writeFileSync(outputPath, output);
console.log(
`Generated ${THEME_ICON_NAMES.length} semantic icons for ${Object.keys(collectionPaths).length} sets.`
);
+96 -14
View File
@@ -15,6 +15,7 @@ use tauri::Manager;
use tauri::State;
use tauri::WebviewUrl;
use tauri::WebviewWindowBuilder;
use tauri::WindowEvent;
use tauri_plugin_fs::FsExt;
use tokio::fs;
use tokio::io::AsyncWriteExt;
@@ -1599,12 +1600,14 @@ pub fn theme_preview_url(path: String) -> Result<String, String> {
}
}
/// Return a hardware-decodable copy of a large VP9/AV1 theme video.
/// Return a hardware-decodable copy of a VP9/AV1 theme video.
///
/// WebKit on macOS decodes VP9 in software. A 1080p 10 Mbps WebM can use
/// enough CPU to starve the audio decoder, while H.264 is decoded by the
/// media engine. The first playback of a large theme video is transcoded to a
/// cached 720p H.264/AAC file; later loads return the cached URL directly.
/// WebKit on macOS decodes VP9/AV1 in software. A 1080p WebM can use enough
/// CPU to starve the audio decoder, while H.264 is decoded by the media
/// engine. The first playback of an installed theme video or a video picked
/// in the editor is transcoded to a cached 720p H.264/AAC file; later loads
/// return the cached URL directly. Videos already encoded as H.264/HEVC, and
/// very small clips, are left untouched.
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ThemeMediaProgress {
@@ -1615,6 +1618,12 @@ pub struct ThemeMediaProgress {
#[cfg(target_os = "macos")]
const FFMPEG_VERSION: &str = "9.0.2";
/// Small clips are usually short stickers; transcoding them would cost more
/// time and disk than it saves. Anything larger may be a 1080p VP9 stream that
/// WebKit cannot decode smoothly in software.
#[cfg(target_os = "macos")]
const THEME_VIDEO_OPTIMIZE_MIN_BYTES: u64 = 2 * 1024 * 1024;
#[cfg(target_os = "macos")]
struct FfmpegBuild {
url: &'static str,
@@ -1659,7 +1668,13 @@ pub async fn optimize_theme_media(app: AppHandle, source_url: String) -> Result<
return Ok(source_url);
}
};
if metadata.len() < 20 * 1024 * 1024 {
if metadata.len() < THEME_VIDEO_OPTIMIZE_MIN_BYTES {
log::debug!(
target: "launcher",
"Skipping theme video optimization for {} ({} bytes)",
source.display(),
metadata.len()
);
return Ok(source_url);
}
@@ -2155,17 +2170,34 @@ pub async fn open_theme_editor(app: AppHandle, theme_folder: Option<String>) ->
let _ = window.set_focus();
app.emit_to("theme-editor", "theme-editor-context", ())
.map_err(|error| error.to_string())?;
let _ = app.emit_to("main", "theme-editor-state", true);
return Ok(());
}
WebviewWindowBuilder::new(&app, "theme-editor", WebviewUrl::App("index.html".into()))
.title("EZPPLauncher Theme Editor")
.inner_size(1440.0, 900.0)
.min_inner_size(1100.0, 720.0)
.resizable(true)
.center()
.build()
.map_err(|error| format!("Could not open the theme editor: {error}"))?;
let window =
WebviewWindowBuilder::new(&app, "theme-editor", WebviewUrl::App("index.html".into()))
.title("EZPPLauncher Theme Editor")
.inner_size(1440.0, 900.0)
.min_inner_size(1100.0, 720.0)
.resizable(true)
// Tauri's native file-drop handler intercepts HTML5 drag events on
// macOS/WebKit. The editor only needs HTML5 drag and drop for its layer
// tree, while the main launcher keeps native theme-file dropping.
.disable_drag_drop_handler()
.center()
.build()
.map_err(|error| format!("Could not open the theme editor: {error}"))?;
// Pause the launcher's own theme media while the editor window exists. The
// editor is a separate WebView, so the launcher needs an explicit signal
// rather than relying on focus/blur events.
let main_window = app.clone();
window.on_window_event(move |event| {
if matches!(event, WindowEvent::Destroyed) {
let _ = main_window.emit_to("main", "theme-editor-state", false);
}
});
let _ = app.emit_to("main", "theme-editor-state", true);
Ok(())
}
@@ -2531,6 +2563,49 @@ fn collect_theme_asset_refs(value: &Value, assets: &mut HashSet<String>) {
}
}
fn collect_svelte_sources(value: &Value, sources: &mut Vec<String>) {
let Some(elements) = value.get("elements").and_then(Value::as_array) else {
return;
};
for element in elements {
if element.get("type").and_then(Value::as_str) != Some("svelte") {
continue;
}
if let Some(source) = element.get("source").and_then(Value::as_str) {
sources.push(source.to_string());
}
}
}
/// Custom Svelte layers load assets through `asset("path")`, which is not a
/// `src` field in theme.json. Keep any asset whose relative path appears in a
/// component source so exports do not drop files used by custom layers.
fn collect_svelte_asset_refs(assets_root: &Path, sources: &[String], assets: &mut HashSet<String>) {
if sources.is_empty() {
return;
}
let Ok(entries) = std::fs::read_dir(assets_root) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_svelte_asset_refs(&path, sources, assets);
continue;
}
if !path.is_file() {
continue;
}
let Ok(relative) = path.strip_prefix(assets_root) else {
continue;
};
let key = relative.to_string_lossy().replace('\\', "/");
if sources.iter().any(|source| source.contains(&key)) {
assets.insert(key);
}
}
}
/// For API 2.0 packages, return the assets referenced by `theme.json` so an
/// export can omit orphaned files left behind by older edits. API 1.0 themes
/// can load assets dynamically from their bundle, so they are exported whole.
@@ -2547,6 +2622,13 @@ fn referenced_theme_assets(theme_json: &Path) -> Option<HashSet<String>> {
// `preview` is omitted from theme.json.
assets.insert("preview.png".to_string());
collect_theme_asset_refs(&parsed, &mut assets);
if let Some(folder) = theme_json.parent() {
let assets_root = folder.join("assets");
let mut sources = Vec::new();
collect_svelte_sources(&parsed, &mut sources);
collect_svelte_asset_refs(&assets_root, &sources, &mut assets);
}
Some(assets)
}
+13 -4
View File
@@ -134,8 +134,9 @@ pub fn media_root() -> Result<PathBuf, String> {
.map_err(|error| format!("Failed to resolve the theme media cache: {error}"))
}
/// Map a loopback theme URL back to the file it serves. Only paths inside the
/// configured themes folder are accepted.
/// Map a loopback theme URL back to the file it serves. Installed theme files
/// are restricted to the themes folder; editor preview URLs must match a token
/// registered through `preview_url`.
pub fn resolve_theme_url(value: &str) -> Option<PathBuf> {
let server = SERVER.get()?;
let url = url::Url::parse(value).ok()?;
@@ -149,8 +150,16 @@ pub fn resolve_theme_url(value: &str) -> Option<PathBuf> {
let path = percent_encoding::percent_decode_str(url.path())
.decode_utf8_lossy()
.to_string();
let relative = path.strip_prefix("/themes/")?;
safe_path(&server.root, relative)
if let Some(relative) = path.strip_prefix("/themes/") {
return safe_path(&server.root, relative);
}
let token = path.strip_prefix("/preview/")?;
server
.previews
.lock()
.ok()
.and_then(|previews| previews.get(token).cloned())
}
/// Public URL for a file in the optimized media cache.
+2 -1
View File
@@ -28,7 +28,8 @@
"security": {
"csp": {
"default-src": "'self' tauri: customprotocol: asset:",
"script-src": "'self' 'unsafe-inline' asset: http://asset.localhost https://asset.localhost http://127.0.0.1:* https://analytics.ez-pp.farm",
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' asset: http://asset.localhost https://asset.localhost http://127.0.0.1:* https://analytics.ez-pp.farm",
"worker-src": ["'self'", "blob:"],
"style-src": "'self' 'unsafe-inline' asset: http://asset.localhost https://asset.localhost http://127.0.0.1:*",
"img-src": "'self' asset: http://asset.localhost https://asset.localhost blob: data: http://127.0.0.1:* https://assets.ppy.sh https://a.ez-pp.farm https://git.ez-pp.farm https://osu.direct",
"connect-src": "'self' ipc: http://ipc.localhost asset: http://asset.localhost https://asset.localhost http://127.0.0.1:* https://ez-pp.farm https://api.ez-pp.farm https://c.ez-pp.farm https://analytics.ez-pp.farm https://git.ez-pp.farm https://osu.direct https://osu.ppy.sh https://api.fontsource.org https://cdn.jsdelivr.net",
+3
View File
@@ -162,6 +162,9 @@
-ms-user-select: none;
user-select: none;
}
html {
overscroll-behavior: none;
}
body {
@apply bg-background text-foreground;
}
+5 -1
View File
@@ -43,7 +43,11 @@
});
$effect(() => {
if (!visible || !src) return;
if (!visible) return;
if (!src) {
renderedFamily = family;
return;
}
let cancelled = false;
const previewFamily = `EZPPSourcePreview_${previewCount++}`;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,764 @@
<script lang="ts">
import {
analyzeThemeSvelteSource,
type ThemeSvelteAnalysis,
type ThemeSvelteDiagnostic,
} from '$lib/themeSvelte';
import {
THEME_SVELTE_COMPONENT_PLACEHOLDER,
THEME_SVELTE_COMPONENT_SKELETON,
} from '$lib/themeSvelteSnippets';
import { Component, CircleAlert, Maximize2, Minimize2, X } from '@lucide/svelte';
import type * as Monaco from 'monaco-editor';
import { onMount } from 'svelte';
type MonacoApi = typeof import('monaco-editor');
let {
value = '',
onchange,
variables = [],
height = 320,
}: {
value?: string;
onchange?: (value: string) => void;
variables?: string[];
height?: number;
} = $props();
const LANGUAGE_ID = 'ezpp-theme-svelte';
const THEME_ID = 'ezpp-theme-svelte-dark';
const MARKER_OWNER = 'ezpp-theme-svelte';
// Keep these split so the Svelte parser cannot mistake a string literal for
// the end of this component's own script block.
const SCRIPT_OPEN = '<scr' + 'ipt>';
const SCRIPT_CLOSE = '</scr' + 'ipt>';
const STYLE_OPEN = '<sty' + 'le>';
const STYLE_CLOSE = '</sty' + 'le>';
const propDocs: { label: string; documentation: string; insert: string }[] = [
{
label: '$props',
insert:
'let { variables = {}, layer = {}, theme = {}, asset = (path) => path, screen = "home" } = $props();',
documentation:
'Receive live theme props: `variables`, `layer`, `theme`, `screen` and the `asset(path)` helper.',
},
{
label: 'variables',
insert: 'variables.${1:name}',
documentation: 'Live rule variables. They update when a Logic rule changes a variable.',
},
{
label: 'layer',
insert: 'layer.${1:width}',
documentation:
'The layer bounds: `layer.id`, `layer.name`, `layer.width` and `layer.height`.',
},
{
label: 'theme',
insert: 'theme.${1:name}',
documentation: 'Theme metadata: `theme.name`, `theme.author` and `theme.accentColor`.',
},
{
label: 'asset',
insert: 'asset("${1:image.png}")',
documentation:
'Resolve a file inside the theme `assets/` folder. Works in the launcher and editor preview.',
},
{
label: 'screen',
insert: 'screen',
documentation: 'The current launcher screen. Custom layers only render on Home today.',
},
{
label: '$state',
insert: 'let ${1:value} = $state(${2:0});',
documentation: 'Create reactive component state.',
},
{
label: '$derived',
insert: 'const ${1:value} = $derived(${2:expression});',
documentation: 'Create a value derived from other reactive state.',
},
{
label: '$effect',
insert: '$effect(() => {\n\t$0\n});',
documentation: 'Run a side effect when reactive values change.',
},
{
label: 'onMount',
insert: 'import { onMount } from "svelte";\n\nonMount(() => {\n\t$0\n});',
documentation: 'Run code after the component mounts. Import it from `svelte`.',
},
{
label: 'onDestroy',
insert: 'import { onDestroy } from "svelte";\n\nonDestroy(() => {\n\t$0\n});',
documentation: 'Clean up timers, listeners and observers before the layer unmounts.',
},
{
label: 'tick',
insert: 'import { tick } from "svelte";\n\nawait tick();',
documentation: 'Wait for pending DOM updates.',
},
{
label: 'import transition',
insert: 'import { ${1:fade} } from "svelte/transition";',
documentation:
'Import transitions such as `fade`, `fly`, `slide`, `scale`, `blur` and `draw` from `svelte/transition`.',
},
{
label: 'import store',
insert: 'import { ${1:writable} } from "svelte/store";',
documentation: 'Svelte stores are available from `svelte/store`.',
},
{
label: 'SvelteMap',
insert: 'import { SvelteMap } from "svelte/reactivity";\n\nlet ${1:items} = new SvelteMap();',
documentation: 'Reactive Map from `svelte/reactivity`.',
},
{
label: 'SvelteSet',
insert: 'import { SvelteSet } from "svelte/reactivity";\n\nlet ${1:items} = new SvelteSet();',
documentation: 'Reactive Set from `svelte/reactivity`.',
},
{
label: '{#if}',
insert: '{#if ${1:condition}}\n\t$0\n{/if}',
documentation: 'Conditionally render markup.',
},
{
label: '{#each}',
insert: '{#each ${1:items} as ${2:item}}\n\t$0\n{/each}',
documentation: 'Render a list. Add `(${3:item.id})` for a keyed each block.',
},
{
label: '{#snippet}',
insert: '{#snippet ${1:name}(${2:argument})}\n\t$0\n{/snippet}',
documentation: 'Declare a reusable snippet and render it with `{@render name(argument)}`.',
},
{
label: '{@render}',
insert: '{@render ${1:name}(${2:argument})}',
documentation: 'Render a snippet declared in this component.',
},
{
label: SCRIPT_OPEN,
insert: `${SCRIPT_OPEN}\n\t$0\n${SCRIPT_CLOSE}`,
documentation: 'Plain JavaScript only. TypeScript is not preprocessed in theme layers.',
},
{
label: STYLE_OPEN,
insert: `${STYLE_OPEN}\n\t$0\n${STYLE_CLOSE}`,
documentation: 'Scoped CSS for this component.',
},
];
let host = $state<HTMLDivElement>();
let monaco: MonacoApi | undefined;
let editor: Monaco.editor.IStandaloneCodeEditor | undefined;
let model: Monaco.editor.ITextModel | undefined;
let ready = $state(false);
let loadError = $state('');
let expanded = $state(false);
let lineCount = $state(1);
let cursorLine = $state(1);
let cursorColumn = $state(1);
let errorCount = $state(0);
let warningCount = $state(0);
let checking = $state(false);
let diagnostics = $state<ThemeSvelteDiagnostic[]>([]);
let currentValue = '';
let validateTimer: ReturnType<typeof setTimeout> | undefined;
let validationToken = 0;
const ensureLanguage = (api: MonacoApi) => {
if (!api.languages.getLanguages().some((language) => language.id === LANGUAGE_ID)) {
api.languages.register({ id: LANGUAGE_ID });
}
api.languages.setLanguageConfiguration(LANGUAGE_ID, {
comments: { blockComment: ['<!--', '-->'] },
brackets: [
['{', '}'],
['[', ']'],
['(', ')'],
],
autoClosingPairs: [
{ open: '{', close: '}' },
{ open: '[', close: ']' },
{ open: '(', close: ')' },
{ open: '"', close: '"' },
{ open: "'", close: "'" },
{ open: '`', close: '`' },
],
surroundingPairs: [
{ open: '{', close: '}' },
{ open: '[', close: ']' },
{ open: '(', close: ')' },
{ open: '"', close: '"' },
{ open: "'", close: "'" },
{ open: '`', close: '`' },
],
indentationRules: {
increaseIndentPattern: /<[^/!][^>]*>\s*$|\{[#:@][^}]*$/,
decreaseIndentPattern: /^\s*<\/|\{\/|\{:else/,
},
});
api.languages.setMonarchTokensProvider(LANGUAGE_ID, {
tokenizer: {
root: [
[/<!--/, { token: 'comment', next: '@comment' }],
[/<scr(?:ipt)\b[^>]*>/, { token: 'tag', next: '@script' }],
[/<sty(?:le)\b[^>]*>/, { token: 'tag', next: '@style' }],
[/<\/?[\w:-]+/, 'tag'],
[/[a-zA-Z_][\w-]*(?=\s*=)/, 'attribute.name'],
[/"[^"]*"|'[^']*'/, 'string'],
[/\{[#:/@][^}]*\}/, 'keyword'],
[/\{[^}]*\}/, 'expression'],
[/[<>/=]/, 'delimiter'],
[/[^<{]+/, 'text'],
[/[<{]/, 'delimiter'],
],
comment: [
[/[^-]+/, 'comment'],
[/-->/, { token: 'comment', next: '@pop' }],
[/./, 'comment'],
],
script: [
[/<\/script\s*>/, { token: 'tag', next: '@pop' }],
[/\/\/.*$/, 'comment'],
[/\/\*/, 'comment', '@blockComment'],
[
/\b(?:import|from|let|const|var|if|else|for|while|return|function|async|await|new|typeof|export|class|extends|try|catch|finally|throw|break|continue|switch|case|default|of|in)\b/,
'keyword',
],
[/\$(?:state|derived|effect|props|bindable|inspect)(?:\.\w+)?/, 'predefined'],
[
/\b(?:onMount|onDestroy|tick|mount|unmount|fade|fly|slide|scale|blur|draw|writable|readable|derived|SvelteMap|SvelteSet|Math|Number|String|Boolean|JSON)\b/,
'predefined',
],
[/\b\d+(?:\.\d+)?\b/, 'number'],
[/[A-Za-z_$][\w$]*/, 'identifier'],
[/===|!==|=>|==|!=|<=|>=|&&|\|\||\+\+|--|[=!<>+\-*/%?:]/, 'operator'],
[/[{}()[\].,;]/, 'delimiter'],
[/[^}\s]/, 'text'],
],
blockComment: [
[/[^*/]+/, 'comment'],
[/\*\//, { token: 'comment', next: '@pop' }],
[/[*/]/, 'comment'],
],
style: [
[/<\/style\s*>/, { token: 'tag', next: '@pop' }],
[/\/\*/, 'comment', '@blockComment'],
[/[.#][\w-]+/, 'attribute.name'],
[
/\b(?:var|calc|rgb|rgba|linear-gradient|radial-gradient|url|blur|clamp|min|max)\b/,
'predefined',
],
[/[a-zA-Z-]+(?=\s*:)/, 'type'],
[/#[0-9a-fA-F]{3,8}\b/, 'number.hex'],
[/\b\d+(?:\.\d+)?(?:px|rem|em|%|vh|vw|deg|s|ms)?\b/, 'number'],
[/"[^"]*"|'[^']*'/, 'string'],
[/[{}();:,]/, 'delimiter'],
[/[^}\s]/, 'text'],
],
},
});
api.editor.defineTheme(THEME_ID, {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'keyword', foreground: 'ff79c6', fontStyle: 'bold' },
{ token: 'predefined', foreground: '8be9fd' },
{ token: 'tag', foreground: 'ff79c6' },
{ token: 'attribute.name', foreground: '50fa7b' },
{ token: 'identifier', foreground: 'f8f8f2' },
{ token: 'expression', foreground: 'f8f8f2' },
{ token: 'number', foreground: 'bd93f9' },
{ token: 'number.hex', foreground: 'bd93f9' },
{ token: 'string', foreground: 'f1fa8c' },
{ token: 'comment', foreground: '6272a4', fontStyle: 'italic' },
{ token: 'operator', foreground: 'ff79c6' },
{ token: 'type', foreground: '8be9fd' },
{ token: 'text', foreground: 'f8f8f2' },
{ token: 'delimiter', foreground: 'a1a1aa' },
],
colors: {
'editor.background': '#08080c',
'editor.foreground': '#f4f4f5',
'editorLineNumber.foreground': '#52525b',
'editorLineNumber.activeForeground': '#e4e4e7',
'editor.lineHighlightBackground': '#ffffff08',
'editor.selectionBackground': '#ff006e33',
'editor.inactiveSelectionBackground': '#ff006e22',
'editorCursor.foreground': '#ff006e',
'editorIndentGuide.background1': '#27272a',
'editorIndentGuide.activeBackground1': '#52525b',
'editorBracketHighlight.foreground1': '#ff79c6',
'editorBracketHighlight.foreground2': '#8be9fd',
'editorBracketHighlight.foreground3': '#f1fa8c',
'editorGutter.background': '#08080c',
'editorWidget.background': '#121218',
'editorWidget.border': '#3f3f46',
'editorSuggestWidget.background': '#121218',
'editorSuggestWidget.border': '#3f3f46',
'editorSuggestWidget.selectedBackground': '#ff006e26',
'editorSuggestWidget.highlightForeground': '#ff79c6',
'editorHoverWidget.background': '#121218',
'editorHoverWidget.border': '#3f3f46',
'editorError.foreground': '#f87171',
'editorWarning.foreground': '#fbbf24',
'editorOverviewRuler.errorForeground': '#f87171',
'editorOverviewRuler.warningForeground': '#fbbf24',
'scrollbarSlider.background': '#52525b55',
'scrollbarSlider.hoverBackground': '#71717a88',
'scrollbarSlider.activeBackground': '#a1a1aa99',
},
});
};
const completionRange = (
api: MonacoApi,
currentModel: Monaco.editor.ITextModel,
position: Monaco.Position
) => {
const word = currentModel.getWordUntilPosition(position);
return new api.Range(
position.lineNumber,
word.startColumn,
position.lineNumber,
word.endColumn
);
};
const completionPrefixes = ['variables.', 'layer.', 'theme.'];
// When the user has already typed `variables.`, Monaco replaces only the
// word after the dot. Strip the prefix from the insertion so the completion
// does not produce `variables.variables.score`.
const stripTypedPrefix = (
insertText: string,
currentModel: Monaco.editor.ITextModel,
range: Monaco.Range
) => {
const typed = currentModel
.getLineContent(range.startLineNumber)
.slice(0, range.startColumn - 1);
for (const prefix of completionPrefixes) {
if (typed.endsWith(prefix) && insertText.startsWith(prefix)) {
return insertText.slice(prefix.length);
}
}
return insertText;
};
const registerIntellisense = (api: MonacoApi) => {
const completion = api.languages.registerCompletionItemProvider(LANGUAGE_ID, {
triggerCharacters: ['.', '{', '#', '"', "'", ':', '<'],
provideCompletionItems(currentModel, position) {
const range = completionRange(api, currentModel, position);
const suggestions: Monaco.languages.CompletionItem[] = [];
for (const entry of propDocs) {
suggestions.push({
label: entry.label,
kind:
entry.label.charCodeAt(0) === 36 || entry.label.startsWith('{')
? api.languages.CompletionItemKind.Keyword
: entry.label.startsWith('import')
? api.languages.CompletionItemKind.Module
: api.languages.CompletionItemKind.Property,
insertText: stripTypedPrefix(entry.insert, currentModel, range),
insertTextRules: api.languages.CompletionItemInsertTextRule.InsertAsSnippet,
range,
documentation: entry.documentation,
sortText: `1_${entry.label}`,
});
}
suggestions.push({
label: 'Svelte component skeleton',
kind: api.languages.CompletionItemKind.Snippet,
insertText: THEME_SVELTE_COMPONENT_SKELETON,
insertTextRules: api.languages.CompletionItemInsertTextRule.InsertAsSnippet,
range,
documentation: 'A ready-to-edit layer with props, markup and scoped styles.',
sortText: '0_skeleton',
});
for (const variable of variables) {
suggestions.push({
label: `variables.${variable}`,
kind: api.languages.CompletionItemKind.Variable,
insertText: stripTypedPrefix(`variables.${variable}`, currentModel, range),
range,
documentation: `Live rule variable \`${variable}\`. Change it from a Logic rule.`,
sortText: `0_${variable}`,
});
}
return { suggestions, incomplete: false };
},
});
const hover = api.languages.registerHoverProvider(LANGUAGE_ID, {
provideHover(currentModel, position) {
const word = currentModel.getWordAtPosition(position);
if (!word) return null;
const entry = propDocs.find(
(candidate) => candidate.label === word.word || candidate.label === `${word.word}`
);
if (!entry) return null;
return {
range: new api.Range(
position.lineNumber,
word.startColumn,
position.lineNumber,
word.endColumn
),
contents: [{ value: `**${entry.label}**\n\n${entry.documentation}` }],
};
},
});
return () => {
completion.dispose();
hover.dispose();
};
};
const syncDiagnostics = (analysis: ThemeSvelteAnalysis) => {
if (!monaco || !model) return;
const all: ThemeSvelteDiagnostic[] = analysis.ok
? analysis.warnings
: [...analysis.errors, ...analysis.warnings];
const markers: Monaco.editor.IMarkerData[] = all.map((diagnostic) => {
const lineNumber = Math.min(Math.max(1, diagnostic.line), model!.getLineCount());
const maxColumn = model!.getLineMaxColumn(lineNumber);
const startColumn = Math.min(Math.max(1, diagnostic.column), maxColumn);
const endLine = Math.min(
Math.max(lineNumber, diagnostic.endLine ?? lineNumber),
model!.getLineCount()
);
const endMaxColumn = model!.getLineMaxColumn(endLine);
const endColumn = Math.min(
Math.max(startColumn, diagnostic.endColumn ?? startColumn + 1),
endMaxColumn
);
return {
severity:
diagnostic.severity === 'error'
? monaco!.MarkerSeverity.Error
: monaco!.MarkerSeverity.Warning,
message: diagnostic.message,
source: 'EZPP Svelte layer',
startLineNumber: lineNumber,
startColumn,
endLineNumber: endLine,
endColumn: Math.max(startColumn + 1, endColumn),
};
});
monaco.editor.setModelMarkers(model, MARKER_OWNER, markers);
errorCount = all.filter((diagnostic) => diagnostic.severity === 'error').length;
warningCount = all.filter((diagnostic) => diagnostic.severity === 'warning').length;
diagnostics = all;
};
const validate = async (source: string) => {
const token = ++validationToken;
checking = true;
const analysis = await analyzeThemeSvelteSource(source, 'ThemeLayer.svelte');
if (token !== validationToken) return;
checking = false;
syncDiagnostics(analysis);
};
const scheduleValidation = (source: string, immediate = false) => {
if (validateTimer) clearTimeout(validateTimer);
checking = true;
validateTimer = setTimeout(
() => {
void validate(source);
},
immediate ? 0 : 350
);
};
const updateStats = (source: string) => {
lineCount = Math.max(1, source.split('\n').length);
};
$effect(() => {
const next = value;
if (!model || !monaco || next === currentValue) return;
currentValue = next;
model.setValue(next);
updateStats(next);
scheduleValidation(next);
});
$effect(() => {
if (!expanded) return;
requestAnimationFrame(() => editor?.layout());
});
onMount(() => {
let disposed = false;
let disposeIntellisense: (() => void) | undefined;
void (async () => {
try {
const { default: EditorWorker } =
await import('monaco-editor/editor/editor.worker.js?worker');
if (disposed) return;
type WorkerHost = typeof globalThis & {
MonacoEnvironment?: { getWorker: () => Worker };
};
(globalThis as WorkerHost).MonacoEnvironment ??= {
getWorker: () => new EditorWorker(),
};
const api = await import('monaco-editor');
if (disposed) return;
monaco = api;
ensureLanguage(api);
if (!host) return;
currentValue = value;
updateStats(currentValue);
model = api.editor.createModel(currentValue, LANGUAGE_ID);
editor = api.editor.create(host, {
model,
theme: THEME_ID,
ariaLabel: 'Custom Svelte component source',
automaticLayout: true,
fontFamily: '"JetBrains Mono Variable", "JetBrains Mono", monospace',
fontSize: 11,
lineHeight: 18,
fontLigatures: true,
lineNumbers: 'on',
lineNumbersMinChars: 3,
glyphMargin: true,
folding: true,
minimap: { enabled: false },
wordWrap: 'on',
wrappingIndent: 'indent',
tabSize: 2,
insertSpaces: true,
scrollBeyondLastLine: false,
smoothScrolling: true,
padding: { top: 8, bottom: 8 },
renderLineHighlight: 'line',
fixedOverflowWidgets: true,
quickSuggestions: { other: true, comments: false, strings: true },
suggestOnTriggerCharacters: true,
suggest: { showWords: false, preview: true },
wordBasedSuggestions: 'off',
bracketPairColorization: { enabled: true },
guides: { bracketPairs: true, indentation: true },
scrollbar: { verticalScrollbarSize: 8, horizontalScrollbarSize: 8 },
});
editor.onDidChangeModelContent(() => {
const next = model?.getValue() ?? '';
currentValue = next;
updateStats(next);
scheduleValidation(next);
onchange?.(next);
});
editor.onDidChangeCursorPosition((event) => {
cursorLine = event.position.lineNumber;
cursorColumn = event.position.column;
});
disposeIntellisense = registerIntellisense(api);
ready = true;
scheduleValidation(currentValue, true);
editor.focus();
} catch (error) {
console.error('Could not load the Monaco Svelte editor', error);
loadError = error instanceof Error ? error.message : String(error);
scheduleValidation(value, true);
}
})();
return () => {
disposed = true;
if (validateTimer) clearTimeout(validateTimer);
disposeIntellisense?.();
editor?.dispose();
model?.dispose();
editor = undefined;
model = undefined;
monaco = undefined;
};
});
function toggleExpanded() {
expanded = !expanded;
requestAnimationFrame(() => {
editor?.layout();
editor?.focus();
});
}
</script>
<svelte:window
onkeydown={(event) => {
if (event.key === 'Escape' && expanded) {
event.preventDefault();
expanded = false;
}
}}
/>
{#if expanded}
<button
type="button"
class="fixed inset-0 z-[100] cursor-default bg-black/75 backdrop-blur-sm"
aria-label="Close expanded component editor"
onclick={() => (expanded = false)}
></button>
{/if}
<div
class="flex flex-col overflow-hidden border border-theme-800 bg-[#08080c] {expanded
? 'fixed inset-4 z-[101] rounded-xl shadow-2xl'
: 'relative rounded-md'}"
style={expanded ? '' : `height:${height}px`}
>
<div
class="flex h-7 shrink-0 items-center gap-1.5 border-b border-theme-800 bg-theme-950/80 px-2"
>
<Component class="size-3 shrink-0 text-primary" />
<span class="text-[9px] font-semibold uppercase tracking-wide text-theme-100">
Svelte component
</span>
<span class="ml-auto flex items-center gap-1.5">
{#if loadError}
<span class="text-[8px] text-destructive">Editor unavailable</span>
{:else if checking}
<span class="text-[8px] text-muted-foreground">Checking…</span>
{:else if errorCount > 0}
<span class="flex items-center gap-0.5 text-[8px] text-destructive">
<CircleAlert class="size-2.5" />
{errorCount}
</span>
{:else}
<span class="text-[8px] text-emerald-400">Ready</span>
{/if}
<button
type="button"
class="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-theme-800 hover:text-theme-50"
title={expanded ? 'Exit fullscreen' : 'Open fullscreen editor'}
aria-label={expanded ? 'Exit fullscreen editor' : 'Open fullscreen editor'}
onclick={toggleExpanded}
>
{#if expanded}
<Minimize2 class="size-3" />
{:else}
<Maximize2 class="size-3" />
{/if}
</button>
{#if expanded}
<button
type="button"
class="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive"
title="Close editor"
aria-label="Close expanded editor"
onclick={() => (expanded = false)}
>
<X class="size-3" />
</button>
{/if}
</span>
</div>
{#if loadError}
<textarea
class="min-h-0 flex-1 resize-none border-0 bg-[#08080c] p-2 font-mono text-[11px] leading-relaxed text-theme-50 outline-none"
spellcheck={false}
{value}
placeholder={THEME_SVELTE_COMPONENT_PLACEHOLDER}
oninput={(event) => {
const next = (event.currentTarget as HTMLTextAreaElement).value;
onchange?.(next);
scheduleValidation(next);
}}
></textarea>
<p class="border-t border-theme-800 px-2 py-1 text-[8px] text-destructive">
Monaco could not load: {loadError}
</p>
{:else}
<div class="relative min-h-0 flex-1">
<div bind:this={host} class="absolute inset-0"></div>
{#if !ready}
<div
class="absolute inset-0 flex items-center justify-center bg-[#08080c] text-[9px] text-muted-foreground"
>
Loading editor…
</div>
{/if}
</div>
{/if}
{#if diagnostics.length > 0}
<div class="max-h-24 shrink-0 overflow-y-auto border-t border-theme-800 bg-theme-950/70">
{#each diagnostics.slice(0, 8) as diagnostic, index (index)}
<div
class="flex items-start gap-1.5 border-b border-theme-800/60 px-2 py-1 last:border-b-0"
>
<CircleAlert
class="mt-px size-2.5 shrink-0 {diagnostic.severity === 'error'
? 'text-destructive'
: 'text-amber-300'}"
/>
<p class="min-w-0 flex-1 text-[8px] leading-relaxed text-theme-100">
{diagnostic.message}
</p>
<button
type="button"
class="shrink-0 font-mono text-[8px] text-muted-foreground hover:text-primary"
title="Jump to this line"
onclick={() => {
editor?.setPosition({ lineNumber: diagnostic.line, column: diagnostic.column });
editor?.revealLineInCenter(diagnostic.line);
editor?.focus();
}}
>
{diagnostic.line}:{diagnostic.column}
</button>
</div>
{/each}
</div>
{/if}
<div
class="flex h-5 shrink-0 items-center gap-3 border-t border-theme-800 px-2 text-[8px] text-muted-foreground"
>
<span>{lineCount} line{lineCount === 1 ? '' : 's'}</span>
<span>Ln {cursorLine}, Col {cursorColumn}</span>
<span class="ml-auto">
{#if checking}
Checking…
{:else if errorCount > 0}
<span class="text-destructive">{errorCount} error{errorCount === 1 ? '' : 's'}</span>
{#if warningCount > 0}
<span class="text-amber-300">
· {warningCount} warning{warningCount === 1 ? '' : 's'}</span
>
{/if}
{:else if warningCount > 0}
<span class="text-amber-300">{warningCount} warning{warningCount === 1 ? '' : 's'}</span>
{:else}
No errors
{/if}
</span>
</div>
</div>
@@ -0,0 +1,79 @@
<script lang="ts">
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte';
import { themeClassTokens } from '$lib/themeV2';
import { Code2, RotateCcw } from '@lucide/svelte';
let {
idPrefix,
value,
onchange,
}: {
idPrefix: string;
value?: string;
onchange: (value: string | undefined) => void;
} = $props();
const label = 'text-[10px] font-medium uppercase tracking-wide text-muted-foreground';
const invalidTokens = $derived(
typeof value === 'string'
? value
.trim()
.split(/\s+/)
.filter(Boolean)
.filter((token) => themeClassTokens(token).length === 0)
: []
);
function setValue(raw: string) {
onchange(raw.trim() ? raw : undefined);
}
</script>
{#snippet resetAction()}
<Button
variant="ghost"
size="sm"
class="h-6 px-1.5 text-[10px] text-muted-foreground"
disabled={!value}
onclick={() => onchange(undefined)}
>
<RotateCcw class="size-3" />
Reset
</Button>
{/snippet}
<ThemeSection
title="CSS classes"
icon={Code2}
open={false}
actions={resetAction}
contentClass="space-y-2 pt-2.5"
>
<div class="space-y-1">
<Label for={`${idPrefix}-css-classes`} class={label}>Classes</Label>
<Input
id={`${idPrefix}-css-classes`}
class="h-8 border-theme-800 bg-theme-950 font-mono text-xs text-theme-50"
value={value ?? ''}
placeholder="glow-card hero-title"
spellcheck="false"
autocomplete="off"
oninput={(event) => setValue(event.currentTarget.value)}
/>
</div>
<p class="text-[10px] leading-relaxed text-muted-foreground">
Separate multiple classes with spaces and style them from the theme's Custom CSS, for example
<code class="rounded bg-theme-900 px-1 py-0.5 text-theme-100">.glow-card</code>.
</p>
{#if invalidTokens.length > 0}
<p class="text-[10px] leading-relaxed text-amber-400">
Ignored: <span class="font-mono">{invalidTokens.join(' ')}</span>. Use letters, numbers,
dashes and underscores.
</p>
{/if}
</ThemeSection>
@@ -0,0 +1,132 @@
<script lang="ts">
import { compileThemeSvelte } from '$lib/themeSvelte';
import { themeAssetUrl, type ThemeV2ScriptValue } from '$lib/themeV2';
import { TriangleAlert } from '@lucide/svelte';
import { mount, unmount } from 'svelte';
let {
source,
assets = '',
assetOverrides = {},
variables = {},
screen = 'home',
layerId,
layerName,
width,
height,
themeName = '',
themeAuthor = '',
accentColor = '#ff006e',
interactive = false,
}: {
source: string;
assets?: string;
assetOverrides?: Record<string, string>;
variables?: Record<string, ThemeV2ScriptValue>;
screen?: string;
layerId: string;
layerName?: string;
width: number;
height: number;
themeName?: string;
themeAuthor?: string;
accentColor?: string;
interactive?: boolean;
} = $props();
type ComponentInstance = ReturnType<typeof mount>;
let host = $state<HTMLDivElement>();
let error = $state('');
let loading = $state(true);
let instance: ComponentInstance | undefined;
let generation = 0;
// A $state proxy can be passed to mount() so $props() in the custom
// component remains reactive when a logic rule changes a variable.
const componentProps = $state<Record<string, unknown>>({
variables: {},
screen: 'home',
layer: { id: '', name: undefined, width: 0, height: 0 },
theme: { name: '', author: '', accentColor: '#ff006e' },
asset: (path: string) => path,
});
const errorMessage = (value: unknown) =>
value instanceof Error ? `${value.name}: ${value.message}` : String(value);
$effect(() => {
componentProps.variables = { ...variables };
componentProps.screen = screen;
componentProps.layer = { id: layerId, name: layerName, width, height };
componentProps.theme = { name: themeName, author: themeAuthor, accentColor };
componentProps.asset = (path: string) => assetOverrides[path] ?? themeAssetUrl(assets, path);
});
$effect(() => {
const current = ++generation;
const currentSource = source;
let mounted: ComponentInstance | undefined;
loading = true;
void (async () => {
const result = await compileThemeSvelte(currentSource, `layer-${layerId}.svelte`);
if (current !== generation || !host) return;
if (!result.component) {
error = result.errors[0]?.message ?? 'The custom component could not be compiled.';
loading = false;
return;
}
try {
mounted = mount(result.component, { target: host, props: componentProps });
instance = mounted;
error = '';
} catch (mountError) {
error = errorMessage(mountError);
} finally {
loading = false;
}
})();
return () => {
// Invalidate a compile that is still in flight so it cannot mount into
// a host that has already been removed with this layer.
generation += 1;
loading = false;
if (mounted) {
void unmount(mounted, { outro: false });
if (instance === mounted) instance = undefined;
}
};
});
$effect(() => {
if (!interactive || !error) return;
console.error(`[theme] Svelte layer ${layerId} failed`, error);
});
</script>
<div class="relative h-full w-full">
<div bind:this={host} class="h-full w-full"></div>
{#if interactive && loading}
<div
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-md border border-dashed border-theme-700 bg-theme-950/65 text-[10px] text-muted-foreground"
>
Compiling Svelte…
</div>
{/if}
{#if interactive && error}
<div
class="absolute inset-0 flex flex-col items-center justify-center gap-1.5 overflow-hidden rounded-md border border-destructive/50 bg-destructive/12 p-3 text-center"
>
<TriangleAlert class="size-4 shrink-0 text-destructive" />
<p class="text-[10px] font-semibold text-destructive">Svelte layer failed</p>
<p class="line-clamp-4 font-mono text-[9px] leading-relaxed text-destructive/90">{error}</p>
<p class="text-[8px] text-muted-foreground">Fix the error in the layer inspector.</p>
</div>
{/if}
</div>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,91 @@
<script lang="ts">
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import Slider from '$lib/components/ui/slider/slider.svelte';
import type { ThemeV2Filters } from '$lib/themeV2';
let {
filters,
onchange,
}: {
filters?: ThemeV2Filters;
onchange: (filters: ThemeV2Filters) => void;
} = $props();
type Control = {
key: keyof ThemeV2Filters;
label: string;
min: number;
max: number;
step: number;
fallback: number;
unit: string;
};
const controls: Control[] = [
{ key: 'blur', label: 'Blur', min: 0, max: 40, step: 1, fallback: 0, unit: 'px' },
{ key: 'brightness', label: 'Brightness', min: 0, max: 3, step: 0.05, fallback: 1, unit: '' },
{ key: 'contrast', label: 'Contrast', min: 0, max: 3, step: 0.05, fallback: 1, unit: '' },
{ key: 'saturate', label: 'Saturation', min: 0, max: 4, step: 0.05, fallback: 1, unit: '' },
{
key: 'hueRotate',
label: 'Hue rotate',
min: -360,
max: 360,
step: 1,
fallback: 0,
unit: 'deg',
},
{ key: 'grayscale', label: 'Grayscale', min: 0, max: 1, step: 0.01, fallback: 0, unit: '' },
{ key: 'sepia', label: 'Sepia', min: 0, max: 1, step: 0.01, fallback: 0, unit: '' },
{ key: 'invert', label: 'Invert', min: 0, max: 1, step: 0.01, fallback: 0, unit: '' },
];
const valueOf = (control: Control) => filters?.[control.key] ?? control.fallback;
const display = (control: Control) => {
const value = valueOf(control);
return `${control.step < 1 ? value.toFixed(2).replace(/\.?0+$/, '') : Math.round(value)}${control.unit}`;
};
const isDefault = $derived(controls.every((control) => valueOf(control) === control.fallback));
function setValue(key: keyof ThemeV2Filters, value: number) {
onchange({ ...(filters ?? {}), [key]: value });
}
</script>
{#snippet filterActions()}
<Button
variant="ghost"
size="sm"
class="h-6 px-1.5 text-[10px] text-muted-foreground"
disabled={isDefault}
onclick={() => onchange({})}
>
Reset
</Button>
{/snippet}
<ThemeSection
title="Filters"
open={false}
actions={filterActions}
contentClass="space-y-2.5 pt-2.5"
>
<div class="space-y-2.5">
{#each controls as control (control.key)}
<div class="space-y-1">
<div class="flex items-center justify-between">
<span class="text-[10px] text-muted-foreground">{control.label}</span>
<span class="font-mono text-[9px] text-theme-100">{display(control)}</span>
</div>
<Slider
min={control.min}
max={control.max}
step={control.step}
value={valueOf(control)}
onValueChange={(value) => setValue(control.key, value)}
/>
</div>
{/each}
</div>
</ThemeSection>
@@ -0,0 +1,236 @@
<script lang="ts">
import ThemeCssClasses from '$lib/components/theme/ThemeCssClasses.svelte';
import ThemeFilterControls from '$lib/components/theme/ThemeFilterControls.svelte';
import ThemeLinkedLogic from '$lib/components/theme/ThemeLinkedLogic.svelte';
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
import ThemeTransformControls from '$lib/components/theme/ThemeTransformControls.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import { Checkbox } from '$lib/components/ui/checkbox';
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte';
import Slider from '$lib/components/ui/slider/slider.svelte';
import * as Select from '$lib/components/ui/select';
import {
type ThemeV2Behavior,
type ThemeV2BehaviorTarget,
type ThemeV2Element,
type ThemeV2Group,
type ThemeV2MaskShape,
} from '$lib/themeV2';
import { Boxes, RefreshCw, Ungroup } from '@lucide/svelte';
let {
group,
behaviors,
elements,
groups,
variables = [],
focusToken = 0,
onungroup,
onpickmask,
onaddbehavior,
ondeleterule,
}: {
group: ThemeV2Group;
behaviors: ThemeV2Behavior[];
elements: ThemeV2Element[];
groups: ThemeV2Group[];
variables?: string[];
focusToken?: number;
onungroup: () => void;
onpickmask: () => void;
onaddbehavior: (target: ThemeV2BehaviorTarget) => ThemeV2Behavior | undefined;
ondeleterule: (id: string) => void;
} = $props();
const field = 'h-8 border-theme-800 bg-theme-950 text-xs text-theme-50';
const label = 'text-[10px] font-medium uppercase tracking-wide text-muted-foreground';
</script>
<div class="space-y-3 p-3">
<div class="flex items-center gap-2 border-b border-theme-800 pb-2.5">
<div class="flex size-8 items-center justify-center rounded-md bg-primary/20 text-primary">
<Boxes class="size-4" />
</div>
<div class="min-w-0">
<p class="truncate text-xs font-semibold text-theme-50">Group</p>
<p class="truncate font-mono text-[10px] text-muted-foreground">{group.id}</p>
</div>
</div>
<ThemeSection title="Group" bordered={false} contentClass="space-y-3 pt-2">
<div class="space-y-1">
<Label for="group-name" class={label}>Group name</Label>
<Input
id="group-name"
class={field}
value={group.name ?? ''}
placeholder="Group name"
oninput={(event) => (group.name = (event.currentTarget as HTMLInputElement).value)}
/>
</div>
<div class="space-y-1">
<div class="flex items-center justify-between">
<Label class={label}>Opacity</Label>
<span class="text-[10px] text-muted-foreground"
>{Math.round((group.opacity ?? 1) * 100)}%</span
>
</div>
<Slider
min={0}
max={1}
step={0.01}
value={group.opacity ?? 1}
onValueChange={(value) => (group.opacity = value)}
/>
</div>
</ThemeSection>
<ThemeTransformControls
transform={group}
idPrefix="group"
includeTranslate
onchange={(patch) => Object.assign(group, patch)}
/>
<ThemeSection
title="Group state"
contentClass="flex flex-wrap items-center gap-x-4 gap-y-2 pt-2.5"
>
<div class="flex items-center gap-1.5">
<Checkbox
id="group-hidden"
checked={group.hidden === true}
onCheckedChange={(checked) => (group.hidden = checked === true)}
/>
<Label for="group-hidden" class="text-[10px] font-normal text-muted-foreground">Hidden</Label>
</div>
<div class="flex items-center gap-1.5">
<Checkbox
id="group-locked"
checked={group.locked === true}
onCheckedChange={(checked) => (group.locked = checked === true)}
/>
<Label for="group-locked" class="text-[10px] font-normal text-muted-foreground">Locked</Label>
</div>
</ThemeSection>
<ThemeCssClasses
idPrefix="group"
value={group.cssClasses}
onchange={(value) => (group.cssClasses = value)}
/>
<ThemeLinkedLogic
target={{ type: 'group', id: group.id }}
{behaviors}
{elements}
{groups}
{variables}
{focusToken}
onadd={onaddbehavior}
ondelete={ondeleterule}
/>
<ThemeSection title="Mask" contentClass="space-y-3 pt-2.5">
<div class="space-y-1">
<Label for="group-mask-shape" class={label}>Clip shape</Label>
<Select.Root
type="single"
value={group.mask?.shape ?? 'none'}
onValueChange={(value) => {
if (!value) return;
if (value === 'none') {
group.mask = undefined;
return;
}
group.mask = {
shape: value as ThemeV2MaskShape,
radius: group.mask?.radius ?? 24,
sides: group.mask?.sides ?? 6,
innerRadius: group.mask?.innerRadius ?? 0.5,
src: group.mask?.src,
fit: group.mask?.fit ?? 'cover',
};
}}
>
<Select.Trigger
id="group-mask-shape"
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
>
{group.mask?.shape ?? 'None'}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
<Select.Item value="none" label="None" />
<Select.Item value="rectangle" label="Rectangle" />
<Select.Item value="ellipse" label="Ellipse" />
<Select.Item value="polygon" label="Polygon" />
<Select.Item value="star" label="Star" />
<Select.Item value="image" label="Image alpha" />
</Select.Content>
</Select.Root>
</div>
{#if group.mask?.shape === 'polygon' || group.mask?.shape === 'star'}
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<Label for="group-mask-sides" class={label}>Points</Label>
<Input
id="group-mask-sides"
class={field}
type="number"
min="3"
max="24"
value={group.mask.sides ?? 6}
oninput={(event) => {
if (group.mask) {
group.mask.sides = Number((event.currentTarget as HTMLInputElement).value) || 3;
}
}}
/>
</div>
{#if group.mask.shape === 'star'}
<div class="space-y-1">
<Label for="group-mask-inner" class={label}>Inner radius</Label>
<Input
id="group-mask-inner"
class={field}
type="number"
min="0.1"
max="0.95"
step="0.05"
value={group.mask.innerRadius ?? 0.5}
oninput={(event) => {
if (group.mask) {
group.mask.innerRadius =
Number((event.currentTarget as HTMLInputElement).value) || 0.5;
}
}}
/>
</div>
{/if}
</div>
{/if}
{#if group.mask?.shape === 'image'}
<div class="space-y-2 rounded-md border border-theme-800 bg-black/20 p-2">
<p class="truncate font-mono text-[10px] text-theme-100">
{group.mask.src ?? 'No mask image yet'}
</p>
<Button variant="secondary" class="h-8 w-full gap-2 text-xs" onclick={onpickmask}>
<RefreshCw class="size-3.5" />
Choose mask image
</Button>
</div>
{/if}
<p class="text-[9px] leading-relaxed text-muted-foreground">
The mask clips every layer in this group. Image masks use the picture's alpha channel.
</p>
</ThemeSection>
<ThemeFilterControls filters={group.filters} onchange={(filters) => (group.filters = filters)} />
<Button variant="secondary" class="h-8 w-full gap-2 text-xs" onclick={onungroup}>
<Ungroup class="size-3.5" />
Ungroup group
</Button>
</div>
+33
View File
@@ -0,0 +1,33 @@
<script lang="ts">
import { THEME_ICON_BODIES } from '$lib/themeIconData';
import { DEFAULT_THEME_ICON_SET, type ThemeIconName, type ThemeIconSet } from '$lib/themeIcons';
let {
name,
set = DEFAULT_THEME_ICON_SET,
size = '1em',
class: className = '',
}: {
name: ThemeIconName;
set?: ThemeIconSet;
size?: number | string;
class?: string;
} = $props();
const icon = $derived(
THEME_ICON_BODIES[set]?.[name] ?? THEME_ICON_BODIES[DEFAULT_THEME_ICON_SET][name]
);
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox={`0 0 ${icon.width} ${icon.height}`}
width={size}
height={size}
class={className}
aria-hidden="true"
focusable="false"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html icon.body}
</svg>
@@ -0,0 +1,195 @@
<script lang="ts">
import ThemeBehaviorInspector from '$lib/components/theme/ThemeBehaviorInspector.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import { themeBehaviorSummary, themeGroupInteractiveCount } from '$lib/themeLogic';
import type {
ThemeV2Behavior,
ThemeV2BehaviorTarget,
ThemeV2Element,
ThemeV2Group,
} from '$lib/themeV2';
import { AlertTriangle, ChevronDown, Plus, Zap } from '@lucide/svelte';
let {
target,
behaviors,
elements,
groups,
variables = [],
focusToken = 0,
onadd,
ondelete,
}: {
target: ThemeV2BehaviorTarget;
behaviors: ThemeV2Behavior[];
elements: ThemeV2Element[];
groups: ThemeV2Group[];
variables?: string[];
focusToken?: number;
onadd: (target: ThemeV2BehaviorTarget) => ThemeV2Behavior | undefined;
ondelete: (id: string) => void;
} = $props();
let open = $state(false);
let selectedId = $state<string | undefined>(undefined);
let section = $state<HTMLElement>();
let lastFocusToken = 0;
const linkedBehaviors = $derived(
behaviors.filter(
(behavior) => behavior.target?.type === target.type && behavior.target?.id === target.id
)
);
const pointerRuleCount = $derived(
linkedBehaviors.filter(
(behavior) =>
behavior.trigger.type === 'click' ||
behavior.trigger.type === 'hover' ||
behavior.trigger.type === 'leave'
).length
);
const interactiveTargets = $derived(
target.type === 'group'
? themeGroupInteractiveCount(groups, elements, target.id)
: elements.find((element) => element.id === target.id)?.interactive
? 1
: 0
);
const needsInteractive = $derived(pointerRuleCount > 0 && interactiveTargets === 0);
$effect(() => {
if (!focusToken || focusToken === lastFocusToken) return;
lastFocusToken = focusToken;
open = true;
if (linkedBehaviors.length === 1) selectedId = linkedBehaviors[0].id;
requestAnimationFrame(() => section?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }));
});
function addRule() {
const behavior = onadd(target);
open = true;
if (behavior) selectedId = behavior.id;
}
function deleteRule(id: string) {
ondelete(id);
if (selectedId === id) selectedId = undefined;
}
</script>
<section bind:this={section} class="border-t border-theme-800 pt-2">
<div class="flex items-center gap-1.5">
<button
type="button"
class="group flex min-w-0 flex-1 items-center gap-1.5 rounded-md px-1 py-1 text-left transition-colors hover:bg-theme-900/60"
aria-expanded={open}
onclick={() => (open = !open)}
>
<Zap class="size-3.5 shrink-0 text-primary" />
<span class="text-[11px] font-semibold text-theme-100">Logic</span>
{#if linkedBehaviors.length > 0}
<span class="rounded-full bg-theme-900 px-1.5 py-0.5 text-[9px] text-muted-foreground">
{linkedBehaviors.length}
</span>
{/if}
{#if needsInteractive}
<span title="A linked pointer rule needs an interactive layer">
<AlertTriangle class="size-3 text-amber-300" />
</span>
{/if}
<ChevronDown
class="ml-auto size-3 shrink-0 text-muted-foreground transition-transform {open
? 'rotate-180'
: ''}"
/>
</button>
<Button
variant="secondary"
size="sm"
class="h-6 shrink-0 gap-1 px-1.5 text-[9px]"
title="Add a rule linked to this layer or group"
onclick={addRule}
>
<Plus class="size-3" />
Add
</Button>
</div>
{#if open}
<div class="mt-1.5 space-y-1.5">
{#if linkedBehaviors.length === 0}
<p
class="rounded-md border border-dashed border-theme-800 px-2 py-1.5 text-[9px] leading-relaxed text-muted-foreground"
>
No rules linked yet. Add one to react to clicks, hovers, timers or screen changes.
</p>
{:else}
{#each linkedBehaviors as behavior (behavior.id)}
<div class="overflow-hidden rounded-md border border-theme-800 bg-black/20">
<button
type="button"
class="flex w-full items-center gap-1.5 px-2 py-1.5 text-left transition-colors hover:bg-theme-900/60"
aria-expanded={selectedId === behavior.id}
onclick={() => (selectedId = selectedId === behavior.id ? undefined : behavior.id)}
>
<span
class="size-1.5 shrink-0 rounded-full {behavior.enabled === false
? 'bg-theme-600'
: 'bg-emerald-400'}"
></span>
<span class="min-w-0 flex-1">
<span class="flex items-center gap-1.5">
<span class="truncate text-[10px] font-medium text-theme-100">
{behavior.name?.trim() || 'Rule'}
</span>
{#if behavior.once}
<span class="rounded bg-theme-900 px-1 py-0.5 text-[8px] text-muted-foreground">
once
</span>
{/if}
</span>
<span class="mt-0.5 block truncate text-[9px] text-muted-foreground">
{themeBehaviorSummary(behavior, elements, groups)}
</span>
</span>
<ChevronDown
class="size-3 shrink-0 text-muted-foreground transition-transform {selectedId ===
behavior.id
? 'rotate-180'
: ''}"
/>
</button>
{#if selectedId === behavior.id}
<div class="border-t border-theme-800 bg-theme-950/60">
<ThemeBehaviorInspector
{behavior}
{elements}
{groups}
{variables}
embedded
ondelete={() => deleteRule(behavior.id)}
/>
</div>
{/if}
</div>
{/each}
{/if}
{#if needsInteractive}
<div
class="flex items-start gap-1.5 rounded-md border border-amber-500/25 bg-amber-500/5 p-1.5"
>
<AlertTriangle class="mt-0.5 size-3 shrink-0 text-amber-300" />
<p class="text-[9px] leading-relaxed text-amber-100/80">
{#if target.type === 'group'}
Mark at least one layer in this group as <strong>Interactive</strong>.
{:else}
Enable <strong>Interactive</strong> for this layer.
{/if}
</p>
</div>
{/if}
</div>
{/if}
</section>
@@ -0,0 +1,47 @@
<script lang="ts">
import Badge from '$lib/components/ui/badge/badge.svelte';
import { AlertTriangle, Zap } from '@lucide/svelte';
let {
count,
warning = false,
onopen,
}: {
count: number;
warning?: boolean;
onopen: () => void;
} = $props();
const title = $derived(
warning
? `${count} linked logic rule${count === 1 ? '' : 's'} · needs an interactive layer · Open in inspector`
: `${count} linked logic rule${count === 1 ? '' : 's'} · Open in inspector`
);
</script>
{#if count > 0}
<button
type="button"
class="-m-0.5 shrink-0 cursor-pointer rounded-full p-0.5 transition-opacity hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
{title}
aria-label={title}
onclick={(event) => {
event.stopPropagation();
onopen();
}}
>
<Badge
variant="outline"
class="pointer-events-none h-4 gap-0.5 border px-1 py-0 text-[8px] font-semibold leading-none {warning
? 'border-amber-500/30 bg-amber-500/10 text-amber-300'
: 'border-primary/25 bg-primary/10 text-primary'}"
>
{#if warning}
<AlertTriangle class="size-2.5" />
{:else}
<Zap class="size-2.5" />
{/if}
{count}
</Badge>
</button>
{/if}
@@ -0,0 +1,72 @@
<script lang="ts">
import lottie, { type AnimationItem } from 'lottie-web';
import { onDestroy, onMount } from 'svelte';
let {
src,
loop = true,
autoplay = true,
speed = 1,
}: {
src: string;
loop?: boolean;
autoplay?: boolean;
speed?: number;
} = $props();
let container = $state<HTMLDivElement | undefined>(undefined);
let animation: AnimationItem | undefined;
let reducedMotion = false;
onMount(() => {
if (!container || !src) return;
reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
let cancelled = false;
const start = (animationData?: unknown) => {
if (cancelled || !container) return;
try {
animation = lottie.loadAnimation({
container,
renderer: 'svg',
loop,
autoplay: autoplay && !reducedMotion,
...(animationData ? { animationData } : { path: src }),
rendererSettings: { preserveAspectRatio: 'xMidYMid meet' },
});
animation.setSpeed(speed);
if (reducedMotion) {
animation.addEventListener('DOMLoaded', () => animation?.goToAndStop(0, true));
}
} catch (error) {
console.warn('Could not load the Lottie layer', error);
}
};
// Fetching the JSON ourselves works with Tauri's asset protocols, where
// lottie-web's internal XHR can be blocked. Fall back to its own loader.
void fetch(src)
.then((response) => (response.ok ? response.json() : Promise.reject(response.status)))
.then((data) => start(data))
.catch(() => start());
return () => {
cancelled = true;
};
});
$effect(() => {
if (!animation) return;
animation.loop = loop;
animation.setSpeed(speed);
if (autoplay && !reducedMotion) animation.play();
else animation.pause();
});
onDestroy(() => {
animation?.destroy();
animation = undefined;
});
</script>
<div bind:this={container} class="h-full w-full" aria-hidden="true"></div>
@@ -0,0 +1,890 @@
<script lang="ts">
import { isThemeClockVariable, THEME_CLOCK_VARIABLE_NAMES } from '$lib/themeClock';
import { parseThemeScript } from '$lib/themeScriptLang';
import type { ThemeV2Element, ThemeV2Group, ThemeV2ScriptScreen } from '$lib/themeV2';
import { Braces, CircleAlert, Maximize2, Minimize2, X } from '@lucide/svelte';
import type * as Monaco from 'monaco-editor';
import { onMount } from 'svelte';
type MonacoApi = typeof import('monaco-editor');
let {
value = '',
onchange,
variables = [],
elements = [],
groups = [],
height = 280,
}: {
value?: string;
onchange?: (value: string) => void;
variables?: string[];
elements?: ThemeV2Element[];
groups?: ThemeV2Group[];
height?: number;
} = $props();
const LANGUAGE_ID = 'ezpp-theme-script';
const THEME_ID = 'ezpp-theme-script-dark';
const MARKER_OWNER = 'ezpp-theme-script';
const screens: ThemeV2ScriptScreen[] = ['home', 'themes', 'settings', 'login'];
const statementDocs: Record<string, string> = {
let: 'Declare a theme variable.\n\n`let score = 0;`',
const: 'Declare a value that will not be reassigned.\n\n`const bonus = 10;`',
var: 'Declare a theme variable using the older JavaScript keyword.\n\n`var score = 0;`',
if: 'Run a block only when a condition is true.\n\n`if (score >= 10) { ... }`',
else: 'Run a block when the `if` condition is false.\n\n`else { ... }`',
for: 'Run a bounded loop.\n\n`for (let i = 0; i < 3; i++) { ... }`',
while: 'Run while a condition stays true. Loops stop after 1,000 iterations.',
true: 'Boolean true.',
false: 'Boolean false.',
screen: 'The current launcher screen, such as `"home"` or `"themes"`.',
Math: 'JavaScript math helpers such as `Math.floor(value)` and `Math.random()`.',
};
const actionFunctions: {
name: string;
label: string;
insert: string;
documentation: string;
}[] = [
{
name: 'showLayer',
label: 'showLayer(id)',
insert: 'showLayer(${1:"layer_id"});',
documentation: 'Make a layer visible.',
},
{
name: 'hideLayer',
label: 'hideLayer(id)',
insert: 'hideLayer(${1:"layer_id"});',
documentation: 'Hide a layer.',
},
{
name: 'toggleLayer',
label: 'toggleLayer(id)',
insert: 'toggleLayer(${1:"layer_id"});',
documentation: 'Flip a layer between visible and hidden.',
},
{
name: 'showGroup',
label: 'showGroup(id)',
insert: 'showGroup(${1:"group_id"});',
documentation: 'Make a group visible.',
},
{
name: 'hideGroup',
label: 'hideGroup(id)',
insert: 'hideGroup(${1:"group_id"});',
documentation: 'Hide a group.',
},
{
name: 'toggleGroup',
label: 'toggleGroup(id)',
insert: 'toggleGroup(${1:"group_id"});',
documentation: 'Flip a group between visible and hidden.',
},
{
name: 'goTo',
label: 'goTo(screen)',
insert: 'goTo(${1:"themes"});',
documentation: 'Navigate to `home`, `themes`, `settings` or `login`.',
},
{
name: 'navigate',
label: 'navigate(screen)',
insert: 'navigate(${1:"themes"});',
documentation: 'Alias for `goTo`.',
},
{
name: 'openUrl',
label: 'openUrl(url)',
insert: 'openUrl(${1:"https://example.com"});',
documentation: 'Open a website after a confirmation dialog. Click triggers only.',
},
{
name: 'launch',
label: 'launch()',
insert: 'launch();',
documentation: 'Start the normal osu! launch flow. Click triggers only.',
},
{
name: 'stop',
label: 'stop()',
insert: 'stop();',
documentation: 'End the current script immediately.',
},
];
const functions: {
name: string;
label: string;
insert: string;
documentation: string;
}[] = [
{
name: 'min',
label: 'min(a, b, ...)',
insert: 'min(${1:a}, ${2:b})',
documentation: 'Returns the smallest number.',
},
{
name: 'max',
label: 'max(a, b, ...)',
insert: 'max(${1:a}, ${2:b})',
documentation: 'Returns the largest number.',
},
{
name: 'clamp',
label: 'clamp(value, min, max)',
insert: 'clamp(${1:value}, ${2:0}, ${3:100})',
documentation: 'Keeps a number inside a range.',
},
{
name: 'round',
label: 'round(value)',
insert: 'round(${1:value})',
documentation: 'Rounds to the nearest whole number.',
},
{
name: 'floor',
label: 'floor(value)',
insert: 'floor(${1:value})',
documentation: 'Rounds down.',
},
{
name: 'ceil',
label: 'ceil(value)',
insert: 'ceil(${1:value})',
documentation: 'Rounds up.',
},
{
name: 'abs',
label: 'abs(value)',
insert: 'abs(${1:value})',
documentation: 'Returns the absolute value.',
},
{
name: 'random',
label: 'random()',
insert: 'random()',
documentation: 'Returns a random number between 0 and 1.',
},
{
name: 'chance',
label: 'chance(percent)',
insert: 'chance(${1:25})',
documentation: 'Returns true with the given percentage chance.',
},
{
name: 'visible',
label: 'visible(layerId)',
insert: 'visible(${1:"layer_id"})',
documentation: 'Whether a layer is currently visible.',
},
{
name: 'groupVisible',
label: 'groupVisible(groupId)',
insert: 'groupVisible(${1:"group_id"})',
documentation: 'Whether a group is currently visible.',
},
{
name: 'contains',
label: 'contains(text, search)',
insert: 'contains(${1:text}, ${2:"search"})',
documentation: 'Whether text contains a smaller string.',
},
{
name: 'length',
label: 'length(text)',
insert: 'length(${1:text})',
documentation: 'Returns the number of characters in text.',
},
{
name: 'number',
label: 'number(value)',
insert: 'number(${1:value})',
documentation: 'Converts a value to a number.',
},
{
name: 'string',
label: 'string(value)',
insert: 'string(${1:value})',
documentation: 'Converts a value to text.',
},
{
name: 'boolean',
label: 'boolean(value)',
insert: 'boolean(${1:value})',
documentation: 'Converts a value to true or false.',
},
];
const snippets: { label: string; insert: string; documentation: string }[] = [
{
label: 'let … = …;',
insert: 'let ${1:name} = ${2:0};',
documentation: 'Declare a theme variable.',
},
{
label: 'if (…) { … }',
insert: 'if (${1:condition}) {\n\t$0\n}',
documentation: 'Run statements when a condition matches.',
},
{
label: 'if (…) { … } else { … }',
insert: 'if (${1:condition}) {\n\t$0\n} else {\n\t\n}',
documentation: 'Run one of two blocks.',
},
{
label: 'for (…) { … }',
insert: 'for (let ${1:i} = 0; ${1:i} < ${2:3}; ${1:i}++) {\n\t$0\n}',
documentation: 'Run a bounded loop.',
},
{
label: 'while (…) { … }',
insert: 'while (${1:condition}) {\n\t$0\n}',
documentation: 'Run while a condition stays true.',
},
{
label: 'clicks += 1;',
insert: '${1:clicks} += ${2:1};',
documentation: 'Add to a number variable.',
},
{
label: 'showLayer(…);',
insert: 'showLayer(${1:"layer_id"});',
documentation: 'Make a layer visible.',
},
{
label: 'hideLayer(…);',
insert: 'hideLayer(${1:"layer_id"});',
documentation: 'Hide a layer.',
},
{
label: 'toggleLayer(…);',
insert: 'toggleLayer(${1:"layer_id"});',
documentation: 'Flip a layer between visible and hidden.',
},
{
label: 'goTo(…);',
insert: 'goTo(${1:"themes"});',
documentation: 'Navigate to another launcher screen.',
},
{
label: 'openUrl(…);',
insert: 'openUrl(${1:"https://example.com"});',
documentation: 'Open a website after a confirmation dialog.',
},
];
let host = $state<HTMLDivElement | undefined>(undefined);
let monaco: MonacoApi | undefined;
let editor: Monaco.editor.IStandaloneCodeEditor | undefined;
let model: Monaco.editor.ITextModel | undefined;
let ready = $state(false);
let loadError = $state('');
let expanded = $state(false);
let lineCount = $state(1);
let cursorLine = $state(1);
let cursorColumn = $state(1);
let errorCount = $state(0);
let currentValue = '';
const ensureLanguage = (api: MonacoApi) => {
if (!api.languages.getLanguages().some((language) => language.id === LANGUAGE_ID)) {
api.languages.register({ id: LANGUAGE_ID });
}
api.languages.setLanguageConfiguration(LANGUAGE_ID, {
comments: { lineComment: '//', blockComment: ['/*', '*/'] },
brackets: [
['{', '}'],
['[', ']'],
['(', ')'],
],
autoClosingPairs: [
{ open: '{', close: '}' },
{ open: '[', close: ']' },
{ open: '(', close: ')' },
{ open: '"', close: '"' },
{ open: "'", close: "'" },
{ open: '`', close: '`' },
],
surroundingPairs: [
{ open: '{', close: '}' },
{ open: '[', close: ']' },
{ open: '(', close: ')' },
{ open: '"', close: '"' },
{ open: "'", close: "'" },
{ open: '`', close: '`' },
],
indentationRules: {
increaseIndentPattern: /\{[^}"']*$/,
decreaseIndentPattern: /^\s*\}/,
},
});
api.languages.setMonarchTokensProvider(LANGUAGE_ID, {
tokenizer: {
root: [
[/\s+/, 'white'],
[/\/\/.*$/, 'comment'],
[/\/\*/, { token: 'comment', next: '@comment' }],
[/"/, { token: 'string.quote', bracket: '@open', next: '@string' }],
[/'/, { token: 'string.quote', bracket: '@open', next: '@singleString' }],
[/`/, { token: 'string.quote', bracket: '@open', next: '@templateString' }],
[
/\b(?:let|const|var|if|else|for|while|do|function|return|break|continue|switch|case|default|new|delete|typeof|instanceof|in|of|class|try|catch|finally|throw|this)\b/,
'keyword',
],
[/\b(?:true|false|null|undefined)\b/, 'keyword'],
[
/\b(?:min|max|clamp|round|floor|ceil|abs|random|chance|visible|groupVisible|contains|length|number|string|boolean|Number|String|Boolean|showLayer|hideLayer|toggleLayer|showGroup|hideGroup|toggleGroup|goTo|navigate|openUrl|launch|stop|Math)\b/,
'predefined',
],
[/\b(?:screen)\b/, 'constant'],
[/\b\d+(?:\.\d+)?\b/, 'number'],
[/[A-Za-z_][A-Za-z0-9_]*/, 'identifier'],
[/===|!==|=>|==|!=|<=|>=|&&|\|\||\+\+|--|\*\*|[=!<>+\-*/%?:]/, 'operator'],
[/[{}()[\]]/, '@brackets'],
[/[,;]/, 'delimiter'],
[/[^\s]/, 'invalid'],
],
string: [
[/[^"\\]+/, 'string'],
[/\\./, 'string.escape'],
[/"/, { token: 'string.quote', bracket: '@close', next: '@pop' }],
],
singleString: [
[/[^'\\]+/, 'string'],
[/\\./, 'string.escape'],
[/'/, { token: 'string.quote', bracket: '@close', next: '@pop' }],
],
templateString: [
[/[^`\\]+/, 'string'],
[/\\./, 'string.escape'],
[/`/, { token: 'string.quote', bracket: '@close', next: '@pop' }],
],
comment: [
[/[^*/]+/, 'comment'],
[/\*\//, { token: 'comment', next: '@pop' }],
[/[*/]/, 'comment'],
],
},
});
api.editor.defineTheme(THEME_ID, {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'keyword', foreground: 'ff79c6', fontStyle: 'bold' },
{ token: 'predefined', foreground: '8be9fd' },
{ token: 'constant', foreground: 'ffb86c' },
{ token: 'identifier', foreground: 'f8f8f2' },
{ token: 'number', foreground: 'bd93f9' },
{ token: 'string', foreground: 'f1fa8c' },
{ token: 'string.escape', foreground: 'ffb86c' },
{ token: 'comment', foreground: '6272a4', fontStyle: 'italic' },
{ token: 'operator', foreground: 'ff79c6' },
{ token: 'invalid', foreground: 'ff5555' },
],
colors: {
'editor.background': '#08080c',
'editor.foreground': '#f4f4f5',
'editorLineNumber.foreground': '#52525b',
'editorLineNumber.activeForeground': '#e4e4e7',
'editor.lineHighlightBackground': '#ffffff08',
'editor.selectionBackground': '#ff006e33',
'editor.inactiveSelectionBackground': '#ff006e22',
'editorCursor.foreground': '#ff006e',
'editorIndentGuide.background1': '#27272a',
'editorIndentGuide.activeBackground1': '#52525b',
'editorBracketHighlight.foreground1': '#ff79c6',
'editorBracketHighlight.foreground2': '#8be9fd',
'editorBracketHighlight.foreground3': '#f1fa8c',
'editorGutter.background': '#08080c',
'editorWidget.background': '#121218',
'editorWidget.border': '#3f3f46',
'editorSuggestWidget.background': '#121218',
'editorSuggestWidget.border': '#3f3f46',
'editorSuggestWidget.selectedBackground': '#ff006e26',
'editorSuggestWidget.highlightForeground': '#ff79c6',
'editorHoverWidget.background': '#121218',
'editorHoverWidget.border': '#3f3f46',
'editorError.foreground': '#f87171',
'editorOverviewRuler.errorForeground': '#f87171',
'scrollbarSlider.background': '#52525b55',
'scrollbarSlider.hoverBackground': '#71717a88',
'scrollbarSlider.activeBackground': '#a1a1aa99',
},
});
};
const completionRange = (
api: MonacoApi,
currentModel: Monaco.editor.ITextModel,
position: Monaco.Position
) => {
const word = currentModel.getWordUntilPosition(position);
return new api.Range(
position.lineNumber,
word.startColumn,
position.lineNumber,
word.endColumn
);
};
const insideString = (text: string) => {
let inside = false;
for (let index = 0; index < text.length; index += 1) {
if (text[index] !== '"') continue;
let backslashes = 0;
for (let check = index - 1; check >= 0 && text[check] === '\\'; check -= 1) {
backslashes += 1;
}
if (backslashes % 2 === 0) inside = !inside;
}
return inside;
};
const registerIntellisense = (api: MonacoApi) => {
const completion = api.languages.registerCompletionItemProvider(LANGUAGE_ID, {
triggerCharacters: ['"', '.'],
provideCompletionItems(currentModel, position) {
const range = completionRange(api, currentModel, position);
const line = currentModel.getLineContent(position.lineNumber);
const before = line.slice(0, position.column - 1);
const suggestions: Monaco.languages.CompletionItem[] = [];
if (insideString(before)) {
const prefix = before.slice(0, before.lastIndexOf('"')).toLowerCase();
const wantsScreen = /(?:goTo|navigate)\s*\(\s*$/.test(prefix);
const wantsGroup = /(?:showGroup|hideGroup|toggleGroup|groupVisible)\s*\(\s*$/.test(
prefix
);
const wantsLayer = /(?:showLayer|hideLayer|toggleLayer|visible)\s*\(\s*$/.test(prefix);
if (wantsScreen) {
for (const screen of screens) {
suggestions.push({
label: screen,
kind: api.languages.CompletionItemKind.EnumMember,
insertText: screen,
range,
documentation: `Go to the ${screen} screen.`,
sortText: `0_${screen}`,
});
}
} else if (wantsGroup) {
for (const group of groups) {
suggestions.push({
label: group.name?.trim() || group.id,
detail: group.id,
kind: api.languages.CompletionItemKind.Struct,
insertText: group.id,
range,
documentation: `Group id: \`${group.id}\``,
sortText: `0_${group.id}`,
});
}
} else if (wantsLayer) {
for (const element of elements) {
suggestions.push({
label: element.name?.trim() || element.id,
detail: element.id,
kind: api.languages.CompletionItemKind.Field,
insertText: element.id,
range,
documentation: `${element.type} layer id: \`${element.id}\``,
sortText: `0_${element.id}`,
});
}
}
return { suggestions, incomplete: false };
}
for (const snippet of snippets) {
suggestions.push({
label: snippet.label,
kind: api.languages.CompletionItemKind.Snippet,
insertText: snippet.insert,
insertTextRules: api.languages.CompletionItemInsertTextRule.InsertAsSnippet,
range,
documentation: snippet.documentation,
sortText: `0_${snippet.label}`,
});
}
for (const entry of Object.entries(statementDocs)) {
suggestions.push({
label: entry[0],
kind: api.languages.CompletionItemKind.Keyword,
insertText: entry[0],
range,
documentation: entry[1],
sortText: `1_${entry[0]}`,
});
}
for (const variable of new Set([...THEME_CLOCK_VARIABLE_NAMES, ...variables])) {
const clock = isThemeClockVariable(variable);
suggestions.push({
label: variable,
kind: api.languages.CompletionItemKind.Variable,
insertText: variable,
range,
documentation: clock
? `Built-in clock value, read-only and refreshed every second. Display it in text layers with \`{${variable}}\`.`
: `Theme variable. Assign it with \`${variable} += 1;\` and display it in text layers with \`{${variable}}\`.`,
sortText: `${clock ? '1' : '2'}_${variable}`,
});
}
for (const screen of screens) {
suggestions.push({
label: `"${screen}"`,
kind: api.languages.CompletionItemKind.EnumMember,
insertText: `"${screen}"`,
range,
documentation: 'Screen name for `goTo()` and `screen`.',
sortText: `3_${screen}`,
});
}
for (const entry of actionFunctions) {
suggestions.push({
label: entry.label,
kind: api.languages.CompletionItemKind.Function,
insertText: entry.insert,
insertTextRules: api.languages.CompletionItemInsertTextRule.InsertAsSnippet,
range,
documentation: entry.documentation,
sortText: `1_${entry.name}`,
});
}
for (const entry of functions) {
suggestions.push({
label: entry.label,
kind: api.languages.CompletionItemKind.Function,
insertText: entry.insert,
insertTextRules: api.languages.CompletionItemInsertTextRule.InsertAsSnippet,
range,
documentation: entry.documentation,
sortText: `1_${entry.name}`,
});
}
return { suggestions, incomplete: false };
},
});
const hover = api.languages.registerHoverProvider(LANGUAGE_ID, {
provideHover(currentModel, position) {
const word = currentModel.getWordAtPosition(position);
if (!word) return null;
let contents = statementDocs[word.word];
let kind = 'Keyword';
if (!contents) {
const entry = [...functions, ...actionFunctions].find(
(candidate) => candidate.name === word.word
);
if (entry) {
contents = `**${entry.label}**\n\n${entry.documentation}`;
kind = 'Function';
}
}
if (!contents && (isThemeClockVariable(word.word) || variables.includes(word.word))) {
contents = isThemeClockVariable(word.word)
? `**${word.word}** — built-in clock value\n\nRefreshes every second. Use it in text layers as \`{${word.word}}\` or read it directly in logic.`
: `**${word.word}** — theme variable\n\nUse it in text layers as \`{${word.word}}\`.`;
kind = 'Variable';
}
if (!contents) {
const element = elements.find((candidate) => candidate.id === word.word);
if (element) {
contents = `**${element.name?.trim() || element.type}**\n\nLayer id: \`${element.id}\``;
kind = 'Layer';
}
}
if (!contents) {
const group = groups.find((candidate) => candidate.id === word.word);
if (group) {
contents = `**${group.name?.trim() || 'Group'}**\n\nGroup id: \`${group.id}\``;
kind = 'Group';
}
}
if (!contents) return null;
return {
range: new api.Range(
position.lineNumber,
word.startColumn,
position.lineNumber,
word.endColumn
),
contents: [{ value: `_${kind}_\n\n${contents}` }],
};
},
});
return () => {
completion.dispose();
hover.dispose();
};
};
const syncDiagnostics = (source: string) => {
if (!monaco || !model) return;
const result = parseThemeScript(source);
const markers: Monaco.editor.IMarkerData[] = result.errors.map((error) => {
const lineNumber = Math.min(Math.max(1, error.line), model!.getLineCount());
const maxColumn = model!.getLineMaxColumn(lineNumber);
const startColumn = Math.min(Math.max(1, error.column), maxColumn);
return {
severity: monaco!.MarkerSeverity.Error,
message: error.message,
source: 'EZPP theme script',
startLineNumber: lineNumber,
startColumn,
endLineNumber: lineNumber,
endColumn: Math.min(maxColumn, startColumn + 1),
};
});
monaco.editor.setModelMarkers(model, MARKER_OWNER, markers);
errorCount = markers.length;
};
const updateStats = (source: string) => {
lineCount = Math.max(1, source.split('\n').length);
};
$effect(() => {
const next = value;
if (!model || !monaco || next === currentValue) return;
currentValue = next;
model.setValue(next);
updateStats(next);
syncDiagnostics(next);
});
$effect(() => {
if (!expanded) return;
requestAnimationFrame(() => editor?.layout());
});
onMount(() => {
let disposed = false;
let disposeIntellisense: (() => void) | undefined;
void (async () => {
try {
const { default: EditorWorker } =
await import('monaco-editor/editor/editor.worker.js?worker');
if (disposed) return;
type WorkerHost = typeof globalThis & {
MonacoEnvironment?: { getWorker: () => Worker };
};
(globalThis as WorkerHost).MonacoEnvironment = {
getWorker: () => new EditorWorker(),
};
const api = await import('monaco-editor');
if (disposed) return;
monaco = api;
ensureLanguage(api);
if (!host) return;
currentValue = value;
updateStats(currentValue);
model = api.editor.createModel(currentValue, LANGUAGE_ID);
editor = api.editor.create(host, {
model,
theme: THEME_ID,
ariaLabel: 'Advanced theme rule script',
automaticLayout: true,
fontFamily: '"JetBrains Mono Variable", "JetBrains Mono", monospace',
fontSize: 12,
lineHeight: 19,
fontLigatures: true,
lineNumbers: 'on',
lineNumbersMinChars: 3,
glyphMargin: true,
folding: true,
minimap: { enabled: false },
wordWrap: 'on',
wrappingIndent: 'indent',
tabSize: 2,
insertSpaces: true,
scrollBeyondLastLine: false,
smoothScrolling: true,
padding: { top: 8, bottom: 8 },
renderLineHighlight: 'line',
fixedOverflowWidgets: true,
quickSuggestions: { other: true, comments: false, strings: false },
suggestOnTriggerCharacters: true,
suggest: { showWords: false, preview: true },
wordBasedSuggestions: 'off',
bracketPairColorization: { enabled: true },
guides: { bracketPairs: true, indentation: true },
scrollbar: { verticalScrollbarSize: 8, horizontalScrollbarSize: 8 },
});
editor.onDidChangeModelContent(() => {
const next = model?.getValue() ?? '';
currentValue = next;
updateStats(next);
syncDiagnostics(next);
onchange?.(next);
});
editor.onDidChangeCursorPosition((event) => {
cursorLine = event.position.lineNumber;
cursorColumn = event.position.column;
});
disposeIntellisense = registerIntellisense(api);
syncDiagnostics(currentValue);
updateStats(currentValue);
ready = true;
editor.focus();
} catch (error) {
console.error('Could not load the Monaco script editor', error);
loadError = error instanceof Error ? error.message : String(error);
}
})();
return () => {
disposed = true;
disposeIntellisense?.();
editor?.dispose();
model?.dispose();
editor = undefined;
model = undefined;
monaco = undefined;
};
});
function toggleExpanded() {
expanded = !expanded;
requestAnimationFrame(() => {
editor?.layout();
editor?.focus();
});
}
</script>
<svelte:window
onkeydown={(event) => {
if (event.key === 'Escape' && expanded) {
event.preventDefault();
expanded = false;
}
}}
/>
{#if expanded}
<button
type="button"
class="fixed inset-0 z-[100] cursor-default bg-black/75 backdrop-blur-sm"
aria-label="Close expanded script editor"
onclick={() => (expanded = false)}
></button>
{/if}
<div
class="flex flex-col overflow-hidden border border-theme-800 bg-[#08080c] {expanded
? 'fixed inset-4 z-[101] rounded-xl shadow-2xl'
: 'relative rounded-md'}"
style={expanded ? '' : `height:${height}px`}
>
<div
class="flex h-7 shrink-0 items-center gap-1.5 border-b border-theme-800 bg-theme-950/80 px-2"
>
<Braces class="size-3 shrink-0 text-primary" />
<span class="text-[9px] font-semibold uppercase tracking-wide text-theme-100">
Advanced script
</span>
<span class="ml-auto flex items-center gap-1.5">
{#if loadError}
<span class="text-[8px] text-destructive">Editor unavailable</span>
{:else if errorCount > 0}
<span class="flex items-center gap-0.5 text-[8px] text-destructive">
<CircleAlert class="size-2.5" />
{errorCount}
</span>
{:else}
<span class="text-[8px] text-emerald-400">Ready</span>
{/if}
<button
type="button"
class="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-theme-800 hover:text-theme-50"
title={expanded ? 'Exit fullscreen' : 'Open fullscreen editor'}
aria-label={expanded ? 'Exit fullscreen editor' : 'Open fullscreen editor'}
onclick={toggleExpanded}
>
{#if expanded}
<Minimize2 class="size-3" />
{:else}
<Maximize2 class="size-3" />
{/if}
</button>
{#if expanded}
<button
type="button"
class="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive"
title="Close editor"
aria-label="Close expanded editor"
onclick={() => (expanded = false)}
>
<X class="size-3" />
</button>
{/if}
</span>
</div>
{#if loadError}
<textarea
class="min-h-0 flex-1 resize-none border-0 bg-[#08080c] p-2 font-mono text-[11px] leading-relaxed text-theme-50 outline-none"
spellcheck={false}
{value}
placeholder={'score += 1;\nif (score >= 3) {\n showLayer("secret");\n}'}
oninput={(event) => {
const next = (event.currentTarget as HTMLTextAreaElement).value;
onchange?.(next);
}}
></textarea>
<p class="border-t border-theme-800 px-2 py-1 text-[8px] text-destructive">
Monaco could not load: {loadError}
</p>
{:else}
<div class="relative min-h-0 flex-1">
<div bind:this={host} class="absolute inset-0"></div>
{#if !ready}
<div
class="absolute inset-0 flex items-center justify-center bg-[#08080c] text-[9px] text-muted-foreground"
>
Loading editor…
</div>
{/if}
</div>
<div
class="flex h-5 shrink-0 items-center gap-3 border-t border-theme-800 px-2 text-[8px] text-muted-foreground"
>
<span>{lineCount} line{lineCount === 1 ? '' : 's'}</span>
<span>Ln {cursorLine}, Col {cursorColumn}</span>
<span class="ml-auto">
{#if errorCount > 0}
<span class="text-destructive">{errorCount} error{errorCount === 1 ? '' : 's'}</span>
{:else}
No errors
{/if}
</span>
</div>
{/if}
</div>
@@ -0,0 +1,72 @@
<script lang="ts">
import { ChevronDown } from '@lucide/svelte';
import type { Component, Snippet } from 'svelte';
let {
title,
open = $bindable(true),
icon: Icon,
badge,
description,
actions,
children,
class: className = '',
contentClass = 'space-y-3 pt-2.5',
headingClass = 'text-xs font-semibold text-theme-100',
bordered = true,
}: {
title: string;
open?: boolean;
icon?: Component<{ class?: string }>;
badge?: string | number;
description?: string;
actions?: Snippet;
children?: Snippet;
class?: string;
contentClass?: string;
headingClass?: string;
bordered?: boolean;
} = $props();
</script>
<section class="{bordered ? 'border-t border-theme-800 pt-3 ' : ''}{className}">
<div class="flex items-center gap-1.5">
<button
type="button"
class="group flex min-w-0 flex-1 items-center gap-1.5 rounded-md py-0.5 text-left transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
aria-expanded={open}
onclick={() => (open = !open)}
>
{#if Icon}
<Icon class="size-3.5 shrink-0 text-primary" />
{/if}
<span class="truncate {headingClass}">{title}</span>
{#if badge !== undefined && badge !== ''}
<span
class="shrink-0 rounded-full bg-theme-900 px-1.5 py-0.5 text-[9px] tabular-nums text-muted-foreground"
>
{badge}
</span>
{/if}
<ChevronDown
class="ml-auto size-3.5 shrink-0 text-muted-foreground transition-transform duration-200 {open
? 'rotate-180'
: ''}"
/>
</button>
{#if actions}
<div class="flex shrink-0 items-center gap-1">
{@render actions()}
</div>
{/if}
</div>
{#if open}
<div class={contentClass}>
{#if description}
<p class="text-[10px] leading-relaxed text-muted-foreground">{description}</p>
{/if}
{@render children?.()}
</div>
{/if}
</section>
+53 -16
View File
@@ -1,41 +1,61 @@
<script lang="ts">
import Logo from '$assets/logo.png';
import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte';
import ThemeV2Background from '$lib/components/theme/ThemeV2Background.svelte';
import ThemeV2Elements from '$lib/components/theme/ThemeV2Elements.svelte';
import { fontStack } from '$lib/themeFonts';
import { readableTextColor, themeFontFaceCss, type ThemeV2Definition } from '$lib/themeV2';
import { Brush, House, Music, Paintbrush, Play, Settings, User, Wifi } from '@lucide/svelte';
import { scopeThemeCss } from '$lib/themeCssScope';
import { fontStack, monoFontStack } from '$lib/themeFonts';
import { DEFAULT_THEME_ICON_SET } from '$lib/themeIcons';
import {
readableTextColor,
themeFontFaceCss,
type ThemeV2Definition,
type ThemeV2Group,
type ThemeV2ScriptValue,
} from '$lib/themeV2';
let {
definition,
assets = '',
assetOverrides = {},
selectedIds = [],
selectedGroupId,
interactive = false,
scale = 1,
variables = {},
onselect,
onselectmany,
onselectgroup,
onmove,
onpatch,
onpatchgroup,
oncontext,
oncontextgroup,
}: {
definition: ThemeV2Definition;
assets?: string;
assetOverrides?: Record<string, string>;
selectedIds?: string[];
selectedGroupId?: string;
interactive?: boolean;
scale?: number;
variables?: Record<string, ThemeV2ScriptValue>;
onselect?: (id: string | undefined, additive?: boolean) => void;
onselectmany?: (ids: string[], additive?: boolean) => void;
onselectgroup?: (id: string) => void;
onmove?: (updates: { id: string; x: number; y: number }[]) => void;
onpatch?: (id: string, patch: Record<string, unknown>) => void;
onpatchgroup?: (id: string, patch: Partial<ThemeV2Group>) => void;
oncontext?: (id: string | undefined, event: MouseEvent) => void;
oncontextgroup?: (id: string, event: MouseEvent) => void;
} = $props();
const bodyFont = $derived(definition.typography?.body);
const headingFont = $derived(definition.typography?.heading);
const monoFont = $derived(definition.typography?.mono);
const accent = $derived(definition.accentColor?.trim() || '#ff006e');
const accentText = $derived(readableTextColor(accent));
const iconSet = $derived(definition.iconSet ?? DEFAULT_THEME_ICON_SET);
const stageStyle = $derived(
[
`font-family:${fontStack(bodyFont)}`,
@@ -46,17 +66,19 @@
`--color-primary-foreground:${accentText}`,
`--color-primary-500:${accent}`,
`--color-ring:${accent}`,
`--font-mono:${monoFontStack(monoFont)}`,
].join(';')
);
const fontFaces = $derived(themeFontFaceCss(definition, assets, assetOverrides));
const customCss = $derived(scopeThemeCss(definition.css, '.theme-stage'));
const elements = $derived(definition.elements ?? []);
</script>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html `<style>${fontFaces}</style>`}
{@html `<style>${fontFaces}\n${customCss}</style>`}
<div
class="theme-stage relative h-[650px] w-[1000px] overflow-hidden bg-theme-950 text-theme-50"
class="theme-stage dark relative h-[650px] w-[1000px] overflow-hidden bg-theme-950 text-theme-50"
style={stageStyle}
role="presentation"
>
@@ -75,24 +97,24 @@
3.2.7
</span>
<div
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] bg-primary/50 p-3 ring-1 ring-inset ring-white/15"
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] bg-primary p-3 text-primary-foreground ring-1 ring-inset ring-white/15"
>
<House class="size-5 text-theme-200" />
<ThemeIcon name="home" set={iconSet} class="size-5" />
</div>
<div
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] border border-black/20 bg-black/20 p-3 ring-1 ring-inset ring-white/15"
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] border border-black/20 bg-black/20 p-3 text-theme-200 ring-1 ring-inset ring-white/15"
>
<Paintbrush class="size-5 text-theme-200" />
<ThemeIcon name="themes" set={iconSet} class="size-5" />
</div>
<div
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] border border-black/20 bg-black/20 p-3 ring-1 ring-inset ring-white/15"
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] border border-black/20 bg-black/20 p-3 text-theme-200 ring-1 ring-inset ring-white/15"
>
<Settings class="size-5 text-theme-200" />
<ThemeIcon name="settings" set={iconSet} class="size-5" />
</div>
<div
class="mt-auto flex size-10 items-center justify-center overflow-hidden rounded-full bg-theme-900"
>
<User class="size-5 text-muted-foreground" />
<ThemeIcon name="user" set={iconSet} class="size-5 text-muted-foreground" />
</div>
</aside>
@@ -139,15 +161,15 @@
>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<Music class="size-3.5 text-blue-400" />
<ThemeIcon name="music" set={iconSet} class="size-3.5 text-blue-400" />
<span class="text-[11px] font-medium text-white/70">1.2k</span>
</div>
<div class="flex items-center gap-2">
<Brush class="size-3.5 text-amber-400" />
<ThemeIcon name="brush" set={iconSet} class="size-3.5 text-amber-400" />
<span class="text-[11px] font-medium text-white/70">42</span>
</div>
<div class="flex items-center gap-2">
<Wifi class="size-3.5 text-emerald-400" />
<ThemeIcon name="wifi" set={iconSet} class="size-3.5 text-emerald-400" />
<span class="text-[11px] font-medium text-white/70">19ms</span>
</div>
<div class="h-4 w-px bg-white/10"></div>
@@ -172,7 +194,11 @@
<div
class="relative z-10 flex size-9 items-center justify-center rounded-full bg-black/30"
>
<Play class="ml-0.5 size-4 fill-current text-primary-foreground" />
<ThemeIcon
name="launch"
set={iconSet}
class="ml-0.5 size-4 fill-current text-primary-foreground"
/>
</div>
</div>
<div class="w-52 pl-1 pr-6 text-center">
@@ -185,15 +211,26 @@
<ThemeV2Elements
{elements}
groups={definition.groups}
{assets}
{assetOverrides}
themeName={definition.name}
themeAuthor={definition.author}
themeVersion={definition.version}
accentColor={definition.accentColor}
{iconSet}
{interactive}
{selectedIds}
{selectedGroupId}
{scale}
{variables}
{onselect}
{onselectmany}
{onselectgroup}
{onmove}
{onpatch}
{onpatchgroup}
{oncontext}
{oncontextgroup}
/>
</div>
@@ -0,0 +1,364 @@
<script lang="ts">
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte';
import * as Select from '$lib/components/ui/select';
import type {
ThemeV2TextDecoration,
ThemeV2TextStyle,
ThemeV2TextTransform,
} from '$lib/themeV2';
import { Bold, Italic, RotateCcw, Strikethrough, Underline } from '@lucide/svelte';
let {
element,
onchange,
}: {
element: ThemeV2TextStyle;
onchange: (patch: Partial<ThemeV2TextStyle>) => void;
} = $props();
const field = 'h-8 border-theme-800 bg-theme-950 text-xs text-theme-50';
const label = 'text-[10px] font-medium uppercase tracking-wide text-muted-foreground';
const colorField = 'h-8 w-10 shrink-0 cursor-pointer border-theme-800 bg-theme-950 p-0.5';
const bold = $derived((element.fontWeight ?? 600) >= 700);
const italic = $derived(element.fontStyle === 'italic');
const underline = $derived((element.textDecoration ?? 'none').includes('underline'));
const strike = $derived((element.textDecoration ?? 'none').includes('line-through'));
const gradient = $derived(element.fillMode === 'gradient');
const transformOptions: { value: ThemeV2TextTransform; label: string }[] = [
{ value: 'none', label: 'None' },
{ value: 'uppercase', label: 'UPPERCASE' },
{ value: 'lowercase', label: 'lowercase' },
{ value: 'capitalize', label: 'Capitalize' },
];
const inputValue = (event: Event) => (event.currentTarget as HTMLInputElement).value;
function toggleDecoration(nextUnderline: boolean, nextStrike: boolean) {
const parts: string[] = [];
if (nextUnderline) parts.push('underline');
if (nextStrike) parts.push('line-through');
onchange({ textDecoration: (parts.join(' ') || 'none') as ThemeV2TextDecoration });
}
function setFillMode(value: ThemeV2TextStyle['fillMode']) {
const fillMode = value === 'gradient' ? 'gradient' : 'solid';
onchange({
fillMode,
...(fillMode === 'gradient'
? {
gradientFrom: element.gradientFrom ?? '#ff006e',
gradientTo: element.gradientTo ?? '#7c3aed',
}
: {}),
});
}
function resetTextStyle() {
onchange({
fontWeight: 600,
fontStyle: 'normal',
textDecoration: 'none',
textTransform: 'none',
fillMode: 'solid',
gradientFrom: '#ff006e',
gradientTo: '#7c3aed',
gradientAngle: 90,
outlineWidth: 0,
outlineColor: '#000000',
shadowBlur: 0,
shadowOffsetX: 0,
shadowOffsetY: 0,
shadowColor: '#000000',
});
}
</script>
{#snippet textStyleActions()}
<Button
variant="ghost"
size="sm"
class="h-6 gap-1 px-1.5 text-[9px] text-muted-foreground"
title="Reset bold, italic, decoration, case, fill, outline and shadow"
onclick={resetTextStyle}
>
<RotateCcw class="size-3" />
Reset
</Button>
{/snippet}
<ThemeSection
title="Text style"
open={false}
actions={textStyleActions}
contentClass="space-y-2 pt-2.5"
>
<div class="grid grid-cols-4 gap-1">
<Button
variant={bold ? 'default' : 'secondary'}
size="icon"
class="size-8 w-full"
title="Bold"
aria-label="Bold"
aria-pressed={bold}
onclick={() => onchange({ fontWeight: bold ? 400 : 700 })}
>
<Bold class="size-3.5" />
</Button>
<Button
variant={italic ? 'default' : 'secondary'}
size="icon"
class="size-8 w-full"
title="Italic"
aria-label="Italic"
aria-pressed={italic}
onclick={() => onchange({ fontStyle: italic ? 'normal' : 'italic' })}
>
<Italic class="size-3.5" />
</Button>
<Button
variant={underline ? 'default' : 'secondary'}
size="icon"
class="size-8 w-full"
title="Underline"
aria-label="Underline"
aria-pressed={underline}
onclick={() => toggleDecoration(!underline, strike)}
>
<Underline class="size-3.5" />
</Button>
<Button
variant={strike ? 'default' : 'secondary'}
size="icon"
class="size-8 w-full"
title="Strikethrough"
aria-label="Strikethrough"
aria-pressed={strike}
onclick={() => toggleDecoration(underline, !strike)}
>
<Strikethrough class="size-3.5" />
</Button>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<Label for="element-text-transform" class={label}>Case</Label>
<Select.Root
type="single"
value={element.textTransform ?? 'none'}
onValueChange={(value) => {
if (value) onchange({ textTransform: value as ThemeV2TextTransform });
}}
>
<Select.Trigger
id="element-text-transform"
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
>
{transformOptions.find((option) => option.value === (element.textTransform ?? 'none'))
?.label ?? 'None'}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
{#each transformOptions as option (option.value)}
<Select.Item value={option.value} label={option.label} />
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1">
<Label for="element-fill-mode" class={label}>Fill</Label>
<Select.Root
type="single"
value={element.fillMode ?? 'solid'}
onValueChange={(value) => setFillMode(value as ThemeV2TextStyle['fillMode'])}
>
<Select.Trigger
id="element-fill-mode"
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
>
{element.fillMode === 'gradient' ? 'Gradient' : 'Solid'}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
<Select.Item value="solid" label="Solid" />
<Select.Item value="gradient" label="Gradient" />
</Select.Content>
</Select.Root>
</div>
</div>
{#if gradient}
<div class="space-y-2">
<div class="space-y-1">
<div class="flex items-center justify-between">
<Label class={label}>Gradient from</Label>
<span class="font-mono text-[9px] text-theme-100"
>{element.gradientFrom ?? '#ff006e'}</span
>
</div>
<div class="flex items-center gap-2">
<Input
class={colorField}
type="color"
value={element.gradientFrom ?? '#ff006e'}
oninput={(event) => onchange({ gradientFrom: inputValue(event) })}
/>
<Input
class="{field} flex-1 font-mono"
value={element.gradientFrom ?? '#ff006e'}
oninput={(event) => onchange({ gradientFrom: inputValue(event) })}
/>
</div>
</div>
<div class="space-y-1">
<div class="flex items-center justify-between">
<Label class={label}>Gradient to</Label>
<span class="font-mono text-[9px] text-theme-100">{element.gradientTo ?? '#7c3aed'}</span>
</div>
<div class="flex items-center gap-2">
<Input
class={colorField}
type="color"
value={element.gradientTo ?? '#7c3aed'}
oninput={(event) => onchange({ gradientTo: inputValue(event) })}
/>
<Input
class="{field} flex-1 font-mono"
value={element.gradientTo ?? '#7c3aed'}
oninput={(event) => onchange({ gradientTo: inputValue(event) })}
/>
</div>
</div>
<div class="space-y-1">
<Label for="element-gradient-angle" class={label}>Gradient angle</Label>
<Input
id="element-gradient-angle"
class={field}
type="number"
min="0"
max="360"
step="1"
value={element.gradientAngle ?? 90}
onchange={(event) =>
onchange({
gradientAngle: Math.min(360, Math.max(0, Number(inputValue(event)) || 0)),
})}
/>
</div>
</div>
{:else}
<div class="space-y-1">
<div class="flex items-center justify-between">
<Label class={label}>Color</Label>
<span class="font-mono text-[9px] text-theme-100">{element.color ?? '#ffffff'}</span>
</div>
<div class="flex items-center gap-2">
<Input
class={colorField}
type="color"
value={element.color ?? '#ffffff'}
oninput={(event) => onchange({ color: inputValue(event) })}
/>
<Input
class="{field} flex-1 font-mono"
value={element.color ?? '#ffffff'}
oninput={(event) => onchange({ color: inputValue(event) })}
/>
</div>
</div>
{/if}
<div class="space-y-1">
<Label class={label}>Outline</Label>
<div class="grid grid-cols-[72px_40px_1fr] gap-2">
<Input
class={field}
type="number"
min="0"
max="20"
step="1"
aria-label="Outline width"
title="Outline width in pixels; 0 disables it"
value={element.outlineWidth ?? 0}
onchange={(event) =>
onchange({ outlineWidth: Math.min(20, Math.max(0, Number(inputValue(event)) || 0)) })}
/>
<Input
class={colorField}
type="color"
aria-label="Outline color"
value={element.outlineColor ?? '#000000'}
oninput={(event) => onchange({ outlineColor: inputValue(event) })}
/>
<Input
class="{field} min-w-0 font-mono"
aria-label="Outline color hex"
value={element.outlineColor ?? '#000000'}
oninput={(event) => onchange({ outlineColor: inputValue(event) })}
/>
</div>
</div>
<div class="space-y-1">
<Label class={label}>Shadow</Label>
<div class="grid grid-cols-3 gap-2">
<Input
class={field}
type="number"
min="0"
max="40"
step="1"
aria-label="Shadow blur"
title="Shadow blur"
value={element.shadowBlur ?? 0}
onchange={(event) =>
onchange({ shadowBlur: Math.min(40, Math.max(0, Number(inputValue(event)) || 0)) })}
/>
<Input
class={field}
type="number"
min="-40"
max="40"
step="1"
aria-label="Shadow offset X"
title="Shadow offset X"
value={element.shadowOffsetX ?? 0}
onchange={(event) =>
onchange({
shadowOffsetX: Math.min(40, Math.max(-40, Number(inputValue(event)) || 0)),
})}
/>
<Input
class={field}
type="number"
min="-40"
max="40"
step="1"
aria-label="Shadow offset Y"
title="Shadow offset Y"
value={element.shadowOffsetY ?? 0}
onchange={(event) =>
onchange({
shadowOffsetY: Math.min(40, Math.max(-40, Number(inputValue(event)) || 0)),
})}
/>
</div>
<div class="flex items-center gap-2">
<Input
class={colorField}
type="color"
aria-label="Shadow color"
value={element.shadowColor ?? '#000000'}
oninput={(event) => onchange({ shadowColor: inputValue(event) })}
/>
<Input
class="{field} flex-1 font-mono"
aria-label="Shadow color hex"
value={element.shadowColor ?? '#000000'}
oninput={(event) => onchange({ shadowColor: inputValue(event) })}
/>
</div>
</div>
</ThemeSection>
@@ -0,0 +1,260 @@
<script lang="ts">
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte';
import type { ThemeV2Transform } from '$lib/themeV2';
import { FlipHorizontal2, FlipVertical2, RotateCcw, RotateCw } from '@lucide/svelte';
type TransformTarget = ThemeV2Transform & { offsetX?: number; offsetY?: number };
type TransformPatch = Partial<TransformTarget>;
let {
transform,
idPrefix,
includeTranslate = false,
onchange,
}: {
transform: TransformTarget;
idPrefix: string;
includeTranslate?: boolean;
onchange: (patch: TransformPatch) => void;
} = $props();
const field = 'h-8 border-theme-800 bg-theme-950 text-xs text-theme-50';
const label = 'text-[10px] font-medium uppercase tracking-wide text-muted-foreground';
const scaleX = $derived(transform.scaleX ?? transform.scale ?? 1);
const scaleY = $derived(transform.scaleY ?? transform.scale ?? 1);
const isDefault = $derived(
(transform.rotation ?? 0) === 0 &&
scaleX === 1 &&
scaleY === 1 &&
(transform.skewX ?? 0) === 0 &&
(transform.skewY ?? 0) === 0 &&
!transform.flipX &&
!transform.flipY &&
(!includeTranslate || ((transform.offsetX ?? 0) === 0 && (transform.offsetY ?? 0) === 0))
);
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
function numberValue(value: string): number | undefined {
if (value.trim() === '') return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function setScale(axis: 'scaleX' | 'scaleY', value: string) {
const parsed = numberValue(value);
if (parsed === undefined) return;
const scale = clamp(parsed / 100, 0.05, 10);
onchange(axis === 'scaleX' ? { scaleX: scale } : { scaleY: scale });
}
function setSkew(axis: 'skewX' | 'skewY', value: string) {
const parsed = numberValue(value);
if (parsed === undefined) return;
const skew = clamp(parsed, -89, 89);
onchange(axis === 'skewX' ? { skewX: skew } : { skewY: skew });
}
function setRotation(value: string) {
const parsed = numberValue(value);
if (parsed === undefined) return;
onchange({ rotation: clamp(parsed, -360, 360) });
}
function rotateBy(delta: number) {
const next = (transform.rotation ?? 0) + delta;
onchange({ rotation: ((((next + 180) % 360) + 360) % 360) - 180 });
}
function setOffset(axis: 'offsetX' | 'offsetY', value: string) {
const parsed = numberValue(value);
if (parsed === undefined) return;
const offset = clamp(parsed, -2000, 2000);
onchange(axis === 'offsetX' ? { offsetX: offset } : { offsetY: offset });
}
function resetTransform() {
onchange({
rotation: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
skewX: 0,
skewY: 0,
flipX: false,
flipY: false,
...(includeTranslate ? { offsetX: 0, offsetY: 0 } : {}),
});
}
</script>
{#snippet transformActions()}
<Button
variant="ghost"
size="sm"
class="h-6 gap-1 px-1.5 text-[9px] text-muted-foreground"
title={includeTranslate
? 'Reset translation, rotation, scale, skew and flips'
: 'Reset rotation, scale, skew and flips'}
disabled={isDefault}
onclick={resetTransform}
>
<RotateCcw class="size-3" />
Reset
</Button>
{/snippet}
<ThemeSection
title="Transform"
open={false}
actions={transformActions}
contentClass="space-y-2 pt-2.5"
>
{#if includeTranslate}
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<Label for="{idPrefix}-translate-x" class={label}>Translate X</Label>
<Input
id="{idPrefix}-translate-x"
class={field}
type="number"
min="-2000"
max="2000"
value={transform.offsetX ?? 0}
onchange={(event) => setOffset('offsetX', event.currentTarget.value)}
/>
</div>
<div class="space-y-1">
<Label for="{idPrefix}-translate-y" class={label}>Translate Y</Label>
<Input
id="{idPrefix}-translate-y"
class={field}
type="number"
min="-2000"
max="2000"
value={transform.offsetY ?? 0}
onchange={(event) => setOffset('offsetY', event.currentTarget.value)}
/>
</div>
</div>
{/if}
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<Label for="{idPrefix}-scale-x" class={label}>Scale X %</Label>
<Input
id="{idPrefix}-scale-x"
class={field}
type="number"
min="5"
max="1000"
step="5"
value={Math.round(scaleX * 100)}
onchange={(event) => setScale('scaleX', event.currentTarget.value)}
/>
</div>
<div class="space-y-1">
<Label for="{idPrefix}-scale-y" class={label}>Scale Y %</Label>
<Input
id="{idPrefix}-scale-y"
class={field}
type="number"
min="5"
max="1000"
step="5"
value={Math.round(scaleY * 100)}
onchange={(event) => setScale('scaleY', event.currentTarget.value)}
/>
</div>
<div class="space-y-1">
<Label for="{idPrefix}-skew-x" class={label}>Skew X °</Label>
<Input
id="{idPrefix}-skew-x"
class={field}
type="number"
min="-89"
max="89"
step="1"
value={transform.skewX ?? 0}
onchange={(event) => setSkew('skewX', event.currentTarget.value)}
/>
</div>
<div class="space-y-1">
<Label for="{idPrefix}-skew-y" class={label}>Skew Y °</Label>
<Input
id="{idPrefix}-skew-y"
class={field}
type="number"
min="-89"
max="89"
step="1"
value={transform.skewY ?? 0}
onchange={(event) => setSkew('skewY', event.currentTarget.value)}
/>
</div>
</div>
<div class="flex items-end gap-2">
<div class="min-w-0 flex-1 space-y-1">
<Label for="{idPrefix}-rotation" class={label}>Rotation °</Label>
<Input
id="{idPrefix}-rotation"
class={field}
type="number"
min="-360"
max="360"
step="1"
value={transform.rotation ?? 0}
onchange={(event) => setRotation(event.currentTarget.value)}
/>
</div>
<div class="flex shrink-0 items-center gap-0.5">
<Button
variant={transform.flipX ? 'default' : 'secondary'}
size="icon"
class="size-7"
title="Flip horizontally"
aria-label="Flip horizontally"
aria-pressed={transform.flipX === true}
onclick={() => onchange({ flipX: !transform.flipX })}
>
<FlipHorizontal2 class="size-3.5" />
</Button>
<Button
variant={transform.flipY ? 'default' : 'secondary'}
size="icon"
class="size-7"
title="Flip vertically"
aria-label="Flip vertically"
aria-pressed={transform.flipY === true}
onclick={() => onchange({ flipY: !transform.flipY })}
>
<FlipVertical2 class="size-3.5" />
</Button>
<Button
variant="secondary"
size="icon"
class="size-7"
title="Rotate left 90°"
aria-label="Rotate left 90 degrees"
onclick={() => rotateBy(-90)}
>
<RotateCcw class="size-3.5" />
</Button>
<Button
variant="secondary"
size="icon"
class="size-7"
title="Rotate right 90°"
aria-label="Rotate right 90 degrees"
onclick={() => rotateBy(90)}
>
<RotateCw class="size-3.5" />
</Button>
</div>
</div>
</ThemeSection>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,94 @@
<script lang="ts">
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import type { ThemeV2ScriptValue } from '$lib/themeV2';
import { RotateCcw } from '@lucide/svelte';
let {
variables,
defaults,
onchange,
}: {
variables: string[];
defaults: Record<string, ThemeV2ScriptValue>;
onchange: (name: string, value: ThemeV2ScriptValue | undefined) => void;
} = $props();
const hasDefault = (name: string) => Object.prototype.hasOwnProperty.call(defaults, name);
const valueLabel = (value: ThemeV2ScriptValue) =>
typeof value === 'boolean' ? 'boolean' : typeof value === 'number' ? 'number' : 'text';
const displayValue = (name: string) => {
if (!hasDefault(name)) return '';
const value = defaults[name];
return typeof value === 'string' ? value : String(value);
};
const parseValue = (raw: string): ThemeV2ScriptValue | undefined => {
if (!raw.trim()) return undefined;
if (raw.trim() === 'true') return true;
if (raw.trim() === 'false') return false;
if (/^-?(?:\d+\.?\d*|\.\d+)$/.test(raw.trim())) {
const number = Number(raw.trim());
if (Number.isFinite(number)) return number;
}
return raw;
};
</script>
<ThemeSection title="Variables" badge={variables.length} contentClass="space-y-2 pt-2">
<p class="text-[10px] leading-relaxed text-muted-foreground">
Starting values for
<code class="rounded bg-theme-900 px-1 py-0.5 text-theme-100">{'{name}'}</code>
placeholders and logic variables. Leave a field empty to start at
<code class="rounded bg-theme-900 px-1 py-0.5 text-theme-100">0</code>.
</p>
{#if variables.length === 0}
<p
class="rounded-md border border-dashed border-theme-800 p-2.5 text-[10px] leading-relaxed text-muted-foreground"
>
Add a
<code class="rounded bg-theme-900 px-1 py-0.5 text-theme-100">{'{placeholder}'}</code>
to a text layer or use a variable in a rule to see it here.
</p>
{:else}
<div class="space-y-1.5">
{#each variables as name (name)}
<div
class="flex items-center gap-1.5 rounded-md border border-theme-800 bg-theme-950 px-2 py-1.5"
>
<div class="min-w-0 flex-1">
<div class="truncate font-mono text-[10px] text-theme-100" title={`{${name}}`}>
{name}
</div>
<div class="text-[8px] leading-tight text-muted-foreground">
{hasDefault(name) ? valueLabel(defaults[name]) : 'no default · starts at 0'}
</div>
</div>
<Input
class="h-7 w-24 shrink-0 border-theme-800 bg-theme-950 px-2 font-mono text-[10px] text-theme-50"
value={displayValue(name)}
placeholder="0"
spellcheck="false"
aria-label={`Default value for ${name}`}
onchange={(event) => onchange(name, parseValue(event.currentTarget.value))}
/>
<Button
variant="ghost"
size="icon"
class="size-7 shrink-0 text-muted-foreground hover:text-theme-100"
title={hasDefault(name) ? `Reset ${name} to 0` : `${name} already starts at 0`}
aria-label={hasDefault(name) ? `Reset ${name} to 0` : `${name} already starts at 0`}
disabled={!hasDefault(name)}
onclick={() => onchange(name, undefined)}
>
<RotateCcw class="size-3" />
</Button>
</div>
{/each}
</div>
{/if}
</ThemeSection>
@@ -1,6 +1,6 @@
<script lang="ts">
import Ellipsis from '@lucide/svelte/icons/ellipsis';
import Play from '@lucide/svelte/icons/play';
import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte';
import { DEFAULT_THEME_ICON_SET, type ThemeIconSet } from '$lib/themeIcons';
const prop: {
downloading: boolean;
@@ -8,6 +8,7 @@
text: string;
subtext?: string;
disabled?: boolean;
iconSet?: ThemeIconSet;
onClick?: () => void;
} = $props();
@@ -66,14 +67,22 @@
<div class="size-9 rounded-full bg-black/30 flex items-center justify-center relative z-10">
{#if prop.downloading}
{#if prop.progress === -1}
<Ellipsis class="size-4 text-primary-foreground fill-current animate-pulse" />
<ThemeIcon
name="ellipsis"
set={prop.iconSet ?? DEFAULT_THEME_ICON_SET}
class="size-4 text-primary-foreground fill-current animate-pulse"
/>
{:else}
<span class="text-[10px] font-bold text-primary-foreground">
{prop.progress.toFixed()}
</span>
{/if}
{:else}
<Play class="size-4 text-primary-foreground fill-current ml-0.5" />
<ThemeIcon
name="launch"
set={prop.iconSet ?? DEFAULT_THEME_ICON_SET}
class="size-4 text-primary-foreground fill-current ml-0.5"
/>
{/if}
</div>
</div>
+15 -8
View File
@@ -1,14 +1,21 @@
<script lang="ts">
import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte';
import { config } from '$lib/config';
import { custom_theme_audio_playing, custom_theme_volume, platform } from '$lib/global';
import {
active_custom_theme,
custom_theme_audio_playing,
custom_theme_volume,
platform,
} from '$lib/global';
import { DEFAULT_THEME_ICON_SET } from '$lib/themeIcons';
import { reduceAnimations } from '$lib/userSettings';
import { Volume, Volume1, VolumeOff } from '@lucide/svelte';
import Minimize from '@lucide/svelte/icons/minus';
import Volume2 from '@lucide/svelte/icons/volume-2';
import Close from '@lucide/svelte/icons/x';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { onMount } from 'svelte';
const iconSet = $derived($active_custom_theme?.definition?.iconSet ?? DEFAULT_THEME_ICON_SET);
onMount(() => {
const appWindow = getCurrentWindow();
@@ -33,13 +40,13 @@
></div>
{#if $custom_theme_volume > 0.7}
<Volume2 class="z-10 pointer-events-none" size={14} />
<ThemeIcon name="volume" set={iconSet} class="z-10 pointer-events-none" size={14} />
{:else if $custom_theme_volume >= 0.15}
<Volume1 class="z-10 pointer-events-none" size={14} />
<ThemeIcon name="volume-low" set={iconSet} class="z-10 pointer-events-none" size={14} />
{:else if $custom_theme_volume > 0}
<Volume class="z-10 pointer-events-none" size={14} />
<ThemeIcon name="volume-low" set={iconSet} class="z-10 pointer-events-none" size={14} />
{:else}
<VolumeOff class="z-10 pointer-events-none" size={14} />
<ThemeIcon name="volume-off" set={iconSet} class="z-10 pointer-events-none" size={14} />
{/if}
</div>
@@ -62,7 +69,7 @@
max="1"
step="0.01"
bind:value={$custom_theme_volume}
on:input={() => {
oninput={() => {
config.value<number>('volume').set($custom_theme_volume);
}}
class="volume-slider"
+2
View File
@@ -30,6 +30,8 @@ export const launching = writable<boolean>(false);
export const custom_themes = writable<Theme[]>([]);
export const custom_theme_container = writable<HTMLElement | undefined>(undefined);
export const active_custom_theme = writable<Theme | undefined>(undefined);
/** Incremented to remount theme media after reloading the active theme from disk. */
export const theme_reload_token = writable(0);
export const custom_theme_audio_playing = writable<boolean>(false);
export const custom_theme_volume = writable<number>(0.15);
custom_theme_volume.subscribe((val) => {
+44
View File
@@ -0,0 +1,44 @@
import type { ThemeV2ScriptValue } from './themeV2';
/**
* Built-in clock values available to theme text layers and logic. They are
* refreshed by the theme script runtime and are read-only for theme authors.
*/
export const THEME_CLOCK_VARIABLE_NAMES = [
'time',
'time12',
'timeSeconds',
'hour',
'minute',
'second',
'dayPeriod',
] as const;
export type ThemeClockVariableName = (typeof THEME_CLOCK_VARIABLE_NAMES)[number];
const CLOCK_VARIABLE_SET = new Set<string>(THEME_CLOCK_VARIABLE_NAMES);
export const isThemeClockVariable = (name: string): name is ThemeClockVariableName =>
CLOCK_VARIABLE_SET.has(name);
const pad = (value: number) => String(value).padStart(2, '0');
/** Local clock values, using the viewer's locale-independent 24h/12h formats. */
export const themeClockValues = (now: Date = new Date()): Record<string, ThemeV2ScriptValue> => {
const hour = now.getHours();
const minute = now.getMinutes();
const second = now.getSeconds();
const time = `${pad(hour)}:${pad(minute)}`;
const dayPeriod = hour < 12 ? 'AM' : 'PM';
const hour12 = hour % 12 || 12;
return {
time,
time12: `${hour12}:${pad(minute)} ${dayPeriod}`,
timeSeconds: `${time}:${pad(second)}`,
hour,
minute,
second,
dayPeriod,
};
};
+109
View File
@@ -0,0 +1,109 @@
/**
* Theme custom CSS is global in the launcher. The advanced editor renders a
* single 1000 x 650 canvas at a time, so previews are scoped to that canvas
* instead of restyling the editor's own chrome.
*/
const ROOT_SELECTORS = /(^|[\s>+~,(])(?::root|html|body)(?=$|[\s>+~.#:[\],)])/g;
const splitSelectorList = (selectorText: string): string[] => {
const selectors: string[] = [];
let start = 0;
let depth = 0;
let quote = '';
let escaped = false;
for (let index = 0; index < selectorText.length; index += 1) {
const character = selectorText[index];
if (escaped) {
escaped = false;
continue;
}
if (character === '\\') {
escaped = true;
continue;
}
if (quote) {
if (character === quote) quote = '';
continue;
}
if (character === '"' || character === "'") {
quote = character;
continue;
}
if (character === '(' || character === '[') {
depth += 1;
continue;
}
if ((character === ')' || character === ']') && depth > 0) {
depth -= 1;
continue;
}
if (character === ',' && depth === 0) {
selectors.push(selectorText.slice(start, index));
start = index + 1;
}
}
selectors.push(selectorText.slice(start));
return selectors;
};
const scopeSelector = (selector: string, scope: string): string => {
const trimmed = selector.trim();
if (!trimmed || trimmed.startsWith(scope)) return trimmed;
const rooted = trimmed.replace(
ROOT_SELECTORS,
(_match, boundary: string) => `${boundary}${scope}`
);
if (rooted.startsWith(scope)) return rooted;
return `${scope} ${rooted}`;
};
/** Prefix every selector in a comma-separated list with the editor canvas scope. */
export const scopeThemeSelectors = (selectorText: string, scope: string): string =>
splitSelectorList(selectorText)
.map((selector) => scopeSelector(selector, scope))
.filter(Boolean)
.join(', ');
const scopeRule = (rule: CSSRule, scope: string) => {
if (rule.type === 1) {
const styleRule = rule as CSSStyleRule;
styleRule.selectorText = scopeThemeSelectors(styleRule.selectorText, scope);
return;
}
const grouping = rule as CSSGroupingRule;
if (grouping.cssRules) {
for (const child of Array.from(grouping.cssRules)) scopeRule(child, scope);
}
};
/**
* Parse custom CSS with the browser and scope its selectors to the preview
* canvas. `@font-face`, keyframes and at-rule blocks are preserved.
*/
export const scopeThemeCss = (css: string | undefined, scope: string): string => {
const source = css?.trim();
if (!source || typeof document === 'undefined') return '';
const style = document.createElement('style');
style.media = 'not all';
style.textContent = source;
document.head.appendChild(style);
try {
const sheet = style.sheet;
if (!sheet) return '';
const rules = Array.from(sheet.cssRules);
for (const rule of rules) scopeRule(rule, scope);
return rules.map((rule) => rule.cssText).join('\n');
} catch {
return '';
} finally {
style.remove();
}
};
+148
View File
@@ -0,0 +1,148 @@
import { currentUserInfo } from './data';
import { launcherVersion, onlineFriends, osuBuild, osuStream, serverPing } from './global';
import { themeClockValues } from './themeClock';
import type { ThemeV2DataField, ThemeV2DataMode } from './themeV2';
import { currentUser } from './userAuthentication';
import { derived } from 'svelte/store';
export type ThemeDataStats = {
pp: number;
rank: number;
plays: number;
accuracy: number;
playtime: number;
};
/** Live launcher values shared by every data layer. */
export type ThemeGlobalData = {
user: {
name: string;
country: string;
stats: Record<ThemeV2DataMode, ThemeDataStats | undefined>;
};
launcher: { version: string; friends: number | undefined };
osu: { build: string | undefined; stream: string | undefined };
server: { ping: number | undefined };
};
export type ThemeDataContext = {
global: ThemeGlobalData;
theme: { name: string; author: string; version: string };
screen: string;
mode?: ThemeV2DataMode;
now?: Date;
};
const MODE_KEYS: Record<ThemeV2DataMode, number> = {
osu: 0,
taiko: 1,
catch: 2,
mania: 3,
};
const number = (value: unknown) =>
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
const emptyStats = (): Record<ThemeV2DataMode, ThemeDataStats | undefined> => ({
osu: undefined,
taiko: undefined,
catch: undefined,
mania: undefined,
});
export const themeGlobalData = derived(
[currentUser, currentUserInfo, launcherVersion, osuBuild, osuStream, serverPing, onlineFriends],
([$user, $info, $version, $build, $stream, $ping, $friends]): ThemeGlobalData => {
const stats = emptyStats();
for (const mode of Object.keys(MODE_KEYS) as ThemeV2DataMode[]) {
const raw = $info?.stats?.[MODE_KEYS[mode]];
if (!raw) continue;
stats[mode] = {
pp: number(raw.pp) ?? 0,
rank: number(raw.rank) ?? 0,
plays: number(raw.plays) ?? 0,
accuracy: number(raw.acc) ?? 0,
playtime: number(raw.playtime) ?? 0,
};
}
return {
user: {
name: $user?.name ?? $info?.info.name ?? '',
country: $info?.info.country ?? '',
stats,
},
launcher: {
version: $version ?? '',
friends: $friends,
},
osu: { build: $build, stream: $stream },
server: { ping: $ping },
};
}
);
const formatInteger = (value: number) => Math.round(value).toLocaleString('en-US');
const formatAccuracy = (value: number) =>
`${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%`;
const formatPlaytime = (seconds: number) => {
const totalMinutes = Math.max(0, Math.round(seconds / 60));
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours > 0) return `${formatInteger(hours)}h ${minutes}m`;
return `${minutes}m`;
};
const formatDate = (date: Date) =>
new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(date);
/** Current value for a data layer field. Missing values render as an empty string. */
export const resolveThemeDataValue = (field: ThemeV2DataField, context: ThemeDataContext) => {
const { global, theme, screen, mode = 'osu', now = new Date() } = context;
const stats = global.user.stats[mode];
switch (field) {
case 'user.name':
return global.user.name;
case 'user.country':
return global.user.country.toUpperCase();
case 'user.pp':
return stats ? formatInteger(stats.pp) : '';
case 'user.rank':
return stats && stats.rank > 0 ? formatInteger(stats.rank) : '';
case 'user.plays':
return stats ? formatInteger(stats.plays) : '';
case 'user.accuracy':
return stats ? formatAccuracy(stats.accuracy) : '';
case 'user.playtime':
return stats ? formatPlaytime(stats.playtime) : '';
case 'launcher.version':
return global.launcher.version;
case 'server.friends':
return global.launcher.friends === undefined ? '' : formatInteger(global.launcher.friends);
case 'osu.build':
return global.osu.build ?? '';
case 'osu.stream':
return global.osu.stream ?? '';
case 'server.ping':
return global.server.ping === undefined ? '' : `${Math.round(global.server.ping)} ms`;
case 'theme.name':
return theme.name;
case 'theme.author':
return theme.author;
case 'theme.version':
return theme.version;
case 'screen':
return screen ? screen.charAt(0).toUpperCase() + screen.slice(1) : '';
case 'clock.time':
return String(themeClockValues(now).time ?? '');
case 'clock.time12':
return String(themeClockValues(now).time12 ?? '');
case 'clock.date':
return formatDate(now);
default:
return '';
}
};
+22 -1
View File
@@ -14,5 +14,26 @@ export const BUNDLED_THEME_FONTS: ThemeFontChoice[] = [
{ label: 'JetBrains Mono', family: 'JetBrains Mono Variable', category: 'Mono' },
];
const quoteFamily = (family: string) => `"${family.replace(/["\\]/g, '\\$&')}"`;
export const fontStack = (family: string | undefined, fallback = 'Sora Variable') =>
`"${(family || fallback).replace(/["\\]/g, '\\$&')}", ui-sans-serif, system-ui, sans-serif`;
`${quoteFamily(family || fallback)}, ui-sans-serif, system-ui, sans-serif`;
export const monoFontStack = (family: string | undefined, fallback = 'Space Mono') =>
`${quoteFamily(family || fallback)}, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace`;
const normalizeFamily = (family: string) =>
family
.trim()
.toLowerCase()
.replace(/\s+variable$/, '');
/** Resolve a Fontsource family name to the family the launcher already ships. */
export const bundledThemeFontFamily = (family: string | undefined) => {
if (!family) return undefined;
const normalized = normalizeFamily(family);
return BUNDLED_THEME_FONTS.find(
(font) =>
normalizeFamily(font.family) === normalized || normalizeFamily(font.label) === normalized
)?.family;
};
+704
View File
@@ -0,0 +1,704 @@
// Generated by scripts/generate-theme-icons.ts. Do not edit by hand.
// Icon artwork: Lucide (ISC), Phosphor (MIT), Tabler (MIT).
import type { ThemeIconName, ThemeIconSet } from './themeIcons';
export type ThemeIconBody = { width: number; height: number; body: string };
export const THEME_ICON_BODIES: Record<ThemeIconSet, Record<ThemeIconName, ThemeIconBody>> = {
lucide: {
home: {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></g>',
},
themes: {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 22a1 1 0 0 1 0-20a10 9 0 0 1 10 9a5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z"/><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/></g>',
},
settings: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 5H3m9 14H3M14 3v4m2 10v4m5-9h-9m9 7h-5m5-14h-7m-6 5v4m0-2H3"/>',
},
launch: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"/>',
},
user: {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></g>',
},
music: {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></g>',
},
brush: {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m11 10l3 3m-7.5 8A3.5 3.5 0 1 0 3 17.5a2.62 2.62 0 0 1-.708 1.792A1 1 0 0 0 3 21z"/><path d="M9.969 17.031L21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031"/></g>',
},
wifi: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 20h.01M2 8.82a15 15 0 0 1 20 0M5 12.859a10 10 0 0 1 14 0m-10.5 3.57a5 5 0 0 1 7 0"/>',
},
'wifi-off': {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 20h.01M8.5 16.429a5 5 0 0 1 7 0M5 12.859a10 10 0 0 1 5.17-2.69m8.83 2.69a10 10 0 0 0-2.007-1.523M2 8.82a15 15 0 0 1 4.177-2.643M22 8.82a15 15 0 0 0-11.288-3.764M2 2l20 20"/>',
},
gameplay: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 11h4M8 9v4m7-1h.01M18 10h.01m-.69-5H6.68a4 4 0 0 0-3.978 3.59l-.017.152C2.604 9.416 2 14.456 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 0 1 9.828 16h4.344a2 2 0 0 1 1.414.586L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.545-.604-6.584-.685-7.258q-.01-.075-.017-.151A4 4 0 0 0 17.32 5"/>',
},
integrations: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 22v-5m3-9V2m2 6a1 1 0 0 1 1 1v4a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1zM9 8V2"/>',
},
installation: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 16h.01m-7.798-4.423a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11zm19.734.436H2.054M6 16h.01"/>',
},
installed: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 16h.01m-7.798-4.423a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11zm19.734.436H2.054M6 16h.01"/>',
},
diagnostics: {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12l2 2l4-4"/></g>',
},
logs: {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M15 12h-5m5-4h-5m9 9V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/></g>',
},
'all-themes': {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/></g>',
},
available: {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 13v8l-4-4m4 4l4-4"/><path d="M4.393 15.269A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.436 8.284"/></g>',
},
updates: {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 12a9 9 0 0 1 9-9a9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5m5 4a9 9 0 0 1-9 9a9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></g>',
},
search: {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m21 21l-4.34-4.34"/><circle cx="11" cy="11" r="8"/></g>',
},
folder: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m6 14l1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/>',
},
plus: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14m-7-7v14"/>',
},
'osu-standard': {
width: 24,
height: 24,
body: '<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>',
},
'osu-taiko': {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m2 2l8 8m12-8l-8 8"/><ellipse cx="12" cy="9" rx="10" ry="5"/><path d="M7 13.4v7.9m5-7.3v8m5-8.6v7.9M2 9v8a10 5 0 0 0 20 0V9"/></g>',
},
'osu-catch': {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M2 17a5 5 0 0 0 10 0c0-2.76-2.5-5-5-3c-2.5-2-5 .24-5 3m10 0a5 5 0 0 0 10 0c0-2.76-2.5-5-5-3c-2.5-2-5 .24-5 3"/><path d="M7 14c3.22-2.91 4.29-8.75 5-12c1.66 2.38 4.94 9 5 12"/><path d="M22 9c-4.29 0-7.14-2.33-10-7c5.71 0 10 4.67 10 7"/></g>',
},
'osu-mania': {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 13v4m4-4v4m4-4v4M2 13h20m0-1.5A3.5 3.5 0 0 0 18.5 8a3.52 3.52 0 0 1-3.173-2A7 7 0 0 0 2 9v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2zM6 13v4"/>',
},
login: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m10 17l5-5l-5-5m5 5H3m12-9h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>',
},
logout: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m16 17l5-5l-5-5m5 5H9m0 9H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>',
},
warning: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21.73 18l-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3M12 9v4m0 4h.01"/>',
},
info: {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4m0-4h.01"/></g>',
},
loader: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 1 1-6.219-8.56"/>',
},
ellipsis: {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></g>',
},
check: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 6L9 17l-5-5"/>',
},
trash: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 11v6m4-6v6m5-11v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
},
edit: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497zM15 5l4 4"/>',
},
history: {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 12a9 9 0 1 0 9-9a9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5m4-1v5l4 2"/></g>',
},
refresh: {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 12a9 9 0 0 1 9-9a9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5m5 4a9 9 0 0 1-9 9a9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></g>',
},
download: {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 13v8l-4-4m4 4l4-4"/><path d="M4.393 15.269A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.436 8.284"/></g>',
},
import: {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 3v12m-4-4l4 4l4-4"/><path d="M8 5H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-4"/></g>',
},
'arrow-right': {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14m-7-7l7 7l-7 7"/>',
},
eye: {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M2.062 12.348a1 1 0 0 1 0-.696a10.75 10.75 0 0 1 19.876 0a1 1 0 0 1 0 .696a10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></g>',
},
'eye-off': {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575a1 1 0 0 1 0 .696a10.8 10.8 0 0 1-1.444 2.49m-6.41-.679a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151a1 1 0 0 1 0-.696a10.75 10.75 0 0 1 4.446-5.143M2 2l20 20"/></g>',
},
wrench: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z"/>',
},
rotate: {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></g>',
},
volume: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298zM16 9a5 5 0 0 1 0 6m3.364 3.364a9 9 0 0 0 0-12.728"/>',
},
'volume-low': {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298zM16 9a5 5 0 0 1 0 6"/>',
},
'volume-off': {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 4.702a.7.7 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.7.7 0 0 0 11 19.298zm5.5 9.798l5-5m-5 0l5 5"/>',
},
},
phosphor: {
home: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/>',
},
themes: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M200.77 53.89A103.27 103.27 0 0 0 128 24h-1.07A104 104 0 0 0 24 128c0 43 26.58 79.06 69.36 94.17A32 32 0 0 0 136 192a16 16 0 0 1 16-16h46.21a31.81 31.81 0 0 0 31.2-24.88a104.4 104.4 0 0 0 2.59-24a103.28 103.28 0 0 0-31.23-73.23m13 93.71a15.89 15.89 0 0 1-15.56 12.4H152a32 32 0 0 0-32 32a16 16 0 0 1-21.31 15.07C62.49 194.3 40 164 40 128a88 88 0 0 1 87.09-88h.9a88.35 88.35 0 0 1 88 87.25a89 89 0 0 1-2.18 20.35ZM140 76a12 12 0 1 1-12-12a12 12 0 0 1 12 12m-44 24a12 12 0 1 1-12-12a12 12 0 0 1 12 12m0 56a12 12 0 1 1-12-12a12 12 0 0 1 12 12m88-56a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/>',
},
settings: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M40 88h33a32 32 0 0 0 62 0h81a8 8 0 0 0 0-16h-81a32 32 0 0 0-62 0H40a8 8 0 0 0 0 16m64-24a16 16 0 1 1-16 16a16 16 0 0 1 16-16m112 104h-17a32 32 0 0 0-62 0H40a8 8 0 0 0 0 16h97a32 32 0 0 0 62 0h17a8 8 0 0 0 0-16m-48 24a16 16 0 1 1 16-16a16 16 0 0 1-16 16"/>',
},
launch: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M232.4 114.49L88.32 26.35a16 16 0 0 0-16.2-.3A15.86 15.86 0 0 0 64 39.87v176.26A15.94 15.94 0 0 0 80 232a16.07 16.07 0 0 0 8.36-2.35l144.04-88.14a15.81 15.81 0 0 0 0-27ZM80 215.94V40l143.83 88Z"/>',
},
user: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/>',
},
music: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M212.92 17.69a8 8 0 0 0-6.86-1.45l-128 32A8 8 0 0 0 72 56v110.08A36 36 0 1 0 88 196v-85.75l112-28v51.83A36 36 0 1 0 216 164V24a8 8 0 0 0-3.08-6.31M52 216a20 20 0 1 1 20-20a20 20 0 0 1-20 20M88 93.75v-31.5l112-28v31.5ZM180 184a20 20 0 1 1 20-20a20 20 0 0 1-20 20"/>',
},
brush: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M232 32a8 8 0 0 0-8-8c-44.08 0-89.31 49.71-114.43 82.63A60 60 0 0 0 32 164c0 30.88-19.54 44.73-20.47 45.37A8 8 0 0 0 16 224h76a60 60 0 0 0 57.37-77.57C182.3 121.31 232 76.08 232 32M92 208H34.63C41.38 198.41 48 183.92 48 164a44 44 0 1 1 44 44m32.42-94.45q5.14-6.66 10.09-12.55A76.2 76.2 0 0 1 155 121.49q-5.9 4.94-12.55 10.09a60.5 60.5 0 0 0-18.03-18.03m42.7-2.68a92.6 92.6 0 0 0-22-22c31.78-34.53 55.75-45 69.9-47.91c-2.85 14.16-13.37 38.13-47.9 69.91"/>',
},
wifi: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M140 204a12 12 0 1 1-12-12a12 12 0 0 1 12 12m97.08-117a172 172 0 0 0-218.16 0a8 8 0 0 0 10.16 12.37a156 156 0 0 1 197.84 0A8 8 0 0 0 237.08 87M205 122.77a124 124 0 0 0-153.94 0A8 8 0 0 0 61 135.31a108 108 0 0 1 134.06 0a8 8 0 0 0 11.24-1.3a8 8 0 0 0-1.3-11.24m-32.26 35.76a76.05 76.05 0 0 0-89.42 0a8 8 0 0 0 9.42 12.94a60 60 0 0 1 70.58 0a8 8 0 1 0 9.42-12.94"/>',
},
'wifi-off': {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M213.92 210.62a8 8 0 1 1-11.84 10.76l-52-57.15a60 60 0 0 0-57.41 7.24a8 8 0 1 1-9.42-12.93A75.43 75.43 0 0 1 128 144c1.28 0 2.55 0 3.82.1l-26.92-29.61A108 108 0 0 0 61 135.31A8 8 0 0 1 49.73 134A8 8 0 0 1 51 122.77a124.3 124.3 0 0 1 41.71-21.66L69.37 75.4a155.4 155.4 0 0 0-40.29 24A8 8 0 0 1 18.92 87A172 172 0 0 1 58 62.86L42.08 45.38a8 8 0 1 1 11.84-10.76ZM128 192a12 12 0 1 0 12 12a12 12 0 0 0-12-12M237.08 87A172.3 172.3 0 0 0 106 49.4a8 8 0 1 0 2 15.87A158 158 0 0 1 128 64a156.25 156.25 0 0 1 98.92 35.37A8 8 0 0 0 237.08 87M195 135.31a8 8 0 0 0 11.24-1.3a8 8 0 0 0-1.3-11.24a124.25 124.25 0 0 0-51.73-24.2a8 8 0 1 0-3.21 15.67a108.1 108.1 0 0 1 45 21.07"/>',
},
gameplay: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M176 112h-24a8 8 0 0 1 0-16h24a8 8 0 0 1 0 16m-72-16h-8v-8a8 8 0 0 0-16 0v8h-8a8 8 0 0 0 0 16h8v8a8 8 0 0 0 16 0v-8h8a8 8 0 0 0 0-16m137.48 104.65a36 36 0 0 1-54.94 4.81c-.12-.12-.24-.24-.35-.37L146.48 160h-37l-39.67 45.09l-.35.37A36.08 36.08 0 0 1 44 216a36 36 0 0 1-35.44-42.25a.7.7 0 0 1 0-.14l16.37-84.09A59.88 59.88 0 0 1 83.89 40H172a60.08 60.08 0 0 1 59 49.25v.18l16.37 84.17a.7.7 0 0 1 0 .14a35.74 35.74 0 0 1-5.89 26.91M172 144a44 44 0 0 0 0-88H83.89a43.9 43.9 0 0 0-43.21 36.37v.13L24.3 176.59A20 20 0 0 0 58 194.3l41.92-47.59a8 8 0 0 1 6-2.71Zm59.7 32.59l-8.74-45A60 60 0 0 1 172 160h-4.2l30.2 34.31a20.09 20.09 0 0 0 17.46 5.39a20 20 0 0 0 16.23-23.11Z"/>',
},
integrations: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M149.66 138.34a8 8 0 0 0-11.32 0L120 156.69L99.31 136l18.35-18.34a8 8 0 0 0-11.32-11.32L88 124.69l-18.34-18.35a8 8 0 0 0-11.32 11.32l6.35 6.34l-23.32 23.31a32 32 0 0 0 0 45.26l5.38 5.37l-28.41 28.4a8 8 0 0 0 11.32 11.32l28.4-28.41l5.37 5.38a32 32 0 0 0 45.26 0L132 191.31l6.34 6.35a8 8 0 0 0 11.32-11.32L131.31 168l18.35-18.34a8 8 0 0 0 0-11.32m-52.29 65a16 16 0 0 1-22.62 0l-22.06-22.09a16 16 0 0 1 0-22.62L76 135.31L120.69 180Zm140.29-185a8 8 0 0 0-11.32 0l-28.4 28.41l-5.37-5.38a32.05 32.05 0 0 0-45.26 0L124 64.69l-6.34-6.35a8 8 0 0 0-11.32 11.32l80 80a8 8 0 0 0 11.32-11.32l-6.35-6.34l23.32-23.31a32 32 0 0 0 0-45.26l-5.38-5.37l28.41-28.4a8 8 0 0 0 0-11.32m-34.35 79L180 120.69L135.31 76l23.32-23.31a16 16 0 0 1 22.62 0l22.06 22a16 16 0 0 1 0 22.68Z"/>',
},
installation: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M208 136H48a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16v-48a16 16 0 0 0-16-16m0 64H48v-48h160zm0-160H48a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 64H48V56h160zm-16-24a12 12 0 1 1-12-12a12 12 0 0 1 12 12m0 96a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/>',
},
installed: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M208 136H48a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16v-48a16 16 0 0 0-16-16m0 64H48v-48h160zm0-160H48a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 64H48V56h160zm-16-24a12 12 0 1 1-12-12a12 12 0 0 1 12 12m0 96a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/>',
},
diagnostics: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M208 40H48a16 16 0 0 0-16 16v56c0 52.72 25.52 84.67 46.93 102.19c23.06 18.86 46 25.26 47 25.53a8 8 0 0 0 4.2 0c1-.27 23.91-6.67 47-25.53C198.48 196.67 224 164.72 224 112V56a16 16 0 0 0-16-16m0 72c0 37.07-13.66 67.16-40.6 89.42a129.3 129.3 0 0 1-39.4 22.2a128.3 128.3 0 0 1-38.92-21.81C61.82 179.51 48 149.3 48 112V56h160ZM82.34 141.66a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 11.32l-56 56a8 8 0 0 1-11.32 0Z"/>',
},
logs: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M96 104a8 8 0 0 1 8-8h64a8 8 0 0 1 0 16h-64a8 8 0 0 1-8-8m8 40h64a8 8 0 0 0 0-16h-64a8 8 0 0 0 0 16m128 48a32 32 0 0 1-32 32H88a32 32 0 0 1-32-32V64a16 16 0 0 0-32 0c0 5.74 4.83 9.62 4.88 9.66A8 8 0 0 1 24 88a7.9 7.9 0 0 1-4.79-1.61C18.05 85.54 8 77.61 8 64a32 32 0 0 1 32-32h136a32 32 0 0 1 32 32v104h8a8 8 0 0 1 4.8 1.6c1.2.86 11.2 8.79 11.2 22.4M96.26 173.48A8.07 8.07 0 0 1 104 168h88V64a16 16 0 0 0-16-16H67.69A31.7 31.7 0 0 1 72 64v128a16 16 0 0 0 32 0c0-5.74-4.83-9.62-4.88-9.66a7.82 7.82 0 0 1-2.86-8.86M216 192a12.58 12.58 0 0 0-3.23-8h-94a27 27 0 0 1 1.21 8a31.8 31.8 0 0 1-4.29 16H200a16 16 0 0 0 16-16"/>',
},
'all-themes': {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M104 40H56a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h48a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 64H56V56h48zm96-64h-48a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h48a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 64h-48V56h48zm-96 32H56a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h48a16 16 0 0 0 16-16v-48a16 16 0 0 0-16-16m0 64H56v-48h48zm96-64h-48a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h48a16 16 0 0 0 16-16v-48a16 16 0 0 0-16-16m0 64h-48v-48h48z"/>',
},
available: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M248 128a87.34 87.34 0 0 1-17.6 52.81a8 8 0 1 1-12.8-9.62A71.34 71.34 0 0 0 232 128a72 72 0 0 0-144 0a8 8 0 0 1-16 0a88 88 0 0 1 3.29-23.88C74.2 104 73.1 104 72 104a48 48 0 0 0 0 96h24a8 8 0 0 1 0 16H72a64 64 0 1 1 9.29-127.32A88 88 0 0 1 248 128m-69.66 42.34L160 188.69V128a8 8 0 0 0-16 0v60.69l-18.34-18.35a8 8 0 0 0-11.32 11.32l32 32a8 8 0 0 0 11.32 0l32-32a8 8 0 0 0-11.32-11.32"/>',
},
updates: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M224 48v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h28.69l-14.63-14.63a79.56 79.56 0 0 0-56.13-23.43h-.45a79.52 79.52 0 0 0-55.89 22.77a8 8 0 0 1-11.18-11.44a96 96 0 0 1 135 .79L208 76.69V48a8 8 0 0 1 16 0m-37.59 135.29a80 80 0 0 1-112.47-.66L59.31 168H88a8 8 0 0 0 0-16H40a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0v-28.69l14.63 14.63A95.43 95.43 0 0 0 130 222.06h.53a95.36 95.36 0 0 0 67.07-27.33a8 8 0 0 0-11.18-11.44Z"/>',
},
search: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="m229.66 218.34l-50.07-50.06a88.11 88.11 0 1 0-11.31 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/>',
},
folder: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M245 110.64a16 16 0 0 0-13-6.64h-16V88a16 16 0 0 0-16-16h-69.33l-27.73-20.8a16.14 16.14 0 0 0-9.6-3.2H40a16 16 0 0 0-16 16v144a8 8 0 0 0 8 8h179.1a8 8 0 0 0 7.59-5.47l28.49-85.47a16.05 16.05 0 0 0-2.18-14.42M93.34 64l29.86 22.4A8 8 0 0 0 128 88h72v16H69.77a16 16 0 0 0-15.18 10.94L40 158.7V64Zm112 136H43.1l26.67-80H232Z"/>',
},
plus: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M224 128a8 8 0 0 1-8 8h-80v80a8 8 0 0 1-16 0v-80H40a8 8 0 0 1 0-16h80V40a8 8 0 0 1 16 0v80h80a8 8 0 0 1 8 8"/>',
},
'osu-standard': {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88"/>',
},
'osu-taiko': {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M212.92 17.69a8 8 0 0 0-6.86-1.45l-128 32A8 8 0 0 0 72 56v110.08A36 36 0 1 0 88 196v-85.75l112-28v51.83A36 36 0 1 0 216 164V24a8 8 0 0 0-3.08-6.31M52 216a20 20 0 1 1 20-20a20 20 0 0 1-20 20M88 93.75v-31.5l112-28v31.5ZM180 184a20 20 0 1 1 20-20a20 20 0 0 1-20 20"/>',
},
'osu-catch': {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M248 80H8a8 8 0 0 0-8 8a128 128 0 0 0 256 0a8 8 0 0 0-8-8M77.4 149.91l42.6-42.6v60.29a79.6 79.6 0 0 1-42.6-17.69M66.09 138.6A79.6 79.6 0 0 1 48.4 96h60.29ZM136 107.31l42.6 42.6A79.6 79.6 0 0 1 136 167.6Zm53.91 31.29L147.31 96h60.29a79.6 79.6 0 0 1-17.69 42.6M128 200A112.15 112.15 0 0 1 16.28 96h16.06a96 96 0 0 0 191.32 0h16.06A112.15 112.15 0 0 1 128 200"/>',
},
'osu-mania': {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M208 32H48a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16M80 48h24v88H80Zm32 104a8 8 0 0 0 8-8V48h16v96a8 8 0 0 0 8 8h8v56h-48v-56Zm40-16V48h24v88ZM48 48h16v96a8 8 0 0 0 8 8h16v56H48Zm160 160h-40v-56h16a8 8 0 0 0 8-8V48h16z"/>',
},
login: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="m141.66 133.66l-40 40a8 8 0 0 1-11.32-11.32L116.69 136H24a8 8 0 0 1 0-16h92.69L90.34 93.66a8 8 0 0 1 11.32-11.32l40 40a8 8 0 0 1 0 11.32M200 32h-64a8 8 0 0 0 0 16h56v160h-56a8 8 0 0 0 0 16h64a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8"/>',
},
logout: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M120 216a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V40a8 8 0 0 1 8-8h64a8 8 0 0 1 0 16H56v160h56a8 8 0 0 1 8 8m109.66-93.66l-40-40a8 8 0 0 0-11.32 11.32L204.69 120H112a8 8 0 0 0 0 16h92.69l-26.35 26.34a8 8 0 0 0 11.32 11.32l40-40a8 8 0 0 0 0-11.32"/>',
},
warning: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M236.8 188.09L149.35 36.22a24.76 24.76 0 0 0-42.7 0L19.2 188.09a23.51 23.51 0 0 0 0 23.72A24.35 24.35 0 0 0 40.55 224h174.9a24.35 24.35 0 0 0 21.33-12.19a23.51 23.51 0 0 0 .02-23.72m-13.87 15.71a8.5 8.5 0 0 1-7.48 4.2H40.55a8.5 8.5 0 0 1-7.48-4.2a7.59 7.59 0 0 1 0-7.72l87.45-151.87a8.75 8.75 0 0 1 15 0l87.45 151.87a7.59 7.59 0 0 1-.04 7.72M120 144v-40a8 8 0 0 1 16 0v40a8 8 0 0 1-16 0m20 36a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/>',
},
info: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m16-40a8 8 0 0 1-8 8a16 16 0 0 1-16-16v-40a8 8 0 0 1 0-16a16 16 0 0 1 16 16v40a8 8 0 0 1 8 8m-32-92a12 12 0 1 1 12 12a12 12 0 0 1-12-12"/>',
},
loader: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M232 128a104 104 0 0 1-208 0c0-41 23.81-78.36 60.66-95.27a8 8 0 0 1 6.68 14.54C60.15 61.59 40 93.27 40 128a88 88 0 0 0 176 0c0-34.73-20.15-66.41-51.34-80.73a8 8 0 0 1 6.68-14.54C208.19 49.64 232 87 232 128"/>',
},
ellipsis: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M140 128a12 12 0 1 1-12-12a12 12 0 0 1 12 12m56-12a12 12 0 1 0 12 12a12 12 0 0 0-12-12m-136 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/>',
},
check: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="m229.66 77.66l-128 128a8 8 0 0 1-11.32 0l-56-56a8 8 0 0 1 11.32-11.32L96 188.69L218.34 66.34a8 8 0 0 1 11.32 11.32"/>',
},
trash: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/>',
},
edit: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z"/>',
},
history: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M136 80v43.47l36.12 21.67a8 8 0 0 1-8.24 13.72l-40-24A8 8 0 0 1 120 128V80a8 8 0 0 1 16 0m-8-48a95.44 95.44 0 0 0-67.92 28.15C52.81 67.51 46.35 74.59 40 82V64a8 8 0 0 0-16 0v40a8 8 0 0 0 8 8h40a8 8 0 0 0 0-16H49c7.15-8.42 14.27-16.35 22.39-24.57a80 80 0 1 1 1.66 114.75a8 8 0 1 0-11 11.64A96 96 0 1 0 128 32"/>',
},
refresh: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M224 48v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h28.69l-14.63-14.63a79.56 79.56 0 0 0-56.13-23.43h-.45a79.52 79.52 0 0 0-55.89 22.77a8 8 0 0 1-11.18-11.44a96 96 0 0 1 135 .79L208 76.69V48a8 8 0 0 1 16 0m-37.59 135.29a80 80 0 0 1-112.47-.66L59.31 168H88a8 8 0 0 0 0-16H40a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0v-28.69l14.63 14.63A95.43 95.43 0 0 0 130 222.06h.53a95.36 95.36 0 0 0 67.07-27.33a8 8 0 0 0-11.18-11.44Z"/>',
},
download: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M248 128a87.34 87.34 0 0 1-17.6 52.81a8 8 0 1 1-12.8-9.62A71.34 71.34 0 0 0 232 128a72 72 0 0 0-144 0a8 8 0 0 1-16 0a88 88 0 0 1 3.29-23.88C74.2 104 73.1 104 72 104a48 48 0 0 0 0 96h24a8 8 0 0 1 0 16H72a64 64 0 1 1 9.29-127.32A88 88 0 0 1 248 128m-69.66 42.34L160 188.69V128a8 8 0 0 0-16 0v60.69l-18.34-18.35a8 8 0 0 0-11.32 11.32l32 32a8 8 0 0 0 11.32 0l32-32a8 8 0 0 0-11.32-11.32"/>',
},
import: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M224 144v64a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-64a8 8 0 0 1 16 0v56h160v-56a8 8 0 0 1 16 0M93.66 77.66L120 51.31V144a8 8 0 0 0 16 0V51.31l26.34 26.35a8 8 0 0 0 11.32-11.32l-40-40a8 8 0 0 0-11.32 0l-40 40a8 8 0 0 0 11.32 11.32"/>',
},
'arrow-right': {
width: 256,
height: 256,
body: '<path fill="currentColor" d="m221.66 133.66l-72 72a8 8 0 0 1-11.32-11.32L196.69 136H40a8 8 0 0 1 0-16h156.69l-58.35-58.34a8 8 0 0 1 11.32-11.32l72 72a8 8 0 0 1 0 11.32"/>',
},
eye: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M247.31 124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57 61.26 162.88 48 128 48S61.43 61.26 36.34 86.35C17.51 105.18 9 124 8.69 124.76a8 8 0 0 0 0 6.5c.35.79 8.82 19.57 27.65 38.4C61.43 194.74 93.12 208 128 208s66.57-13.26 91.66-38.34c18.83-18.83 27.3-37.61 27.65-38.4a8 8 0 0 0 0-6.5M128 192c-30.78 0-57.67-11.19-79.93-33.25A133.5 133.5 0 0 1 25 128a133.3 133.3 0 0 1 23.07-30.75C70.33 75.19 97.22 64 128 64s57.67 11.19 79.93 33.25A133.5 133.5 0 0 1 231.05 128c-7.21 13.46-38.62 64-103.05 64m0-112a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32"/>',
},
'eye-off': {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M53.92 34.62a8 8 0 1 0-11.84 10.76l19.24 21.17C25 88.84 9.38 123.2 8.69 124.76a8 8 0 0 0 0 6.5c.35.79 8.82 19.57 27.65 38.4C61.43 194.74 93.12 208 128 208a127.1 127.1 0 0 0 52.07-10.83l22 24.21a8 8 0 1 0 11.84-10.76Zm47.33 75.84l41.67 45.85a32 32 0 0 1-41.67-45.85M128 192c-30.78 0-57.67-11.19-79.93-33.25A133.2 133.2 0 0 1 25 128c4.69-8.79 19.66-33.39 47.35-49.38l18 19.75a48 48 0 0 0 63.66 70l14.73 16.2A112 112 0 0 1 128 192m6-95.43a8 8 0 0 1 3-15.72a48.16 48.16 0 0 1 38.77 42.64a8 8 0 0 1-7.22 8.71a6 6 0 0 1-.75 0a8 8 0 0 1-8-7.26A32.09 32.09 0 0 0 134 96.57m113.28 34.69c-.42.94-10.55 23.37-33.36 43.8a8 8 0 1 1-10.67-11.92a132.8 132.8 0 0 0 27.8-35.14a133.2 133.2 0 0 0-23.12-30.77C185.67 75.19 158.78 64 128 64a118.4 118.4 0 0 0-19.36 1.57A8 8 0 1 1 106 49.79A134 134 0 0 1 128 48c34.88 0 66.57 13.26 91.66 38.35c18.83 18.83 27.3 37.62 27.65 38.41a8 8 0 0 1 0 6.5Z"/>',
},
wrench: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M226.76 69a8 8 0 0 0-12.84-2.88l-40.3 37.19l-17.23-3.7l-3.7-17.23l37.19-40.3A8 8 0 0 0 187 29.24A72 72 0 0 0 88 96a72.3 72.3 0 0 0 6 28.94L33.79 177c-.15.12-.29.26-.43.39a32 32 0 0 0 45.26 45.26c.13-.13.27-.28.39-.42L131.06 162A72 72 0 0 0 232 96a71.6 71.6 0 0 0-5.24-27M160 152a56.14 56.14 0 0 1-27.07-7a8 8 0 0 0-9.92 1.77l-55.9 64.74a16 16 0 0 1-22.62-22.62L109.18 133a8 8 0 0 0 1.77-9.93a56 56 0 0 1 58.36-82.31l-31.2 33.81a8 8 0 0 0-1.94 7.1l5.66 26.33a8 8 0 0 0 6.14 6.14l26.35 5.66a8 8 0 0 0 7.1-1.94l33.81-31.2A56.06 56.06 0 0 1 160 152"/>',
},
rotate: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M224 48v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h28.69l-14.63-14.63a79.56 79.56 0 0 0-56.13-23.43h-.45a79.52 79.52 0 0 0-55.89 22.77a8 8 0 0 1-11.18-11.44a96 96 0 0 1 135 .79L208 76.69V48a8 8 0 0 1 16 0m-37.59 135.29a80 80 0 0 1-112.47-.66L59.31 168H88a8 8 0 0 0 0-16H40a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0v-28.69l14.63 14.63A95.43 95.43 0 0 0 130 222.06h.53a95.36 95.36 0 0 0 67.07-27.33a8 8 0 0 0-11.18-11.44Z"/>',
},
volume: {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M155.51 24.81a8 8 0 0 0-8.42.88L77.25 80H32a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h45.25l69.84 54.31A8 8 0 0 0 160 224V32a8 8 0 0 0-4.49-7.19M32 96h40v64H32Zm112 111.64l-56-43.55V91.91l56-43.55Zm54-106.08a40 40 0 0 1 0 52.88a8 8 0 0 1-12-10.58a24 24 0 0 0 0-31.72a8 8 0 0 1 12-10.58M248 128a79.9 79.9 0 0 1-20.37 53.34a8 8 0 0 1-11.92-10.67a64 64 0 0 0 0-85.33a8 8 0 1 1 11.92-10.67A79.83 79.83 0 0 1 248 128"/>',
},
'volume-low': {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M155.51 24.81a8 8 0 0 0-8.42.88L77.25 80H32a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h45.25l69.84 54.31A8 8 0 0 0 160 224V32a8 8 0 0 0-4.49-7.19M32 96h40v64H32Zm112 111.64l-56-43.55V91.91l56-43.55ZM208 128a39.93 39.93 0 0 1-10 26.46a8 8 0 0 1-12-10.58a24 24 0 0 0 0-31.72a8 8 0 1 1 12-10.58A40 40 0 0 1 208 128"/>',
},
'volume-off': {
width: 256,
height: 256,
body: '<path fill="currentColor" d="M53.92 34.62a8 8 0 1 0-11.84 10.76L73.55 80H32a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h45.25l69.84 54.31A8 8 0 0 0 160 224v-48.91l42.08 46.29a8 8 0 1 0 11.84-10.76ZM32 96h40v64H32Zm112 111.64l-56-43.55v-68.2l56 61.6Zm42-63.77a24 24 0 0 0 0-31.72a8 8 0 1 1 12-10.57a40 40 0 0 1 0 52.88a8 8 0 0 1-12-10.59m-80.16-76a8 8 0 0 1 1.4-11.23l39.85-31A8 8 0 0 1 160 32v74.83a8 8 0 0 1-16 0V48.36l-26.94 21a8 8 0 0 1-11.22-1.45ZM248 128a79.9 79.9 0 0 1-20.37 53.34a8 8 0 0 1-11.92-10.67a64 64 0 0 0 0-85.33a8 8 0 1 1 11.92-10.67A79.83 79.83 0 0 1 248 128"/>',
},
},
tabler: {
home: {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M5 12H3l9-9l9 9h-2M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7"/><path d="M9 21v-6a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v6"/></g>',
},
themes: {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 21a9 9 0 0 1 0-18c4.97 0 9 3.582 9 8c0 1.06-.474 2.078-1.318 2.828S17.693 15 16.5 15H14a2 2 0 0 0-1 3.75A1.3 1.3 0 0 1 12 21"/><path d="M7.5 10.5a1 1 0 1 0 2 0a1 1 0 1 0-2 0m4-3a1 1 0 1 0 2 0a1 1 0 1 0-2 0m4 3a1 1 0 1 0 2 0a1 1 0 1 0-2 0"/></g>',
},
settings: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6a2 2 0 1 0 4 0a2 2 0 1 0-4 0M4 6h8m4 0h4M6 12a2 2 0 1 0 4 0a2 2 0 1 0-4 0m-2 0h2m4 0h10m-5 6a2 2 0 1 0 4 0a2 2 0 1 0-4 0M4 18h11m4 0h1"/>',
},
launch: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16l13-8z"/>',
},
user: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0-8 0M6 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/>',
},
music: {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 17a3 3 0 1 0 6 0a3 3 0 0 0-6 0m10 0a3 3 0 1 0 6 0a3 3 0 0 0-6 0"/><path d="M9 17V4h10v13M9 8h10"/></g>',
},
brush: {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 21v-4a4 4 0 1 1 4 4z"/><path d="M21 3A16 16 0 0 0 8.2 13.2M21 3a16 16 0 0 1-10.2 12.8"/><path d="M10.6 9a9 9 0 0 1 4.4 4.4"/></g>',
},
wifi: {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 18h.01m-2.838-2.828a4 4 0 0 1 5.656 0m-8.485-2.829a8 8 0 0 1 11.314 0"/><path d="M3.515 9.515c4.686-4.687 12.284-4.687 17 0"/></g>',
},
'wifi-off': {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01m-2.838-2.828a4 4 0 0 1 5.656 0m-8.485-2.829a7.96 7.96 0 0 1 3.864-2.14m4.163.155a8 8 0 0 1 3.287 2M3.515 9.515A12 12 0 0 1 7.059 7.06m3.101-.92a12 12 0 0 1 10.325 3.374M3 3l18 18"/>',
},
gameplay: {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 5h3.5a5 5 0 0 1 0 10H10l-4.015 4.227a2.3 2.3 0 0 1-3.923-2.035l1.634-8.173A5 5 0 0 1 8.6 5z"/><path d="m14 15l4.07 4.284a2.3 2.3 0 0 0 3.925-2.023l-1.6-8.232M8 9v2m-1-1h2m5 0h2"/></g>',
},
integrations: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.785 6L18 14.215l-2.054 2.054a5.81 5.81 0 1 1-8.215-8.215zM4 20l3.5-3.5M15 4l-3.5 3.5M20 9l-3.5 3.5"/>',
},
installation: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v2a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3zm0 8a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v2a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3zm4-7v.01M7 16v.01"/>',
},
installed: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v2a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3zm0 8a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v2a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3zm4-7v.01M7 16v.01"/>',
},
diagnostics: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.46 20.846A12 12 0 0 1 3.5 6A12 12 0 0 0 12 3a12 12 0 0 0 8.5 3a12 12 0 0 1-.09 7.06M15 19l2 2l4-4"/>',
},
logs: {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2M9 9h1m-1 4h6m-6 4h6"/></g>',
},
'all-themes': {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1zm10 0a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1zM4 15a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1zm10 0a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1z"/>',
},
available: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 18a3.5 3.5 0 0 0 0-7h-1A5 4.5 0 0 0 7 9a4.6 4.4 0 0 0-2.1 8.4M12 13v9m-3-3l3 3l3-3"/>',
},
updates: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 11A8.1 8.1 0 0 0 4.5 9M4 5v4h4m-4 4a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4"/>',
},
search: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10a7 7 0 1 0 14 0a7 7 0 1 0-14 0m18 11l-6-6"/>',
},
folder: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m5 19l2.757-7.351A1 1 0 0 1 8.693 11H21a1 1 0 0 1 .986 1.164l-.996 5.211A2 2 0 0 1 19.026 19za2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h4l3 3h7a2 2 0 0 1 2 2v2"/>',
},
plus: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v14m-7-7h14"/>',
},
'osu-standard': {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0"/>',
},
'osu-taiko': {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 17a3 3 0 1 0 6 0a3 3 0 0 0-6 0m10 0a3 3 0 1 0 6 0a3 3 0 0 0-6 0"/><path d="M9 17V4h10v13M9 8h10"/></g>',
},
'osu-catch': {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M4 16.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0-7 0M14 18a3 3 0 1 0 6 0a3 3 0 1 0-6 0"/><path d="M9 13c.366-2 1.866-3.873 4.5-5.6M17 15c-1.333-2.333-2.333-5.333-1-9"/><path d="M5 6q5.5-4 11 0q-5.5 4-11 0"/></g>',
},
'osu-mania': {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zm6 12v-6"/><path d="M8 5v8h2V5m5 14v-6m-1-8v8h2V5"/></g>',
},
login: {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M15 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2v-2"/><path d="M21 12H8l3-3m0 6l-3-3"/></g>',
},
logout: {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M14 8V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2v-2"/><path d="M9 12h12l-3-3m0 6l3-3"/></g>',
},
warning: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v4m-1.637-9.409L2.257 17.125a1.914 1.914 0 0 0 1.636 2.871h16.214a1.914 1.914 0 0 0 1.636-2.87L13.637 3.59a1.914 1.914 0 0 0-3.274 0M12 16h.01"/>',
},
info: {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0-18 0m9-3h.01"/><path d="M11 12h1v4h1"/></g>',
},
loader: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V3m4.25 4.75L18.4 5.6M18 12h3m-4.75 4.25l2.15 2.15M12 18v3m-4.25-4.75L5.6 18.4M6 12H3m4.75-4.25L5.6 5.6"/>',
},
ellipsis: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 12a1 1 0 1 0 2 0a1 1 0 1 0-2 0m7 0a1 1 0 1 0 2 0a1 1 0 1 0-2 0m7 0a1 1 0 1 0 2 0a1 1 0 1 0-2 0"/>',
},
check: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m5 12l5 5L20 7"/>',
},
trash: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7h16m-10 4v6m4-6v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3"/>',
},
edit: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 20h4L18.5 9.5a2.828 2.828 0 1 0-4-4L4 16zm9.5-13.5l4 4"/>',
},
history: {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 8v4l2 2"/><path d="M3.05 11a9 9 0 1 1 .5 4m-.5 5v-5h5"/></g>',
},
refresh: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 11A8.1 8.1 0 0 0 4.5 9M4 5v4h4m-4 4a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4"/>',
},
download: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 18a3.5 3.5 0 0 0 0-7h-1A5 4.5 0 0 0 7 9a4.6 4.4 0 0 0-2.1 8.4M12 13v9m-3-3l3 3l3-3"/>',
},
import: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2M7 9l5-5l5 5m-5-5v12"/>',
},
'arrow-right': {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14m-6 6l6-6m-6-6l6 6"/>',
},
eye: {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0-4 0"/><path d="M21 12q-3.6 6-9 6t-9-6q3.6-6 9-6t9 6"/></g>',
},
'eye-off': {
width: 24,
height: 24,
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M10.585 10.587a2 2 0 0 0 2.829 2.828"/><path d="M16.681 16.673A8.7 8.7 0 0 1 12 18q-5.4 0-9-6q1.908-3.18 4.32-4.674m2.86-1.146A9 9 0 0 1 12 6q5.4 0 9 6q-1 1.665-2.138 2.87M3 3l18 18"/></g>',
},
wrench: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 10h3V7L6.5 3.5a6 6 0 0 1 8 8l6 6a2 2 0 0 1-3 3l-6-6a6 6 0 0 1-8-8z"/>',
},
rotate: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.05 11a8 8 0 1 1 .5 4m-.5 5v-5h5"/>',
},
volume: {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 8a5 5 0 0 1 0 8m2.7-11a9 9 0 0 1 0 14M6 15H4a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h2l3.5-4.5A.8.8 0 0 1 11 5v14a.8.8 0 0 1-1.5.5z"/>',
},
'volume-low': {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 8a5 5 0 0 1 0 8m-9-1H4a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h2l3.5-4.5A.8.8 0 0 1 11 5v14a.8.8 0 0 1-1.5.5z"/>',
},
'volume-off': {
width: 24,
height: 24,
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 8a5 5 0 0 1 1.912 4.934m-1.377 2.602A5 5 0 0 1 15 16m2.7-11a9 9 0 0 1 2.362 11.086m-1.676 2.299A9 9 0 0 1 17.7 19M9.069 5.054L9.5 4.5A.8.8 0 0 1 11 5v2m0 4v8a.8.8 0 0 1-1.5.5L6 15H4a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h2l1.294-1.664M3 3l18 18"/>',
},
},
};
+93
View File
@@ -0,0 +1,93 @@
export const THEME_ICON_SETS = [
{
value: 'lucide',
label: 'Lucide',
description: 'Clean geometric outlines. The launcher default.',
},
{
value: 'phosphor',
label: 'Phosphor',
description: 'Soft, rounded shapes with a friendly feel.',
},
{
value: 'tabler',
label: 'Tabler',
description: 'Crisp, technical outlines with a larger family.',
},
] as const;
export type ThemeIconSet = (typeof THEME_ICON_SETS)[number]['value'];
export const DEFAULT_THEME_ICON_SET: ThemeIconSet = 'lucide';
export const isThemeIconSet = (value: unknown): value is ThemeIconSet =>
THEME_ICON_SETS.some((set) => set.value === value);
/**
* Semantic icon names used by launcher chrome. Themes choose a family, never a
* component import, so the launcher can swap families without changing layout.
*/
export const THEME_ICON_NAMES = [
'home',
'themes',
'settings',
'launch',
'user',
'music',
'brush',
'wifi',
'wifi-off',
'gameplay',
'integrations',
'installation',
'installed',
'diagnostics',
'logs',
'all-themes',
'available',
'updates',
'search',
'folder',
'plus',
'osu-standard',
'osu-taiko',
'osu-catch',
'osu-mania',
'login',
'logout',
'warning',
'info',
'loader',
'ellipsis',
'check',
'trash',
'edit',
'history',
'refresh',
'download',
'import',
'arrow-right',
'eye',
'eye-off',
'wrench',
'rotate',
'volume',
'volume-low',
'volume-off',
] as const;
export type ThemeIconName = (typeof THEME_ICON_NAMES)[number];
const ICON_NAME_SET = new Set<string>(THEME_ICON_NAMES);
export const isThemeIconName = (value: unknown): value is ThemeIconName =>
typeof value === 'string' && ICON_NAME_SET.has(value);
/** A small representative row used by the editor's live icon preview. */
export const THEME_ICON_PREVIEW_NAMES: ThemeIconName[] = [
'home',
'themes',
'settings',
'launch',
'user',
];
+174
View File
@@ -0,0 +1,174 @@
import type {
ThemeV2ClockElement,
ThemeV2CountdownElement,
ThemeV2ParticlesElement,
} from './themeV2';
/**
* Shared helpers for the dynamic API 2.0 layer types. The DOM renderer and the
* canvas thumbnail renderer both use these so a layer looks the same in the
* launcher, the editor canvas and the exported preview image.
*/
const pad = (value: number) => String(Math.max(0, Math.floor(value))).padStart(2, '0');
export const themeClockTime = (element: ThemeV2ClockElement, now: Date): string => {
const hours = now.getHours();
const minutes = now.getMinutes();
const seconds = now.getSeconds();
const showSeconds = element.showSeconds !== false;
if (element.hour12) {
const hour = hours % 12 || 12;
return `${hour}:${pad(minutes)}${showSeconds ? `:${pad(seconds)}` : ''} ${hours < 12 ? 'AM' : 'PM'}`;
}
return `${pad(hours)}:${pad(minutes)}${showSeconds ? `:${pad(seconds)}` : ''}`;
};
export const themeClockDate = (now: Date): string =>
now.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
export type ThemeCountdownParts = {
days: number;
hours: number;
minutes: number;
seconds: number;
/** True when the target is a valid date that has already passed. */
finished: boolean;
/** False for empty or unparsable targets; the layer then shows zeroes. */
valid: boolean;
};
export const themeCountdownParts = (target: string, now: Date): ThemeCountdownParts => {
const timestamp = target.trim() ? Date.parse(target) : Number.NaN;
if (!Number.isFinite(timestamp)) {
return { days: 0, hours: 0, minutes: 0, seconds: 0, finished: false, valid: false };
}
const remaining = Math.max(0, timestamp - now.getTime());
const totalSeconds = Math.floor(remaining / 1000);
return {
days: Math.floor(totalSeconds / 86_400),
hours: Math.floor((totalSeconds % 86_400) / 3_600),
minutes: Math.floor((totalSeconds % 3_600) / 60),
seconds: totalSeconds % 60,
finished: remaining <= 0,
valid: true,
};
};
export const themeCountdownText = (element: ThemeV2CountdownElement, now: Date): string => {
const parts = themeCountdownParts(element.target, now);
if (parts.finished) return element.finishedText?.trim() || 'The wait is over';
const template = element.text?.trim() ? element.text : '{days}d {hours}h {minutes}m {seconds}s';
return template
.replaceAll('{days}', String(parts.days))
.replaceAll('{hours}', pad(parts.hours))
.replaceAll('{minutes}', pad(parts.minutes))
.replaceAll('{seconds}', pad(parts.seconds));
};
export type ThemeParticleSeed = {
/** Horizontal position in percent of the layer. */
left: number;
/** Vertical offset in percent of the layer. */
top: number;
/** Full animation duration in seconds. */
duration: number;
/** Negative delay so particles start already spread along their path. */
delay: number;
/** Particle size multiplier, 0.45–1.4. */
size: number;
/** Horizontal drift in percent of the layer width. */
drift: number;
/** Total rotation in degrees. */
spin: number;
/** Use the secondary color. */
alternate: boolean;
/** Peak opacity, 0.35–1. */
opacity: number;
};
const seeded = (seed: number) => {
let state = seed || 1;
return () => {
state = (state * 9301 + 49297) % 233_280;
return state / 233_280;
};
};
export const themeParticleSeeds = (element: ThemeV2ParticlesElement): ThemeParticleSeed[] => {
const density = Math.max(4, Math.min(120, Math.round(element.density ?? 28)));
const speed = Math.max(0.1, Math.min(3, element.speed ?? 1));
const wind = Math.max(-1, Math.min(1, element.wind ?? 0));
const random = seeded(density * 7919 + 17);
return Array.from({ length: density }, () => {
const duration = (6 + random() * 10) / speed;
return {
left: random() * 100,
top: random() * 100,
duration,
delay: -duration * random(),
size: 0.45 + random() * 0.95,
drift: (random() * 2 - 1) * 10 + wind * 16,
spin: (random() * 2 - 1) * 720,
alternate: random() > 0.5,
opacity: 0.35 + random() * 0.65,
};
});
};
/** SVG linear-gradient vector for an angle, in 0–1 gradient units. */
export const themeSvgGradientVector = (angle: number) => {
const radians = (angle * Math.PI) / 180;
const x = Math.sin(radians);
const y = -Math.cos(radians);
return {
x1: 0.5 - x / 2,
y1: 0.5 - y / 2,
x2: 0.5 + x / 2,
y2: 0.5 + y / 2,
};
};
/**
* Arc geometry in a 0–100 view box. Angles start at 12 o'clock and run
* clockwise; the stroke is inset so it never clips at the layer edge.
*/
export const themeArcGeometry = (
startAngle: number,
endAngle: number,
thickness: number,
width: number,
height: number
) => {
const minSide = Math.max(1, Math.min(width, height));
const strokeWidth = Math.max(0.5, Math.min(60, (thickness / minSide) * 100));
const radius = Math.max(1, 50 - strokeWidth / 2);
const start = ((startAngle % 360) + 360) % 360;
let end = ((endAngle % 360) + 360) % 360;
if (end <= start) end += 360;
const span = Math.min(360, end - start);
const point = (angle: number) => {
const radians = (angle * Math.PI) / 180;
return {
x: 50 + radius * Math.sin(radians),
y: 50 - radius * Math.cos(radians),
};
};
const from = point(start);
if (span >= 359.99) {
const to = point(start + 180);
return {
d: `M ${from.x} ${from.y} A ${radius} ${radius} 0 1 1 ${to.x} ${to.y} A ${radius} ${radius} 0 1 1 ${from.x} ${from.y}`,
strokeWidth,
};
}
const to = point(start + span);
const largeArc = span > 180 ? 1 : 0;
return {
d: `M ${from.x} ${from.y} A ${radius} ${radius} 0 ${largeArc} 1 ${to.x} ${to.y}`,
strokeWidth,
};
};
+158
View File
@@ -0,0 +1,158 @@
import {
themeGroupAncestors,
type ThemeV2Behavior,
type ThemeV2BehaviorAction,
type ThemeV2BehaviorTarget,
type ThemeV2Element,
type ThemeV2Group,
} from './themeV2';
export const themeElementLabel = (element: ThemeV2Element): string => {
if (element.name?.trim()) return element.name.trim();
if (element.type === 'text') return element.text.trim().slice(0, 32) || 'Text layer';
if (element.type === 'data') return element.field;
if (element.type === 'shape') return element.shape;
if (element.type === 'icon') return `${element.icon} icon`;
if (element.type === 'ticker') return element.text.trim().slice(0, 32) || 'Ticker';
if (element.type === 'gauge') return `${element.mode ?? 'bar'} gauge`;
if (element.type === 'button') return element.label.trim() || 'Button';
if (element.type === 'repeater') {
return element.source === 'image' ? 'Image repeater' : `${element.icon ?? 'Icon'} repeater`;
}
if (element.type === 'lottie') return element.src.split('/').pop() || 'Lottie animation';
if (element.type === 'path') return 'Vector path';
if (element.type === 'particles') return `${element.preset} particles`;
if (element.type === 'clock') return `${element.mode ?? 'digital'} clock`;
if (element.type === 'countdown')
return element.target ? `Countdown · ${element.target}` : 'Countdown';
if (element.type === 'svelte') return 'Svelte component';
return element.src.split('/').pop() || element.type;
};
export const themeGroupLabel = (group: ThemeV2Group): string =>
group.name?.trim() || 'Untitled group';
export const themeGroupContainsElement = (
groups: ThemeV2Group[],
groupId: string,
element: ThemeV2Element
) => themeGroupAncestors(groups, element).some((group) => group.id === groupId);
export const themeGroupInteractiveCount = (
groups: ThemeV2Group[],
elements: ThemeV2Element[],
groupId: string
) =>
elements.filter(
(element) => element.interactive === true && themeGroupContainsElement(groups, groupId, element)
).length;
export const themeBehaviorTargetLabel = (
target: ThemeV2BehaviorTarget | undefined,
elements: ThemeV2Element[],
groups: ThemeV2Group[]
): string => {
if (!target) return 'Global';
if (target.type === 'element') {
const element = elements.find((candidate) => candidate.id === target.id);
return element ? `Layer · ${themeElementLabel(element)}` : 'Layer · missing';
}
const group = groups.find((candidate) => candidate.id === target.id);
return group ? `Group · ${themeGroupLabel(group)}` : 'Group · missing';
};
const pointerVerb = (triggerType: 'click' | 'hover' | 'leave') => {
if (triggerType === 'hover') return 'Hover';
if (triggerType === 'leave') return 'Leave';
return 'Click';
};
export const themeBehaviorTriggerLabel = (
behavior: ThemeV2Behavior,
elements: ThemeV2Element[],
groups: ThemeV2Group[]
): string => {
const trigger = behavior.trigger;
if (trigger.type === 'load') return 'Theme loads';
if (trigger.type === 'screen') {
return `On ${trigger.screen[0].toUpperCase()}${trigger.screen.slice(1)} screen`;
}
if (trigger.type === 'timer') return `Every ${trigger.interval}s`;
const verb = pointerVerb(trigger.type);
if (behavior.target?.type === 'element') {
const element = elements.find((candidate) => candidate.id === behavior.target?.id);
return `${verb} · ${element ? themeElementLabel(element) : 'linked layer'}`;
}
if (behavior.target?.type === 'group') {
const group = groups.find((candidate) => candidate.id === behavior.target?.id);
return `${verb} · inside ${group ? themeGroupLabel(group) : 'linked group'}`;
}
if (trigger.elementId) {
const element = elements.find((candidate) => candidate.id === trigger.elementId);
return `${verb} · ${element ? themeElementLabel(element) : 'missing layer'}`;
}
return `${verb} · any interactive layer`;
};
export const themeBehaviorActionLabel = (
action: ThemeV2BehaviorAction,
elements: ThemeV2Element[],
groups: ThemeV2Group[]
): string => {
const elementLabel = (id: string) => {
const element = elements.find((candidate) => candidate.id === id);
return element ? themeElementLabel(element) : 'missing layer';
};
const groupLabel = (id: string) => {
const group = groups.find((candidate) => candidate.id === id);
return group ? themeGroupLabel(group) : 'missing group';
};
switch (action.type) {
case 'setVariable':
return `Set ${action.variable}`;
case 'changeVariable':
return `${action.amount >= 0 ? 'Add' : 'Subtract'} ${Math.abs(action.amount)} · ${action.variable}`;
case 'toggleVariable':
return `Toggle ${action.variable}`;
case 'showElement':
return `Show ${elementLabel(action.elementId)}`;
case 'hideElement':
return `Hide ${elementLabel(action.elementId)}`;
case 'toggleElement':
return `Toggle ${elementLabel(action.elementId)}`;
case 'showGroup':
return `Show group · ${groupLabel(action.groupId)}`;
case 'hideGroup':
return `Hide group · ${groupLabel(action.groupId)}`;
case 'toggleGroup':
return `Toggle group · ${groupLabel(action.groupId)}`;
case 'navigate':
return `Go to ${action.screen}`;
case 'launch':
return 'Launch osu!';
case 'openUrl':
return 'Open URL';
}
};
export const themeBehaviorSummary = (
behavior: ThemeV2Behavior,
elements: ThemeV2Element[],
groups: ThemeV2Group[],
limit = 2
): string => {
const trigger = themeBehaviorTriggerLabel(behavior, elements, groups);
if (behavior.script !== undefined) {
const lines = behavior.script.split('\n').filter((line) => line.trim()).length;
return `${trigger} → Advanced script · ${lines} line${lines === 1 ? '' : 's'}`;
}
if (behavior.actions.length === 0) return `${trigger} · no action`;
const actions = behavior.actions.map((action) =>
themeBehaviorActionLabel(action, elements, groups)
);
const visible = actions.slice(0, limit);
const remaining = actions.length - visible.length;
return `${trigger} → ${visible.join(' · ')}${remaining > 0 ? ` +${remaining}` : ''}`;
};
+236 -14
View File
@@ -8,6 +8,10 @@
* one-byte ranges, which can starve the audio decoder. A blob avoids that, but
* WebKit's blob-backed media path starts stuttering past roughly 30 MB, so
* large files are left on the direct URL instead.
*
* WebKit also decodes VP9/AV1 video in software on macOS. Large installed
* theme videos and videos picked in the editor (served as /preview/ URLs) are
* transcoded to a cached H.264 copy by the Rust backend before playback.
*/
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
@@ -16,10 +20,12 @@ import { writable } from 'svelte/store';
type MediaCleanup = () => void;
const assetPattern = /^(?:asset:|https?:\/\/asset\.localhost(?:\/|$))/i;
const loopbackThemePattern = /^http:\/\/127\.0\.0\.1:\d+\/themes\//i;
const videoFilePattern = /\.(?:webm|mp4|m4v|mov|ogv|mkv)(?:[?#]|$)/i;
const loopbackThemePattern = /^http:\/\/127\.0\.0\.1:\d+\/(?:themes|preview)\//i;
const MAX_BLOB_BYTES = 24 * 1024 * 1024;
const OPTIMIZE_AFTER_BYTES = 20 * 1024 * 1024;
const OPTIMIZE_AFTER_BYTES = 2 * 1024 * 1024;
const FADE_OUT_MS = 260;
const FADE_IN_MS = 420;
const FADE_STEP_MS = 16;
// The request-storm problem is specific to WKWebView. Keep the native
// streaming path everywhere else, where the asset protocol behaves correctly
@@ -31,6 +37,8 @@ const useBlobMedia = isMac;
export const themeMediaPreparing = writable(false);
/** Human-readable detail for the preparation overlay. */
export const themeMediaStatus = writable('');
/** True while the launcher's theme media should stay paused, e.g. in the editor. */
export const themeMediaSuspended = writable(false);
type ThemeMediaProgress = {
stage: 'download' | 'extract' | 'optimize';
@@ -78,16 +86,15 @@ let cacheGeneration = 0;
export const isThemeAssetUrl = (url: string | undefined | null): url is string =>
!!url && assetPattern.test(url);
const isLoopbackThemeVideo = (source: string) =>
isMac && loopbackThemePattern.test(source) && videoFilePattern.test(source);
/**
* WebKit's VP9 decoder is software-only on macOS, and a large 1080p theme
* video can starve the audio decoder. Ask the launcher for a cached 720p
* WebKit's VP9/AV1 decoders are software-only on macOS, and even a short 1080p
* theme video can starve the audio decoder. Ask the launcher for a cached 720p
* H.264/AAC version, which the macOS media engine decodes in hardware.
* `video` avoids transcoding audio-only media and lets editor /preview/ URLs
* participate even though their token has no file extension.
*/
const optimizedThemeMediaUrl = async (source: string): Promise<string> => {
if (!isLoopbackThemeVideo(source)) return source;
const optimizedThemeMediaUrl = async (source: string, video: boolean): Promise<string> => {
if (!isMac || !video || !loopbackThemePattern.test(source)) return source;
const cached = optimizedUrlPromises.get(source);
if (cached) return cached;
@@ -122,8 +129,8 @@ const optimizedThemeMediaUrl = async (source: string): Promise<string> => {
};
/** Resolve a theme asset to a blob URL, falling back to the original URL. */
export const themeMediaUrl = async (source: string): Promise<string> => {
const optimized = await optimizedThemeMediaUrl(source);
export const themeMediaUrl = async (source: string, video = false): Promise<string> => {
const optimized = await optimizedThemeMediaUrl(source, video);
if (optimized !== source) return optimized;
if (!useBlobMedia || !isThemeAssetUrl(source)) return source;
@@ -219,13 +226,14 @@ const loadMediaSource = (node: HTMLMediaElement, source: string) => {
const previousTime = Number.isFinite(node.currentTime) ? node.currentTime : 0;
const wasPlaying = !node.paused;
const needsPreparation = node instanceof HTMLVideoElement && isLoopbackThemeVideo(source);
const needsPreparation =
node instanceof HTMLVideoElement && isMac && loopbackThemePattern.test(source);
// Keep the last frame visible while the first H.264 encode runs instead of
// letting WebKit struggle through the software VP9 track.
if (needsPreparation) node.pause();
void themeMediaUrl(source).then((url) => {
void themeMediaUrl(source, node instanceof HTMLVideoElement).then((url) => {
if (mediaTokens.get(node) !== token || !node.isConnected) return;
if (node.src === url) {
@@ -275,6 +283,220 @@ const loadMediaSource = (node: HTMLMediaElement, source: string) => {
});
};
const easeInOut = (progress: number) => 0.5 - Math.cos(Math.PI * progress) / 2;
/**
* Ramp a media element's volume to `target`. Returns a stop function that
* leaves the current volume untouched, so a caller can continue from where the
* ramp ended (for example when the editor is reopened mid fade-in).
*/
const fadeMediaVolume = (
media: HTMLMediaElement,
target: number,
duration: number,
done: () => void
): (() => void) => {
const from = media.volume;
const to = Math.min(1, Math.max(0, target));
let frame: ReturnType<typeof setInterval> | undefined;
let guard: ReturnType<typeof setTimeout> | undefined;
let finished = false;
const stopTimers = () => {
if (frame) clearInterval(frame);
if (guard) clearTimeout(guard);
frame = undefined;
guard = undefined;
};
const finish = () => {
if (finished) return;
finished = true;
stopTimers();
media.volume = to;
done();
};
if (duration <= 0 || Math.abs(from - to) < 0.005) {
finish();
return () => {};
}
const startedAt = performance.now();
frame = setInterval(() => {
const progress = Math.min(1, (performance.now() - startedAt) / duration);
media.volume = Math.min(1, Math.max(0, from + (to - from) * easeInOut(progress)));
if (progress >= 1) finish();
}, FADE_STEP_MS);
// The editor takes focus while its window opens, which can throttle timers in
// the launcher. Never leave a track stuck at a partial volume because the
// ramp stalled.
guard = setTimeout(finish, duration + 150);
return () => {
if (finished) return;
finished = true;
stopTimers();
};
};
/**
* Pause theme media while the advanced editor window is open. The editor has
* its own preview with audio and video, so leaving the launcher's background
* track playing would make both overlap. The launcher fades its volume down
* before pausing and back up on return, and only media that was actually
* playing when the editor opened is resumed when it closes.
*/
export function themeMediaSuspension(node: HTMLElement) {
/** Paused media, with the volume to restore on resume. */
const pausedBySuspension = new Map<HTMLMediaElement, number>();
/** In-flight volume ramps, so a quick open/close can reverse cleanly. */
const ramps = new Map<HTMLMediaElement, () => void>();
let suspended = false;
const mediaIn = (root: ParentNode) =>
Array.from(root.querySelectorAll<HTMLMediaElement>('audio, video'));
const clamp = (volume: number) => Math.min(1, Math.max(0, volume));
const cancelRamp = (media: HTMLMediaElement) => {
ramps.get(media)?.();
ramps.delete(media);
};
/** Fade a playing track down, then pause it and restore its volume. */
const suspendMedia = (media: HTMLMediaElement, fade: boolean) => {
if (media.paused || media.ended) return;
// Keep the original listening level, even if a previous fade-in is still
// running when the editor is reopened.
const restore = pausedBySuspension.get(media) ?? media.volume;
cancelRamp(media);
pausedBySuspension.set(media, restore);
if (!fade || media.muted || media.volume === 0) {
media.pause();
return;
}
ramps.set(
media,
fadeMediaVolume(media, 0, FADE_OUT_MS, () => {
ramps.delete(media);
media.pause();
// Keep the element's own volume intact while it is paused; resuming
// restores the listening level explicitly.
media.volume = restore;
})
);
};
/** Start playback and fade the volume up to `volume`. */
const fadeIn = (media: HTMLMediaElement, volume: number) => {
const target = clamp(volume);
cancelRamp(media);
if (!media.isConnected) {
pausedBySuspension.delete(media);
return;
}
if (media.muted || target === 0) {
media.volume = target;
pausedBySuspension.delete(media);
void media.play().catch(() => {});
return;
}
media.volume = 0;
void media
.play()
.then(() => {
// The editor was reopened before the first frame of the fade-in.
if (suspended) {
suspendMedia(media, true);
return;
}
ramps.set(
media,
fadeMediaVolume(media, target, FADE_IN_MS, () => {
ramps.delete(media);
pausedBySuspension.delete(media);
})
);
})
.catch(() => {
media.volume = target;
pausedBySuspension.delete(media);
});
};
const suspendAll = (root: ParentNode = node) => {
for (const media of mediaIn(root)) suspendMedia(media, true);
};
const resumeAll = () => {
for (const [media, volume] of pausedBySuspension) {
if (!media.isConnected) {
pausedBySuspension.delete(media);
continue;
}
if (!media.paused) {
// A very quick open/close reversed the fade-out before it paused:
// bring the level back up on the track that never stopped.
cancelRamp(media);
media.volume = clamp(volume);
pausedBySuspension.delete(media);
continue;
}
fadeIn(media, volume);
}
};
// A theme can remount its media (for example after the active theme is
// reloaded) while the editor is open. Catch those autoplaying elements too.
const onPlay = (event: Event) => {
if (suspended && event.target instanceof HTMLMediaElement) suspendMedia(event.target, false);
};
const observer = new MutationObserver((mutations) => {
if (!suspended) return;
for (const mutation of mutations) {
for (const added of mutation.addedNodes) {
if (added instanceof HTMLMediaElement) suspendMedia(added, false);
else if (added instanceof HTMLElement) suspendAll(added);
}
}
});
node.addEventListener('play', onPlay, true);
const unsubscribe = themeMediaSuspended.subscribe((value) => {
if (value === suspended) return;
suspended = value;
if (suspended) {
observer.observe(node, { childList: true, subtree: true });
suspendAll();
} else {
observer.disconnect();
resumeAll();
}
});
return {
destroy() {
unsubscribe();
observer.disconnect();
node.removeEventListener('play', onPlay, true);
for (const cancel of ramps.values()) cancel();
ramps.clear();
for (const [media, volume] of pausedBySuspension) media.volume = volume;
pausedBySuspension.clear();
},
};
}
/**
* Pause background videos while the launcher is hidden or unfocused, then
* resume only the videos this helper paused itself. This keeps muted theme
+1079 -52
View File
File diff suppressed because it is too large. Load diff
+504
View File
@@ -0,0 +1,504 @@
import { isThemeClockVariable, themeClockValues } from './themeClock';
import {
executeThemeScript,
formatThemeScriptError,
parseThemeScript,
themeScriptSourceVariables,
type ThemeScriptProgram,
} from './themeScriptLang';
import { themeTextVariableNames } from './themeTextVariables';
import {
themeGroupAncestors,
type ThemeV2Behavior,
type ThemeV2BehaviorAction,
type ThemeV2BehaviorCondition,
type ThemeV2BehaviorOperator,
type ThemeV2BehaviorTrigger,
type ThemeV2Definition,
type ThemeV2Element,
type ThemeV2Group,
type ThemeV2ScriptScreen,
type ThemeV2ScriptValue,
} from './themeV2';
import { get, writable } from 'svelte/store';
export type ThemeScriptState = {
variables: Record<string, ThemeV2ScriptValue>;
/** Per-element visibility overrides set by rules. */
visibility: Record<string, boolean>;
/** Per-group visibility overrides set by rules. */
groupVisibility: Record<string, boolean>;
};
export type ThemeScriptHandlers = {
navigate?: (screen: ThemeV2ScriptScreen) => void;
launch?: () => void;
openUrl?: (url: string) => void;
};
export type ThemeScriptEvalContext = {
variables: Record<string, ThemeV2ScriptValue>;
screen: ThemeV2ScriptScreen;
isVisible: (elementId: string) => boolean;
isGroupVisible: (groupId: string) => boolean;
random: () => number;
};
const emptyState = (): ThemeScriptState => ({
variables: {},
visibility: {},
groupVisibility: {},
});
export const themeScriptState = writable<ThemeScriptState>(emptyState());
let definition: ThemeV2Definition | undefined;
let currentScreen: ThemeV2ScriptScreen = 'home';
let handlers: ThemeScriptHandlers = {};
let state = emptyState();
let firedRules = new Set<string>();
const timerHandles = new Map<string, ReturnType<typeof setInterval>>();
let compiledScripts = new Map<string, ThemeScriptProgram>();
let clockHandle: ReturnType<typeof setInterval> | undefined;
const publish = () => {
themeScriptState.set({
variables: { ...state.variables },
visibility: { ...state.visibility },
groupVisibility: { ...state.groupVisibility },
});
};
const elementById = (elementId: string): ThemeV2Element | undefined =>
definition?.elements?.find((element) => element.id === elementId);
const baseHidden = (element: ThemeV2Element) =>
element.hidden === true ||
themeGroupAncestors(definition?.groups, element).some((group) => group.hidden === true);
const groupChain = (groupId: string): ThemeV2Group[] => {
const byId = new Map((definition?.groups ?? []).map((group) => [group.id, group]));
const chain: ThemeV2Group[] = [];
const seen = new Set<string>();
let current = byId.get(groupId);
while (current && !seen.has(current.id)) {
chain.push(current);
seen.add(current.id);
current = current.parentId ? byId.get(current.parentId) : undefined;
}
return chain;
};
export const themeScriptGroupVisible = (groupId: string): boolean => {
const chain = groupChain(groupId);
if (chain.length === 0) return false;
if (chain.some((group) => state.groupVisibility[group.id] === false)) return false;
const own = state.groupVisibility[groupId];
if (own !== undefined) return own;
if (chain.some((group) => state.groupVisibility[group.id] === true)) return true;
return !chain.some((group) => group.hidden === true);
};
export const themeScriptElementVisible = (elementId: string): boolean => {
const element = elementById(elementId);
if (!element) return false;
const ancestors = themeGroupAncestors(definition?.groups, element);
if (ancestors.some((group) => state.groupVisibility[group.id] === false)) return false;
const override = state.visibility[elementId];
if (override !== undefined) return override;
if (ancestors.some((group) => state.groupVisibility[group.id] === true)) return true;
return !baseHidden(element);
};
export const themeScriptVariableNames = (theme: ThemeV2Definition | undefined): string[] => {
const names = new Set<string>();
if (!theme) return [];
// Include variables that only declare a starting value so they stay visible
// in the editor and can be reset instead of disappearing silently. Clock
// values are supplied by the runtime and are not editable defaults.
for (const name of Object.keys(theme.variables ?? {})) {
if (!isThemeClockVariable(name)) names.add(name);
}
for (const element of theme.elements ?? []) {
if (element.type === 'text') {
for (const name of themeTextVariableNames(element.text)) {
if (!isThemeClockVariable(name)) names.add(name);
}
} else if (element.type === 'svelte') {
// Custom layers read variables as props. Include those names so the
// variable defaults panel and IntelliSense know about them.
for (const match of element.source.matchAll(/\bvariables\.([A-Za-z_][A-Za-z0-9_]*)/g)) {
if (!isThemeClockVariable(match[1])) names.add(match[1]);
}
}
}
for (const behavior of theme.behaviors ?? []) {
for (const condition of behavior.conditions ?? []) {
if (condition.type === 'variable' && !isThemeClockVariable(condition.variable)) {
names.add(condition.variable);
}
}
for (const action of behavior.actions) {
if (
(action.type === 'setVariable' ||
action.type === 'changeVariable' ||
action.type === 'toggleVariable') &&
!isThemeClockVariable(action.variable)
) {
names.add(action.variable);
}
}
if (behavior.script !== undefined) {
for (const name of themeScriptSourceVariables(behavior.script)) {
if (!isThemeClockVariable(name)) names.add(name);
}
}
}
return [...names];
};
/** All variables a theme starts with; rule triggers and text layers add more. */
export const themeScriptInitialVariables = (
theme: ThemeV2Definition | undefined
): Record<string, ThemeV2ScriptValue> => {
const defaults = theme?.variables ?? {};
const values = Object.fromEntries(
themeScriptVariableNames(theme).map((name) => [
name,
Object.prototype.hasOwnProperty.call(defaults, name) ? defaults[name] : 0,
])
);
// Clock values are always current and override any same-named defaults.
return { ...values, ...themeClockValues() };
};
const sameValue = (actual: ThemeV2ScriptValue | undefined, expected: ThemeV2ScriptValue) => {
if (typeof expected === 'number') return Number(actual) === expected;
if (typeof expected === 'boolean') return Boolean(actual) === expected;
return String(actual ?? '') === expected;
};
const compareValues = (
actual: ThemeV2ScriptValue | undefined,
expected: ThemeV2ScriptValue,
operator: ThemeV2BehaviorOperator
) => {
switch (operator) {
case 'notEquals':
return !sameValue(actual, expected);
case 'greaterThan':
return Number(actual) > Number(expected);
case 'lessThan':
return Number(actual) < Number(expected);
case 'greaterOrEqual':
return Number(actual) >= Number(expected);
case 'lessOrEqual':
return Number(actual) <= Number(expected);
default:
return sameValue(actual, expected);
}
};
export const themeScriptConditionMatches = (
condition: ThemeV2BehaviorCondition,
context: ThemeScriptEvalContext
): boolean => {
switch (condition.type) {
case 'variable':
return compareValues(
context.variables[condition.variable],
condition.value,
condition.operator
);
case 'visible':
return context.isVisible(condition.elementId) === condition.value;
case 'groupVisible':
return context.isGroupVisible(condition.groupId) === condition.value;
case 'screen':
return context.screen === condition.screen;
case 'chance':
return context.random() * 100 < condition.percent;
default:
return false;
}
};
export const themeScriptConditionsMatch = (
conditions: ThemeV2BehaviorCondition[] | undefined,
mode: 'all' | 'any',
context: ThemeScriptEvalContext
): boolean => {
if (!conditions || conditions.length === 0) return true;
if (mode === 'any')
return conditions.some((condition) => themeScriptConditionMatches(condition, context));
return conditions.every((condition) => themeScriptConditionMatches(condition, context));
};
const triggerMatches = (
trigger: ThemeV2BehaviorTrigger,
type: 'load' | 'screen' | 'click' | 'hover' | 'leave' | 'timer',
detail: { elementId?: string; screen?: ThemeV2ScriptScreen }
) => {
if (trigger.type !== type) return false;
if (trigger.type === 'screen') return trigger.screen === detail.screen;
if (trigger.type === 'click' || trigger.type === 'hover' || trigger.type === 'leave') {
return !trigger.elementId || trigger.elementId === detail.elementId;
}
return true;
};
const context = (): ThemeScriptEvalContext => ({
variables: state.variables,
screen: currentScreen,
isVisible: themeScriptElementVisible,
isGroupVisible: themeScriptGroupVisible,
random: Math.random,
});
const applyActions = (actions: ThemeV2BehaviorAction[], allowExternal: boolean) => {
if (actions.length === 0) return;
for (const action of actions) {
switch (action.type) {
case 'setVariable':
if (isThemeClockVariable(action.variable)) break;
state.variables[action.variable] = action.value;
break;
case 'changeVariable':
if (isThemeClockVariable(action.variable)) break;
state.variables[action.variable] =
Number(state.variables[action.variable] ?? 0) + action.amount;
break;
case 'toggleVariable':
if (isThemeClockVariable(action.variable)) break;
state.variables[action.variable] = !state.variables[action.variable];
break;
case 'showElement':
state.visibility[action.elementId] = true;
break;
case 'hideElement':
state.visibility[action.elementId] = false;
break;
case 'toggleElement':
state.visibility[action.elementId] = !themeScriptElementVisible(action.elementId);
break;
case 'showGroup':
state.groupVisibility[action.groupId] = true;
break;
case 'hideGroup':
state.groupVisibility[action.groupId] = false;
break;
case 'toggleGroup':
state.groupVisibility[action.groupId] = !themeScriptGroupVisible(action.groupId);
break;
case 'navigate':
handlers.navigate?.(action.screen);
break;
case 'launch':
// Leaving the launcher is only allowed from an explicit click.
if (allowExternal) handlers.launch?.();
break;
case 'openUrl':
if (allowExternal && /^https?:\/\//i.test(action.url)) handlers.openUrl?.(action.url);
break;
default:
break;
}
}
publish();
};
const compileThemeScripts = (theme: ThemeV2Definition | undefined) => {
compiledScripts = new Map<string, ThemeScriptProgram>();
for (const rule of theme?.behaviors ?? []) {
if (rule.script === undefined) continue;
const result = parseThemeScript(rule.script);
if (result.errors.length > 0) {
console.warn(
`Theme rule "${rule.name ?? rule.id}" has an invalid advanced script: ${formatThemeScriptError(
result.errors[0]
)}`
);
continue;
}
compiledScripts.set(rule.id, result.program);
}
};
const runCompiledScript = (program: ThemeScriptProgram, allowExternal: boolean) => {
executeThemeScript(program, {
variables: state.variables,
screen: currentScreen,
random: Math.random,
isVisible: themeScriptElementVisible,
isGroupVisible: themeScriptGroupVisible,
setElementVisible: (elementId, visible) => {
state.visibility[elementId] = visible;
},
setGroupVisible: (groupId, visible) => {
state.groupVisibility[groupId] = visible;
},
navigate: (screen) => handlers.navigate?.(screen),
openUrl: (url) => handlers.openUrl?.(url),
launch: () => handlers.launch?.(),
allowExternal,
});
publish();
};
const stopRuleTimer = (ruleId: string) => {
const handle = timerHandles.get(ruleId);
if (handle !== undefined) {
clearInterval(handle);
timerHandles.delete(ruleId);
}
};
const behaviorTargetMatches = (rule: ThemeV2Behavior, detail: { elementId?: string }): boolean => {
if (!rule.target) return true;
if (
rule.trigger.type !== 'click' &&
rule.trigger.type !== 'hover' &&
rule.trigger.type !== 'leave'
) {
return true;
}
if (!detail.elementId) return false;
if (rule.target.type === 'element') return rule.target.id === detail.elementId;
const element = elementById(detail.elementId);
if (!element) return false;
return themeGroupAncestors(definition?.groups, element).some(
(group) => group.id === rule.target?.id
);
};
const runRule = (
rule: ThemeV2Behavior,
type: 'load' | 'screen' | 'click' | 'hover' | 'leave' | 'timer',
detail: { elementId?: string; screen?: ThemeV2ScriptScreen } = {}
) => {
if (rule.enabled === false) return;
if (rule.once && firedRules.has(rule.id)) return;
if (!triggerMatches(rule.trigger, type, detail)) return;
if (!behaviorTargetMatches(rule, detail)) return;
if (!themeScriptConditionsMatch(rule.conditions, rule.conditionMode ?? 'all', context())) return;
if (rule.once) {
firedRules.add(rule.id);
stopRuleTimer(rule.id);
}
if (rule.script !== undefined) {
const program = compiledScripts.get(rule.id);
if (program) runCompiledScript(program, type === 'click');
return;
}
applyActions(rule.actions, type === 'click');
};
const runRules = (
type: 'load' | 'screen' | 'click' | 'hover' | 'leave' | 'timer',
detail: { elementId?: string; screen?: ThemeV2ScriptScreen } = {}
) => {
if (!definition) return;
for (const rule of definition.behaviors ?? []) {
runRule(rule, type, detail);
}
};
const updateClock = () => {
if (!definition) return;
Object.assign(state.variables, themeClockValues());
publish();
};
const stopClock = () => {
if (clockHandle !== undefined) {
clearInterval(clockHandle);
clockHandle = undefined;
}
};
const startClock = () => {
stopClock();
updateClock();
clockHandle = setInterval(updateClock, 1000);
};
const startTimers = () => {
for (const rule of definition?.behaviors ?? []) {
if (rule.enabled === false || rule.trigger.type !== 'timer') continue;
const milliseconds = Math.max(500, rule.trigger.interval * 1000);
const handle = setInterval(() => runRule(rule, 'timer'), milliseconds);
timerHandles.set(rule.id, handle);
}
};
const stopTimers = () => {
for (const handle of timerHandles.values()) clearInterval(handle);
timerHandles.clear();
};
export const setThemeScriptHandlers = (next: ThemeScriptHandlers) => {
handlers = { ...handlers, ...next };
};
/** Load a theme's rules, reset its variables and start timer triggers. */
export const loadThemeScript = (next: ThemeV2Definition | undefined) => {
stopTimers();
stopClock();
definition = next;
state = emptyState();
firedRules = new Set<string>();
compileThemeScripts(next);
state.variables = themeScriptInitialVariables(next);
publish();
if (!next) return;
startClock();
startTimers();
runRules('load');
runRules('screen', { screen: currentScreen });
};
export const stopThemeScript = () => {
stopTimers();
stopClock();
definition = undefined;
state = emptyState();
firedRules = new Set<string>();
compiledScripts = new Map<string, ThemeScriptProgram>();
publish();
};
export const setThemeScriptScreen = (screen: ThemeV2ScriptScreen) => {
if (currentScreen === screen) return;
currentScreen = screen;
runRules('screen', { screen });
};
export const triggerThemeScriptElement = (
elementId: string,
interaction: 'click' | 'hover' | 'leave'
) => {
runRules(interaction, { elementId });
};
/** Exposed for editor previews and tests. */
export const evaluateThemeScriptConditions = (
conditions: ThemeV2BehaviorCondition[] | undefined,
mode: 'all' | 'any',
variables: Record<string, ThemeV2ScriptValue>,
screen: ThemeV2ScriptScreen,
isVisible: (elementId: string) => boolean,
random: () => number = Math.random,
isGroupVisible: (groupId: string) => boolean = () => true
) =>
themeScriptConditionsMatch(conditions, mode, {
variables,
screen,
isVisible,
isGroupVisible,
random,
});
export const getThemeScriptState = () => get(themeScriptState);
File diff suppressed because it is too large. Load diff
+490
View File
@@ -0,0 +1,490 @@
/* eslint-disable svelte/no-svelte-internal -- Svelte has no public constructor for components compiled at runtime; theme layers must use the client runtime that `svelte/compiler` emits. */
import { parse as parseAcorn } from 'acorn';
import type {
ExportDefaultDeclaration,
ExportNamedDeclaration,
ImportDeclaration,
Literal,
Node as AcornNode,
Program,
} from 'acorn';
import type { Component } from 'svelte';
import * as SvelteRuntime from 'svelte';
import * as SvelteAnimate from 'svelte/animate';
import * as SvelteEasing from 'svelte/easing';
import * as SvelteClient from 'svelte/internal/client';
import * as SvelteLegacy from 'svelte/legacy';
import * as SvelteMotion from 'svelte/motion';
import * as SvelteReactivity from 'svelte/reactivity';
import * as SvelteStore from 'svelte/store';
import * as SvelteTransition from 'svelte/transition';
export type ThemeSvelteDiagnostic = {
message: string;
line: number;
column: number;
endLine?: number;
endColumn?: number;
severity: 'error' | 'warning';
code?: string;
};
export type ThemeSvelteAnalysis =
| {
ok: true;
/** Compiled module rewritten to run through the launcher's runtime map. */
code: string;
/** Side-effect runtime flags the component needs before it is mounted. */
flags?: string[];
warnings: ThemeSvelteDiagnostic[];
}
| {
ok: false;
errors: ThemeSvelteDiagnostic[];
warnings: ThemeSvelteDiagnostic[];
};
export type ThemeSvelteComponent = Component<Record<string, unknown>>;
export type ThemeSvelteCompileResult = {
component?: ThemeSvelteComponent;
errors: ThemeSvelteDiagnostic[];
warnings: ThemeSvelteDiagnostic[];
};
/** Public Svelte modules a theme component may import. */
const runtimeModules: Record<string, Record<string, unknown>> = {
svelte: SvelteRuntime as unknown as Record<string, unknown>,
'svelte/internal/client': SvelteClient as unknown as Record<string, unknown>,
'svelte/internal/disclose-version': {},
'svelte/animate': SvelteAnimate as unknown as Record<string, unknown>,
'svelte/easing': SvelteEasing as unknown as Record<string, unknown>,
'svelte/legacy': SvelteLegacy as unknown as Record<string, unknown>,
'svelte/motion': SvelteMotion as unknown as Record<string, unknown>,
'svelte/reactivity': SvelteReactivity as unknown as Record<string, unknown>,
'svelte/store': SvelteStore as unknown as Record<string, unknown>,
'svelte/transition': SvelteTransition as unknown as Record<string, unknown>,
};
const LEGACY_FLAG_MODULE = 'svelte/internal/flags/legacy';
runtimeModules['svelte/internal/flags/async'] = {};
runtimeModules['svelte/internal/flags/legacy'] = {};
runtimeModules['svelte/internal/flags/tracing'] = {};
const SUPPORTED_IMPORTS = [
'svelte',
'svelte/animate',
'svelte/easing',
'svelte/motion',
'svelte/reactivity',
'svelte/store',
'svelte/transition',
].join(', ');
const MAX_CACHED_PROGRAMS = 24;
const analysisCache = new Map<string, Promise<ThemeSvelteAnalysis>>();
type Replacement = { start: number; end: number; text: string };
const applyReplacements = (code: string, replacements: Replacement[]) => {
let next = code;
for (const replacement of [...replacements].sort((a, b) => b.start - a.start)) {
next = `${next.slice(0, replacement.start)}${replacement.text}${next.slice(replacement.end)}`;
}
return next;
};
const nodeValue = (node: AcornNode | null | undefined): Record<string, unknown> | undefined =>
node ? (node as unknown as Record<string, unknown>) : undefined;
const literalValue = (node: unknown): string | undefined => {
const value = nodeValue(node as AcornNode | undefined)?.value;
return typeof value === 'string' ? value : undefined;
};
const scriptStartLine = (source: string) => {
const index = source.indexOf('<script');
if (index < 0) return 1;
return source.slice(0, index).split('\n').length;
};
const unsupportedModuleDiagnostic = (
source: string,
moduleName: string
): ThemeSvelteDiagnostic => ({
message: `Importing "${moduleName}" is not available inside a theme layer. Theme components can import ${SUPPORTED_IMPORTS}.`,
line: scriptStartLine(source),
column: 1,
severity: 'error',
});
const dynamicImportDiagnostic = (source: string): ThemeSvelteDiagnostic => ({
message:
'Dynamic import() is not available inside a theme layer. Import from the Svelte runtime at the top of the component instead.',
line: scriptStartLine(source),
column: 1,
severity: 'error',
});
const importMetaDiagnostic = (source: string): ThemeSvelteDiagnostic => ({
message:
'import.meta is not available inside a theme layer. Theme components run from a local WebView module, not a bundled file.',
line: scriptStartLine(source),
column: 1,
severity: 'error',
});
const importReplacement = (node: ImportDeclaration): Replacement | undefined => {
const moduleName = literalValue(node.source);
if (!moduleName || !runtimeModules[moduleName]) return undefined;
if (node.specifiers.length === 0) {
return { start: node.start, end: node.end, text: '' };
}
const namespaceLines: string[] = [];
const defaultLines: string[] = [];
const namedSpecifiers: string[] = [];
for (const specifier of node.specifiers) {
if (specifier.type === 'ImportDefaultSpecifier') {
defaultLines.push(
`const ${specifier.local.name} = __themeModules[${JSON.stringify(moduleName)}].default;`
);
continue;
}
if (specifier.type === 'ImportNamespaceSpecifier') {
namespaceLines.push(
`const ${specifier.local.name} = __themeModules[${JSON.stringify(moduleName)}];`
);
continue;
}
const imported =
specifier.imported.type === 'Identifier'
? specifier.imported.name
: String((specifier.imported as Literal).value);
namedSpecifiers.push(
imported === specifier.local.name
? imported
: `${JSON.stringify(imported)}: ${specifier.local.name}`
);
}
if (namedSpecifiers.length > 0) {
namespaceLines.push(
`const { ${namedSpecifiers.join(', ')} } = __themeModules[${JSON.stringify(moduleName)}];`
);
}
return {
start: node.start,
end: node.end,
text: [...namespaceLines, ...defaultLines].join('\n'),
};
};
/**
* Re-shape Svelte's ESM output into a function body. The compiler emits only
* a default export and imports from Svelte's runtime; evaluating that module
* directly would require a blob URL, which the Tauri CSP intentionally blocks.
* Keeping the transform deterministic also lets the editor show import errors
* without mounting anything.
*/
export const rewriteCompiledSvelteModule = (
compiled: string,
source = ''
): { code?: string; errors: ThemeSvelteDiagnostic[]; flags?: string[] } => {
let program: Program;
try {
program = parseAcorn(compiled, {
ecmaVersion: 'latest',
sourceType: 'module',
}) as unknown as Program;
} catch (error) {
return {
errors: [
{
message: `The compiled Svelte module could not be parsed: ${
error instanceof Error ? error.message : String(error)
}`,
line: 1,
column: 1,
severity: 'error',
},
],
};
}
const errors: ThemeSvelteDiagnostic[] = [];
const replacements: Replacement[] = [];
const flags: string[] = [];
let defaultExportName: string | undefined;
let hasReturnedDefault = false;
for (const node of program.body) {
if (node.type === 'ImportDeclaration') {
const declaration = node as ImportDeclaration;
const moduleName = literalValue(declaration.source);
if (!moduleName || !runtimeModules[moduleName]) {
errors.push(unsupportedModuleDiagnostic(source, moduleName ?? 'unknown module'));
continue;
}
if (moduleName === LEGACY_FLAG_MODULE && flags.length === 0) {
flags.push(LEGACY_FLAG_MODULE);
}
const replacement = importReplacement(declaration);
if (replacement) replacements.push(replacement);
continue;
}
if (node.type === 'ExportDefaultDeclaration') {
const declaration = node as ExportDefaultDeclaration;
const exported = declaration.declaration;
const exportedNode = nodeValue(exported as AcornNode);
const exportedName =
exportedNode?.type === 'FunctionDeclaration' || exportedNode?.type === 'ClassDeclaration'
? (nodeValue(exportedNode.id as AcornNode)?.name as string | undefined)
: undefined;
if (exportedName) {
defaultExportName = exportedName;
replacements.push({ start: declaration.start, end: exported.start, text: '' });
} else {
hasReturnedDefault = true;
replacements.push({ start: declaration.start, end: exported.start, text: 'return ' });
}
continue;
}
if (node.type === 'ExportNamedDeclaration') {
const declaration = node as ExportNamedDeclaration;
if (declaration.source) {
errors.push({
message: 'Re-exporting from another module is not available inside a theme layer.',
line: scriptStartLine(source),
column: 1,
severity: 'error',
});
continue;
}
if (declaration.declaration) {
replacements.push({
start: declaration.start,
end: declaration.declaration.start,
text: '',
});
} else {
replacements.push({ start: declaration.start, end: declaration.end, text: '' });
}
}
}
const walk = (value: unknown) => {
if (!value || typeof value !== 'object') return;
if (Array.isArray(value)) {
for (const item of value) walk(item);
return;
}
const record = value as Record<string, unknown>;
if (record.type === 'ImportExpression') {
errors.push(dynamicImportDiagnostic(source));
return;
}
if (record.type === 'MetaProperty') {
const meta = nodeValue(record.meta as AcornNode);
if (meta?.name === 'import') {
errors.push(importMetaDiagnostic(source));
return;
}
}
for (const [key, child] of Object.entries(record)) {
if (key === 'start' || key === 'end' || key === 'loc') continue;
walk(child);
}
};
walk(program);
if (errors.length > 0) {
return { errors: dedupeDiagnostics(errors) };
}
if (!defaultExportName && !hasReturnedDefault) {
return {
errors: [
{
message:
'A custom Svelte layer needs a component. Add markup to the file; Svelte emits the default component automatically.',
line: scriptStartLine(source),
column: 1,
severity: 'error',
},
],
};
}
let code = applyReplacements(compiled, replacements);
if (defaultExportName) code += `\nreturn ${defaultExportName};`;
return { code, errors: [], flags };
};
const dedupeDiagnostics = (diagnostics: ThemeSvelteDiagnostic[]) => {
const seen = new Set<string>();
return diagnostics.filter((diagnostic) => {
const key = `${diagnostic.message}:${diagnostic.line}:${diagnostic.column}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
const compilerDiagnostic = (
value: unknown,
severity: 'error' | 'warning'
): ThemeSvelteDiagnostic => {
const error = (value ?? {}) as {
message?: unknown;
code?: unknown;
start?: { line?: number; column?: number };
end?: { line?: number; column?: number };
};
const line = Math.max(1, Number(error.start?.line ?? 1));
const column = Math.max(1, Number(error.start?.column ?? 0) + 1);
const endLine = Math.max(line, Number(error.end?.line ?? line));
const endColumn = Math.max(1, Number(error.end?.column ?? 0) + 1);
return {
message: typeof error.message === 'string' ? error.message : String(value),
line,
column,
endLine,
endColumn,
severity,
code: typeof error.code === 'string' ? error.code : undefined,
};
};
const analyzeUncached = async (source: string, filename: string): Promise<ThemeSvelteAnalysis> => {
let compiler: typeof import('svelte/compiler');
try {
compiler = await import('svelte/compiler');
} catch (error) {
return {
ok: false,
errors: [
{
message: `The Svelte compiler could not be loaded: ${
error instanceof Error ? error.message : String(error)
}`,
line: 1,
column: 1,
severity: 'error',
},
],
warnings: [],
};
}
try {
const result = compiler.compile(source, {
generate: 'client',
filename,
css: 'injected',
dev: false,
hmr: false,
preserveComments: true,
});
const warnings = (result.warnings ?? []).map((warning) =>
compilerDiagnostic(warning, 'warning')
);
const rewritten = rewriteCompiledSvelteModule(result.js.code, source);
if (!rewritten.code) {
return {
ok: false,
errors: dedupeDiagnostics(rewritten.errors),
warnings,
};
}
return { ok: true, code: rewritten.code, flags: rewritten.flags, warnings };
} catch (error) {
return {
ok: false,
errors: [compilerDiagnostic(error, 'error')],
warnings: [],
};
}
};
/** Compile/inspect a layer's source. Shared by the editor and the renderer. */
export const analyzeThemeSvelteSource = (
source: string,
filename = 'ThemeLayer.svelte'
): Promise<ThemeSvelteAnalysis> => {
const normalized = source;
const key = `${filename}\u0000${normalized}`;
const cached = analysisCache.get(key);
if (cached) return cached;
const analysis = analyzeUncached(normalized, filename);
analysisCache.set(key, analysis);
if (analysisCache.size > MAX_CACHED_PROGRAMS) {
const oldest = analysisCache.keys().next().value;
if (oldest) analysisCache.delete(oldest);
}
return analysis;
};
const runtimeErrorMessage = (error: unknown) => {
if (error instanceof Error) {
return `${error.name}: ${error.message}`;
}
return String(error);
};
/** Compile and instantiate the component body, ready for Svelte's mount(). */
export const compileThemeSvelte = async (
source: string,
filename = 'ThemeLayer.svelte'
): Promise<ThemeSvelteCompileResult> => {
const analysis = await analyzeThemeSvelteSource(source, filename);
if (!analysis.ok) {
return { errors: analysis.errors, warnings: analysis.warnings };
}
try {
if (analysis.flags?.includes(LEGACY_FLAG_MODULE)) {
// Svelte enables legacy component semantics through a side-effect module
// that the compiler emits for imports and `export let` props.
await import('svelte/internal/flags/legacy');
}
const factory = new Function('__themeModules', analysis.code) as (
modules: Record<string, Record<string, unknown>>
) => unknown;
const component = factory(runtimeModules);
if (typeof component !== 'function') {
return {
errors: [
{
message: 'The component did not export a mountable Svelte component.',
line: 1,
column: 1,
severity: 'error',
},
],
warnings: analysis.warnings,
};
}
return {
component: component as ThemeSvelteComponent,
errors: [],
warnings: analysis.warnings,
};
} catch (error) {
return {
errors: [
{
message: `The component could not be evaluated: ${runtimeErrorMessage(error)}`,
line: 1,
column: 1,
severity: 'error',
},
],
warnings: analysis.warnings,
};
}
};
+69
View File
@@ -0,0 +1,69 @@
/**
* Starter source for new custom Svelte layers. Keep this plain JavaScript:
* theme components are compiled in the WebView, without a TypeScript pass.
*/
export const DEFAULT_THEME_SVELTE_SOURCE = `<script>
let { variables = {}, layer = {} } = $props();
const clicks = $derived(Number(variables.clicks ?? 0));
const player = $derived(String(variables.player ?? 'osu!'));
</script>
<div class="custom-layer">
<span class="glow"></span>
<p class="eyebrow">CUSTOM SVELTE LAYER</p>
<h3>Hello, {player}</h3>
<p class="hint">{Math.round(layer.width ?? 0)} × {Math.round(layer.height ?? 0)} · {clicks} clicks</p>
</div>
<style>
.custom-layer {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
padding: 22px 26px;
overflow: hidden;
border: 1px solid rgb(255 255 255 / 0.14);
border-radius: 18px;
background: linear-gradient(135deg, rgb(255 0 110 / 0.24), rgb(10 10 16 / 0.82));
box-shadow: 0 18px 50px rgb(0 0 0 / 0.35);
color: #fff;
font-family: 'Sora Variable', sans-serif;
}
.glow {
position: absolute;
top: -40%;
right: -10%;
width: 190px;
height: 190px;
border-radius: 999px;
background: #ff006e;
filter: blur(60px);
opacity: 0.42;
}
.eyebrow {
margin: 0 0 6px;
color: #ff9ac4;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.18em;
}
h3 {
margin: 0;
font-size: 30px;
font-weight: 650;
line-height: 1.1;
}
.hint {
margin: 8px 0 0;
color: rgb(255 255 255 / 0.62);
font-size: 11px;
}
</style>
`;
+33
View File
@@ -0,0 +1,33 @@
/** Editor-only snippets for custom Svelte layers. */
export const THEME_SVELTE_COMPONENT_SKELETON = `<script>
let { variables = {}, layer = {}, asset } = $props();
const score = $derived(Number(variables.score ?? 0));
</script>
<div class="custom-layer">
<p>Score: {score}</p>
<p>{Math.round(layer.width)} × {Math.round(layer.height)}</p>
</div>
<style>
.custom-layer {
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
padding: 18px 22px;
border: 1px solid rgb(255 255 255 / 0.14);
border-radius: 16px;
background: linear-gradient(135deg, rgb(255 0 110 / 0.22), rgb(10 10 16 / 0.82));
color: #fff;
font-family: 'Sora Variable', sans-serif;
}
</style>
`;
export const THEME_SVELTE_COMPONENT_PLACEHOLDER = `<script>
let { variables = {} } = $props();
</script>
<div>{variables.score}</div>`;
+43
View File
@@ -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<string>();
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, ThemeV2ScriptValue>
): 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 ?? '');
});
};
+17 -4
View File
@@ -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' | 'scriptUrl'>) =>
theme.apiVersion.split('.')[0] === '1' && theme.scriptUrl.length > 0;
export type ThemeCodeKind = 'none' | 'v1' | 'v2-svelte';
export const themeCodeKind = (
theme: Pick<Theme, 'apiVersion' | 'scriptUrl' | 'definition'>
): 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<Theme, 'apiVersion' | 'scriptUrl' | 'definition'>) =>
themeCodeKind(theme) !== 'none';
export const themeTrustKey = (theme: Pick<Theme, 'folder_name' | 'version'>) =>
`${theme.folder_name}@${theme.version}`;
+1750 -23
View File
File diff suppressed because it is too large. Load diff
+11 -11
View File
@@ -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;
+34 -7
View File
@@ -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<boolean>('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 @@
</AlertDialog.Content>
</AlertDialog.Root>
<main>
<main use:themeMediaSuspension>
<div class:hidden={!!$active_custom_theme?.definition}>
<div class="opacity-30">
<div transition:fade={{ duration: $reduceAnimations ? 0 : 500 }}>
@@ -345,10 +370,12 @@
</div>
{#if $active_custom_theme?.definition}
<div class="absolute inset-0 z-0">
<ThemeV2Background
definition={$active_custom_theme.definition}
assets={$active_custom_theme.assets}
/>
{#key $theme_reload_token}
<ThemeV2Background
definition={$active_custom_theme.definition}
assets={$active_custom_theme.assets}
/>
{/key}
</div>
{/if}
{@render children()}
+1197 -1048
View File
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -566,9 +566,9 @@
<div class="min-w-0">
<Label class="text-[13px]" for="wizard-patch">Patching</Label>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
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}
</p>
</div>
<Checkbox
+5
View File
@@ -0,0 +1,5 @@
// The internal client module is intentionally untyped in Svelte's package
// exports. Theme components only need its namespace at runtime, so treat it as
// an opaque module rather than importing private implementation types.
declare module 'svelte/internal/client';
declare module 'svelte/internal/flags/legacy';