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"