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

+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;