Files
EZPPLauncher/src/lib/components/theme/ThemeEditorWindow.svelte
T
HorizonCode e52399911c fix(macos): resolve osu updater loop and patcher wine compatibility
- Remove host Apple Braille font entries from Wine prefix registry to prevent cold-start GDI+ AccessViolationException.
- Purge .require_update, help.txt, and _pending prior to osu! launch so the game launches directly with -devserver ez-pp.farm instead of entering the updater.
- Reset _UpdateFailCount in osu!.cfg and ensure execute.bat points to ez-pp.farm.
- Update ensure_patcher_compatibility to patch osu!.patcher.exe and embedded ManagedInjector.Lib for .NET 4.0 compatibility under Wine.
- Include theme editor, cursor, and download button refinements on themes-v2 branch.
2026-11-01 10:52:39 +01:00

6573 lines
246 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import Logo from '$assets/logo.png';
import FontPreview from '$lib/components/theme/FontPreview.svelte';
import ThemeBehaviorInspector from '$lib/components/theme/ThemeBehaviorInspector.svelte';
import ThemeElementInspector from '$lib/components/theme/ThemeElementInspector.svelte';
import ThemeGroupInspector from '$lib/components/theme/ThemeGroupInspector.svelte';
import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte';
import ThemeLogicBadge from '$lib/components/theme/ThemeLogicBadge.svelte';
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
import ThemeStage from '$lib/components/theme/ThemeStage.svelte';
import ThemeVariableDefaults from '$lib/components/theme/ThemeVariableDefaults.svelte';
import Badge from '$lib/components/ui/badge/badge.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as ContextMenu from '$lib/components/ui/context-menu';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
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 Slider from '$lib/components/ui/slider/slider.svelte';
import Textarea from '$lib/components/ui/textarea/textarea.svelte';
import { config } from '$lib/config';
import { currentUserInfo } from '$lib/data';
import {
defaultFontsourceOptions,
downloadFontsourceFont,
fontsourceFontKey,
fontsourceFontUrl,
getFontsourceCatalog,
themeFontKey,
type FontsourceFont,
} from '$lib/fontsource';
import {
launcherVersion,
onlineFriends,
osuBuild,
osuStream,
serverPing,
setupValues,
} from '$lib/global';
import { getLauncherVersion, getReleaseStream, getVersion } from '$lib/osuUtil';
import {
THEME_CLOCK_VARIABLE_NAMES,
isThemeClockVariable,
themeClockValues,
} from '$lib/themeClock';
import {
BUNDLED_THEME_FONTS,
bundledThemeFontFamily,
fontStack,
type ThemeFontChoice,
} from '$lib/themeFonts';
import { THEME_ICON_PREVIEW_NAMES, THEME_ICON_SETS } from '$lib/themeIcons';
import {
themeBehaviorSummary,
themeBehaviorTargetLabel,
themeElementLabel,
themeGroupContainsElement,
themeGroupInteractiveCount,
} from '$lib/themeLogic';
import { renderThemePreview } from '$lib/themePreview';
import { themeScriptInitialVariables, themeScriptVariableNames } from '$lib/themeScript';
import {
formatThemeScriptError,
parseThemeScript,
themeScriptProgramCanLeaveLauncher,
themeScriptProgramReferences,
} from '$lib/themeScriptLang';
import { analyzeThemeSvelteSource } from '$lib/themeSvelte';
import {
createDefaultThemeV2,
createThemeV2Behavior,
createThemeV2Element,
normalizeChrome,
normalizeThemeV2,
themeAssetName,
themeHasLaunchButton,
type ThemeV2Asset,
type ThemeV2Background,
type ThemeV2BehaviorTarget,
type ThemeV2ButtonAction,
type ThemeV2Chrome,
type ThemeV2Definition,
type ThemeV2Element,
type ThemeV2Font,
type ThemeV2Group,
type ThemeV2ImageElement,
type ThemeV2LayoutMode,
type ThemeV2LottieElement,
type ThemeV2ScriptValue,
type ThemeV2VideoElement,
type ThemeV2WidgetElement,
type ThemeV2WidgetKind,
} from '$lib/themeV2';
import {
exportThemeFolder,
saveThemeProject,
themeAssetBase,
themesDirectory,
toSafeName,
} from '$lib/themes';
import type { EZPPUser, EZPPUserInfo } from '$lib/types';
import { currentUser } from '$lib/userAuthentication';
import { osuInstallationPath } from '$lib/userSettings';
import { formatBytes, openURL } from '$lib/utils';
import {
AlertTriangle,
Boxes,
Check,
CheckCircle2,
ChevronDown,
ChevronRight,
ChevronUp,
ChevronsDown,
ChevronsUp,
CircleUser,
ClipboardPaste,
Clock,
Code2,
Component,
Copy,
CopyPlus,
Download,
Eye,
EyeOff,
Film,
Gauge,
GripVertical,
Hand,
Image as ImageIcon,
Keyboard,
Layers,
LayoutGrid,
LoaderCircle,
Lock,
Maximize2,
Megaphone,
Minus,
MousePointer2,
MousePointerClick,
Music,
Plus,
Redo2,
RefreshCw,
Repeat,
Save,
Scissors,
Search,
Shapes,
ShieldCheck,
SlidersHorizontal,
Sparkles,
Spline,
Timer,
Trash2,
Type,
Undo2,
Ungroup,
Unlock,
Upload,
Video,
X,
Zap,
} from '@lucide/svelte';
import { convertFileSrc, invoke } from '@tauri-apps/api/core';
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event';
import * as path from '@tauri-apps/api/path';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { open, save as saveDialog } from '@tauri-apps/plugin-dialog';
import * as fs from '@tauri-apps/plugin-fs';
import { sileo } from 'sileo';
import { onMount, tick } from 'svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
type ThemeEditorContext = {
folderName?: string | null;
folderPath?: string | null;
themeJson?: string | null;
};
type ThemeEditorDraft = {
definition: ThemeV2Definition;
pickedAssets: ThemeV2Asset[];
savedFolder?: string;
savedAt: number;
};
type ThemeValidationIssue = {
level: 'error' | 'warning';
message: string;
};
type ThemeValidationReport = {
issues: ThemeValidationIssue[];
totalBytes: number;
fileCount: number;
checkedAt: number;
};
type AssetKind = 'image' | 'video' | 'audio' | 'font' | 'lottie';
type LayerClipboard = {
groups: ThemeV2Group[];
elements: ThemeV2Element[];
};
type LayerRow =
| { kind: 'group'; group: ThemeV2Group; depth: number }
| { kind: 'element'; element: ThemeV2Element; depth: number };
type LayerDragItem = { kind: 'element' | 'group'; id: string };
type LayerDropTarget =
| { mode: 'inside'; groupId: string }
| { mode: 'between'; elementId: string; position: 'above' | 'below' }
| { mode: 'root' };
const labelClass = 'text-[10px] font-medium uppercase tracking-wide text-muted-foreground';
const fieldClass = 'h-9 border-theme-800 bg-theme-950 text-xs text-theme-50';
const shortcutRows: { label: string; keys: string[] }[] = [
{ label: 'Save theme', keys: ['Cmd/Ctrl', 'S'] },
{ label: 'Undo', keys: ['Cmd/Ctrl', 'Z'] },
{ label: 'Redo', keys: ['Cmd/Ctrl', 'Shift', 'Z'] },
{ label: 'Find layers and groups', keys: ['Cmd/Ctrl', 'F'] },
{ label: 'Zoom canvas in / out', keys: ['Cmd/Ctrl', '+ / -'] },
{ label: 'Fit canvas to window', keys: ['Cmd/Ctrl', '0'] },
{ label: 'Copy / cut / paste', keys: ['Cmd/Ctrl', 'C / X / V'] },
{ label: 'Duplicate layer or group', keys: ['Cmd/Ctrl', 'D'] },
{ label: 'Select all layers', keys: ['Cmd/Ctrl', 'A'] },
{ label: 'Group or ungroup selection', keys: ['Cmd/Ctrl', 'G'] },
{ label: 'Add to selection', keys: ['Shift/Cmd', 'Click'] },
{ label: 'Nudge selected layers', keys: ['Arrows'] },
{ label: 'Nudge by 10px', keys: ['Shift', 'Arrows'] },
{ label: 'Reorder selected layer', keys: ['Cmd/Ctrl', '↑ / ↓'] },
{ label: 'Jump to linked logic', keys: ['Alt/Option', '↑ / ↓'] },
{ label: 'Delete selection', keys: ['Delete'] },
{ label: 'Clear selection / close dialog', keys: ['Escape'] },
{ label: 'Show this shortcut list', keys: ['?'] },
];
let definition = $state<ThemeV2Definition>(createDefaultThemeV2());
let pickedAssets = $state<ThemeV2Asset[]>([]);
let lastImageBackground = $state<Extract<ThemeV2Background, { type: 'image' }> | undefined>(
undefined
);
let lastVideoBackground = $state<Extract<ThemeV2Background, { type: 'video' }> | undefined>(
undefined
);
let lastColorBackground = $state<Extract<ThemeV2Background, { type: 'color' }> | undefined>(
undefined
);
let lastGradientBackground = $state<Extract<ThemeV2Background, { type: 'gradient' }> | undefined>(
undefined
);
let assetBase = $state('');
let savedFolder = $state<string | undefined>(undefined);
let editorFolderPath = $state<string | undefined>(undefined);
let loading = $state(true);
let saving = $state(false);
let exporting = $state(false);
let selectedIds = $state<string[]>([]);
let selectedGroupId = $state<string | undefined>(undefined);
let selectedBehaviorId = $state<string | undefined>(undefined);
let logicQuery = $state('');
let layerClipboard = $state<LayerClipboard | undefined>(undefined);
let pasteSerial = 0;
let leftTab = $state<'layers' | 'theme' | 'fonts' | 'logic'>('layers');
let layerQuery = $state('');
let layerSearchInput = $state<HTMLInputElement | null>(null);
let layerListHost = $state<HTMLDivElement | null>(null);
let shortcutsOpen = $state(false);
let logicFocusKey = $state('');
let logicFocusToken = $state(0);
let canvasHost = $state<HTMLDivElement | undefined>(undefined);
let canvasFitScale = $state(0.8);
let canvasZoom = $state(1);
let canvasPanX = $state(0);
let canvasPanY = $state(0);
let panMode = $state(false);
let panning = $state(false);
let panStart = { x: 0, y: 0, panX: 0, panY: 0 };
const canvasScale = $derived(canvasFitScale * canvasZoom);
let statusMessage = $state('Ready');
let renamingId = $state<string | undefined>(undefined);
let draggingLayer = $state<LayerDragItem | undefined>(undefined);
let dropTarget = $state<LayerDropTarget | undefined>(undefined);
let lastSavedKey = $state('');
let draftSavedAt = $state<number | undefined>(undefined);
let recoverableDraft = $state<ThemeEditorDraft | undefined>(undefined);
let closeDialogOpen = $state(false);
let allowClose = false;
let validationReport = $state<ThemeValidationReport | undefined>(undefined);
let validationOpen = $state(false);
let exportAfterValidation = $state(false);
let contextMenuOpen = $state(false);
let layerContextMenuOpen = $state(false);
let contextMenuTargetId = $state<string | undefined>(undefined);
let contextMenuGroupId = $state<string | undefined>(undefined);
let deleteGroupId = $state<string | undefined>(undefined);
let fontCatalog = $state<FontsourceFont[]>([]);
let fontCatalogLoading = $state(false);
let fontCatalogError = $state('');
let fontQuery = $state('');
let fontCategory = $state('all');
let fontResultLimit = $state(60);
let selectedFontsourceId = $state<string | undefined>(undefined);
let fontWeight = $state('400');
let fontStyle = $state<'normal' | 'italic'>('normal');
let fontSubset = $state('latin');
let fontDownloading = $state(false);
const elements = $derived(definition.elements ?? []);
const groups = $derived(definition.groups ?? []);
const behaviors = $derived(definition.behaviors ?? []);
const themeChrome = $derived(
definition.chrome ?? normalizeChrome(undefined, definition.elements)
);
const hasValidLaunchButton = $derived(themeHasLaunchButton(definition));
function updateChrome<K extends keyof ThemeV2Chrome>(key: K, value: ThemeV2Chrome[K]) {
const current = definition.chrome ?? normalizeChrome(undefined, definition.elements);
definition.chrome = {
...current,
[key]: value,
};
}
function setLayoutMode(mode: ThemeV2LayoutMode) {
const current = definition.chrome ?? normalizeChrome(undefined, definition.elements);
if (mode === 'dashboard') {
definition.chrome = {
...current,
layoutMode: 'dashboard',
playerCard: true,
recentActivity: true,
pills: true,
dock: true,
};
} else if (mode === 'classic') {
definition.chrome = {
...current,
layoutMode: 'classic',
playerCard: true,
recentActivity: false,
pills: false,
dock: true,
};
} else {
definition.chrome = {
...current,
layoutMode: 'custom',
};
}
}
const logicBadges = $derived.by(() => {
const badges = new SvelteMap<string, { count: number; warning: boolean }>();
for (const behavior of behaviors) {
const target = behavior.target;
if (!target) continue;
const key = `${target.type}:${target.id}`;
const badge = badges.get(key) ?? { count: 0, warning: false };
badge.count += 1;
const pointerTrigger =
behavior.trigger.type === 'click' ||
behavior.trigger.type === 'hover' ||
behavior.trigger.type === 'leave';
if (pointerTrigger) {
const interactive =
target.type === 'element'
? elements.some((element) => element.id === target.id && element.interactive === true)
: themeGroupInteractiveCount(groups, elements, target.id) > 0;
if (!interactive) badge.warning = true;
}
badges.set(key, badge);
}
return badges;
});
const filteredBehaviors = $derived.by(() => {
const query = logicQuery.trim().toLowerCase();
if (!query) return behaviors;
return behaviors.filter((behavior) => {
const haystack = [
behavior.name ?? '',
themeBehaviorTargetLabel(behavior.target, elements, groups),
themeBehaviorSummary(behavior, elements, groups),
]
.join(' ')
.toLowerCase();
return haystack.includes(query);
});
});
const scriptVariables = $derived([
...new Set<string>([...THEME_CLOCK_VARIABLE_NAMES, ...themeScriptVariableNames(definition)]),
]);
const editableScriptVariables = $derived(
scriptVariables.filter((name) => !isThemeClockVariable(name))
);
let previewClock = $state(Date.now());
$effect(() => {
const handle = setInterval(() => (previewClock = Date.now()), 1000);
return () => clearInterval(handle);
});
const previewScriptVariables = $derived({
...themeScriptInitialVariables(definition),
...themeClockValues(new Date(previewClock)),
});
const selectedGroupIndex = $derived(
selectedGroupId
? (definition.groups ?? []).findIndex((group) => group.id === selectedGroupId)
: -1
);
const selectedGroup = $derived(
selectedGroupIndex >= 0 && definition.groups ? definition.groups[selectedGroupIndex] : undefined
);
const selectedBehaviorIndex = $derived(
selectedBehaviorId
? (definition.behaviors ?? []).findIndex((behavior) => behavior.id === selectedBehaviorId)
: -1
);
const selectedBehavior = $derived(
selectedBehaviorIndex >= 0 && definition.behaviors
? definition.behaviors[selectedBehaviorIndex]
: undefined
);
function buildLayerRows(includeCollapsed: boolean): LayerRow[] {
const groupIds = new SvelteSet(groups.map((group) => group.id));
const elementOrder = new SvelteMap(elements.map((element, index) => [element.id, index]));
const parentElement = (element: ThemeV2Element) =>
element.groupId && groupIds.has(element.groupId) ? element.groupId : undefined;
const parentGroup = (group: ThemeV2Group) =>
group.parentId && groupIds.has(group.parentId) ? group.parentId : undefined;
const groupZ = (groupId: string, stack = new SvelteSet<string>()): number => {
if (stack.has(groupId)) return -1;
stack.add(groupId);
let top = -1;
for (const element of elements) {
if (parentElement(element) === groupId) {
top = Math.max(top, elementOrder.get(element.id) ?? -1);
}
}
for (const group of groups) {
if (parentGroup(group) === groupId) top = Math.max(top, groupZ(group.id, stack));
}
return top;
};
const entriesFor = (parentId: string | undefined) => {
const groupEntries = groups
.filter((group) => parentGroup(group) === parentId)
.map((group) => ({ kind: 'group' as const, group }));
const elementEntries = elements
.filter((element) => parentElement(element) === parentId)
.map((element) => ({ kind: 'element' as const, element }));
return [...groupEntries, ...elementEntries].sort((a, b) => {
const aZ = a.kind === 'group' ? groupZ(a.group.id) : (elementOrder.get(a.element.id) ?? -1);
const bZ = b.kind === 'group' ? groupZ(b.group.id) : (elementOrder.get(b.element.id) ?? -1);
return bZ - aZ;
});
};
const rows: LayerRow[] = [];
const addEntries = (parentId: string | undefined, depth: number) => {
for (const entry of entriesFor(parentId)) {
if (entry.kind === 'group') {
rows.push({ kind: 'group', group: entry.group, depth });
if (includeCollapsed || !entry.group.collapsed) addEntries(entry.group.id, depth + 1);
} else {
rows.push({ kind: 'element', element: entry.element, depth });
}
}
};
addEntries(undefined, 0);
return rows;
}
const layerRows = $derived.by(() => buildLayerRows(false));
const layerRowKey = (row: LayerRow) =>
row.kind === 'group' ? `g:${row.group.id}` : `e:${row.element.id}`;
const layerRowMatches = (row: LayerRow, query: string) => {
const haystack =
row.kind === 'group'
? groupLabel(row.group)
: `${elementLabel(row.element)} ${row.element.name ?? ''} ${row.element.type}`;
return haystack.toLowerCase().includes(query);
};
const layerSearchQuery = $derived(layerQuery.trim().toLowerCase());
const layerSearchActive = $derived(layerSearchQuery.length > 0);
const layerSearchMatchCount = $derived.by(() => {
if (!layerSearchActive) return 0;
return buildLayerRows(true).filter((row) => layerRowMatches(row, layerSearchQuery)).length;
});
const visibleLayerRows = $derived.by(() => {
if (!layerSearchActive) return layerRows;
const allRows = buildLayerRows(true);
const byKey = new SvelteMap(allRows.map((row) => [layerRowKey(row), row]));
const matches = new SvelteSet(
allRows.filter((row) => layerRowMatches(row, layerSearchQuery)).map((row) => layerRowKey(row))
);
const visible = new SvelteSet(matches);
// A matching group is shown with its full contents, so the expanded chevron is accurate.
for (const row of allRows) {
let parentId = row.kind === 'group' ? row.group.parentId : row.element.groupId;
const seen = new SvelteSet<string>();
while (parentId && !seen.has(parentId)) {
seen.add(parentId);
if (matches.has(`g:${parentId}`)) {
visible.add(layerRowKey(row));
break;
}
const parent = byKey.get(`g:${parentId}`);
parentId = parent?.kind === 'group' ? parent.group.parentId : undefined;
}
}
// Keep the ancestors of each match visible so the list still shows hierarchy.
for (const key of [...visible]) {
const row = byKey.get(key);
let parentId = row?.kind === 'group' ? row.group.parentId : row?.element.groupId;
const seen = new SvelteSet<string>();
while (parentId && !seen.has(parentId)) {
seen.add(parentId);
const parentKey = `g:${parentId}`;
if (visible.has(parentKey)) break;
visible.add(parentKey);
const parent = byKey.get(parentKey);
parentId = parent?.kind === 'group' ? parent.group.parentId : undefined;
}
}
return allRows.filter((row) => visible.has(layerRowKey(row)));
});
const anyGroupExpanded = $derived(groups.some((group) => !group.collapsed));
const logicTargets = $derived.by(() =>
buildLayerRows(true)
.map((row) =>
row.kind === 'group'
? { type: 'group' as const, id: row.group.id }
: { type: 'element' as const, id: row.element.id }
)
.filter((target) => (logicBadges.get(`${target.type}:${target.id}`)?.count ?? 0) > 0)
);
const currentLogicTargetIndex = $derived(
logicTargets.findIndex((target) =>
target.type === 'group'
? target.id === selectedGroupId
: selectedIds.length === 1 && target.id === selectedIds[0]
)
);
const selectedSet = $derived(new SvelteSet(selectedIds));
const selectedElements = $derived(elements.filter((element) => selectedSet.has(element.id)));
const movableSelectedElements = $derived(
selectedElements.filter((element) => !element.locked && !isElementLockedByGroups(element))
);
const selectedIndex = $derived(
selectedIds.length === 1 ? elements.findIndex((element) => element.id === selectedIds[0]) : -1
);
const selectedElement = $derived(selectedIndex >= 0 ? elements[selectedIndex] : undefined);
const contextMenuSelection = $derived(contextMenuTargetId ? selectedElements : []);
const contextMenuGroup = $derived(
contextMenuGroupId ? groups.find((group) => group.id === contextMenuGroupId) : undefined
);
const deleteGroupTarget = $derived(
deleteGroupId ? groups.find((group) => group.id === deleteGroupId) : undefined
);
const deleteGroupIds = $derived(
deleteGroupTarget ? collectGroupIds(deleteGroupTarget.id) : new SvelteSet<string>()
);
const deleteGroupLayerCount = $derived(
elements.filter((element) => element.groupId && deleteGroupIds.has(element.groupId)).length
);
const deleteGroupNestedCount = $derived(Math.max(0, deleteGroupIds.size - 1));
const deleteGroupLayerLabel = $derived(
`${deleteGroupLayerCount} layer${deleteGroupLayerCount === 1 ? '' : 's'}`
);
const deleteGroupNestedLabel = $derived(
`${deleteGroupNestedCount} nested group${deleteGroupNestedCount === 1 ? '' : 's'}`
);
const contextMenuAllHidden = $derived(
contextMenuSelection.length > 0 && contextMenuSelection.every((element) => element.hidden)
);
const contextMenuAllLocked = $derived(
contextMenuSelection.length > 0 && contextMenuSelection.every((element) => element.locked)
);
let previewAssetUrls = $state<Record<string, string>>({});
// On Linux the custom asset protocol cannot serve theme media, so picked
// files are registered with the loopback asset server first.
$effect(() => {
const assets = pickedAssets.map((asset) => ({ ...asset }));
let cancelled = false;
void (async () => {
const base = await themeAssetBase();
const entries = await Promise.all(
assets.map(async (asset) => {
if (!base) return [asset.name, convertFileSrc(asset.source)] as const;
try {
const url = await invoke<string>('theme_preview_url', { path: asset.source });
return [asset.name, url || convertFileSrc(asset.source)] as const;
} catch {
return [asset.name, convertFileSrc(asset.source)] as const;
}
})
);
if (!cancelled) previewAssetUrls = Object.fromEntries(entries);
})();
return () => {
cancelled = true;
};
});
const previewAssets = $derived(previewAssetUrls);
const fontOptions = $derived.by<ThemeFontChoice[]>(() => {
const choices = new SvelteMap<string, ThemeFontChoice>();
for (const font of BUNDLED_THEME_FONTS) choices.set(font.family, font);
for (const font of definition.fonts ?? []) {
if (!choices.has(font.family)) {
choices.set(font.family, {
label: font.family,
family: font.family,
category: 'Custom' as const,
});
}
}
return [...choices.values()];
});
const customFonts = $derived(definition.fonts ?? []);
const fontSourceCategories = [
{ value: 'all', label: 'All' },
{ value: 'sans-serif', label: 'Sans' },
{ value: 'serif', label: 'Serif' },
{ value: 'display', label: 'Display' },
{ value: 'monospace', label: 'Mono' },
{ value: 'handwriting', label: 'Script' },
] as const;
const FONT_PREVIEW_FAMILY = 'EZPPFontPreview';
const selectedFontsource = $derived(fontCatalog.find((font) => font.id === selectedFontsourceId));
const selectedFontBundledFamily = $derived(
selectedFontsource ? bundledThemeFontFamily(selectedFontsource.family) : undefined
);
const selectedFontIsBundled = $derived(selectedFontBundledFamily !== undefined);
const selectedFontsourceOptions = $derived({
weight: Number(fontWeight),
style: fontStyle,
subset: fontSubset,
});
const selectedFontPreviewUrl = $derived(
selectedFontsource && !selectedFontIsBundled
? fontsourceFontUrl(selectedFontsource, selectedFontsourceOptions)
: ''
);
const selectedFontPreviewCss = $derived.by(() => {
if (!selectedFontsource || !selectedFontPreviewUrl) return '';
const weight = selectedFontsource.variable ? '100 900' : fontWeight;
return `@font-face{font-family:"${FONT_PREVIEW_FAMILY}";src:url("${selectedFontPreviewUrl}") format("woff2");font-weight:${weight};font-style:${fontStyle};font-display:swap;}`;
});
const selectedFontIsAdded = $derived.by(() => {
if (!selectedFontsource) return false;
const key = fontsourceFontKey(selectedFontsource, selectedFontsourceOptions);
return (definition.fonts ?? []).some(
(font) => themeFontKey(font.family, font.weight ?? '400', font.style ?? 'normal') === key
);
});
const filteredFontsourceFonts = $derived.by(() => {
const query = fontQuery.trim().toLowerCase();
return fontCatalog.filter(
(font) =>
(fontCategory === 'all' || font.category === fontCategory) &&
(!query || `${font.family} ${font.id}`.toLowerCase().includes(query))
);
});
const visibleFontsourceFonts = $derived(filteredFontsourceFonts.slice(0, fontResultLimit));
const backgroundTypes = [
{ value: 'gradient', label: 'Gradient' },
{ value: 'color', label: 'Solid' },
{ value: 'image', label: 'Image' },
{ value: 'video', label: 'Video' },
] as const;
const effectTypes = [
{ value: 'none', label: 'None' },
{ value: 'stars', label: 'Stars' },
{ value: 'snow', label: 'Snow' },
{ value: 'petals', label: 'Petals' },
{ value: 'bubbles', label: 'Bubbles' },
] as const;
const notifyError = (description: string) => sileo.error({ title: 'Theme editor', description });
async function loadFontsourceCatalog() {
if (fontCatalogLoading || fontCatalog.length > 0) return;
fontCatalogLoading = true;
fontCatalogError = '';
statusMessage = 'Loading Fontsource...';
try {
fontCatalog = await getFontsourceCatalog();
statusMessage = `${fontCatalog.length} Fontsource fonts loaded`;
} catch (error) {
console.error(error);
fontCatalogError =
error instanceof Error ? error.message : 'Could not reach the Fontsource catalog.';
statusMessage = 'Fontsource unavailable';
} finally {
fontCatalogLoading = false;
}
}
function openFontBrowser() {
leftTab = 'fonts';
loadFontsourceCatalog();
}
function selectFontsource(font: FontsourceFont) {
selectedFontsourceId = font.id;
fontWeight = String(font.weights.includes(400) ? 400 : (font.weights[0] ?? 400));
fontStyle = font.styles.includes('normal') ? 'normal' : 'italic';
fontSubset = font.subsets.includes(font.defSubset)
? font.defSubset
: (font.subsets[0] ?? 'latin');
}
function setFontCategory(category: string) {
fontCategory = category;
fontResultLimit = 60;
}
async function addSelectedFontsource() {
if (!selectedFontsource || fontDownloading || selectedFontIsAdded || selectedFontIsBundled)
return;
fontDownloading = true;
statusMessage = `Downloading ${selectedFontsource.family}...`;
try {
const asset = await downloadFontsourceFont(selectedFontsource, selectedFontsourceOptions);
const weight = selectedFontsource.variable ? '100 900' : fontWeight;
const entry: ThemeV2Font = {
family: selectedFontsource.family,
src: asset.name,
weight,
style: fontStyle,
};
const entryKey = themeFontKey(entry.family, weight, fontStyle);
definition.fonts = [
...(definition.fonts ?? []).filter(
(font) =>
themeFontKey(font.family, font.weight ?? '400', font.style ?? 'normal') !== entryKey
),
entry,
];
pickedAssets = [
...pickedAssets.filter((candidate) => candidate.name !== asset.name),
{ name: asset.name, source: asset.source },
];
statusMessage = `Added ${selectedFontsource.family}`;
sileo.success({
title: 'Font added',
description: `${selectedFontsource.family} will be bundled with the theme when you save.`,
});
} catch (error) {
console.error(error);
statusMessage = 'Font download failed';
notifyError(error instanceof Error ? error.message : String(error));
} finally {
fontDownloading = false;
}
}
const CANVAS_WIDTH = 1000;
const CANVAS_HEIGHT = 650;
const CANVAS_MIN_SCALE = 0.1;
const CANVAS_MAX_SCALE = 5;
const CANVAS_ZOOM_STEP = 1.2;
const updateCanvasScale = () => {
if (!canvasHost) return;
const rect = canvasHost.getBoundingClientRect();
canvasFitScale = Math.max(
0.25,
Math.min((rect.width - 56) / CANVAS_WIDTH, (rect.height - 56) / CANVAS_HEIGHT)
);
};
function zoomCanvasAt(focusX: number, focusY: number, nextScale: number) {
if (!canvasHost) return;
const rect = canvasHost.getBoundingClientRect();
const currentScale = canvasScale;
const clampedScale = Math.max(CANVAS_MIN_SCALE, Math.min(CANVAS_MAX_SCALE, nextScale));
if (Math.abs(clampedScale - currentScale) < 0.0001) return;
// Keep the canvas point under the cursor fixed while the scale changes.
const canvasX =
(focusX - rect.width / 2 - canvasPanX + (CANVAS_WIDTH * currentScale) / 2) / currentScale;
const canvasY =
(focusY - rect.height / 2 - canvasPanY + (CANVAS_HEIGHT * currentScale) / 2) / currentScale;
canvasPanX =
focusX - rect.width / 2 - canvasX * clampedScale + (CANVAS_WIDTH * clampedScale) / 2;
canvasPanY =
focusY - rect.height / 2 - canvasY * clampedScale + (CANVAS_HEIGHT * clampedScale) / 2;
canvasZoom = clampedScale / canvasFitScale;
}
function zoomCanvasBy(factor: number) {
if (!canvasHost) {
canvasZoom = Math.max(
CANVAS_MIN_SCALE / canvasFitScale,
Math.min(CANVAS_MAX_SCALE / canvasFitScale, canvasZoom * factor)
);
return;
}
const rect = canvasHost.getBoundingClientRect();
zoomCanvasAt(rect.width / 2, rect.height / 2, canvasScale * factor);
}
function resetCanvasViewState() {
canvasZoom = 1;
canvasPanX = 0;
canvasPanY = 0;
panMode = false;
panning = false;
}
function resetCanvasView() {
resetCanvasViewState();
updateCanvasScale();
statusMessage = 'Canvas fit to window';
}
function handleCanvasWheel(event: WheelEvent) {
if (!canvasHost) return;
event.preventDefault();
const rect = canvasHost.getBoundingClientRect();
if (event.ctrlKey || event.metaKey) {
const factor = Math.exp(-event.deltaY * 0.0015);
zoomCanvasAt(event.clientX - rect.left, event.clientY - rect.top, canvasScale * factor);
return;
}
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? rect.height : 1;
canvasPanX -= event.deltaX * unit;
canvasPanY -= event.deltaY * unit;
}
function startCanvasPan(event: PointerEvent) {
if (event.button !== 0) return;
event.preventDefault();
panning = true;
panStart = { x: event.clientX, y: event.clientY, panX: canvasPanX, panY: canvasPanY };
(event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId);
}
function moveCanvasPan(event: PointerEvent) {
if (!panning) return;
canvasPanX = panStart.panX + (event.clientX - panStart.x);
canvasPanY = panStart.panY + (event.clientY - panStart.y);
}
function endCanvasPan(event: PointerEvent) {
if (!panning) return;
panning = false;
const target = event.currentTarget as HTMLElement;
if (target.hasPointerCapture?.(event.pointerId)) target.releasePointerCapture(event.pointerId);
}
const ensureDefaults = (theme: ThemeV2Definition): ThemeV2Definition => {
if (theme.background.type === 'image' && theme.background.src && !lastImageBackground) {
lastImageBackground = { ...theme.background };
} else if (theme.background.type === 'video' && theme.background.src && !lastVideoBackground) {
lastVideoBackground = { ...theme.background };
}
return {
...theme,
typography: {
body: theme.typography?.body ?? 'Sora Variable',
heading: theme.typography?.heading ?? theme.typography?.body ?? 'Sora Variable',
...(theme.typography?.mono ? { mono: theme.typography.mono } : {}),
},
fonts: theme.fonts ?? [],
elements: theme.elements ?? [],
groups: theme.groups ?? [],
};
};
type EditorSnapshot = {
definition: ThemeV2Definition;
pickedAssets: ThemeV2Asset[];
};
const HISTORY_LIMIT = 100;
const HISTORY_DELAY = 450;
const DRAFT_KEY = 'ezpplauncher.theme-editor.draft.v1';
const DRAFT_DELAY = 800;
let undoStack: EditorSnapshot[] = [];
let redoStack: EditorSnapshot[] = [];
let historyBase: EditorSnapshot | undefined;
let historyKey = '';
let historyDirty = false;
let historyTimer: number | undefined;
let canUndo = $state(false);
let canRedo = $state(false);
const captureSnapshot = (): EditorSnapshot => ({
definition: $state.snapshot(definition) as ThemeV2Definition,
pickedAssets: $state.snapshot(pickedAssets) as ThemeV2Asset[],
});
const snapshotKey = (snapshot: EditorSnapshot) => JSON.stringify(snapshot);
const dirty = $derived(
!loading && Boolean(lastSavedKey) && snapshotKey(captureSnapshot()) !== lastSavedKey
);
$effect(() => {
const searching = layerSearchActive;
if (searching || leftTab !== 'layers') return;
const key = selectedGroupId
? `g:${selectedGroupId}`
: selectedIds[0]
? `e:${selectedIds[0]}`
: '';
if (!key || !layerListHost) return;
const host = layerListHost;
requestAnimationFrame(() => {
host
.querySelector<HTMLElement>(`[data-layer-key="${key}"]`)
?.scrollIntoView({ block: 'nearest' });
});
});
function readDraft(): ThemeEditorDraft | undefined {
try {
const raw = localStorage.getItem(DRAFT_KEY);
if (!raw) return undefined;
const parsed = JSON.parse(raw) as ThemeEditorDraft;
if (!parsed || typeof parsed !== 'object' || !parsed.definition) return undefined;
if (!Array.isArray(parsed.pickedAssets) || typeof parsed.savedAt !== 'number') {
return undefined;
}
return parsed;
} catch {
return undefined;
}
}
function clearDraft() {
try {
localStorage.removeItem(DRAFT_KEY);
} catch {
// Storage can be unavailable in restricted webviews; autosave is optional.
}
draftSavedAt = undefined;
}
function restoreDraft() {
if (!recoverableDraft) return;
definition = ensureDefaults(normalizeThemeV2(recoverableDraft.definition));
pickedAssets = recoverableDraft.pickedAssets.map((asset) => ({ ...asset }));
selectedIds = [];
selectedGroupId = undefined;
recoverableDraft = undefined;
resetHistory();
statusMessage = 'Draft restored';
}
function discardDraft() {
recoverableDraft = undefined;
clearDraft();
statusMessage = 'Draft discarded';
}
$effect(() => {
const current = captureSnapshot();
const key = snapshotKey(current);
if (loading || !lastSavedKey || !definition.name) return;
if (key === lastSavedKey) {
// Keep a draft that is waiting to be restored; only clear drafts that
// belong to the already-saved state.
if (!recoverableDraft) clearDraft();
return;
}
const timer = window.setTimeout(() => {
try {
const draft: ThemeEditorDraft = {
definition: current.definition,
pickedAssets: current.pickedAssets,
savedFolder,
savedAt: Date.now(),
};
localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
draftSavedAt = draft.savedAt;
} catch {
// Ignore quota errors; the explicit save button still works.
}
}, DRAFT_DELAY);
return () => window.clearTimeout(timer);
});
function refreshHistoryButtons() {
canUndo = historyDirty || undoStack.length > 0;
canRedo = !historyDirty && redoStack.length > 0;
}
function stopHistoryTimer() {
if (historyTimer !== undefined) {
window.clearTimeout(historyTimer);
historyTimer = undefined;
}
}
function resetHistory() {
stopHistoryTimer();
undoStack = [];
redoStack = [];
const snapshot = captureSnapshot();
historyBase = snapshot;
historyKey = snapshotKey(snapshot);
historyDirty = false;
refreshHistoryButtons();
}
function applySnapshot(snapshot: EditorSnapshot) {
definition = snapshot.definition;
pickedAssets = snapshot.pickedAssets.map((asset) => ({ ...asset }));
selectedIds = selectedIds.filter((id) =>
definition.elements?.some((element) => element.id === id)
);
selectedGroupId = definition.groups?.some((group) => group.id === selectedGroupId)
? selectedGroupId
: undefined;
selectedBehaviorId = definition.behaviors?.some(
(behavior) => behavior.id === selectedBehaviorId
)
? selectedBehaviorId
: undefined;
}
function selectElement(id: string | undefined, additive = false) {
selectedGroupId = undefined;
selectedBehaviorId = undefined;
if (leftTab === 'logic') leftTab = 'layers';
if (!id) {
selectedIds = [];
return;
}
if (additive) {
selectedIds = selectedSet.has(id)
? selectedIds.filter((selected) => selected !== id)
: [...selectedIds, id];
return;
}
selectedIds = [id];
}
function selectMany(ids: string[], additive = false) {
selectedGroupId = undefined;
selectedBehaviorId = undefined;
if (leftTab === 'logic') leftTab = 'layers';
const valid = ids.filter((id) => elements.some((element) => element.id === id));
selectedIds = additive ? [...new SvelteSet([...selectedIds, ...valid])] : valid;
}
function selectGroup(id: string | undefined) {
selectedIds = [];
selectedBehaviorId = undefined;
selectedGroupId = id;
leftTab = 'layers';
}
function expandGroupPath(groupId: string | undefined) {
const seen = new SvelteSet<string>();
let current = groupId ? groups.find((group) => group.id === groupId) : undefined;
while (current && !seen.has(current.id)) {
seen.add(current.id);
current.collapsed = false;
current = current.parentId
? groups.find((group) => group.id === current?.parentId)
: undefined;
}
}
function openLinkedLogic(type: 'element' | 'group', id: string) {
const groupId =
type === 'group'
? groups.find((group) => group.id === id)?.parentId
: elements.find((element) => element.id === id)?.groupId;
expandGroupPath(groupId);
if (type === 'group') selectGroup(id);
else selectElement(id);
leftTab = 'layers';
logicFocusKey = `${type}:${id}`;
logicFocusToken += 1;
}
function jumpLogicTarget(direction: 1 | -1) {
if (logicTargets.length === 0) return;
const index =
currentLogicTargetIndex === -1
? direction > 0
? 0
: logicTargets.length - 1
: (currentLogicTargetIndex + direction + logicTargets.length) % logicTargets.length;
const target = logicTargets[index];
openLinkedLogic(target.type, target.id);
}
function openLogicTab() {
selectedIds = [];
selectedGroupId = undefined;
leftTab = 'logic';
if (!selectedBehaviorId) selectedBehaviorId = behaviors[0]?.id;
}
function groupLabel(group: ThemeV2Group) {
return group.name?.trim() || 'Group';
}
function isElementLockedByGroups(element: ThemeV2Element) {
let group = element.groupId
? groups.find((candidate) => candidate.id === element.groupId)
: undefined;
const seen = new SvelteSet<string>();
while (group && !seen.has(group.id)) {
if (group.locked) return true;
seen.add(group.id);
group = group.parentId
? groups.find((candidate) => candidate.id === group?.parentId)
: undefined;
}
return false;
}
function selectBehavior(id: string | undefined) {
selectedBehaviorId = id;
selectedIds = [];
selectedGroupId = undefined;
leftTab = 'logic';
}
function createBehavior(target?: ThemeV2BehaviorTarget) {
if (behaviors.length >= 100) {
notifyError('A theme can contain at most 100 logic rules.');
return undefined;
}
const behavior = createThemeV2Behavior(behaviors.length, target);
definition.behaviors = [...behaviors, behavior];
if (target?.type === 'element') {
const element = elements.find((candidate) => candidate.id === target.id);
if (element) element.interactive = true;
}
return behavior;
}
function addBehavior() {
const behavior = createBehavior();
if (behavior) selectBehavior(behavior.id);
}
function deleteBehavior(id = selectedBehaviorId) {
if (!id) return;
definition.behaviors = behaviors.filter((behavior) => behavior.id !== id);
if (selectedBehaviorId === id) selectBehavior(undefined);
}
function collectGroupIds(rootId: string) {
const ids = new SvelteSet<string>();
const visit = (groupId: string) => {
if (ids.has(groupId)) return;
ids.add(groupId);
for (const child of groups.filter((group) => group.parentId === groupId)) {
visit(child.id);
}
};
visit(rootId);
return ids;
}
function pruneBehaviorsAfterRemoval({
groups: removedGroups,
elements: removedElements,
}: {
groups?: ReadonlySet<string>;
elements?: ReadonlySet<string>;
} = {}) {
const groupIds = removedGroups ?? new SvelteSet<string>();
const elementIds = removedElements ?? new SvelteSet<string>();
const current = definition.behaviors ?? [];
const remaining = current.filter((behavior) => {
if (behavior.target?.type === 'element' && elementIds.has(behavior.target.id)) return false;
if (behavior.target?.type === 'group' && groupIds.has(behavior.target.id)) return false;
const trigger = behavior.trigger;
if (
(trigger.type === 'click' || trigger.type === 'hover' || trigger.type === 'leave') &&
trigger.elementId &&
elementIds.has(trigger.elementId)
) {
return false;
}
return true;
});
let changed = remaining.length !== current.length;
const cleaned = remaining.map((behavior) => {
let behaviorChanged = false;
const conditions = (behavior.conditions ?? []).filter((condition) => {
if (condition.type === 'visible' && elementIds.has(condition.elementId)) {
behaviorChanged = true;
return false;
}
if (condition.type === 'groupVisible' && groupIds.has(condition.groupId)) {
behaviorChanged = true;
return false;
}
return true;
});
const actions = behavior.actions.filter((action) => {
if (
(action.type === 'showElement' ||
action.type === 'hideElement' ||
action.type === 'toggleElement') &&
elementIds.has(action.elementId)
) {
behaviorChanged = true;
return false;
}
if (
(action.type === 'showGroup' ||
action.type === 'hideGroup' ||
action.type === 'toggleGroup') &&
groupIds.has(action.groupId)
) {
behaviorChanged = true;
return false;
}
return true;
});
if (!behaviorChanged) return behavior;
changed = true;
return { ...behavior, conditions, actions };
});
if (!changed) return 0;
definition.behaviors = cleaned;
if (selectedBehaviorId && !cleaned.some((behavior) => behavior.id === selectedBehaviorId)) {
selectedBehaviorId = undefined;
}
return current.length - remaining.length;
}
function removeGroupTree(rootId: string) {
const ids = collectGroupIds(rootId);
const removedElements = new SvelteSet(
elements
.filter((element) => element.groupId && ids.has(element.groupId))
.map((element) => element.id)
);
definition.groups = groups.filter((group) => !ids.has(group.id));
definition.elements = elements.filter((element) => !removedElements.has(element.id));
pruneBehaviorsAfterRemoval({ groups: ids, elements: removedElements });
if (selectedGroupId && ids.has(selectedGroupId)) selectedGroupId = undefined;
selectedIds = selectedIds.filter((id) => !removedElements.has(id));
}
function requestGroupDelete(id = selectedGroupId) {
if (!id || !groups.some((group) => group.id === id)) return;
deleteGroupId = id;
}
function confirmGroupDelete() {
const id = deleteGroupId;
if (!id) return;
const group = groups.find((candidate) => candidate.id === id);
const ids = collectGroupIds(id);
const layerCount = elements.filter(
(element) => element.groupId && ids.has(element.groupId)
).length;
const label = group ? groupLabel(group) : 'Group';
deleteGroupId = undefined;
removeGroupTree(id);
statusMessage =
layerCount > 0
? `Deleted ${label} and ${layerCount} layer${layerCount === 1 ? '' : 's'}`
: `Deleted ${label}`;
}
function addGroup(name?: string) {
let id = `group_${Date.now().toString(36)}`;
let suffix = 2;
while (groups.some((group) => group.id === id))
id = `group_${Date.now().toString(36)}_${suffix++}`;
const group: ThemeV2Group = {
id,
name: name ?? `Group ${groups.length + 1}`,
collapsed: false,
};
definition.groups = [...groups, group];
selectedIds = [];
selectedGroupId = id;
leftTab = 'layers';
return group;
}
function groupSelection() {
if (selectedElements.length === 0) return;
const targets = [...selectedElements];
const parents = new SvelteSet(targets.map((element) => element.groupId));
const parentId = parents.size === 1 ? [...parents][0] : undefined;
const group = addGroup();
if (parentId) {
group.parentId = parentId;
const parent = groups.find((candidate) => candidate.id === parentId);
if (parent) parent.collapsed = false;
}
const targetSet = new SvelteSet(targets.map((element) => element.id));
definition.elements = elements.map((element) =>
targetSet.has(element.id) ? { ...element, groupId: group.id } : element
);
}
function removeSelectedFromGroup() {
if (selectedElements.length === 0) return;
for (const element of selectedElements) {
if (!element.groupId) continue;
element.groupId = undefined;
}
}
function ungroupGroup(id = selectedGroupId) {
const group = id ? groups.find((candidate) => candidate.id === id) : undefined;
if (!group) return;
const parentId = group.parentId;
definition.groups = groups
.filter((candidate) => candidate.id !== group.id)
.map((candidate) =>
candidate.parentId === group.id ? { ...candidate, parentId } : candidate
);
definition.elements = elements.map((element) =>
element.groupId === group.id ? { ...element, groupId: parentId } : element
);
pruneBehaviorsAfterRemoval({ groups: new SvelteSet([group.id]) });
selectedGroupId = undefined;
}
function toggleGroupCollapsed(group: ThemeV2Group) {
group.collapsed = !group.collapsed;
}
function setAllGroupsCollapsed(collapsed: boolean) {
if (groups.length === 0) return;
definition.groups = groups.map((group) => ({ ...group, collapsed }));
statusMessage = collapsed ? 'Collapsed all groups' : 'Expanded all groups';
}
function toggleAllGroups() {
setAllGroupsCollapsed(anyGroupExpanded);
}
async function focusLayerSearch() {
leftTab = 'layers';
await tick();
layerSearchInput?.focus();
layerSearchInput?.select();
}
function toggleGroupFlag(group: ThemeV2Group, flag: 'hidden' | 'locked') {
group[flag] = !group[flag];
}
function groupDescendantCount(groupId: string, seen = new SvelteSet<string>()): number {
if (seen.has(groupId)) return 0;
seen.add(groupId);
let count = elements.filter((element) => element.groupId === groupId).length;
for (const child of groups.filter((group) => group.parentId === groupId)) {
count += groupDescendantCount(child.id, seen);
}
return count;
}
function validGroupId(id: string | undefined) {
return id && groups.some((group) => group.id === id) ? id : undefined;
}
function canNestGroup(groupId: string, parentId: string | undefined) {
if (!parentId) return true;
if (groupId === parentId) return false;
return !collectGroupIds(groupId).has(parentId);
}
function moveElementIntoGroup(sourceId: string, groupId: string) {
const source = elements.find((element) => element.id === sourceId);
const group = groups.find((candidate) => candidate.id === groupId);
if (!source || !group) return false;
const remaining = elements.filter((element) => element.id !== sourceId);
const groupIds = collectGroupIds(groupId);
let topIndex = -1;
remaining.forEach((element, index) => {
if (element.groupId && groupIds.has(element.groupId)) topIndex = index;
});
// Elements are stored bottom-to-top, so inserting after the group's top
// child places the dragged layer at the top of that group.
source.groupId = groupId;
remaining.splice(topIndex + 1, 0, source);
definition.elements = remaining;
group.collapsed = false;
expandGroupPath(group.id);
statusMessage = `Moved ${elementLabel(source)} into ${groupLabel(group)}`;
return true;
}
function moveElementBetween(sourceId: string, targetId: string, position: 'above' | 'below') {
if (sourceId === targetId) return false;
const source = elements.find((element) => element.id === sourceId);
const target = elements.find((element) => element.id === targetId);
if (!source || !target) return false;
const remaining = elements.filter((element) => element.id !== sourceId);
const targetIndex = remaining.findIndex((element) => element.id === targetId);
if (targetIndex < 0) return false;
// The layer list is rendered top-to-bottom, while `elements` is ordered
// bottom-to-top. Inserting after the target in the array moves the layer
// above it in the list.
const insertAt = position === 'above' ? targetIndex + 1 : targetIndex;
const parentId = validGroupId(target.groupId);
source.groupId = parentId;
remaining.splice(insertAt, 0, source);
definition.elements = remaining;
if (parentId) expandGroupPath(parentId);
statusMessage = `Moved ${elementLabel(source)} ${position} ${elementLabel(target)}`;
return true;
}
function moveGroupToParent(groupId: string, parentId: string | undefined) {
const group = groups.find((candidate) => candidate.id === groupId);
if (!group || group.parentId === parentId) return false;
if (!canNestGroup(groupId, parentId)) return false;
const parent = parentId ? groups.find((candidate) => candidate.id === parentId) : undefined;
group.parentId = parent?.id;
if (parent) {
parent.collapsed = false;
expandGroupPath(parent.id);
}
statusMessage = parent
? `Moved ${groupLabel(group)} into ${groupLabel(parent)}`
: `Moved ${groupLabel(group)} out of its group`;
return true;
}
function moveElementToRoot(sourceId: string) {
const source = elements.find((element) => element.id === sourceId);
if (!source || !source.groupId) return false;
source.groupId = undefined;
statusMessage = `Moved ${elementLabel(source)} out of its group`;
return true;
}
function closeContextMenu() {
contextMenuOpen = false;
layerContextMenuOpen = false;
contextMenuTargetId = undefined;
contextMenuGroupId = undefined;
}
function prepareContextMenu(targetId: string | undefined) {
if (loading) return;
contextMenuGroupId = undefined;
if (targetId) {
if (!selectedSet.has(targetId)) selectElement(targetId);
} else {
selectedIds = [];
selectedGroupId = undefined;
}
contextMenuTargetId = targetId;
}
function prepareGroupContextMenu(groupId: string) {
if (loading) return;
selectedIds = [];
selectedGroupId = groupId;
contextMenuTargetId = undefined;
contextMenuGroupId = groupId;
}
function elementLabel(element: ThemeV2Element) {
return themeElementLabel(element);
}
function moveElements(updates: { id: string; x: number; y: number }[]) {
for (const update of updates) {
updateElement(update.id, { x: update.x, y: update.y });
}
}
function nudgeSelected(deltaX: number, deltaY: number) {
if (movableSelectedElements.length === 0) return;
const minX = Math.min(...movableSelectedElements.map((element) => element.x));
const minY = Math.min(...movableSelectedElements.map((element) => element.y));
const maxX = Math.max(...movableSelectedElements.map((element) => element.x + element.width));
const maxY = Math.max(...movableSelectedElements.map((element) => element.y + element.height));
const dx = Math.min(1000 - maxX, Math.max(-minX, deltaX));
const dy = Math.min(650 - maxY, Math.max(-minY, deltaY));
for (const element of movableSelectedElements) {
updateElement(element.id, { x: element.x + dx, y: element.y + dy });
}
}
function toggleElementFlag(element: ThemeV2Element, flag: 'hidden' | 'locked') {
updateElement(element.id, { [flag]: !element[flag] });
}
function toggleSelectedFlag(flag: 'hidden' | 'locked') {
const next = !selectedElements.every((element) => element[flag] === true);
for (const element of selectedElements) {
updateElement(element.id, { [flag]: next });
}
}
function alignSelected(edge: 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom') {
if (movableSelectedElements.length < 2) return;
const minX = Math.min(...movableSelectedElements.map((element) => element.x));
const minY = Math.min(...movableSelectedElements.map((element) => element.y));
const maxX = Math.max(...movableSelectedElements.map((element) => element.x + element.width));
const maxY = Math.max(...movableSelectedElements.map((element) => element.y + element.height));
for (const element of movableSelectedElements) {
if (edge === 'left') updateElement(element.id, { x: minX });
if (edge === 'center') {
updateElement(element.id, { x: Math.round((minX + maxX - element.width) / 2) });
}
if (edge === 'right') updateElement(element.id, { x: maxX - element.width });
if (edge === 'top') updateElement(element.id, { y: minY });
if (edge === 'middle') {
updateElement(element.id, { y: Math.round((minY + maxY - element.height) / 2) });
}
if (edge === 'bottom') updateElement(element.id, { y: maxY - element.height });
}
}
function distributeSelected(axis: 'horizontal' | 'vertical') {
if (movableSelectedElements.length < 3) return;
const sorted = [...movableSelectedElements].sort((a, b) =>
axis === 'horizontal' ? a.x - b.x : a.y - b.y
);
const start = axis === 'horizontal' ? sorted[0].x : sorted[0].y;
const end =
axis === 'horizontal'
? Math.max(...sorted.map((element) => element.x + element.width))
: Math.max(...sorted.map((element) => element.y + element.height));
const totalSize = sorted.reduce(
(sum, element) => sum + (axis === 'horizontal' ? element.width : element.height),
0
);
const gap = Math.max(0, (end - start - totalSize) / (sorted.length - 1));
let cursor = start;
for (const element of sorted) {
if (axis === 'horizontal') {
updateElement(element.id, { x: Math.round(cursor) });
cursor += element.width + gap;
} else {
updateElement(element.id, { y: Math.round(cursor) });
cursor += element.height + gap;
}
}
}
function startLayerDrag(event: DragEvent, kind: 'element' | 'group', id: string) {
draggingLayer = { kind, id };
dropTarget = undefined;
if (kind === 'group') {
if (selectedGroupId !== id) selectGroup(id);
} else if (!selectedSet.has(id)) {
selectElement(id);
}
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', id);
}
}
function dropPosition(event: DragEvent): 'above' | 'below' {
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
return event.clientY < rect.top + rect.height / 2 ? 'above' : 'below';
}
function updateGroupDrop(event: DragEvent, groupId: string) {
const source = draggingLayer;
if (!source) return;
if (source.kind === 'group' && !canNestGroup(source.id, groupId)) {
if (dropTarget?.mode === 'inside' && dropTarget.groupId === groupId) {
dropTarget = undefined;
}
return;
}
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
dropTarget = { mode: 'inside', groupId };
}
function updateElementDrop(event: DragEvent, elementId: string) {
if (draggingLayer?.kind !== 'element' || draggingLayer.id === elementId) {
if (dropTarget?.mode === 'between' && dropTarget.elementId === elementId) {
dropTarget = undefined;
}
return;
}
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
dropTarget = { mode: 'between', elementId, position: dropPosition(event) };
}
function updateRootDrop(event: DragEvent) {
if (!draggingLayer) return;
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
dropTarget = { mode: 'root' };
}
function dropLayerOnGroup(event: DragEvent, groupId: string) {
event.preventDefault();
event.stopPropagation();
const source = draggingLayer;
if (source) {
if (source.kind === 'element') moveElementIntoGroup(source.id, groupId);
else moveGroupToParent(source.id, groupId);
}
endLayerDrag();
}
function dropLayerOnElement(event: DragEvent, elementId: string) {
event.preventDefault();
event.stopPropagation();
const source = draggingLayer;
if (source?.kind === 'element') {
const position = dropTarget?.mode === 'between' ? dropTarget.position : dropPosition(event);
moveElementBetween(source.id, elementId, position);
}
endLayerDrag();
}
function dropLayerOnRoot(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
const source = draggingLayer;
if (source) {
if (source.kind === 'element') moveElementToRoot(source.id);
else moveGroupToParent(source.id, undefined);
}
endLayerDrag();
}
function endLayerDrag() {
draggingLayer = undefined;
dropTarget = undefined;
}
function renameElement(id: string, value: string) {
updateElement(id, { name: value });
}
function focusInput(node: HTMLInputElement) {
requestAnimationFrame(() => {
node.focus();
node.select();
});
return {};
}
function commitPendingHistory() {
stopHistoryTimer();
if (!historyBase) {
resetHistory();
return;
}
const current = captureSnapshot();
const currentKey = snapshotKey(current);
const baseKey = snapshotKey(historyBase);
if (currentKey !== baseKey) {
undoStack.push(historyBase);
if (undoStack.length > HISTORY_LIMIT) undoStack.shift();
redoStack = [];
}
historyBase = current;
historyKey = currentKey;
historyDirty = false;
refreshHistoryButtons();
}
function undo() {
commitPendingHistory();
const previous = undoStack.pop();
if (!previous) {
refreshHistoryButtons();
return;
}
redoStack.push(captureSnapshot());
applySnapshot(previous);
historyBase = captureSnapshot();
historyKey = snapshotKey(historyBase);
historyDirty = false;
statusMessage = 'Undid last change';
refreshHistoryButtons();
}
function redo() {
commitPendingHistory();
const next = redoStack.pop();
if (!next) {
refreshHistoryButtons();
return;
}
undoStack.push(captureSnapshot());
if (undoStack.length > HISTORY_LIMIT) undoStack.shift();
applySnapshot(next);
historyBase = captureSnapshot();
historyKey = snapshotKey(historyBase);
historyDirty = false;
statusMessage = 'Redid last change';
refreshHistoryButtons();
}
$effect(() => {
const current = captureSnapshot();
const currentKey = snapshotKey(current);
if (!historyBase) {
historyBase = current;
historyKey = currentKey;
return;
}
if (currentKey === historyKey) return;
historyKey = currentKey;
if (currentKey === snapshotKey(historyBase)) {
stopHistoryTimer();
historyDirty = false;
refreshHistoryButtons();
return;
}
redoStack = [];
historyDirty = true;
refreshHistoryButtons();
stopHistoryTimer();
historyTimer = window.setTimeout(() => {
historyTimer = undefined;
commitPendingHistory();
}, HISTORY_DELAY);
});
async function loadContext() {
loading = true;
try {
const context = await invoke<ThemeEditorContext>('theme_editor_context');
const parsed = context.themeJson
? normalizeThemeV2(JSON.parse(context.themeJson) as Partial<ThemeV2Definition>)
: createDefaultThemeV2();
definition = ensureDefaults(parsed);
savedFolder = context.folderName ?? undefined;
editorFolderPath = context.folderPath ?? undefined;
const httpBase = await themeAssetBase();
assetBase =
httpBase && context.folderName
? `${httpBase}/${encodeURIComponent(context.folderName)}/assets`
: context.folderPath
? convertFileSrc(await path.normalize(await path.join(context.folderPath, 'assets')))
: '';
pickedAssets = [];
selectedIds = [];
selectedGroupId = undefined;
layerClipboard = undefined;
selectedFontsourceId = undefined;
fontDownloading = false;
renamingId = undefined;
resetCanvasViewState();
resetHistory();
lastSavedKey = snapshotKey(captureSnapshot());
const draft = readDraft();
recoverableDraft =
draft && (draft.savedFolder ?? '') === (context.folderName ?? '') ? draft : undefined;
statusMessage = context.themeJson ? 'Theme loaded' : 'New theme';
requestAnimationFrame(updateCanvasScale);
} catch (error) {
console.error(error);
definition = ensureDefaults(createDefaultThemeV2());
savedFolder = undefined;
editorFolderPath = undefined;
assetBase = '';
pickedAssets = [];
selectedIds = [];
selectedGroupId = undefined;
layerClipboard = undefined;
selectedFontsourceId = undefined;
fontDownloading = false;
resetCanvasViewState();
resetHistory();
lastSavedKey = snapshotKey(captureSnapshot());
recoverableDraft = undefined;
notifyError(error instanceof Error ? error.message : String(error));
} finally {
loading = false;
requestAnimationFrame(updateCanvasScale);
}
}
type ThemeEditorGlobalSyncPayload = {
user?: EZPPUser | null;
info?: EZPPUserInfo | null;
friends?: number;
osuBuild?: string;
osuStream?: string;
serverPing?: number;
launcherVersion?: string;
};
onMount(() => {
const observer = new ResizeObserver(updateCanvasScale);
if (canvasHost) observer.observe(canvasHost);
let unlisten: UnlistenFn | undefined;
let unlistenClose: UnlistenFn | undefined;
let unlistenGlobalSync: UnlistenFn | undefined;
listen('theme-editor-context', () => loadContext()).then((stop) => (unlisten = stop));
listen<ThemeEditorGlobalSyncPayload>('sync-theme-editor-global-data', (event) => {
const data = event.payload;
if (!data) return;
if (data.user !== undefined) currentUser.set(data.user ?? undefined);
if (data.info !== undefined) currentUserInfo.set(data.info ?? undefined);
if (data.friends !== undefined) onlineFriends.set(data.friends);
if (data.osuBuild) osuBuild.set(data.osuBuild);
if (data.osuStream) osuStream.set(data.osuStream);
if (data.serverPing !== undefined) serverPing.set(data.serverPing);
if (data.launcherVersion) launcherVersion.set(data.launcherVersion);
}).then((stop) => (unlistenGlobalSync = stop));
emit('request-theme-editor-global-data').catch(() => {});
getCurrentWindow()
.onCloseRequested((event) => {
if (allowClose || !dirty) return;
event.preventDefault();
closeDialogOpen = true;
})
.then((stop) => (unlistenClose = stop));
loadContext();
getLauncherVersion()
.then((ver) => {
if (ver) launcherVersion.set(ver);
})
.catch(() => {});
const teardownValues = setupValues();
config
.value<string>('osu_installation_path')
.get('')
.then(async (path) => {
if (!path) return;
osuInstallationPath.set(path);
try {
const [stream, version] = await Promise.all([
getReleaseStream(path).catch(() => undefined),
getVersion(path).catch(() => undefined),
]);
if (stream) osuStream.set(stream);
if (version) osuBuild.set(version);
} catch {}
})
.catch(() => {});
return () => {
observer.disconnect();
unlisten?.();
unlistenClose?.();
unlistenGlobalSync?.();
teardownValues?.();
stopHistoryTimer();
};
});
const assetFilters: Record<AssetKind, { name: string; extensions: string[] }[]> = {
image: [{ name: 'Image', extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif', 'avif'] }],
video: [{ name: 'Video (WebM recommended)', extensions: ['webm', 'mp4', 'm4v', 'mov'] }],
audio: [{ name: 'Audio', extensions: ['mp3', 'ogg', 'wav', 'm4a', 'flac'] }],
font: [{ name: 'Font', extensions: ['woff2', 'woff', 'ttf', 'otf'] }],
lottie: [{ name: 'Lottie animation', extensions: ['json'] }],
};
async function chooseAsset(kind: AssetKind): Promise<ThemeV2Asset | undefined> {
const selected = await open({
multiple: false,
directory: false,
title:
kind === 'image'
? 'Choose an image'
: kind === 'video'
? 'Choose a video'
: kind === 'audio'
? 'Choose theme music'
: kind === 'lottie'
? 'Choose a Lottie JSON animation'
: 'Import a font file',
filters: assetFilters[kind],
});
if (typeof selected !== 'string') return;
const prefix = kind === 'font' ? 'font' : kind === 'audio' ? 'audio' : kind;
const name = themeAssetName(selected, prefix);
const asset = { source: selected, name };
pickedAssets = [...pickedAssets.filter((candidate) => candidate.name !== name), asset];
return asset;
}
function setVariableDefault(name: string, value: ThemeV2ScriptValue | undefined) {
const variables = { ...(definition.variables ?? {}) };
if (value === undefined) {
delete variables[name];
} else {
variables[name] = value;
}
definition.variables = variables;
}
function setBackgroundType(type: ThemeV2Definition['background']['type']) {
const current = definition.background;
// Cache the active background before switching
if (current.type === 'image' && current.src) {
lastImageBackground = { ...current };
} else if (current.type === 'video' && current.src) {
lastVideoBackground = { ...current };
} else if (current.type === 'color') {
lastColorBackground = { ...current };
} else if (current.type === 'gradient') {
lastGradientBackground = { ...current };
}
const opacity = current.opacity ?? 1;
const blur = current.type === 'image' || current.type === 'video' ? (current.blur ?? 0) : 0;
const fit =
current.type === 'image' || current.type === 'video' ? (current.fit ?? 'cover') : 'cover';
if (type === 'color') {
definition.background = {
type: 'color',
color: current.type === 'color' ? current.color : (lastColorBackground?.color ?? '#181825'),
opacity: lastColorBackground?.opacity ?? opacity,
};
} else if (type === 'gradient') {
definition.background = {
type: 'gradient',
from:
current.type === 'gradient' ? current.from : (lastGradientBackground?.from ?? '#181825'),
to: current.type === 'gradient' ? current.to : (lastGradientBackground?.to ?? '#3b1d4f'),
angle:
current.type === 'gradient'
? (current.angle ?? 135)
: (lastGradientBackground?.angle ?? 135),
opacity: lastGradientBackground?.opacity ?? opacity,
};
} else if (type === 'video') {
const candidateVideo =
current.type === 'video' && current.src
? current.src
: lastVideoBackground?.src ||
pickedAssets.find((a) => /\.(mp4|webm|ogv|mov)$/i.test(a.name))?.name ||
'';
definition.background = {
type: 'video',
src: candidateVideo,
fit: current.type === 'video' ? fit : (lastVideoBackground?.fit ?? fit),
opacity: current.type === 'video' ? opacity : (lastVideoBackground?.opacity ?? opacity),
blur: current.type === 'video' ? blur : (lastVideoBackground?.blur ?? blur),
muted:
current.type === 'video' ? current.muted !== false : lastVideoBackground?.muted !== false,
};
if (candidateVideo) {
lastVideoBackground = { ...definition.background };
}
definition.audio = undefined;
} else {
const candidateImage =
current.type === 'image' && current.src
? current.src
: lastImageBackground?.src ||
pickedAssets.find((a) => /\.(png|jpg|jpeg|webp|gif|avif)$/i.test(a.name))?.name ||
'';
definition.background = {
type: 'image',
src: candidateImage,
fit: current.type === 'image' ? fit : (lastImageBackground?.fit ?? fit),
opacity: current.type === 'image' ? opacity : (lastImageBackground?.opacity ?? opacity),
blur: current.type === 'image' ? blur : (lastImageBackground?.blur ?? blur),
};
if (candidateImage) {
lastImageBackground = { ...definition.background };
}
}
}
async function chooseBackground(type: 'image' | 'video') {
const asset = await chooseAsset(type);
if (!asset) return;
const current = definition.background;
const fit =
current.type === 'image' || current.type === 'video' ? (current.fit ?? 'cover') : 'cover';
const opacity = current.opacity ?? 1;
const blur = current.type === 'image' || current.type === 'video' ? (current.blur ?? 0) : 0;
if (type === 'video') {
const bg: Extract<ThemeV2Background, { type: 'video' }> = {
type,
src: asset.name,
fit,
opacity,
blur,
muted: current.type === 'video' ? current.muted !== false : true,
};
definition.background = bg;
lastVideoBackground = { ...bg };
} else {
const bg: Extract<ThemeV2Background, { type: 'image' }> = {
type,
src: asset.name,
fit,
opacity,
blur,
};
definition.background = bg;
lastImageBackground = { ...bg };
}
}
async function chooseAudio() {
if (definition.background.type === 'video') {
notifyError('Theme music cannot be used while a video is selected as the background.');
return;
}
const asset = await chooseAsset('audio');
if (!asset) return;
definition.audio = { src: asset.name, loop: definition.audio?.loop !== false };
}
function uniqueFamily(label: string) {
const existing = new SvelteSet((definition.fonts ?? []).map((font) => font.family));
let family = label.trim() || 'Custom Font';
let suffix = 2;
while (existing.has(family)) family = `${label.trim()} ${suffix++}`;
return family;
}
async function importFont() {
const asset = await chooseAsset('font');
if (!asset) return;
const stem = (asset.source.split(/[\\/]/).pop() ?? 'Custom Font')
.replace(/\.[^.]+$/, '')
.replace(/[_-]+/g, ' ')
.trim();
const font: ThemeV2Font = {
family: uniqueFamily(stem || 'Custom Font'),
src: asset.name,
weight: '400',
style: 'normal',
};
definition.fonts = [...(definition.fonts ?? []), font];
statusMessage = `Imported ${font.family}`;
sileo.success({ title: 'Font imported', description: font.family });
}
function assetIsUsed(name: string) {
const background = definition.background;
if ((background.type === 'image' || background.type === 'video') && background.src === name) {
return true;
}
if (definition.audio?.src === name) return true;
if ((definition.fonts ?? []).some((font) => font.src === name)) return true;
return elements.some(
(element) =>
((element.type === 'image' || element.type === 'video') && element.src === name) ||
(element.type === 'svelte' && element.source.includes(name))
);
}
function removeFont(font: ThemeV2Font) {
const remaining = (definition.fonts ?? []).filter(
(candidate) =>
candidate.family !== font.family ||
candidate.src !== font.src ||
(candidate.weight ?? '400') !== (font.weight ?? '400') ||
(candidate.style ?? 'normal') !== (font.style ?? 'normal')
);
definition.fonts = remaining;
if (!remaining.some((candidate) => candidate.family === font.family)) {
if (definition.typography?.body === font.family) {
definition.typography.body = 'Sora Variable';
}
if (definition.typography?.heading === font.family) {
definition.typography.heading = 'Sora Variable';
}
if (definition.typography?.mono === font.family) {
definition.typography.mono = 'Space Mono';
}
for (const element of elements) {
if (element.type === 'text' && element.fontFamily === font.family) {
element.fontFamily = 'Sora Variable';
}
}
}
if (!remaining.some((candidate) => candidate.src === font.src) && !assetIsUsed(font.src)) {
pickedAssets = pickedAssets.filter((asset) => asset.name !== font.src);
}
}
function addText() {
const element = createThemeV2Element('text', elements.length);
if (selectedGroupId) element.groupId = selectedGroupId;
definition.elements = [...elements, element];
selectedGroupId = undefined;
selectedIds = [element.id];
leftTab = 'layers';
}
async function addMedia(kind: 'image' | 'video') {
const asset = await chooseAsset(kind);
if (!asset) return;
const element = createThemeV2Element(kind, elements.length) as
| ThemeV2ImageElement
| ThemeV2VideoElement;
element.src = asset.name;
if (selectedGroupId) element.groupId = selectedGroupId;
definition.elements = [...elements, element];
selectedGroupId = undefined;
selectedIds = [element.id];
leftTab = 'layers';
}
function addShape() {
const element = createThemeV2Element('shape', elements.length);
if (selectedGroupId) element.groupId = selectedGroupId;
definition.elements = [...elements, element];
selectedGroupId = undefined;
selectedIds = [element.id];
leftTab = 'layers';
}
function addSvelte() {
const element = createThemeV2Element('svelte', elements.length);
if (selectedGroupId) element.groupId = selectedGroupId;
definition.elements = [...elements, element];
selectedGroupId = undefined;
selectedIds = [element.id];
leftTab = 'layers';
}
function addLayer(
type:
| 'data'
| 'icon'
| 'repeater'
| 'gauge'
| 'ticker'
| 'button'
| 'path'
| 'particles'
| 'clock'
| 'countdown'
| 'widget',
widgetKind: ThemeV2WidgetKind = 'launch-button'
) {
const element = createThemeV2Element(type, elements.length);
if (element.type === 'widget') {
element.widget = widgetKind;
if (widgetKind === 'player-card') {
element.width = 380;
element.height = 230;
} else if (widgetKind === 'avatar') {
element.width = 72;
element.height = 72;
element.variant = 'circle';
element.showCountry = true;
element.showLevel = true;
element.showOnlineRing = true;
} else if (widgetKind === 'recent-activity') {
element.width = 340;
element.height = 230;
} else if (widgetKind === 'telemetry') {
element.width = 320;
element.height = 42;
} else if (widgetKind === 'header') {
element.width = 340;
element.height = 64;
} else if (widgetKind === 'launch-button') {
element.width = 256;
element.height = 48;
}
}
if (selectedGroupId) element.groupId = selectedGroupId;
definition.elements = [...elements, element];
selectedGroupId = undefined;
selectedIds = [element.id];
leftTab = 'layers';
}
function handleDetachWidget(widgetKind: ThemeV2WidgetKind) {
const newElements = [...(definition.elements ?? [])];
const currentChrome = definition.chrome ?? normalizeChrome(undefined, newElements);
if (widgetKind === 'header') {
let existing = newElements.find((el) => el.type === 'widget' && el.widget === 'header');
if (!existing) {
const headerWidget = createThemeV2Element(
'widget',
newElements.length
) as ThemeV2WidgetElement;
headerWidget.widget = 'header';
headerWidget.name = 'Header Title';
headerWidget.x = 88;
headerWidget.y = 28;
headerWidget.width = 320;
headerWidget.height = 64;
newElements.push(headerWidget);
existing = headerWidget;
}
definition.chrome = {
...currentChrome,
layoutMode: 'custom',
header: false,
};
definition.elements = newElements;
selectedIds = [existing.id];
leftTab = 'layers';
} else if (widgetKind === 'launch-button') {
let existing = newElements.find(
(el) =>
(el.type === 'widget' && el.widget === 'launch-button') ||
(el.type === 'button' && el.action.type === 'launch')
);
if (!existing) {
const launchBtn = createThemeV2Element(
'widget',
newElements.length
) as ThemeV2WidgetElement;
launchBtn.widget = 'launch-button';
launchBtn.name = 'Launch Button';
launchBtn.x = 720;
launchBtn.y = 575;
launchBtn.width = 256;
launchBtn.height = 48;
newElements.push(launchBtn);
existing = launchBtn;
}
definition.chrome = {
...currentChrome,
layoutMode: 'custom',
dock: false,
};
definition.elements = newElements;
selectedIds = [existing.id];
leftTab = 'layers';
} else if (widgetKind === 'player-card') {
let existing = newElements.find((el) => el.type === 'widget' && el.widget === 'player-card');
if (!existing) {
const playerCard = createThemeV2Element(
'widget',
newElements.length
) as ThemeV2WidgetElement;
playerCard.widget = 'player-card';
playerCard.name = 'Player Card';
playerCard.x = 100;
playerCard.y = 150;
playerCard.width = 380;
playerCard.height = 230;
newElements.push(playerCard);
existing = playerCard;
}
definition.chrome = {
...currentChrome,
layoutMode: 'custom',
playerCard: false,
};
definition.elements = newElements;
selectedIds = [existing.id];
leftTab = 'layers';
} else if (widgetKind === 'avatar') {
let existing = newElements.find((el) => el.type === 'widget' && el.widget === 'avatar');
if (!existing) {
const avatarWidget = createThemeV2Element(
'widget',
newElements.length
) as ThemeV2WidgetElement;
avatarWidget.widget = 'avatar';
avatarWidget.name = 'Player Avatar';
avatarWidget.x = 80;
avatarWidget.y = 80;
avatarWidget.width = 72;
avatarWidget.height = 72;
avatarWidget.variant = 'circle';
avatarWidget.showCountry = true;
avatarWidget.showLevel = true;
avatarWidget.showOnlineRing = true;
newElements.push(avatarWidget);
existing = avatarWidget;
}
definition.chrome = {
...currentChrome,
layoutMode: 'custom',
};
definition.elements = newElements;
selectedIds = [existing.id];
leftTab = 'layers';
} else if (widgetKind === 'recent-activity') {
let existing = newElements.find(
(el) => el.type === 'widget' && el.widget === 'recent-activity'
);
if (!existing) {
const recentActivity = createThemeV2Element(
'widget',
newElements.length
) as ThemeV2WidgetElement;
recentActivity.widget = 'recent-activity';
recentActivity.name = 'Recent Activity';
recentActivity.x = 510;
recentActivity.y = 150;
recentActivity.width = 340;
recentActivity.height = 230;
newElements.push(recentActivity);
existing = recentActivity;
}
definition.chrome = {
...currentChrome,
layoutMode: 'custom',
recentActivity: false,
};
definition.elements = newElements;
selectedIds = [existing.id];
leftTab = 'layers';
}
}
function detachAllToCanvas() {
handleDetachWidget('header');
handleDetachWidget('launch-button');
handleDetachWidget('player-card');
handleDetachWidget('recent-activity');
}
function restoreNativeDock() {
const currentChrome = definition.chrome ?? normalizeChrome(undefined, definition.elements);
definition.chrome = {
...currentChrome,
layoutMode: 'dashboard',
dock: true,
playerCard: true,
recentActivity: true,
header: true,
};
}
function handleTestButtonAction(action: ThemeV2ButtonAction) {
if (action.type === 'navigate') {
sileo.info({
title: 'Button Action Test',
description: `Navigates to screen "${action.screen}" in launcher.`,
});
} else if (action.type === 'launch') {
sileo.info({
title: 'Button Action Test',
description: 'Launches osu! game.',
});
} else if (action.type === 'url') {
if (action.url) {
sileo.info({
title: 'Button Action Test',
description: `Opens ${action.url}`,
});
void openURL(action.url);
} else {
sileo.warning({
title: 'Button Action Test',
description: 'No URL specified',
});
}
}
}
async function addLottie() {
const asset = await chooseAsset('lottie');
if (!asset) return;
const element = createThemeV2Element('lottie', elements.length) as ThemeV2LottieElement;
element.src = asset.name;
if (selectedGroupId) element.groupId = selectedGroupId;
definition.elements = [...elements, element];
selectedGroupId = undefined;
selectedIds = [element.id];
leftTab = 'layers';
}
function patchElement(id: string, patch: Record<string, unknown>) {
const element = elements.find((candidate) => candidate.id === id);
if (element) Object.assign(element, patch);
}
function patchGroup(id: string, patch: Partial<ThemeV2Group>) {
const group = groups.find((candidate) => candidate.id === id);
if (group) Object.assign(group, patch);
}
function updateElement(id: string, patch: Partial<ThemeV2Element>) {
patchElement(id, patch);
}
async function replaceSelectedAsset(kind: 'image' | 'video' | 'lottie') {
const element = selectedElement;
if (!element) return;
if (kind === 'lottie' ? element.type !== 'lottie' : element.type !== kind) return;
const asset = await chooseAsset(kind);
if (asset) updateElement(element.id, { src: asset.name });
}
async function pickElementMask() {
const element = selectedElement;
if (!element) return;
const asset = await chooseAsset('image');
if (!asset) return;
element.mask = {
shape: 'image',
src: asset.name,
fit: element.mask?.fit ?? 'cover',
radius: element.mask?.radius ?? 0,
sides: element.mask?.sides ?? 6,
innerRadius: element.mask?.innerRadius ?? 0.5,
};
}
async function pickRepeaterImage() {
const element = selectedElement;
if (!element || element.type !== 'repeater') return;
const asset = await chooseAsset('image');
if (asset) updateElement(element.id, { src: asset.name, source: 'image' });
}
async function pickGroupMask() {
const group = selectedGroup;
if (!group) return;
const asset = await chooseAsset('image');
if (!asset) return;
group.mask = {
shape: 'image',
src: asset.name,
fit: group.mask?.fit ?? 'cover',
radius: group.mask?.radius ?? 0,
sides: group.mask?.sides ?? 6,
innerRadius: group.mask?.innerRadius ?? 0.5,
};
}
function copySelection() {
if (!selectedGroup && selectedElements.length === 0) return false;
const snapshot = $state.snapshot(definition) as ThemeV2Definition;
if (selectedGroup) {
const groupIds = collectGroupIds(selectedGroup.id);
const copiedGroups = (snapshot.groups ?? []).filter((group) => groupIds.has(group.id));
const copiedElements = (snapshot.elements ?? []).filter(
(element) => element.groupId && groupIds.has(element.groupId)
);
layerClipboard = { groups: copiedGroups, elements: copiedElements };
statusMessage = `Copied ${groupLabel(selectedGroup)}`;
return true;
}
if (selectedElements.length === 0) return false;
const copiedElements = (snapshot.elements ?? []).filter((element) =>
selectedSet.has(element.id)
);
if (copiedElements.length === 0) return false;
layerClipboard = { groups: [], elements: copiedElements };
statusMessage = `Copied ${copiedElements.length} layer${copiedElements.length === 1 ? '' : 's'}`;
return true;
}
function cutSelection() {
if (selectedGroup) {
if (!copySelection()) return;
const label = groupLabel(selectedGroup);
removeGroupTree(selectedGroup.id);
statusMessage = `Cut ${label}`;
return;
}
if (selectedElements.length === 0) return;
const count = selectedElements.length;
if (!copySelection()) return;
deleteSelected();
statusMessage = `Cut ${count} layer${count === 1 ? '' : 's'}`;
}
function pasteClipboard(targetGroupId?: string) {
const clipboard = layerClipboard;
if (!clipboard) return;
const targetGroup = targetGroupId
? groups.find((group) => group.id === targetGroupId)
: undefined;
if (clipboard.groups.length === 0 && clipboard.elements.length === 0) return;
pasteSerial += 1;
const suffix = `${Date.now().toString(36)}_${pasteSerial}`;
const groupIdMap = new SvelteMap<string, string>();
for (const [index, group] of clipboard.groups.entries()) {
groupIdMap.set(group.id, `group_${suffix}_${index}`);
}
const copiedGroups = clipboard.groups.map((group) => ({
...group,
id: groupIdMap.get(group.id)!,
parentId: (group.parentId ? groupIdMap.get(group.parentId) : undefined) ?? targetGroup?.id,
collapsed: false,
}));
const copiedElements = clipboard.elements.map((element, index) => {
let groupId: string | undefined;
if (element.groupId && groupIdMap.has(element.groupId)) {
groupId = groupIdMap.get(element.groupId);
} else if (targetGroup) {
groupId = targetGroup.id;
} else if (element.groupId && groups.some((group) => group.id === element.groupId)) {
groupId = element.groupId;
}
return {
...element,
id: `element_${suffix}_${index}`,
groupId,
x: Math.max(0, Math.min(1000 - element.width, element.x + 24)),
y: Math.max(0, Math.min(650 - element.height, element.y + 24)),
} as ThemeV2Element;
});
definition.groups = [...groups, ...copiedGroups];
definition.elements = [...elements, ...copiedElements];
if (targetGroup) targetGroup.collapsed = false;
selectedBehaviorId = undefined;
leftTab = 'layers';
if (copiedGroups.length > 0) {
const primary = copiedGroups.find((group) => !group.parentId) ?? copiedGroups[0];
selectedIds = [];
selectedGroupId = primary?.id;
} else {
selectedIds = copiedElements.map((element) => element.id);
selectedGroupId = undefined;
}
const parts: string[] = [];
if (copiedGroups.length > 0) {
parts.push(`${copiedGroups.length} group${copiedGroups.length === 1 ? '' : 's'}`);
}
if (copiedElements.length > 0) {
parts.push(`${copiedElements.length} layer${copiedElements.length === 1 ? '' : 's'}`);
}
statusMessage = `Pasted ${parts.join(' and ')}`;
}
function duplicateGroup(groupId = selectedGroupId) {
const source = groupId ? groups.find((group) => group.id === groupId) : undefined;
if (!source) return false;
const snapshot = $state.snapshot(definition) as ThemeV2Definition;
const treeIds = collectGroupIds(source.id);
const sourceGroups = (snapshot.groups ?? []).filter((group) => treeIds.has(group.id));
const sourceElements = (snapshot.elements ?? []).filter(
(element) => element.groupId && treeIds.has(element.groupId)
);
const stamp = `${Date.now().toString(36)}_${++pasteSerial}`;
const idMap = new SvelteMap<string, string>();
sourceGroups.forEach((group, index) => idMap.set(group.id, `group_${stamp}_${index}`));
const copiedGroups = sourceGroups.map((group) => ({
...group,
id: idMap.get(group.id)!,
parentId: group.id === source.id ? group.parentId : idMap.get(group.parentId ?? ''),
name: group.id === source.id ? `${groupLabel(source)} copy` : group.name,
collapsed: group.id === source.id ? false : group.collapsed,
}));
const copiedElements = sourceElements.map((element, index) => ({
...element,
id: `element_${stamp}_${index}`,
groupId: element.groupId ? idMap.get(element.groupId) : undefined,
x: Math.max(0, Math.min(1000 - element.width, element.x + 24)),
y: Math.max(0, Math.min(650 - element.height, element.y + 24)),
})) as ThemeV2Element[];
definition.groups = [
...groups.map((group) =>
group.id === source.parentId ? { ...group, collapsed: false } : group
),
...copiedGroups,
];
definition.elements = [...elements, ...copiedElements];
selectedIds = [];
selectedGroupId = idMap.get(source.id);
leftTab = 'layers';
statusMessage = `Duplicated ${groupLabel(source)}`;
return true;
}
function duplicateSelected() {
if (selectedGroup) {
duplicateGroup(selectedGroup.id);
return;
}
if (selectedElements.length === 0) return;
const stamp = Date.now().toString(36);
const copies = selectedElements.map((element, index) => ({
...element,
id: `element_${stamp}_${elements.length + index}`,
x: Math.min(element.x + 24, 1000 - element.width),
y: Math.min(element.y + 24, 650 - element.height),
})) as ThemeV2Element[];
definition.elements = [...elements, ...copies];
selectedIds = copies.map((copy) => copy.id);
}
function deleteSelected() {
if (selectedIds.length === 0) return;
const removedElements = new SvelteSet(selectedIds);
definition.elements = elements.filter((element) => !removedElements.has(element.id));
pruneBehaviorsAfterRemoval({ elements: removedElements });
selectedIds = [];
}
function moveSelected(direction: -1 | 1) {
if (!selectedElement) return;
const index = elements.findIndex((element) => element.id === selectedElement.id);
const target = index + direction;
if (target < 0 || target >= elements.length) return;
const reordered = [...elements];
[reordered[index], reordered[target]] = [reordered[target], reordered[index]];
definition.elements = reordered;
}
function handleKeydown(event: KeyboardEvent) {
const target = document.activeElement as HTMLElement | undefined;
const typing =
target &&
(target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable);
const modifier = event.metaKey || event.ctrlKey;
const key = event.key.toLowerCase();
if (
!typing &&
event.altKey &&
logicTargets.length > 0 &&
(event.key === 'ArrowDown' || event.key === 'ArrowUp')
) {
event.preventDefault();
jumpLogicTarget(event.key === 'ArrowDown' ? 1 : -1);
return;
}
if (
modifier &&
key === 'f' &&
!typing &&
!shortcutsOpen &&
!closeDialogOpen &&
!validationOpen
) {
event.preventDefault();
void focusLayerSearch();
return;
}
if (modifier && !typing && (key === '+' || key === '=')) {
event.preventDefault();
zoomCanvasBy(CANVAS_ZOOM_STEP);
return;
}
if (modifier && !typing && key === '-') {
event.preventDefault();
zoomCanvasBy(1 / CANVAS_ZOOM_STEP);
return;
}
if (modifier && !typing && key === '0') {
event.preventDefault();
resetCanvasView();
return;
}
if (!typing && event.key === '?') {
event.preventDefault();
shortcutsOpen = true;
return;
}
if (modifier && key === 'z') {
if (typing) return;
event.preventDefault();
if (event.shiftKey) redo();
else undo();
return;
}
if (modifier && key === 'y') {
if (typing) return;
event.preventDefault();
redo();
return;
}
if (modifier && key === 's') {
event.preventDefault();
saveTheme();
return;
}
if (modifier && key === 'c') {
if (typing) return;
event.preventDefault();
copySelection();
return;
}
if (modifier && key === 'x') {
if (typing) return;
event.preventDefault();
cutSelection();
return;
}
if (modifier && key === 'v') {
if (typing) return;
event.preventDefault();
const copiedGroupIsSelected =
selectedGroupId &&
layerClipboard?.groups.some((group) => group.id === selectedGroupId) === true;
const targetGroupId = copiedGroupIsSelected
? undefined
: (selectedGroupId ??
(selectedElements.length === 1 ? selectedElements[0].groupId : undefined));
pasteClipboard(targetGroupId);
return;
}
if (event.key === 'Escape') {
if (shortcutsOpen) {
shortcutsOpen = false;
return;
}
if (contextMenuOpen || layerContextMenuOpen) {
closeContextMenu();
return;
}
if (validationOpen) {
validationOpen = false;
exportAfterValidation = false;
return;
}
if (closeDialogOpen) {
closeDialogOpen = false;
return;
}
if (deleteGroupId) {
deleteGroupId = undefined;
return;
}
selectedIds = [];
selectedGroupId = undefined;
renamingId = undefined;
return;
}
if (modifier && key === 'a') {
if (typing) return;
event.preventDefault();
selectedIds = elements.filter((element) => !element.hidden).map((element) => element.id);
return;
}
if (modifier && key === 'g') {
if (typing) return;
event.preventDefault();
if (selectedGroup) ungroupGroup();
else groupSelection();
return;
}
if ((event.key === 'Delete' || event.key === 'Backspace') && selectedGroup) {
event.preventDefault();
requestGroupDelete();
return;
}
if (modifier && key === 'd' && !typing) {
if (!selectedGroup && selectedIds.length === 0) return;
event.preventDefault();
duplicateSelected();
return;
}
if (typing || selectedIds.length === 0) return;
if (modifier && event.key === 'ArrowUp') {
event.preventDefault();
moveSelected(1);
return;
}
if (modifier && event.key === 'ArrowDown') {
event.preventDefault();
moveSelected(-1);
return;
}
if (event.key === 'Delete' || event.key === 'Backspace') {
event.preventDefault();
deleteSelected();
return;
}
const step = event.shiftKey ? 10 : 1;
const moves: Record<string, [number, number]> = {
ArrowLeft: [-step, 0],
ArrowRight: [step, 0],
ArrowUp: [0, -step],
ArrowDown: [0, step],
};
const move = moves[event.key];
if (move) {
event.preventDefault();
nudgeSelected(move[0], move[1]);
}
}
async function generateThemePreview(folderName: string) {
const assetsFolder = await path.join(await themesDirectory(), folderName, 'assets');
const blob = await renderThemePreview({
definition: normalizeThemeV2(definition),
logoUrl: Logo,
readAsset: async (assetPath) => {
const parts = assetPath.replace(/\\/g, '/').split('/');
if (assetPath.startsWith('/') || parts.some((part) => !part || part === '..')) {
return undefined;
}
const relative = parts.filter((part) => part !== '.').join('/');
if (!relative) return undefined;
const filePath = await path.join(assetsFolder, relative);
if (!(await fs.exists(filePath))) return undefined;
return await fs.readFile(filePath);
},
});
await fs.writeFile(
await path.join(assetsFolder, 'preview.png'),
new Uint8Array(await blob.arrayBuffer())
);
}
async function saveTheme(options: { notify?: boolean; emitSaved?: boolean } = {}) {
if (saving) return false;
if (!definition.name.trim()) {
notifyError('Give your theme a name first.');
return false;
}
if (!definition.author.trim()) {
notifyError('Add an author name so people know who made the theme.');
return false;
}
const folderName = savedFolder ?? toSafeName(definition.name);
if (!folderName.replace(/_/g, '')) {
notifyError('The theme name needs at least one letter or number.');
return false;
}
if (!savedFolder) {
const target = await path.join(await themesDirectory(), folderName);
if (await fs.exists(target)) {
notifyError('A theme with that folder already exists. Choose another name.');
return false;
}
}
saving = true;
statusMessage = 'Saving...';
try {
const themeToSave = normalizeThemeV2(definition);
themeToSave.preview = 'preview.png';
await saveThemeProject(folderName, themeToSave, pickedAssets);
savedFolder = folderName;
editorFolderPath = await path.join(await themesDirectory(), folderName);
lastSavedKey = snapshotKey(captureSnapshot());
clearDraft();
recoverableDraft = undefined;
let previewError: string | undefined;
statusMessage = 'Creating preview...';
try {
await generateThemePreview(folderName);
} catch (error) {
console.error('Could not create the theme preview', error);
previewError = error instanceof Error ? error.message : String(error);
}
statusMessage = previewError ? 'Saved (preview failed)' : 'Saved';
if (options.emitSaved !== false) await emit('theme-saved', folderName);
if (options.notify !== false) {
sileo.success({
title: 'Theme saved',
description: previewError
? 'The theme was saved, but its preview image could not be created.'
: `"${definition.name}" was saved with a fresh preview image.`,
});
}
if (previewError) console.warn(`Theme preview was not updated: ${previewError}`);
return true;
} catch (error) {
console.error(error);
statusMessage = 'Save failed';
notifyError(error instanceof Error ? error.message : String(error));
return false;
} finally {
saving = false;
}
}
function referencedAssets() {
const refs: string[] = [];
const add = (value: string | undefined) => {
if (!value) return;
if (/^(?:[a-z]+:)?\/\//i.test(value) || /^(?:data|blob|asset):/i.test(value)) return;
const relative = value.replace(/\\/g, '/').replace(/^\.?\//, '');
if (relative && !refs.includes(relative)) refs.push(relative);
};
const background = definition.background;
if (background.type === 'image' || background.type === 'video') add(background.src);
add(definition.audio?.src);
for (const element of elements) {
if (
element.type === 'image' ||
element.type === 'video' ||
element.type === 'lottie' ||
(element.type === 'repeater' && element.source === 'image')
) {
add(element.src);
}
add(element.mask?.src);
if (element.type === 'svelte') {
for (const match of element.source.matchAll(/asset\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g)) {
add(match[1]);
}
}
}
for (const group of definition.groups ?? []) add(group.mask?.src);
for (const font of definition.fonts ?? []) add(font.src);
return refs;
}
async function validateTheme(): Promise<ThemeValidationReport> {
const issues: ThemeValidationIssue[] = [];
const error = (message: string) => issues.push({ level: 'error', message });
const warning = (message: string) => issues.push({ level: 'warning', message });
if (!definition.name.trim()) error('The theme needs a name.');
if (!definition.author.trim()) error('The theme needs an author.');
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(definition.version.trim())) {
warning('The version should use semantic versioning, for example 1.0.0.');
}
if (!themeHasLaunchButton(definition)) {
error(
'A launch button is required to launch osu!. Keep the bottom dock visible or add a launch button widget to your canvas.'
);
}
const background = definition.background;
if ((background.type === 'image' || background.type === 'video') && !background.src.trim()) {
error(`Choose a ${background.type} for the background, or switch to a color/gradient.`);
}
if (definition.audio && !definition.audio.src.trim()) {
error('The theme has music enabled but no audio file.');
}
const seenIds: string[] = [];
for (const element of elements) {
if (seenIds.includes(element.id)) error(`Duplicate layer id: ${element.id}`);
seenIds.push(element.id);
if ((element.type === 'image' || element.type === 'video') && !element.src.trim()) {
error(`The ${element.type} layer ${elementLabel(element)} has no asset.`);
}
if (element.type === 'repeater' && element.source === 'image' && !element.src?.trim()) {
error(`The repeater ${elementLabel(element)} has no image.`);
}
if (element.mask?.shape === 'image' && !element.mask.src?.trim()) {
error(`The mask on ${elementLabel(element)} has no image.`);
}
if (
element.type === 'button' &&
element.action.type === 'url' &&
!/^https?:\/\//i.test(element.action.url)
) {
error(`The button ${elementLabel(element)} needs a full http(s) URL.`);
}
if (element.type === 'svelte') {
const analysis = await analyzeThemeSvelteSource(
element.source,
`layer-${element.id}.svelte`
);
if (!element.source.trim()) {
error(`The Svelte layer ${elementLabel(element)} has no source.`);
} else if (!analysis.ok) {
const first = analysis.errors[0];
error(
`Svelte layer “${elementLabel(element)}”${first ? `: ${first.message}` : ' could not be compiled.'}`
);
}
for (const issue of analysis.warnings.slice(0, 2)) {
warning(
`Svelte layer “${elementLabel(element)}” at ${issue.line}:${issue.column}: ${issue.message}`
);
}
}
}
for (const group of groups) {
if (group.mask?.shape === 'image' && !group.mask.src?.trim()) {
error(`The mask on group “${group.name?.trim() || group.id}” has no image.`);
}
}
const elementIds = new SvelteSet(seenIds);
const groupIds = new SvelteSet(groups.map((group) => group.id));
const interactiveIds = new SvelteSet(
elements.filter((element) => element.interactive).map((element) => element.id)
);
for (const behavior of behaviors) {
const ruleName = behavior.name?.trim() || behavior.id;
const pointerTrigger =
behavior.trigger.type === 'click' ||
behavior.trigger.type === 'hover' ||
behavior.trigger.type === 'leave';
const elementTargetId = behavior.target?.type === 'element' ? behavior.target.id : undefined;
const groupTargetId = behavior.target?.type === 'group' ? behavior.target.id : undefined;
const hasScript = behavior.script !== undefined;
if (!hasScript && behavior.actions.length === 0) {
warning(`Rule “${ruleName}” has no actions.`);
}
if (hasScript) {
const parsed = parseThemeScript(behavior.script ?? '');
const firstError = parsed.errors[0];
if (firstError) {
error(`Rule “${ruleName}” script: ${formatThemeScriptError(firstError)}`);
} else if (!(behavior.script ?? '').trim()) {
warning(`Rule “${ruleName}” has an empty advanced script.`);
} else {
if (
themeScriptProgramCanLeaveLauncher(parsed.program) &&
behavior.trigger.type !== 'click'
) {
warning(
`Rule “${ruleName}” has an external action that only runs from a click trigger.`
);
}
const scriptRefs = themeScriptProgramReferences(parsed.program);
for (const elementId of scriptRefs.elementIds) {
if (!elementIds.has(elementId)) {
error(
`Rule “${ruleName}” script references a layer that no longer exists: ${elementId}`
);
}
}
for (const groupId of scriptRefs.groupIds) {
if (!groupIds.has(groupId)) {
error(
`Rule “${ruleName}” script references a group that no longer exists: ${groupId}`
);
}
}
}
}
if (elementTargetId && !elementIds.has(elementTargetId)) {
error(`Rule “${ruleName}” is linked to a layer that no longer exists.`);
}
if (groupTargetId && !groupIds.has(groupTargetId)) {
error(`Rule “${ruleName}” is linked to a group that no longer exists.`);
}
if (pointerTrigger && elementTargetId && !interactiveIds.has(elementTargetId)) {
warning(`Rule “${ruleName}” is linked to a layer that is not marked Interactive.`);
}
if (
pointerTrigger &&
groupTargetId &&
!elements.some(
(element) =>
element.interactive === true &&
themeGroupContainsElement(groups, groupTargetId, element)
)
) {
warning(`Rule “${ruleName}” needs at least one Interactive layer inside its linked group.`);
}
if (
(behavior.trigger.type === 'click' ||
behavior.trigger.type === 'hover' ||
behavior.trigger.type === 'leave') &&
behavior.trigger.elementId &&
!elementIds.has(behavior.trigger.elementId)
) {
error(`Rule “${ruleName}” targets a layer that no longer exists.`);
}
for (const condition of behavior.conditions ?? []) {
if (condition.type === 'visible' && !elementIds.has(condition.elementId)) {
error(`Rule “${ruleName}” checks a layer that no longer exists.`);
}
if (condition.type === 'groupVisible' && !groupIds.has(condition.groupId)) {
error(`Rule “${ruleName}” checks a group that no longer exists.`);
}
}
if (!hasScript) {
for (const action of behavior.actions) {
if (
(action.type === 'showElement' ||
action.type === 'hideElement' ||
action.type === 'toggleElement') &&
!elementIds.has(action.elementId)
) {
error(`Rule “${ruleName}” targets a layer that no longer exists.`);
}
if (
(action.type === 'showGroup' ||
action.type === 'hideGroup' ||
action.type === 'toggleGroup') &&
!groupIds.has(action.groupId)
) {
error(`Rule “${ruleName}” targets a group that no longer exists.`);
}
if (action.type === 'openUrl' && !/^https?:\/\//i.test(action.url)) {
error(`Rule “${ruleName}” has a URL that does not start with http:// or https://.`);
}
if (
(action.type === 'launch' || action.type === 'openUrl') &&
behavior.trigger.type !== 'click'
) {
warning(
`Rule “${ruleName}” has an external action that only runs from a click trigger.`
);
}
}
}
if (
pointerTrigger &&
'elementId' in behavior.trigger &&
behavior.trigger.elementId &&
!interactiveIds.has(behavior.trigger.elementId)
) {
warning(`Rule “${ruleName}” listens to a layer that is not marked Interactive.`);
}
}
const refs = referencedAssets();
// The editor is opened with the resolved theme folder, which can be
// outside the managed themes directory. Prefer it over rebuilding the
// path from the folder name, and use the native metadata command instead
// of the fs plugin so the check is not limited by its path scope.
const assetsFolder = editorFolderPath
? await path.join(editorFolderPath, 'assets')
: savedFolder
? await path.join(await themesDirectory(), savedFolder, 'assets')
: undefined;
let totalBytes = 0;
let fileCount = 0;
for (const ref of refs) {
const picked = pickedAssets.find((asset) => asset.name === ref);
const candidate =
picked?.source ?? (assetsFolder ? await path.join(assetsFolder, ref) : undefined);
const size = candidate
? ((await invoke<number | null>('file_size', { path: candidate }).catch(() => null)) ??
undefined)
: undefined;
if (size === undefined) {
error(`Missing asset: ${ref}. Re-import it before exporting.`);
continue;
}
totalBytes += size;
fileCount += 1;
if (/\.(?:webm|mp4|m4v|mov)$/i.test(ref) && size > 20 * 1024 * 1024) {
warning(`${ref} is ${formatBytes(size)}. Consider compressing videos to WebM.`);
} else if (/\.(?:png|jpe?g|webp|gif|avif)$/i.test(ref) && size > 8 * 1024 * 1024) {
warning(`${ref} is ${formatBytes(size)}. Large images make theme downloads slower.`);
}
}
const unused = pickedAssets.filter((asset) => !refs.includes(asset.name));
if (unused.length > 0) {
warning(
`${unused.length} imported asset${unused.length === 1 ? '' : 's'} will not be exported because no layer uses ${unused.length === 1 ? 'it' : 'them'}.`
);
}
if (elements.length === 0) {
warning(
'This theme has no custom layers. It will only change the launcher background and colors.'
);
}
return {
issues,
totalBytes,
fileCount,
checkedAt: Date.now(),
};
}
async function checkTheme() {
statusMessage = 'Checking theme...';
validationReport = await validateTheme();
validationOpen = true;
const errors = validationReport.issues.filter((issue) => issue.level === 'error').length;
exportAfterValidation = errors === 0;
statusMessage =
errors === 0
? 'Theme check passed'
: `Theme check found ${errors} error${errors === 1 ? '' : 's'}`;
}
async function exportCurrentTheme(skipValidation = false) {
if (exporting || saving) return;
if (!skipValidation) {
const report = await validateTheme();
validationReport = report;
const errors = report.issues.some((issue) => issue.level === 'error');
const warnings = report.issues.some((issue) => issue.level === 'warning');
if (errors || warnings) {
validationOpen = true;
exportAfterValidation = !errors;
return;
}
}
validationOpen = false;
exportAfterValidation = false;
const alreadySaved = Boolean(savedFolder);
const saved = await saveTheme({ notify: false, emitSaved: !alreadySaved });
if (!saved || !savedFolder) return;
const destination = await saveDialog({
defaultPath: `${toSafeName(definition.name)}.ezpplauncher-theme`,
filters: [{ name: 'EZPPLauncher Theme', extensions: ['ezpplauncher-theme'] }],
});
if (typeof destination !== 'string') return;
exporting = true;
statusMessage = 'Exporting...';
try {
await exportThemeFolder(savedFolder, destination);
let size = 0;
try {
size = (await invoke<number | null>('file_size', { path: destination })) ?? 0;
} catch {
// The export succeeded; only the size read failed.
}
statusMessage = size > 0 ? `Exported ${formatBytes(size)}` : 'Exported';
sileo.success({
title: 'Theme exported',
description:
size > 0
? `The ${formatBytes(size)} .ezpplauncher-theme file is ready to share.`
: 'The .ezpplauncher-theme file is ready to share.',
});
} catch (error) {
console.error(error);
statusMessage = 'Export failed';
sileo.error({
title: 'Export failed',
description: error instanceof Error ? error.message : String(error),
});
} finally {
exporting = false;
}
}
async function closeEditor() {
if (saving) return;
if (dirty) {
closeDialogOpen = true;
return;
}
allowClose = true;
await invoke('close_theme_editor');
}
async function saveAndClose() {
const saved = await saveTheme({ notify: false });
if (!saved) return;
clearDraft();
allowClose = true;
closeDialogOpen = false;
await invoke('close_theme_editor');
}
async function discardAndClose() {
clearDraft();
allowClose = true;
closeDialogOpen = false;
await invoke('close_theme_editor');
}
</script>
<svelte:window
onkeydown={handleKeydown}
onresize={closeContextMenu}
onblur={() => {
closeContextMenu();
panning = false;
}}
/>
{#snippet contextMenuItems()}
<ContextMenu.Label
class="px-1.5 py-1 text-[9px] font-semibold uppercase tracking-wide text-muted-foreground"
>
{#if contextMenuGroup}
{groupLabel(contextMenuGroup)}
{:else if contextMenuTargetId}
{contextMenuSelection.length > 1
? `${contextMenuSelection.length} layers selected`
: contextMenuSelection[0]
? elementLabel(contextMenuSelection[0])
: 'Layer'}
{:else}
Canvas
{/if}
</ContextMenu.Label>
{#if contextMenuGroup}
<ContextMenu.Item class="gap-2 py-1.5 text-[11px]" onclick={copySelection}>
<Copy />
Copy group
</ContextMenu.Item>
<ContextMenu.Item
class="gap-2 py-1.5 text-[11px]"
onclick={() => duplicateGroup(contextMenuGroup?.id)}
>
<CopyPlus />
Duplicate group
</ContextMenu.Item>
<ContextMenu.Item class="gap-2 py-1.5 text-[11px]" onclick={cutSelection}>
<Scissors />
Cut group
</ContextMenu.Item>
<ContextMenu.Item
class="gap-2 py-1.5 text-[11px]"
disabled={!layerClipboard}
onclick={() => pasteClipboard(contextMenuGroup.id)}
>
<ClipboardPaste />
Paste into group
</ContextMenu.Item>
<ContextMenu.Separator class="my-1 bg-theme-800" />
<ContextMenu.Item
class="gap-2 py-1.5 text-[11px]"
onclick={() => ungroupGroup(contextMenuGroup.id)}
>
<Ungroup />
Ungroup group
</ContextMenu.Item>
<ContextMenu.Item
variant="destructive"
class="gap-2 py-1.5 text-[11px]"
onclick={() => requestGroupDelete(contextMenuGroup.id)}
>
<Trash2 />
Delete group
</ContextMenu.Item>
<ContextMenu.Separator class="my-1 bg-theme-800" />
<ContextMenu.Item
class="gap-2 py-1.5 text-[11px]"
onclick={() => toggleGroupFlag(contextMenuGroup, 'hidden')}
>
{#if contextMenuGroup.hidden}
<Eye />
Show group
{:else}
<EyeOff />
Hide group
{/if}
</ContextMenu.Item>
<ContextMenu.Item
class="gap-2 py-1.5 text-[11px]"
onclick={() => toggleGroupFlag(contextMenuGroup, 'locked')}
>
{#if contextMenuGroup.locked}
<Unlock />
Unlock group
{:else}
<Lock />
Lock group
{/if}
</ContextMenu.Item>
<ContextMenu.Separator class="my-1 bg-theme-800" />
<ContextMenu.Item
class="gap-2 py-1.5 text-[11px]"
onclick={() => selectGroup(contextMenuGroup.id)}
>
<MousePointer2 />
Edit group
</ContextMenu.Item>
{:else if contextMenuTargetId}
<ContextMenu.Item class="gap-2 py-1.5 text-[11px]" onclick={copySelection}>
<Copy />
Copy
</ContextMenu.Item>
<ContextMenu.Item class="gap-2 py-1.5 text-[11px]" onclick={cutSelection}>
<Scissors />
Cut
</ContextMenu.Item>
<ContextMenu.Item
class="gap-2 py-1.5 text-[11px]"
disabled={!layerClipboard}
onclick={() =>
pasteClipboard(
contextMenuSelection.length === 1 ? contextMenuSelection[0].groupId : undefined
)}
>
<ClipboardPaste />
Paste
</ContextMenu.Item>
<ContextMenu.Separator class="my-1 bg-theme-800" />
<ContextMenu.Item class="gap-2 py-1.5 text-[11px]" onclick={duplicateSelected}>
<CopyPlus />
Duplicate
</ContextMenu.Item>
{#if contextMenuSelection.length === 1 && contextMenuSelection[0].groupId}
<ContextMenu.Item
class="gap-2 py-1.5 text-[11px]"
onclick={() => {
const groupId = contextMenuSelection[0]?.groupId;
if (groupId) selectGroup(groupId);
}}
>
<Boxes />
Select parent group
</ContextMenu.Item>
{/if}
<ContextMenu.Item class="gap-2 py-1.5 text-[11px]" onclick={groupSelection}>
<Boxes />
Group selection
</ContextMenu.Item>
{#if contextMenuSelection.some((element) => element.groupId)}
<ContextMenu.Item class="gap-2 py-1.5 text-[11px]" onclick={removeSelectedFromGroup}>
<Ungroup />
Remove from group
</ContextMenu.Item>
{/if}
{#if contextMenuSelection.length === 1}
<ContextMenu.Item
class="gap-2 py-1.5 text-[11px]"
disabled={selectedIndex >= elements.length - 1}
onclick={() => moveSelected(1)}
>
<ChevronUp />
Bring forward
</ContextMenu.Item>
<ContextMenu.Item
class="gap-2 py-1.5 text-[11px]"
disabled={selectedIndex <= 0}
onclick={() => moveSelected(-1)}
>
<ChevronDown />
Send backward
</ContextMenu.Item>
{/if}
<ContextMenu.Separator class="my-1 bg-theme-800" />
<ContextMenu.Item class="gap-2 py-1.5 text-[11px]" onclick={() => toggleSelectedFlag('hidden')}>
{#if contextMenuAllHidden}
<Eye />
Show {contextMenuSelection.length === 1 ? 'layer' : 'layers'}
{:else}
<EyeOff />
Hide {contextMenuSelection.length === 1 ? 'layer' : 'layers'}
{/if}
</ContextMenu.Item>
<ContextMenu.Item class="gap-2 py-1.5 text-[11px]" onclick={() => toggleSelectedFlag('locked')}>
{#if contextMenuAllLocked}
<Unlock />
Unlock {contextMenuSelection.length === 1 ? 'layer' : 'layers'}
{:else}
<Lock />
Lock {contextMenuSelection.length === 1 ? 'layer' : 'layers'}
{/if}
</ContextMenu.Item>
<ContextMenu.Separator class="my-1 bg-theme-800" />
<ContextMenu.Item
variant="destructive"
class="gap-2 py-1.5 text-[11px]"
onclick={deleteSelected}
>
<Trash2 />
Delete {contextMenuSelection.length === 1 ? 'layer' : 'layers'}
</ContextMenu.Item>
{:else}
<ContextMenu.Item
class="gap-2 py-1.5 text-[11px]"
disabled={!layerClipboard}
onclick={() => pasteClipboard()}
>
<ClipboardPaste />
Paste
</ContextMenu.Item>
<ContextMenu.Separator class="my-1 bg-theme-800" />
<ContextMenu.Item class="gap-2 py-1.5 text-[11px]" onclick={addText}>
<Type />
Add text
</ContextMenu.Item>
<ContextMenu.Item class="gap-2 py-1.5 text-[11px]" onclick={() => addMedia('image')}>
<ImageIcon />
Add image
</ContextMenu.Item>
<ContextMenu.Item class="gap-2 py-1.5 text-[11px]" onclick={() => addMedia('video')}>
<Video />
Add video
</ContextMenu.Item>
<ContextMenu.Item class="gap-2 py-1.5 text-[11px]" onclick={addShape}>
<Shapes />
Add shape
</ContextMenu.Item>
<ContextMenu.Item class="gap-2 py-1.5 text-[11px]" onclick={addSvelte}>
<Component />
Add Svelte component
</ContextMenu.Item>
<ContextMenu.Separator class="my-1 bg-theme-800" />
<ContextMenu.Item class="gap-2 py-1.5 text-[11px]" onclick={() => addLayer('data')}>
<Type />
Add data layer
</ContextMenu.Item>
<ContextMenu.Item class="gap-2 py-1.5 text-[11px]" onclick={() => addLayer('icon')}>
<Sparkles />
Add icon
</ContextMenu.Item>
<ContextMenu.Item class="gap-2 py-1.5 text-[11px]" onclick={() => addLayer('repeater')}>
<Repeat />
Add repeater
</ContextMenu.Item>
<ContextMenu.Item class="gap-2 py-1.5 text-[11px]" onclick={() => addLayer('gauge')}>
<Gauge />
Add gauge
</ContextMenu.Item>
<ContextMenu.Item class="gap-2 py-1.5 text-[11px]" onclick={() => addLayer('ticker')}>
<Megaphone />
Add ticker
</ContextMenu.Item>
<ContextMenu.Item class="gap-2 py-1.5 text-[11px]" onclick={() => addLayer('button')}>
<MousePointerClick />
Add button
</ContextMenu.Item>
<ContextMenu.Item class="gap-2 py-1.5 text-[11px]" onclick={() => addLayer('path')}>
<Spline />
Add vector path
</ContextMenu.Item>
<ContextMenu.Item class="gap-2 py-1.5 text-[11px]" onclick={() => addLayer('particles')}>
<Sparkles />
Add particles
</ContextMenu.Item>
<ContextMenu.Item class="gap-2 py-1.5 text-[11px]" onclick={() => addLayer('clock')}>
<Clock />
Add clock
</ContextMenu.Item>
<ContextMenu.Item class="gap-2 py-1.5 text-[11px]" onclick={() => addLayer('countdown')}>
<Timer />
Add countdown
</ContextMenu.Item>
<ContextMenu.Item
class="gap-2 py-1.5 text-[11px]"
onclick={() => addLayer('widget', 'launch-button')}
>
<LayoutGrid />
Add launch button widget
</ContextMenu.Item>
<ContextMenu.Item
class="gap-2 py-1.5 text-[11px]"
onclick={() => addLayer('widget', 'player-card')}
>
<LayoutGrid />
Add player card widget
</ContextMenu.Item>
<ContextMenu.Item class="gap-2 py-1.5 text-[11px]" onclick={() => addLayer('widget', 'avatar')}>
<CircleUser />
Add player avatar widget
</ContextMenu.Item>
<ContextMenu.Item
class="gap-2 py-1.5 text-[11px]"
onclick={() => addLayer('widget', 'recent-activity')}
>
<LayoutGrid />
Add recent activity widget
</ContextMenu.Item>
<ContextMenu.Item class="gap-2 py-1.5 text-[11px]" onclick={addLottie}>
<Film />
Add Lottie animation
</ContextMenu.Item>
<ContextMenu.Separator class="my-1 bg-theme-800" />
<ContextMenu.Item
class="gap-2 py-1.5 text-[11px]"
disabled={elements.length === 0}
onclick={() => {
selectedIds = elements.filter((element) => !element.hidden).map((element) => element.id);
}}
>
<MousePointer2 />
Select all layers
</ContextMenu.Item>
{/if}
{/snippet}
{#if selectedFontPreviewCss}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html `<style>${selectedFontPreviewCss}</style>`}
{/if}
<div class="flex h-screen flex-col overflow-hidden bg-theme-950 text-theme-50">
<header
class="flex h-14 shrink-0 items-center justify-between border-b border-theme-800 bg-black/40 px-4"
>
<div class="flex min-w-0 items-center gap-3">
<div class="rounded-xl bg-primary/25 p-1 ring-1 ring-inset ring-white/15">
<img src={Logo} alt="" class="size-7" />
</div>
<div class="min-w-0">
<div class="flex items-center gap-2">
<h1 class="truncate text-sm font-semibold">Theme editor</h1>
<Badge
variant="outline"
class="border-primary/25 bg-primary/15 px-2 py-0.5 text-[9px] text-primary"
>
API 2.0
</Badge>
{#if definition.nsfw}
<Badge variant="destructive" class="px-2 py-0.5 text-[9px]" title="Adult content">
18+
</Badge>
{/if}
</div>
<p class="truncate text-[10px] text-muted-foreground">
{definition.name || 'Untitled theme'}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<span
class="mr-2 hidden text-[10px] sm:block {dirty
? 'text-amber-300'
: 'text-muted-foreground'}"
title={dirty ? 'You have changes that are not saved to the theme folder' : statusMessage}
>
{dirty ? 'Unsaved changes' : statusMessage}
</span>
<Button
variant="ghost"
size="icon"
class="size-8"
title="Keyboard shortcuts (?)"
aria-label="Keyboard shortcuts"
onclick={() => (shortcutsOpen = true)}
>
<Keyboard class="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="size-8"
title="Undo (Cmd/Ctrl+Z)"
aria-label="Undo"
onclick={undo}
disabled={!canUndo}
>
<Undo2 class="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="size-8"
title="Redo (Cmd/Ctrl+Shift+Z)"
aria-label="Redo"
onclick={redo}
disabled={!canRedo}
>
<Redo2 class="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="size-8"
title="Check theme before export"
aria-label="Check theme"
onclick={checkTheme}
disabled={loading || saving || exporting}
>
<ShieldCheck class="size-4" />
</Button>
<Button
variant="secondary"
size="sm"
onclick={() => exportCurrentTheme()}
disabled={exporting || saving}
>
{#if exporting}
<LoaderCircle class="animate-spin" />
{:else}
<Download />
{/if}
Export
</Button>
<Button size="sm" onclick={() => saveTheme()} disabled={saving || loading}>
{#if saving}
<LoaderCircle class="animate-spin" />
{:else}
<Save />
{/if}
Save theme
</Button>
<Button variant="ghost" size="icon" title="Close editor" onclick={closeEditor}>
<X />
</Button>
</div>
</header>
{#if recoverableDraft}
<div
class="flex shrink-0 flex-wrap items-center gap-2 border-b border-amber-500/25 bg-amber-500/10 px-4 py-2 text-[11px] text-amber-100"
>
<AlertTriangle class="size-4 shrink-0 text-amber-300" />
<span class="min-w-0 flex-1">
An unsaved draft from {new Date(recoverableDraft.savedAt).toLocaleString()} is available.
</span>
<Button size="sm" class="h-7 text-[10px]" onclick={restoreDraft}>Restore draft</Button>
<Button
variant="ghost"
size="sm"
class="h-7 text-[10px] text-amber-100 hover:bg-amber-500/10"
onclick={discardDraft}
>
Discard
</Button>
</div>
{/if}
<div class="grid min-h-0 flex-1 grid-cols-[282px_minmax(0,1fr)_324px] overflow-hidden">
<aside class="flex min-h-0 flex-col border-r border-theme-800 bg-theme-900/25">
<div class="grid shrink-0 grid-cols-4 gap-1 border-b border-theme-800 p-2">
<Button
variant="ghost"
size="sm"
class="h-8 gap-1.5 px-1.5 text-[10px] {leftTab === 'layers'
? 'bg-primary/20 text-primary hover:bg-primary/25'
: 'text-muted-foreground'}"
aria-pressed={leftTab === 'layers'}
onclick={() => (leftTab = 'layers')}
>
<Layers class="size-3.5" />
Layers
</Button>
<Button
variant="ghost"
size="sm"
class="h-8 gap-1.5 px-1.5 text-[10px] {leftTab === 'theme'
? 'bg-primary/20 text-primary hover:bg-primary/25'
: 'text-muted-foreground'}"
aria-pressed={leftTab === 'theme'}
onclick={() => (leftTab = 'theme')}
>
<SlidersHorizontal class="size-3.5" />
Theme
</Button>
<Button
variant="ghost"
size="sm"
class="h-8 gap-1.5 px-1 text-[10px] {leftTab === 'logic'
? 'bg-primary/20 text-primary hover:bg-primary/25'
: 'text-muted-foreground'}"
aria-pressed={leftTab === 'logic'}
onclick={openLogicTab}
>
<Zap class="size-3.5" />
Logic
</Button>
<Button
variant="ghost"
size="sm"
class="h-8 gap-1.5 px-1 text-[10px] {leftTab === 'fonts'
? 'bg-primary/20 text-primary hover:bg-primary/25'
: 'text-muted-foreground'}"
aria-pressed={leftTab === 'fonts'}
onclick={openFontBrowser}
>
<Type class="size-3.5" />
Fonts
</Button>
</div>
{#if leftTab === 'layers'}
<div class="flex min-h-0 flex-1 flex-col">
<div class="shrink-0 border-b border-theme-800 p-2">
<div class="flex items-center justify-between px-1">
<span class="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{#if layerSearchActive}
{layerSearchMatchCount} match{layerSearchMatchCount === 1 ? '' : 'es'}
{:else}
{elements.length} layer{elements.length === 1 ? '' : 's'}
{#if groups.length > 0}
· {groups.length} group{groups.length === 1 ? '' : 's'}
{/if}
{/if}
</span>
<div class="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon"
class="size-6 text-muted-foreground"
title="Previous layer or group with logic (Alt/Option+↑)"
aria-label="Previous layer or group with logic"
disabled={logicTargets.length === 0}
onclick={() => jumpLogicTarget(-1)}
>
<ChevronUp class="size-3" />
</Button>
<Button
variant="ghost"
size="icon"
class="size-6 text-muted-foreground"
title="Next layer or group with logic (Alt/Option+↓)"
aria-label="Next layer or group with logic"
disabled={logicTargets.length === 0}
onclick={() => jumpLogicTarget(1)}
>
<ChevronDown class="size-3" />
</Button>
<Button
variant="ghost"
size="icon"
class="size-6 text-muted-foreground"
title={anyGroupExpanded ? 'Collapse all groups' : 'Expand all groups'}
aria-label={anyGroupExpanded ? 'Collapse all groups' : 'Expand all groups'}
disabled={groups.length === 0}
onclick={toggleAllGroups}
>
{#if anyGroupExpanded}
<ChevronsUp class="size-3" />
{:else}
<ChevronsDown class="size-3" />
{/if}
</Button>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="ghost"
size="sm"
class="h-6 gap-1 px-1.5 text-[10px] text-muted-foreground"
>
<Plus class="size-3" />
Add
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-44 border-theme-700 bg-theme-950">
<DropdownMenu.Label
class="px-1.5 py-1 text-[9px] font-semibold uppercase tracking-wide text-muted-foreground"
>
Add layer
</DropdownMenu.Label>
<DropdownMenu.Item class="gap-2 text-[11px]" onclick={addText}>
<Type />
Text
</DropdownMenu.Item>
<DropdownMenu.Item class="gap-2 text-[11px]" onclick={() => addMedia('image')}>
<ImageIcon />
Image
</DropdownMenu.Item>
<DropdownMenu.Item class="gap-2 text-[11px]" onclick={() => addMedia('video')}>
<Video />
Video
</DropdownMenu.Item>
<DropdownMenu.Item class="gap-2 text-[11px]" onclick={addShape}>
<Shapes />
Shape
</DropdownMenu.Item>
<DropdownMenu.Item class="gap-2 text-[11px]" onclick={addSvelte}>
<Component />
Svelte component
</DropdownMenu.Item>
<DropdownMenu.Separator class="my-1 bg-theme-800" />
<DropdownMenu.Item class="gap-2 text-[11px]" onclick={() => addLayer('data')}>
<Type />
Data
</DropdownMenu.Item>
<DropdownMenu.Item class="gap-2 text-[11px]" onclick={() => addLayer('icon')}>
<Sparkles />
Icon
</DropdownMenu.Item>
<DropdownMenu.Item
class="gap-2 text-[11px]"
onclick={() => addLayer('repeater')}
>
<Repeat />
Repeater
</DropdownMenu.Item>
<DropdownMenu.Item class="gap-2 text-[11px]" onclick={() => addLayer('gauge')}>
<Gauge />
Gauge
</DropdownMenu.Item>
<DropdownMenu.Item class="gap-2 text-[11px]" onclick={() => addLayer('ticker')}>
<Megaphone />
Ticker
</DropdownMenu.Item>
<DropdownMenu.Item class="gap-2 text-[11px]" onclick={() => addLayer('button')}>
<MousePointerClick />
Button
</DropdownMenu.Item>
<DropdownMenu.Item class="gap-2 text-[11px]" onclick={() => addLayer('path')}>
<Spline />
Vector path
</DropdownMenu.Item>
<DropdownMenu.Item
class="gap-2 text-[11px]"
onclick={() => addLayer('particles')}
>
<Sparkles />
Particles
</DropdownMenu.Item>
<DropdownMenu.Item class="gap-2 text-[11px]" onclick={() => addLayer('clock')}>
<Clock />
Clock
</DropdownMenu.Item>
<DropdownMenu.Item
class="gap-2 text-[11px]"
onclick={() => addLayer('countdown')}
>
<Timer />
Countdown
</DropdownMenu.Item>
<DropdownMenu.Item
class="gap-2 text-[11px]"
onclick={() => addLayer('widget', 'launch-button')}
>
<LayoutGrid />
Launch Button Widget
</DropdownMenu.Item>
<DropdownMenu.Item
class="gap-2 text-[11px]"
onclick={() => addLayer('widget', 'player-card')}
>
<LayoutGrid />
Player Card Widget
</DropdownMenu.Item>
<DropdownMenu.Item
class="gap-2 text-[11px]"
onclick={() => addLayer('widget', 'avatar')}
>
<CircleUser />
Player Avatar Widget
</DropdownMenu.Item>
<DropdownMenu.Item
class="gap-2 text-[11px]"
onclick={() => addLayer('widget', 'recent-activity')}
>
<LayoutGrid />
Recent Activity Widget
</DropdownMenu.Item>
<DropdownMenu.Item class="gap-2 text-[11px]" onclick={addLottie}>
<Film />
Lottie animation
</DropdownMenu.Item>
<DropdownMenu.Separator class="my-1 bg-theme-800" />
<DropdownMenu.Item class="gap-2 text-[11px]" onclick={() => addGroup()}>
<Boxes />
Group
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</div>
<div class="relative mt-2">
<Search
class="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
/>
<Input
class="h-7 border-theme-800 bg-theme-950 pl-7 pr-7 text-[10px] text-theme-50"
placeholder="Find layers and groups…"
aria-label="Find layers and groups"
bind:ref={layerSearchInput}
bind:value={layerQuery}
onkeydown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
layerQuery = '';
return;
}
if (event.key !== 'Enter' || !layerSearchActive) return;
const first = visibleLayerRows.find((row) =>
layerRowMatches(row, layerSearchQuery)
);
if (first?.kind === 'group') {
selectGroup(first.group.id);
} else if (first) {
selectElement(first.element.id, false);
}
}}
/>
{#if layerSearchActive}
<button
type="button"
class="absolute right-1 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground hover:bg-theme-800 hover:text-theme-100"
title="Clear search"
aria-label="Clear layer search"
onclick={() => {
layerQuery = '';
layerSearchInput?.focus();
}}
>
<X class="size-3" />
</button>
{/if}
</div>
</div>
<div class="min-h-0 flex-1 overflow-y-auto p-2" bind:this={layerListHost}>
{#if elements.length === 0 && groups.length === 0}
<div class="rounded-lg border border-dashed border-theme-800 p-4 text-center">
<Layers class="mx-auto size-6 text-theme-600" />
<p class="mt-2 text-xs font-medium text-theme-100">Your canvas is empty</p>
<p class="mt-1 text-[10px] leading-relaxed text-muted-foreground">
Add text, images, videos or shapes to build on top of the launcher home screen.
</p>
</div>
{:else if layerSearchActive && visibleLayerRows.length === 0}
<div class="rounded-lg border border-dashed border-theme-800 p-4 text-center">
<Search class="mx-auto size-5 text-theme-600" />
<p class="mt-2 text-xs font-medium text-theme-100">No matching layers</p>
<p class="mt-1 text-[10px] leading-relaxed text-muted-foreground">
Try a layer name, group name or layer type. Collapsed groups are searched too.
</p>
<Button
variant="secondary"
size="sm"
class="mt-3 h-7 text-[10px]"
onclick={() => {
layerQuery = '';
layerSearchInput?.focus();
}}
>
Clear search
</Button>
</div>
{:else}
<ContextMenu.Root bind:open={layerContextMenuOpen}>
<ContextMenu.Trigger class="block">
<div
class="space-y-1"
role="list"
oncontextmenu={(event) => {
if (event.target === event.currentTarget) prepareContextMenu(undefined);
}}
>
{#each visibleLayerRows as row (layerRowKey(row))}
{#if row.kind === 'group'}
{@const badge = logicBadges.get(`group:${row.group.id}`)}
{@const groupExpanded = layerSearchActive || !row.group.collapsed}
<div
role="listitem"
data-layer-key={`g:${row.group.id}`}
class="group relative flex items-center gap-1 rounded-md border px-1 py-1 {selectedGroupId ===
row.group.id
? 'border-primary/50 bg-primary/10'
: 'border-transparent hover:border-theme-800'} {row.group.hidden
? 'opacity-60'
: ''} {dropTarget?.mode === 'inside' &&
dropTarget.groupId === row.group.id
? 'border-primary bg-primary/20 ring-1 ring-primary/40'
: ''} {draggingLayer?.kind === 'group' &&
draggingLayer.id === row.group.id
? 'opacity-40'
: ''}"
style="margin-left:{row.depth * 14}px"
ondragover={(event) => updateGroupDrop(event, row.group.id)}
ondrop={(event) => dropLayerOnGroup(event, row.group.id)}
oncontextmenu={() => prepareGroupContextMenu(row.group.id)}
>
<button
type="button"
class="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-theme-800 hover:text-theme-100"
title={layerSearchActive
? 'Clear the search to change group expansion'
: groupExpanded
? 'Collapse group'
: 'Expand group'}
aria-label={groupExpanded ? 'Collapse group' : 'Expand group'}
aria-expanded={groupExpanded}
onclick={() => {
if (layerSearchActive) return;
toggleGroupCollapsed(row.group);
}}
>
{#if groupExpanded}
<ChevronDown class="size-3.5" />
{:else}
<ChevronRight class="size-3.5" />
{/if}
</button>
<span
class="flex size-6 shrink-0 items-center justify-center rounded bg-theme-900"
>
<Boxes class="size-3.5" />
</span>
<button
type="button"
class="min-w-0 flex-1 text-left"
onclick={() => selectGroup(row.group.id)}
>
<span class="block truncate text-[11px] font-medium text-theme-100">
{groupLabel(row.group)}
</span>
<span class="block font-mono text-[9px] text-muted-foreground">
{groupDescendantCount(row.group.id)} layer{groupDescendantCount(
row.group.id
) === 1
? ''
: 's'}
</span>
</button>
<ThemeLogicBadge
count={badge?.count ?? 0}
warning={badge?.warning ?? false}
onopen={() => openLinkedLogic('group', row.group.id)}
/>
<div class="flex shrink-0 items-center gap-0.5">
<button
type="button"
class="flex size-6 items-center justify-center rounded text-muted-foreground hover:bg-theme-800 hover:text-theme-100"
title={row.group.hidden ? 'Show group' : 'Hide group'}
aria-label={row.group.hidden ? 'Show group' : 'Hide group'}
onclick={() => toggleGroupFlag(row.group, 'hidden')}
>
{#if row.group.hidden}
<EyeOff class="size-3.5" />
{:else}
<Eye class="size-3.5" />
{/if}
</button>
<button
type="button"
class="flex size-6 items-center justify-center rounded text-muted-foreground hover:bg-theme-800 hover:text-theme-100"
title={row.group.locked ? 'Unlock group' : 'Lock group'}
aria-label={row.group.locked ? 'Unlock group' : 'Lock group'}
onclick={() => toggleGroupFlag(row.group, 'locked')}
>
{#if row.group.locked}
<Lock class="size-3.5" />
{:else}
<Unlock class="size-3.5" />
{/if}
</button>
<button
type="button"
draggable="true"
class="flex size-6 cursor-grab items-center justify-center rounded text-muted-foreground hover:bg-theme-800 hover:text-theme-100 active:cursor-grabbing"
title="Drag group into another group, or out to the root"
aria-label="Drag group"
ondragstart={(event) => startLayerDrag(event, 'group', row.group.id)}
ondragend={endLayerDrag}
>
<GripVertical class="size-3.5" />
</button>
</div>
</div>
{:else}
{@const element = row.element}
{@const selected = selectedSet.has(element.id)}
{@const badge = logicBadges.get(`element:${element.id}`)}
<div
role="listitem"
data-layer-key={`e:${element.id}`}
class="group relative flex items-center gap-1 rounded-md border px-1 py-1 {selected
? 'border-primary/50 bg-primary/10'
: 'border-transparent hover:border-theme-800'} {element.hidden
? 'opacity-60'
: ''} {draggingLayer?.kind === 'element' &&
draggingLayer.id === element.id
? 'opacity-40'
: ''}"
style="margin-left:{row.depth * 14}px"
ondragover={(event) => updateElementDrop(event, element.id)}
ondrop={(event) => dropLayerOnElement(event, element.id)}
ondragleave={(event) => {
const next = event.relatedTarget as Node | null;
if (next && event.currentTarget.contains(next)) return;
if (
dropTarget?.mode === 'between' &&
dropTarget.elementId === element.id
) {
dropTarget = undefined;
}
}}
oncontextmenu={() => prepareContextMenu(element.id)}
>
{#if dropTarget?.mode === 'between' && dropTarget.elementId === element.id}
<span
class="pointer-events-none absolute inset-x-1 z-10 h-0.5 rounded bg-primary {dropTarget.position ===
'above'
? '-top-px'
: '-bottom-px'}"
></span>
{/if}
{#if renamingId === element.id}
<div class="flex min-w-0 flex-1 items-center gap-1.5 px-1">
<span
class="flex size-6 shrink-0 items-center justify-center rounded bg-theme-900"
>
{#if element.type === 'text'}
<Type class="size-3.5" />
{:else if element.type === 'image'}
<ImageIcon class="size-3.5" />
{:else if element.type === 'video'}
<Video class="size-3.5" />
{:else if element.type === 'svelte'}
<Component class="size-3.5" />
{:else if element.type === 'path'}
<Spline class="size-3.5" />
{:else if element.type === 'particles'}
<Sparkles class="size-3.5" />
{:else if element.type === 'clock'}
<Clock class="size-3.5" />
{:else if element.type === 'countdown'}
<Timer class="size-3.5" />
{:else}
<Shapes class="size-3.5" />
{/if}
</span>
<input
class="h-6 min-w-0 flex-1 rounded border border-primary/50 bg-theme-950 px-1.5 text-[11px] text-theme-50 outline-none"
value={element.name ?? ''}
placeholder={elementLabel(element)}
use:focusInput
oninput={(event) =>
renameElement(
element.id,
(event.currentTarget as HTMLInputElement).value
)}
onblur={() => (renamingId = undefined)}
onkeydown={(event) => {
if (event.key === 'Enter' || event.key === 'Escape') {
event.preventDefault();
renamingId = undefined;
}
}}
/>
</div>
{:else}
<button
type="button"
class="flex min-w-0 flex-1 items-center gap-1.5 rounded px-1 py-0.5 text-left"
aria-pressed={selected}
onclick={(event) => {
selectElement(
element.id,
event.shiftKey || event.metaKey || event.ctrlKey
);
leftTab = 'layers';
}}
>
<span
class="flex size-6 shrink-0 items-center justify-center rounded bg-theme-900"
>
{#if element.type === 'text'}
<Type class="size-3.5" />
{:else if element.type === 'image'}
<ImageIcon class="size-3.5" />
{:else if element.type === 'video'}
<Video class="size-3.5" />
{:else if element.type === 'svelte'}
<Component class="size-3.5" />
{:else if element.type === 'path'}
<Spline class="size-3.5" />
{:else if element.type === 'particles'}
<Sparkles class="size-3.5" />
{:else if element.type === 'clock'}
<Clock class="size-3.5" />
{:else if element.type === 'countdown'}
<Timer class="size-3.5" />
{:else}
<Shapes class="size-3.5" />
{/if}
</span>
<span class="min-w-0 flex-1">
<span
class="block truncate text-[11px] font-medium text-theme-100"
title="Double-click to rename"
role="presentation"
ondblclick={(event) => {
event.stopPropagation();
renamingId = element.id;
}}
>
{elementLabel(element)}
</span>
<span class="block font-mono text-[9px] text-muted-foreground">
{Math.round(element.width)}×{Math.round(element.height)} · {element.type}
</span>
</span>
</button>
{/if}
<ThemeLogicBadge
count={badge?.count ?? 0}
warning={badge?.warning ?? false}
onopen={() => openLinkedLogic('element', element.id)}
/>
<div class="flex shrink-0 items-center gap-0.5">
<button
type="button"
class="flex size-6 items-center justify-center rounded text-muted-foreground hover:bg-theme-800 hover:text-theme-100"
title={element.hidden ? 'Show layer' : 'Hide layer'}
aria-label={element.hidden ? 'Show layer' : 'Hide layer'}
onclick={(event) => {
event.stopPropagation();
toggleElementFlag(element, 'hidden');
}}
>
{#if element.hidden}
<EyeOff class="size-3.5" />
{:else}
<Eye class="size-3.5" />
{/if}
</button>
<button
type="button"
class="flex size-6 items-center justify-center rounded text-muted-foreground hover:bg-theme-800 hover:text-theme-100"
title={element.locked ? 'Unlock layer' : 'Lock layer'}
aria-label={element.locked ? 'Unlock layer' : 'Lock layer'}
onclick={(event) => {
event.stopPropagation();
toggleElementFlag(element, 'locked');
}}
>
{#if element.locked}
<Lock class="size-3.5" />
{:else}
<Unlock class="size-3.5" />
{/if}
</button>
<button
type="button"
draggable="true"
class="flex size-6 cursor-grab items-center justify-center rounded text-muted-foreground hover:bg-theme-800 hover:text-theme-100 active:cursor-grabbing"
title="Drag to reorder, move into a group, or move out of one"
aria-label="Drag layer"
ondragstart={(event) => startLayerDrag(event, 'element', element.id)}
ondragend={endLayerDrag}
>
<GripVertical class="size-3.5" />
</button>
</div>
</div>
{/if}
{/each}
{#if draggingLayer}
<div
role="listitem"
aria-label="Drop here to move out of all groups"
class="mt-1 flex h-9 items-center justify-center gap-1.5 rounded-md border border-dashed px-2 text-[10px] transition-colors {dropTarget?.mode ===
'root'
? 'border-primary bg-primary/15 text-primary'
: 'border-theme-700 text-muted-foreground'}"
ondragover={updateRootDrop}
ondrop={dropLayerOnRoot}
>
<Ungroup class="size-3.5 shrink-0" />
Drop here to move out of all groups
</div>
{/if}
</div>
</ContextMenu.Trigger>
<ContextMenu.Content class="w-56 border-theme-700 bg-theme-950">
{@render contextMenuItems()}
</ContextMenu.Content>
</ContextMenu.Root>
{/if}
</div>
</div>
{:else if leftTab === 'fonts'}
<div class="flex min-h-0 flex-1 flex-col">
<div class="shrink-0 space-y-2 border-b border-theme-800 p-2">
<div class="relative">
<Search
class="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
/>
<Input
class="h-8 border-theme-800 bg-theme-950 pl-8 text-xs"
placeholder="Search all Fontsource fonts…"
bind:value={fontQuery}
oninput={() => (fontResultLimit = 60)}
/>
</div>
<div class="flex flex-wrap gap-1">
{#each fontSourceCategories as category (category.value)}
<Button
variant="ghost"
size="sm"
class="h-6 rounded px-1.5 text-[9px] {fontCategory === category.value
? 'bg-primary/20 text-primary hover:bg-primary/25'
: 'text-muted-foreground'}"
aria-pressed={fontCategory === category.value}
onclick={() => setFontCategory(category.value)}
>
{category.label}
</Button>
{/each}
</div>
</div>
<div class="min-h-0 flex-1 overflow-y-auto p-2">
{#if fontCatalogLoading}
<div class="flex flex-col items-center gap-2 py-8 text-center text-muted-foreground">
<LoaderCircle class="size-5 animate-spin" />
<span class="text-[10px]">Loading all Fontsource fonts…</span>
</div>
{:else if fontCatalogError}
<div
class="space-y-2 rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-center"
>
<p class="text-[10px] leading-relaxed text-muted-foreground">{fontCatalogError}</p>
<Button
size="sm"
variant="secondary"
class="h-7 text-[10px]"
onclick={loadFontsourceCatalog}
>
<RefreshCw class="size-3.5" />
Retry
</Button>
</div>
{:else if fontCatalog.length === 0}
<div class="flex flex-col items-center gap-2 py-8 text-center text-muted-foreground">
<Search class="size-5" />
<p class="text-[10px]">Load every family from Fontsource to start browsing.</p>
<Button
size="sm"
variant="secondary"
class="h-7 text-[10px]"
onclick={loadFontsourceCatalog}
>
Load fonts
</Button>
</div>
{:else}
<p
class="mb-2 flex items-center justify-between px-1 text-[9px] text-muted-foreground"
>
<span>{filteredFontsourceFonts.length} fonts</span>
<span>{fontCatalog.length} available</span>
</p>
<div class="space-y-0.5">
{#each visibleFontsourceFonts as font (font.id)}
{@const fontPreview = defaultFontsourceOptions(font)}
{@const bundledFamily = bundledThemeFontFamily(font.family)}
<Button
variant="ghost"
class="h-auto w-full justify-between gap-2 whitespace-normal rounded px-2 py-1.5 text-left font-normal {selectedFontsourceId ===
font.id
? 'bg-primary/15 text-primary hover:bg-primary/20'
: ''}"
aria-pressed={selectedFontsourceId === font.id}
onclick={() => selectFontsource(font)}
>
<span class="min-w-0">
<FontPreview
family={bundledFamily ?? font.family}
src={bundledFamily ? '' : fontsourceFontUrl(font, fontPreview)}
weight={String(fontPreview.weight)}
style={fontPreview.style}
class="block truncate text-[12px] leading-tight"
/>
<span class="block text-[9px] capitalize text-muted-foreground"
>{font.category.replace('-', ' ')}</span
>
</span>
{#if bundledFamily}
<Badge
variant="outline"
class="shrink-0 border-primary/40 bg-primary/10 px-1 py-0 text-[8px] text-primary"
title="Bundled with the launcher — no download needed"
>
BUNDLED
</Badge>
{:else if font.variable}
<Badge
variant="outline"
class="shrink-0 border-primary/25 px-1 py-0 text-[8px] text-primary"
>
VAR
</Badge>
{/if}
</Button>
{/each}
</div>
{#if filteredFontsourceFonts.length > visibleFontsourceFonts.length}
<Button
variant="ghost"
class="mt-2 h-7 w-full text-[10px] text-muted-foreground"
onclick={() => (fontResultLimit += 60)}
>
Show more ({filteredFontsourceFonts.length - visibleFontsourceFonts.length} left)
</Button>
{/if}
{#if filteredFontsourceFonts.length === 0}
<div class="py-8 text-center text-[10px] text-muted-foreground">
No Fontsource fonts match that search.
</div>
{/if}
{/if}
</div>
{#if selectedFontsource}
<div class="shrink-0 space-y-2 border-t border-theme-800 bg-black/20 p-2">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-[11px] font-semibold text-theme-100">
{selectedFontsource.family}
</p>
<p class="truncate text-[9px] capitalize text-muted-foreground">
{selectedFontsource.category.replace('-', ' ')} · {selectedFontsource.subsets
.length}
subsets
</p>
{#if selectedFontIsBundled}
<p class="mt-0.5 flex items-center gap-1 text-[9px] text-primary">
<CheckCircle2 class="size-3 shrink-0" />
Bundled with the launcher
</p>
{/if}
</div>
<div class="flex shrink-0 items-center gap-1">
{#if selectedFontIsBundled}
<Badge
variant="outline"
class="border-primary/40 bg-primary/10 px-1.5 py-0 text-[8px] text-primary"
>
Bundled
</Badge>
{/if}
{#if selectedFontsource.variable}
<Badge
variant="outline"
class="shrink-0 border-primary/25 px-1.5 py-0 text-[8px] text-primary"
>
Variable
</Badge>
{/if}
</div>
</div>
<div
class="overflow-hidden rounded-md border border-theme-800 bg-theme-950 px-2 py-2"
>
<p
class="truncate text-base text-theme-100"
style:font-family={selectedFontIsBundled
? fontStack(selectedFontBundledFamily)
: FONT_PREVIEW_FAMILY}
style:font-weight={selectedFontsource.variable ? 600 : fontWeight}
>
The quick brown fox
</p>
</div>
<div class="grid grid-cols-2 gap-1.5">
{#if !selectedFontsource.variable}
<div class="space-y-1">
<Label for="fontsource-weight" class={labelClass}>Weight</Label>
<Select.Root
type="single"
value={fontWeight}
onValueChange={(value) => value && (fontWeight = value)}
>
<Select.Trigger
id="fontsource-weight"
class="h-7 border-theme-800 bg-theme-950 text-[10px] text-theme-50"
>
{fontWeight}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
{#each selectedFontsource.weights as weight (weight)}
<Select.Item value={String(weight)} label={String(weight)} />
{/each}
</Select.Content>
</Select.Root>
</div>
{/if}
<div class="space-y-1 {selectedFontsource.variable ? 'col-span-2' : ''}">
<Label for="fontsource-style" class={labelClass}>Style</Label>
<Select.Root
type="single"
value={fontStyle}
onValueChange={(value) => value && (fontStyle = value as 'normal' | 'italic')}
>
<Select.Trigger
id="fontsource-style"
class="h-7 border-theme-800 bg-theme-950 text-[10px] text-theme-50"
>
{fontStyle}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
{#each selectedFontsource.styles as style (style)}
<Select.Item value={style} label={style} />
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
<div class="space-y-1">
<Label for="fontsource-subset" class={labelClass}>Subset</Label>
<Select.Root
type="single"
value={fontSubset}
onValueChange={(value) => value && (fontSubset = value)}
>
<Select.Trigger
id="fontsource-subset"
class="h-7 border-theme-800 bg-theme-950 text-[10px] text-theme-50"
>
{fontSubset}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
{#each selectedFontsource.subsets as subset (subset)}
<Select.Item value={subset} label={subset} />
{/each}
</Select.Content>
</Select.Root>
</div>
<Button
class="h-8 w-full gap-1.5 text-[10px]"
onclick={addSelectedFontsource}
disabled={fontDownloading || selectedFontIsAdded || selectedFontIsBundled}
>
{#if fontDownloading}
<LoaderCircle class="animate-spin" />
Downloading…
{:else if selectedFontIsBundled}
<CheckCircle2 class="size-3.5" />
Bundled with launcher
{:else if selectedFontIsAdded}
<Check class="size-3.5" />
Added to theme
{:else}
<Download class="size-3.5" />
Download & add
{/if}
</Button>
{#if selectedFontIsBundled}
<p class="text-center text-[9px] leading-relaxed text-muted-foreground">
Already available in the launcher font menus — no download needed.
</p>
{/if}
</div>
{/if}
</div>
{:else if leftTab === 'logic'}
<div class="flex min-h-0 flex-1 flex-col">
<div class="shrink-0 space-y-2 border-b border-theme-800 p-2">
<div class="flex items-center justify-between gap-2">
<span class="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{behaviors.length} rule{behaviors.length === 1 ? '' : 's'}
</span>
<Button
variant="ghost"
size="sm"
class="h-6 gap-1 px-1.5 text-[10px] text-muted-foreground"
onclick={addBehavior}
>
<Plus class="size-3" />
Add
</Button>
</div>
{#if behaviors.length > 0}
<div class="relative">
<Search
class="pointer-events-none absolute left-2 top-1/2 size-3 -translate-y-1/2 text-muted-foreground"
/>
<Input
class="h-7 border-theme-800 bg-theme-950 pl-7 text-[10px] text-theme-50"
placeholder="Search rules or targets"
aria-label="Search logic rules"
bind:value={logicQuery}
/>
</div>
{/if}
</div>
<div class="min-h-0 flex-1 overflow-y-auto p-2">
{#if behaviors.length === 0}
<div class="rounded-lg border border-dashed border-theme-800 p-4 text-center">
<Zap class="mx-auto size-6 text-theme-600" />
<p class="mt-2 text-xs font-medium text-theme-100">No rules yet</p>
<p class="mt-1 text-[10px] leading-relaxed text-muted-foreground">
Add a WHEN / IF / THEN rule to react to clicks, hovers, timers and screen changes.
</p>
</div>
{:else if filteredBehaviors.length === 0}
<div class="rounded-lg border border-dashed border-theme-800 p-4 text-center">
<Search class="mx-auto size-5 text-theme-600" />
<p class="mt-2 text-xs font-medium text-theme-100">No matching rules</p>
<p class="mt-1 text-[10px] leading-relaxed text-muted-foreground">
Try a rule name, layer name or group name.
</p>
</div>
{:else}
<div class="space-y-1">
{#each filteredBehaviors as behavior (behavior.id)}
<button
type="button"
class="flex w-full items-start gap-1.5 rounded-md border px-1.5 py-1.5 text-left transition-colors {selectedBehaviorId ===
behavior.id
? 'border-primary/50 bg-primary/10'
: 'border-transparent hover:border-theme-800 hover:bg-theme-900/60'}"
onclick={() => selectBehavior(behavior.id)}
>
<span
class="flex size-6 shrink-0 items-center justify-center rounded bg-theme-900"
>
<Zap class="size-3.5 text-primary" />
</span>
<span class="min-w-0 flex-1">
<span class="flex items-center gap-1.5">
<span class="truncate text-[11px] font-medium text-theme-100">
{behavior.name?.trim() || 'Rule'}
</span>
{#if behavior.target}
<span
class="shrink-0 rounded bg-theme-900 px-1 py-0.5 text-[8px] text-primary/90"
title={themeBehaviorTargetLabel(behavior.target, elements, groups)}
>
{behavior.target.type === 'group' ? 'Group' : 'Layer'}
</span>
{/if}
</span>
<span class="mt-0.5 block truncate text-[9px] text-muted-foreground">
{themeBehaviorSummary(behavior, elements, groups)}
</span>
</span>
{#if behavior.enabled === false}
<span class="mt-0.5 text-[8px] font-semibold uppercase text-muted-foreground"
>Off</span
>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<p
class="shrink-0 border-t border-theme-800 p-2 text-[9px] leading-relaxed text-muted-foreground"
>
Linked rules are editable from their layer or group inspector. Pointer rules need
<strong class="text-theme-100">Interactive</strong> layers.
</p>
</div>
{:else}
<div class="min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
<ThemeSection title="Theme details" bordered={false} contentClass="space-y-3 pt-2">
<div class="space-y-1.5">
<Label for="theme-name" class={labelClass}>Name</Label>
<Input
id="theme-name"
bind:value={definition.name}
class={fieldClass}
placeholder="Midnight"
/>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1.5">
<Label for="theme-author" class={labelClass}>Author</Label>
<Input
id="theme-author"
bind:value={definition.author}
class={fieldClass}
placeholder="You"
/>
</div>
<div class="space-y-1.5">
<Label for="theme-version" class={labelClass}>Version</Label>
<Input
id="theme-version"
bind:value={definition.version}
class={fieldClass}
placeholder="1.0.0"
/>
</div>
</div>
<div class="space-y-1.5">
<Label for="theme-description" class={labelClass}>Description</Label>
<Textarea
id="theme-description"
bind:value={definition.description}
class="min-h-16 resize-y border-theme-800 bg-theme-950 text-xs text-theme-50"
/>
</div>
<div
class="flex items-start justify-between gap-3 rounded-md border border-theme-800 bg-theme-950 px-3 py-2.5"
>
<div class="space-y-0.5">
<Label for="theme-nsfw" class="text-xs font-medium text-theme-100">
NSFW theme
</Label>
<p class="text-[10px] leading-relaxed text-muted-foreground">
Mark this theme as adult content. It will show an 18+ badge in the theme manager.
</p>
</div>
<Checkbox
id="theme-nsfw"
class="mt-0.5"
checked={definition.nsfw === true}
onCheckedChange={(checked) => {
definition.nsfw = checked === true;
}}
/>
</div>
<div class="space-y-1.5">
<Label for="theme-accent" class={labelClass}>Accent</Label>
<div class="flex items-center gap-2">
<Input
type="color"
class="h-9 w-10 shrink-0 cursor-pointer border-theme-800 bg-theme-950 p-0.5"
bind:value={definition.accentColor}
/>
<Input
id="theme-accent"
bind:value={definition.accentColor}
class="h-9 flex-1 font-mono text-xs"
/>
</div>
</div>
</ThemeSection>
<ThemeVariableDefaults
variables={editableScriptVariables}
defaults={definition.variables ?? {}}
onchange={setVariableDefault}
/>
<ThemeSection title="Launcher Layout & Chrome" contentClass="space-y-3 pt-2.5">
<div class="space-y-1.5">
<Label class={labelClass}>Layout Preset</Label>
<div class="grid grid-cols-3 gap-1 rounded-md bg-black/30 p-1">
{#each [{ value: 'dashboard', label: 'Dashboard' }, { value: 'classic', label: 'Classic' }, { value: 'custom', label: 'Custom' }] as mode (mode.value)}
<Button
variant="ghost"
size="sm"
class="h-7 px-1 text-[10px] {themeChrome.layoutMode === mode.value
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'text-muted-foreground'}"
onclick={() => setLayoutMode(mode.value as ThemeV2LayoutMode)}
>
{mode.label}
</Button>
{/each}
</div>
<p class="text-[10px] leading-relaxed text-muted-foreground">
{#if themeChrome.layoutMode === 'dashboard'}
Modern dashboard with large player card and recent activity highlights.
{:else if themeChrome.layoutMode === 'classic'}
Original minimal layout with compact bottom-left stats and open canvas.
{:else}
Custom freeform canvas: default cards are hidden so your layers take center stage.
{/if}
</p>
</div>
<!-- Freeform Moveable Elements Box -->
<div class="space-y-2 rounded-lg border border-primary/30 bg-primary/10 p-3">
<div class="flex items-center justify-between">
<span class="text-xs font-bold text-theme-50">Moveable Canvas Elements</span>
<Badge variant="outline" class="border-primary/40 text-[9px] text-primary"
>Freeform</Badge
>
</div>
<p class="text-[10px] leading-relaxed text-theme-200">
Convert the Launch button, Player card, and Activity card into draggable layers so
you can position them anywhere.
</p>
<div class="flex flex-col gap-1.5 pt-1">
<Button
size="sm"
class="h-7 bg-primary text-xs font-semibold text-primary-foreground hover:bg-primary/90"
onclick={detachAllToCanvas}
>
✨ Detach All to Canvas
</Button>
<div class="grid grid-cols-2 gap-1 pt-0.5">
<Button
variant="outline"
size="sm"
class="h-6 border-theme-700 bg-theme-900/50 text-[10px] hover:bg-theme-800"
onclick={() => handleDetachWidget('header')}
>
Move Header Title
</Button>
<Button
variant="outline"
size="sm"
class="h-6 border-theme-700 bg-theme-900/50 text-[10px] hover:bg-theme-800"
onclick={() => handleDetachWidget('launch-button')}
>
Move Launch Button
</Button>
<Button
variant="outline"
size="sm"
class="h-6 border-theme-700 bg-theme-900/50 text-[10px] hover:bg-theme-800"
onclick={() => handleDetachWidget('player-card')}
>
Move Player Card
</Button>
<Button
variant="outline"
size="sm"
class="h-6 border-theme-700 bg-theme-900/50 text-[10px] hover:bg-theme-800"
onclick={() => handleDetachWidget('avatar')}
>
Move Avatar
</Button>
<Button
variant="outline"
size="sm"
class="h-6 border-theme-700 bg-theme-900/50 text-[10px] hover:bg-theme-800"
onclick={() => handleDetachWidget('recent-activity')}
>
Move Activity Card
</Button>
<Button
variant="ghost"
size="sm"
class="h-6 text-[10px] text-muted-foreground hover:text-white"
onclick={restoreNativeDock}
>
Restore Dock
</Button>
</div>
</div>
</div>
<div class="space-y-2 border-t border-theme-800 pt-2">
<Label class="text-xs font-semibold text-theme-100">Component Visibility</Label>
<div class="space-y-2">
<label
class="flex cursor-pointer items-center justify-between gap-2 text-[11px] text-theme-200"
>
<span>Header Title & Greeting</span>
<Checkbox
checked={themeChrome.header !== false}
onCheckedChange={(c) => updateChrome('header', c === true)}
/>
</label>
<label
class="flex cursor-pointer items-center justify-between gap-2 text-[11px] text-theme-200"
>
<span>Player Card</span>
<Checkbox
checked={themeChrome.playerCard !== false}
onCheckedChange={(c) => updateChrome('playerCard', c === true)}
/>
</label>
<label
class="flex cursor-pointer items-center justify-between gap-2 text-[11px] text-theme-200"
>
<span>Recent Activity Card</span>
<Checkbox
checked={themeChrome.recentActivity !== false}
onCheckedChange={(c) => updateChrome('recentActivity', c === true)}
/>
</label>
<label
class="flex cursor-pointer items-center justify-between gap-2 text-[11px] text-theme-200"
>
<span>Bottom Dock</span>
<Checkbox
checked={themeChrome.dock !== false}
onCheckedChange={(c) => updateChrome('dock', c === true)}
/>
</label>
<label
class="flex cursor-pointer items-center justify-between gap-2 text-[11px] text-theme-200"
>
<span>Dock Telemetry Chips</span>
<Checkbox
checked={themeChrome.dockTelemetry !== false}
onCheckedChange={(c) => updateChrome('dockTelemetry', c === true)}
/>
</label>
</div>
</div>
<!-- Launch Button Requirement Indicator -->
<div
class="rounded-md border p-2.5 text-[11px] {hasValidLaunchButton
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300'
: 'border-amber-500/30 bg-amber-500/10 text-amber-300'}"
>
<div class="flex items-center gap-1.5 font-bold">
{#if hasValidLaunchButton}
<CheckCircle2 class="size-3.5 text-emerald-400" />
<span>Launch button satisfied</span>
{:else}
<AlertTriangle class="size-3.5 text-amber-400" />
<span>Launch button required!</span>
{/if}
</div>
<p class="mt-1 text-[10px] leading-relaxed opacity-85">
{#if hasValidLaunchButton}
Theme includes osu! launch capability ({themeChrome.dock !== false
? 'via bottom dock'
: 'via canvas launch widget'}).
{:else}
osu! requires a launch button. Turn on the Bottom Dock or add a Launch Button
widget to the canvas.
{/if}
</p>
</div>
</ThemeSection>
<ThemeSection title="Background" contentClass="space-y-3 pt-2.5">
<div class="grid grid-cols-4 gap-1 rounded-md bg-black/30 p-1">
{#each backgroundTypes as option (option.value)}
<Button
variant="ghost"
size="sm"
class="h-7 px-1 text-[10px] {definition.background.type === option.value
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'text-muted-foreground'}"
aria-pressed={definition.background.type === option.value}
onclick={() => setBackgroundType(option.value)}
>
{option.label}
</Button>
{/each}
</div>
{#if definition.background.type === 'gradient'}
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1.5">
<Label for="background-from" class={labelClass}>From</Label>
<div class="flex gap-1.5">
<Input
type="color"
class="h-9 w-9 shrink-0 cursor-pointer border-theme-800 bg-theme-950 p-0.5"
bind:value={definition.background.from}
/>
<Input
id="background-from"
bind:value={definition.background.from}
class="h-9 flex-1 font-mono text-[10px]"
/>
</div>
</div>
<div class="space-y-1.5">
<Label for="background-to" class={labelClass}>To</Label>
<div class="flex gap-1.5">
<Input
type="color"
class="h-9 w-9 shrink-0 cursor-pointer border-theme-800 bg-theme-950 p-0.5"
bind:value={definition.background.to}
/>
<Input
id="background-to"
bind:value={definition.background.to}
class="h-9 flex-1 font-mono text-[10px]"
/>
</div>
</div>
</div>
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<Label class={labelClass}>Angle</Label>
<span class="text-[10px] text-muted-foreground"
>{Math.round(definition.background.angle ?? 135)}°</span
>
</div>
<Slider
min={0}
max={360}
value={definition.background.angle ?? 135}
onValueChange={(value) => {
if (definition.background.type === 'gradient') {
definition.background.angle = value;
}
}}
/>
</div>
{:else if definition.background.type === 'color'}
<div class="space-y-1.5">
<Label for="background-color" class={labelClass}>Color</Label>
<div class="flex gap-2">
<Input
type="color"
class="h-9 w-10 shrink-0 cursor-pointer border-theme-800 bg-theme-950 p-0.5"
bind:value={definition.background.color}
/>
<Input
id="background-color"
bind:value={definition.background.color}
class="h-9 flex-1 font-mono text-xs"
/>
</div>
</div>
{:else}
<div class="flex items-center gap-2">
<Button
variant="secondary"
class="h-9 flex-1 gap-1.5 text-[10px]"
onclick={() => chooseBackground(definition.background.type as 'image' | 'video')}
>
{#if definition.background.type === 'video'}
<Video class="size-3.5" />
{:else}
<ImageIcon class="size-3.5" />
{/if}
{definition.background.src ? 'Replace' : 'Choose'}
</Button>
<span class="min-w-0 flex-1 truncate font-mono text-[9px] text-muted-foreground">
{definition.background.src || 'No file selected'}
</span>
</div>
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<Label class={labelClass}>Blur</Label>
<span class="text-[10px] text-muted-foreground"
>{Math.round(definition.background.blur ?? 0)}px</span
>
</div>
<Slider
min={0}
max={40}
value={definition.background.blur ?? 0}
onValueChange={(value) => {
if (
definition.background.type === 'image' ||
definition.background.type === 'video'
) {
definition.background.blur = value;
if (definition.background.type === 'image' && lastImageBackground) {
lastImageBackground.blur = value;
} else if (definition.background.type === 'video' && lastVideoBackground) {
lastVideoBackground.blur = value;
}
}
}}
/>
</div>
<div class="flex items-center gap-2">
<Checkbox
id="background-fit"
checked={definition.background.fit === 'contain'}
onCheckedChange={(checked) => {
if (
definition.background.type === 'image' ||
definition.background.type === 'video'
) {
const fitVal = checked ? 'contain' : 'cover';
definition.background.fit = fitVal;
if (definition.background.type === 'image' && lastImageBackground) {
lastImageBackground.fit = fitVal;
} else if (definition.background.type === 'video' && lastVideoBackground) {
lastVideoBackground.fit = fitVal;
}
}
}}
/>
<Label for="background-fit" class="text-[10px] font-normal text-muted-foreground">
Fit inside the window instead of covering it
</Label>
</div>
{#if definition.background.type === 'video'}
<div
class="flex items-start gap-2 rounded-md border border-theme-800 bg-black/20 p-2"
>
<Checkbox
id="background-muted"
class="mt-0.5"
checked={definition.background.muted !== false}
onCheckedChange={(checked) => {
if (definition.background.type === 'video') {
definition.background.muted = checked === true;
if (lastVideoBackground) {
lastVideoBackground.muted = checked === true;
}
}
}}
/>
<div class="space-y-0.5">
<Label for="background-muted" class="text-[10px] font-normal text-theme-100">
Mute background video
</Label>
<p class="text-[10px] leading-relaxed text-muted-foreground">
Unmuted videos may wait for a click before they start, because of autoplay
policies.
</p>
</div>
</div>
<p class="text-[10px] leading-relaxed text-muted-foreground">
Prefer <strong class="text-theme-100">WebM</strong> to keep theme downloads small. MP4,
MOV and M4V are accepted too.
</p>
{/if}
{/if}
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<Label class={labelClass}>Background opacity</Label>
<span class="text-[10px] text-muted-foreground"
>{Math.round((definition.background.opacity ?? 1) * 100)}%</span
>
</div>
<Slider
min={0}
max={1}
step={0.01}
value={definition.background.opacity ?? 1}
onValueChange={(value) => (definition.background.opacity = value)}
/>
</div>
</ThemeSection>
<ThemeSection
title="Overlay & effects"
icon={Sparkles}
open={false}
contentClass="space-y-3 pt-2.5"
>
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<Label class={labelClass}>Dim</Label>
<span class="text-[10px] text-muted-foreground"
>{Math.round((definition.overlay?.opacity ?? 0) * 100)}%</span
>
</div>
<Slider
min={0}
max={1}
step={0.01}
value={definition.overlay.opacity ?? 0}
onValueChange={(value) => (definition.overlay.opacity = value)}
/>
</div>
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<Label class={labelClass}>Vignette</Label>
<span class="text-[10px] text-muted-foreground"
>{Math.round((definition.overlay?.vignette ?? 0) * 100)}%</span
>
</div>
<Slider
min={0}
max={1}
step={0.01}
value={definition.overlay.vignette ?? 0}
onValueChange={(value) => (definition.overlay.vignette = value)}
/>
</div>
</div>
<div class="space-y-1.5">
<Label for="overlay-color" class={labelClass}>Overlay color</Label>
<div class="flex items-center gap-2">
<Input
type="color"
class="h-9 w-10 shrink-0 cursor-pointer border-theme-800 bg-theme-950 p-0.5"
bind:value={definition.overlay.color}
/>
<Input
id="overlay-color"
bind:value={definition.overlay.color}
class="h-9 flex-1 font-mono text-xs"
/>
</div>
</div>
<div class="space-y-1.5">
<Label for="effects-type" class={labelClass}>Particles</Label>
<Select.Root
type="single"
value={definition.effects.type}
onValueChange={(value) => {
if (value) {
definition.effects.type = value as ThemeV2Definition['effects']['type'];
}
}}
>
<Select.Trigger
id="effects-type"
class="h-9 border-theme-800 bg-theme-950 text-xs text-theme-50"
>
{effectTypes.find((option) => option.value === definition.effects.type)?.label}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
{#each effectTypes as option (option.value)}
<Select.Item value={option.value} label={option.label} />
{/each}
</Select.Content>
</Select.Root>
</div>
{#if definition.effects.type !== 'none'}
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<Label class={labelClass}>Density</Label>
<span class="text-[10px] text-muted-foreground"
>{definition.effects.density ?? 30}</span
>
</div>
<Slider
min={0}
max={120}
value={definition.effects.density ?? 30}
onValueChange={(value) => (definition.effects.density = value)}
/>
</div>
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<Label class={labelClass}>Speed</Label>
<span class="text-[10px] text-muted-foreground"
>{(definition.effects.speed ?? 1).toFixed(1)}×</span
>
</div>
<Slider
min={0.2}
max={3}
step={0.1}
value={definition.effects.speed ?? 1}
onValueChange={(value) => (definition.effects.speed = value)}
/>
</div>
</div>
<div class="space-y-1.5">
<Label for="effects-color" class={labelClass}>Particle color</Label>
<div class="flex items-center gap-2">
<Input
type="color"
class="h-9 w-10 shrink-0 cursor-pointer border-theme-800 bg-theme-950 p-0.5"
bind:value={definition.effects.color}
/>
<Input
id="effects-color"
bind:value={definition.effects.color}
class="h-9 flex-1 font-mono text-xs"
/>
</div>
</div>
{/if}
</ThemeSection>
<ThemeSection title="Typography" icon={Type} open={false} contentClass="space-y-3 pt-2.5">
<div class="space-y-1.5">
<Label for="font-body" class={labelClass}>Launcher font</Label>
<Select.Root
type="single"
value={definition.typography.body ?? 'Sora Variable'}
onValueChange={(value) => {
if (value) definition.typography.body = value;
}}
>
<Select.Trigger
id="font-body"
class="h-9 border-theme-800 bg-theme-950 text-xs text-theme-50"
>
{definition.typography.body ?? 'Sora Variable'}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
{#each fontOptions as font (font.family)}
<Select.Item value={font.family} label={`${font.label} · ${font.category}`} />
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1.5">
<Label for="font-heading" class={labelClass}>Heading font</Label>
<Select.Root
type="single"
value={definition.typography.heading ?? 'Sora Variable'}
onValueChange={(value) => {
if (value) definition.typography.heading = value;
}}
>
<Select.Trigger
id="font-heading"
class="h-9 border-theme-800 bg-theme-950 text-xs text-theme-50"
>
{definition.typography.heading ?? 'Sora Variable'}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
{#each fontOptions as font (font.family)}
<Select.Item value={font.family} label={`${font.label} · ${font.category}`} />
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1.5">
<Label for="font-mono" class={labelClass}>Monospace font</Label>
<Select.Root
type="single"
value={definition.typography.mono ?? 'Space Mono'}
onValueChange={(value) => {
if (value) definition.typography.mono = value;
}}
>
<Select.Trigger
id="font-mono"
class="h-9 border-theme-800 bg-theme-950 text-xs text-theme-50"
>
{definition.typography.mono ?? 'Space Mono'}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
{#each fontOptions as font (font.family)}
<Select.Item value={font.family} label={`${font.label} · ${font.category}`} />
{/each}
</Select.Content>
</Select.Root>
<p class="text-[10px] leading-relaxed text-muted-foreground">
Used for paths, versions and telemetry text.
</p>
</div>
<div class="grid grid-cols-2 gap-2">
<Button variant="secondary" class="h-8 gap-1.5 text-[10px]" onclick={openFontBrowser}>
<Search class="size-3.5" />
Browse fonts
</Button>
<Button variant="secondary" class="h-8 gap-1.5 text-[10px]" onclick={importFont}>
<Upload class="size-3.5" />
Import file
</Button>
</div>
{#if customFonts.length > 0}
<div class="space-y-1">
{#each customFonts as font (`${font.family}-${font.src}-${font.weight ?? '400'}-${font.style ?? 'normal'}`)}
<div
class="flex items-center gap-2 rounded-md border border-theme-800 bg-black/20 p-2"
>
<div class="min-w-0 flex-1">
<p class="truncate text-[11px] font-medium text-theme-100">{font.family}</p>
<p class="truncate font-mono text-[9px] text-muted-foreground">
{font.weight ?? '400'} · {font.style ?? 'normal'} · {font.src}
</p>
</div>
<Button
variant="ghost"
size="icon"
class="size-7 text-muted-foreground hover:text-destructive"
title="Remove font"
onclick={() => removeFont(font)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/each}
</div>
{/if}
<p class="text-[10px] leading-relaxed text-muted-foreground">
Browse every Fontsource family, or import your own WOFF2, WOFF, TTF or OTF file.
Downloaded fonts are bundled into the theme when you save.
</p>
</ThemeSection>
<ThemeSection
title="Icon set"
icon={Sparkles}
open={false}
contentClass="space-y-3 pt-2.5"
>
<Select.Root
type="single"
value={definition.iconSet ?? 'lucide'}
onValueChange={(value) => {
if (value === 'lucide' || value === 'phosphor' || value === 'tabler') {
definition.iconSet = value;
}
}}
>
<Select.Trigger
id="theme-icon-set"
class="h-9 border-theme-800 bg-theme-950 text-xs text-theme-50"
>
{THEME_ICON_SETS.find((option) => option.value === (definition.iconSet ?? 'lucide'))
?.label ?? 'Lucide'}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
{#each THEME_ICON_SETS as option (option.value)}
<Select.Item value={option.value} label={option.label} />
{/each}
</Select.Content>
</Select.Root>
<div
class="flex items-center gap-2 rounded-md border border-theme-800 bg-theme-950 p-2"
>
{#each THEME_ICON_PREVIEW_NAMES as name (name)}
<span
class="flex size-8 items-center justify-center rounded-md bg-theme-900 text-theme-100"
>
<ThemeIcon {name} set={definition.iconSet ?? 'lucide'} class="size-4" />
</span>
{/each}
<span class="ml-auto text-[9px] text-muted-foreground">Live preview</span>
</div>
<p class="text-[10px] leading-relaxed text-muted-foreground">
Changes launcher navigation, status, settings and theme-browser icons. The icon data
ships with the launcher, so themes still work offline.
</p>
</ThemeSection>
<ThemeSection title="Music" icon={Music} open={false} contentClass="space-y-3 pt-2.5">
{#if definition.background.type === 'video'}
<div
class="flex items-start gap-2 rounded-md border border-theme-800 bg-black/20 p-2"
>
<Lock class="mt-0.5 size-3.5 text-muted-foreground" />
<p class="text-[10px] leading-relaxed text-muted-foreground">
Music is unavailable while a video is used as the background. Switch to an image,
color, or gradient to choose theme music.
</p>
</div>
{:else}
<div class="flex items-center gap-2">
<Button
variant="secondary"
class="h-8 flex-1 gap-1.5 text-[10px]"
onclick={chooseAudio}
>
<Upload class="size-3.5" />
{definition.audio?.src ? 'Replace' : 'Choose music'}
</Button>
<span class="min-w-0 flex-1 truncate font-mono text-[9px] text-muted-foreground">
{definition.audio?.src || 'Optional MP3, OGG, WAV, M4A or FLAC'}
</span>
{#if definition.audio}
<Button
variant="ghost"
size="icon"
class="size-8 text-muted-foreground"
title="Remove music"
onclick={() => (definition.audio = undefined)}
>
<X class="size-3.5" />
</Button>
{/if}
</div>
{#if definition.audio}
<div class="flex items-center gap-2">
<Checkbox
id="audio-loop"
checked={definition.audio.loop !== false}
onCheckedChange={(checked) => {
if (definition.audio) definition.audio.loop = checked === true;
}}
/>
<Label for="audio-loop" class="text-[10px] font-normal text-muted-foreground">
Loop the music. Playback uses the title bar volume control.
</Label>
</div>
{/if}
{/if}
</ThemeSection>
<ThemeSection
title="Custom CSS"
icon={Code2}
open={false}
contentClass="space-y-3 pt-2.5"
>
<Textarea
class="min-h-28 resize-y border-theme-800 bg-theme-950 font-mono text-[10px] text-theme-50"
spellcheck="false"
placeholder={':root { --radius: 1rem; }'}
bind:value={definition.css}
/>
<p class="text-[10px] text-muted-foreground">
Applied after the launcher styles. The live canvas previews these rules; keep them
scoped where possible.
</p>
</ThemeSection>
</div>
{/if}
</aside>
<main class="flex min-h-0 min-w-0 flex-col bg-theme-900/50">
<div
class="flex h-10 shrink-0 items-center justify-between border-b border-theme-800 px-3 text-[10px] text-muted-foreground"
>
<span class="flex min-w-0 items-center gap-2">
<span class="size-1.5 shrink-0 rounded-full bg-emerald-400"></span>
<span class="truncate">Launcher home · live canvas</span>
</span>
<div class="flex shrink-0 items-center gap-0.5">
<Button
variant="ghost"
size="icon"
class="size-6 text-muted-foreground"
title="Zoom out (Cmd/Ctrl+-)"
aria-label="Zoom out"
onclick={() => zoomCanvasBy(1 / CANVAS_ZOOM_STEP)}
>
<Minus class="size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
class="h-6 w-12 px-0 font-mono text-[10px] text-theme-100"
title="Reset to fit to window (Cmd/Ctrl+0)"
aria-label="Reset canvas zoom to fit to window"
onclick={resetCanvasView}
>
{Math.round(canvasScale * 100)}%
</Button>
<Button
variant="ghost"
size="icon"
class="size-6 text-muted-foreground"
title="Zoom in (Cmd/Ctrl++)"
aria-label="Zoom in"
onclick={() => zoomCanvasBy(CANVAS_ZOOM_STEP)}
>
<Plus class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="size-6 text-muted-foreground"
title="Fit to window (Cmd/Ctrl+0)"
aria-label="Fit canvas to window"
onclick={resetCanvasView}
>
<Maximize2 class="size-3.5" />
</Button>
<span class="mx-0.5 h-4 w-px bg-theme-800"></span>
<Button
variant="ghost"
size="icon"
class="size-6 {panMode
? 'bg-primary/20 text-primary hover:bg-primary/25'
: 'text-muted-foreground'}"
title="Pan mode — drag the canvas to move it"
aria-label="Pan mode"
aria-pressed={panMode}
onclick={() => {
panMode = !panMode;
panning = false;
}}
>
<Hand class="size-3.5" />
</Button>
</div>
</div>
<div
class="relative min-h-0 flex-1 overflow-hidden p-6"
style="background-image:radial-gradient(circle at 1px 1px, rgba(255,255,255,0.10) 1px, transparent 0);background-size:24px 24px;"
bind:this={canvasHost}
role="presentation"
onwheel={handleCanvasWheel}
>
{#if loading}
<div
class="absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground"
>
<LoaderCircle class="size-6 animate-spin" />
<span class="text-xs">Opening theme…</span>
</div>
{:else}
<div
class="absolute left-1/2 top-1/2"
style="transform:translate(calc(-50% + {canvasPanX}px), calc(-50% + {canvasPanY}px));"
>
<ContextMenu.Root bind:open={contextMenuOpen}>
<ContextMenu.Trigger
class="relative overflow-hidden rounded-xl border border-theme-700 shadow-2xl"
style="width:{CANVAS_WIDTH * canvasScale}px;height:{CANVAS_HEIGHT * canvasScale}px;"
>
<div
style="width:{CANVAS_WIDTH}px;height:{CANVAS_HEIGHT}px;transform:scale({canvasScale});transform-origin:top left;"
>
<ThemeStage
{definition}
assets={assetBase}
assetOverrides={previewAssets}
{selectedIds}
{selectedGroupId}
interactive
scale={canvasScale}
variables={previewScriptVariables}
onselect={(id, additive) => selectElement(id, additive)}
onselectmany={(ids, additive) => selectMany(ids, additive)}
onselectgroup={(id) => selectGroup(id)}
onmove={moveElements}
onpatch={(id, patch) => patchElement(id, patch)}
onpatchgroup={(id, patch) => patchGroup(id, patch)}
oncontext={(id) => prepareContextMenu(id)}
oncontextgroup={(id) => prepareGroupContextMenu(id)}
ondetach={handleDetachWidget}
onbuttonaction={handleTestButtonAction}
/>
</div>
</ContextMenu.Trigger>
<ContextMenu.Content class="w-56 border-theme-700 bg-theme-950">
{@render contextMenuItems()}
</ContextMenu.Content>
</ContextMenu.Root>
</div>
{#if panMode}
<div
class="absolute inset-0 z-30 touch-none {panning ? 'cursor-grabbing' : 'cursor-grab'}"
role="presentation"
onpointerdown={startCanvasPan}
onpointermove={moveCanvasPan}
onpointerup={endCanvasPan}
onpointercancel={endCanvasPan}
></div>
{/if}
{/if}
</div>
<div
class="flex h-8 shrink-0 items-center justify-between border-t border-theme-800 px-4 text-[9px] text-muted-foreground"
>
<span> Drag to move · Wheel pan · Ctrl/Cmd+wheel zoom · Cmd/Ctrl+0 fit · ? shortcuts </span>
<span class="font-mono">{elements.length} elements</span>
</div>
</main>
<aside class="flex min-h-0 flex-col border-l border-theme-800 bg-theme-900/35">
{#if leftTab === 'logic'}
{#if selectedBehavior && selectedBehaviorIndex >= 0 && definition.behaviors}
<div class="min-h-0 flex-1 overflow-y-auto">
<ThemeBehaviorInspector
bind:behavior={definition.behaviors[selectedBehaviorIndex]}
{elements}
{groups}
variables={scriptVariables}
ondelete={() => deleteBehavior(selectedBehavior.id)}
/>
</div>
{:else}
<div class="flex min-h-0 flex-1 items-center justify-center p-4">
<div class="rounded-xl border border-theme-800 bg-black/20 p-4 text-center">
<div
class="mx-auto flex size-10 items-center justify-center rounded-full bg-primary/15 text-primary"
>
<Zap class="size-5" />
</div>
<p class="mt-3 text-xs font-semibold text-theme-100">Select a rule</p>
<p class="mt-1 text-[10px] leading-relaxed text-muted-foreground">
Choose a rule on the left, or add one to start wiring interactions.
</p>
<Button
variant="secondary"
size="sm"
class="mt-3 h-8 gap-1.5 text-[10px]"
onclick={addBehavior}
>
<Plus class="size-3.5" />
Add rule
</Button>
</div>
</div>
{/if}
{:else if selectedIds.length > 1}
<div class="flex shrink-0 items-center gap-1 border-b border-theme-800 p-2">
<span class="flex-1 text-[10px] font-semibold text-theme-100">
{selectedIds.length} layers selected
</span>
<Button
variant="secondary"
size="icon"
class="size-8"
title="Duplicate selected layers"
onclick={duplicateSelected}
>
<Copy class="size-3.5" />
</Button>
<Button
variant="destructive"
size="icon"
class="size-8"
title="Delete selected layers"
onclick={deleteSelected}
>
<Trash2 class="size-3.5" />
</Button>
</div>
<div class="min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
<ThemeSection
title="Align"
bordered={false}
headingClass="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"
contentClass="space-y-2 pt-2"
>
<div class="grid grid-cols-3 gap-1">
<Button
variant="secondary"
size="sm"
class="h-8 text-[10px]"
onclick={() => alignSelected('left')}>Left</Button
>
<Button
variant="secondary"
size="sm"
class="h-8 text-[10px]"
onclick={() => alignSelected('center')}>Center</Button
>
<Button
variant="secondary"
size="sm"
class="h-8 text-[10px]"
onclick={() => alignSelected('right')}>Right</Button
>
<Button
variant="secondary"
size="sm"
class="h-8 text-[10px]"
onclick={() => alignSelected('top')}>Top</Button
>
<Button
variant="secondary"
size="sm"
class="h-8 text-[10px]"
onclick={() => alignSelected('middle')}>Middle</Button
>
<Button
variant="secondary"
size="sm"
class="h-8 text-[10px]"
onclick={() => alignSelected('bottom')}>Bottom</Button
>
</div>
</ThemeSection>
<ThemeSection
title="Distribute"
headingClass="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"
contentClass="space-y-2 pt-2.5"
>
<div class="grid grid-cols-2 gap-1">
<Button
variant="secondary"
size="sm"
class="h-8 text-[10px]"
onclick={() => distributeSelected('horizontal')}>Horizontal</Button
>
<Button
variant="secondary"
size="sm"
class="h-8 text-[10px]"
onclick={() => distributeSelected('vertical')}>Vertical</Button
>
</div>
<p class="text-[10px] leading-relaxed text-muted-foreground">
Distribute needs at least three selected layers.
</p>
</ThemeSection>
<ThemeSection
title="Layers"
headingClass="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"
contentClass="space-y-2 pt-2.5"
>
<div class="grid grid-cols-2 gap-1">
<Button
variant="secondary"
size="sm"
class="h-8 text-[10px]"
onclick={() => toggleSelectedFlag('hidden')}>Hide / show</Button
>
<Button
variant="secondary"
size="sm"
class="h-8 text-[10px]"
onclick={() => toggleSelectedFlag('locked')}>Lock / unlock</Button
>
<Button
variant="secondary"
size="sm"
class="col-span-2 h-8 gap-1.5 text-[10px]"
onclick={groupSelection}
>
<Boxes class="size-3.5" />
Group selection
</Button>
</div>
<p class="text-[10px] leading-relaxed text-muted-foreground">
Use the alignment buttons to arrange a multi-layer selection. Press Escape to clear
it.
</p>
</ThemeSection>
</div>
{:else if selectedGroup}
<div class="flex shrink-0 items-center gap-1 border-b border-theme-800 p-2">
<span class="min-w-0 flex-1 truncate text-[10px] font-semibold text-theme-100">
{groupLabel(selectedGroup)}
</span>
<Button
variant="secondary"
size="sm"
class="h-8 px-2 text-[10px]"
onclick={() => ungroupGroup()}
>
Ungroup
</Button>
<Button
variant="secondary"
size="icon"
class="size-8"
title="Duplicate group"
aria-label="Duplicate group"
onclick={() => duplicateGroup()}
>
<CopyPlus class="size-3.5" />
</Button>
<Button
variant="destructive"
size="icon"
class="size-8"
title="Delete group and its layers"
aria-label="Delete group and its layers"
onclick={() => requestGroupDelete()}
>
<Trash2 class="size-3.5" />
</Button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
{#if selectedGroupIndex >= 0 && definition.groups}
<ThemeGroupInspector
bind:group={definition.groups[selectedGroupIndex]}
{behaviors}
{elements}
{groups}
variables={scriptVariables}
focusToken={logicFocusKey === `group:${selectedGroup.id}` ? logicFocusToken : 0}
onungroup={() => ungroupGroup()}
onpickmask={pickGroupMask}
onaddbehavior={createBehavior}
ondeleterule={deleteBehavior}
/>
{/if}
</div>
{:else if selectedElement && selectedIndex >= 0 && definition.elements}
<div class="flex shrink-0 items-center gap-1 border-b border-theme-800 p-2">
<Button
variant="secondary"
size="sm"
class="h-8 flex-1 px-2 text-[10px]"
onclick={duplicateSelected}
>
<Copy class="size-3.5" />
Duplicate
</Button>
<Button
variant="secondary"
size="icon"
class="size-8"
title="Bring forward"
onclick={() => moveSelected(1)}
disabled={selectedIndex === elements.length - 1}
>
<ChevronUp class="size-3.5" />
</Button>
<Button
variant="secondary"
size="icon"
class="size-8"
title="Send backward"
onclick={() => moveSelected(-1)}
disabled={selectedIndex === 0}
>
<ChevronDown class="size-3.5" />
</Button>
<Button
variant="destructive"
size="icon"
class="size-8"
title="Delete layer"
onclick={deleteSelected}
>
<Trash2 class="size-3.5" />
</Button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
<ThemeElementInspector
bind:element={definition.elements[selectedIndex]}
iconSet={definition.iconSet}
themeName={definition.name}
themeAuthor={definition.author}
themeVersion={definition.version}
{fontOptions}
{elements}
{behaviors}
{groups}
variables={scriptVariables}
focusToken={logicFocusKey === `element:${selectedElement.id}` ? logicFocusToken : 0}
onreplace={replaceSelectedAsset}
onpickmask={pickElementMask}
onpickrepeater={pickRepeaterImage}
onaddbehavior={createBehavior}
ondeleterule={deleteBehavior}
/>
</div>
{:else}
<div class="min-h-0 flex-1 overflow-y-auto p-4">
<div class="rounded-xl border border-theme-800 bg-black/20 p-4 text-center">
<div
class="mx-auto flex size-10 items-center justify-center rounded-full bg-primary/15 text-primary"
>
<MousePointer2 class="size-5" />
</div>
<p class="mt-3 text-xs font-semibold text-theme-100">Select a layer</p>
<p class="mt-1 text-[10px] leading-relaxed text-muted-foreground">
Click any layer on the canvas or in the layer list to edit its position, size and
style. Nothing is selected right now.
</p>
</div>
<div class="mt-4 grid grid-cols-2 gap-2">
<Button
variant="secondary"
class="h-auto flex-col gap-1 p-3 text-[10px]"
onclick={addText}
>
<Type class="size-4 text-primary" />
Text
</Button>
<Button
variant="secondary"
class="h-auto flex-col gap-1 p-3 text-[10px]"
onclick={() => addMedia('image')}
>
<ImageIcon class="size-4 text-primary" />
Image
</Button>
<Button
variant="secondary"
class="h-auto flex-col gap-1 p-3 text-[10px]"
onclick={() => addMedia('video')}
>
<Video class="size-4 text-primary" />
Video
</Button>
<Button
variant="secondary"
class="h-auto flex-col gap-1 p-3 text-[10px]"
onclick={addShape}
>
<Shapes class="size-4 text-primary" />
Shape
</Button>
<Button
variant="secondary"
class="h-auto flex-col gap-1 p-3 text-[10px]"
onclick={addSvelte}
>
<Component class="size-4 text-primary" />
Svelte
</Button>
<Button
variant="secondary"
class="h-auto flex-col gap-1 p-3 text-[10px]"
onclick={() => addLayer('data')}
>
<Type class="size-4 text-primary" />
Data
</Button>
<Button
variant="secondary"
class="h-auto flex-col gap-1 p-3 text-[10px]"
onclick={() => addLayer('icon')}
>
<Sparkles class="size-4 text-primary" />
Icon
</Button>
<Button
variant="secondary"
class="h-auto flex-col gap-1 p-3 text-[10px]"
onclick={() => addLayer('repeater')}
>
<Repeat class="size-4 text-primary" />
Repeater
</Button>
<Button
variant="secondary"
class="h-auto flex-col gap-1 p-3 text-[10px]"
onclick={() => addLayer('gauge')}
>
<Gauge class="size-4 text-primary" />
Gauge
</Button>
<Button
variant="secondary"
class="h-auto flex-col gap-1 p-3 text-[10px]"
onclick={() => addLayer('ticker')}
>
<Megaphone class="size-4 text-primary" />
Ticker
</Button>
<Button
variant="secondary"
class="h-auto flex-col gap-1 p-3 text-[10px]"
onclick={() => addLayer('button')}
>
<MousePointerClick class="size-4 text-primary" />
Button
</Button>
<Button
variant="secondary"
class="h-auto flex-col gap-1 p-3 text-[10px]"
onclick={() => addLayer('path')}
>
<Spline class="size-4 text-primary" />
Path
</Button>
<Button
variant="secondary"
class="h-auto flex-col gap-1 p-3 text-[10px]"
onclick={() => addLayer('particles')}
>
<Sparkles class="size-4 text-primary" />
Particles
</Button>
<Button
variant="secondary"
class="h-auto flex-col gap-1 p-3 text-[10px]"
onclick={() => addLayer('clock')}
>
<Clock class="size-4 text-primary" />
Clock
</Button>
<Button
variant="secondary"
class="h-auto flex-col gap-1 p-3 text-[10px]"
onclick={() => addLayer('countdown')}
>
<Timer class="size-4 text-primary" />
Countdown
</Button>
<Button
variant="secondary"
class="h-auto flex-col gap-1 p-3 text-[10px]"
onclick={addLottie}
>
<Film class="size-4 text-primary" />
Lottie
</Button>
<Button
variant="secondary"
class="h-auto flex-col gap-1 p-3 text-[10px]"
onclick={() => addLayer('widget', 'launch-button')}
>
<LayoutGrid class="size-4 text-primary" />
Launch Button
</Button>
<Button
variant="secondary"
class="h-auto flex-col gap-1 p-3 text-[10px]"
onclick={() => addLayer('widget', 'player-card')}
>
<LayoutGrid class="size-4 text-primary" />
Player Card
</Button>
<Button
variant="secondary"
class="h-auto flex-col gap-1 p-3 text-[10px]"
onclick={() => addLayer('widget', 'avatar')}
>
<CircleUser class="size-4 text-primary" />
Player Avatar
</Button>
<Button
variant="secondary"
class="h-auto flex-col gap-1 p-3 text-[10px]"
onclick={() => addLayer('widget', 'recent-activity')}
>
<LayoutGrid class="size-4 text-primary" />
Recent Activity
</Button>
</div>
<ThemeSection
title="Theme summary"
class="mt-4"
bordered={false}
contentClass="space-y-2 pt-2"
>
<div class="space-y-2 rounded-lg border border-theme-800 bg-black/20 p-3">
<div class="flex items-center justify-between text-[10px]">
<span class="text-muted-foreground">Background</span>
<span class="capitalize text-theme-100">{definition.background.type}</span>
</div>
<div class="flex items-center justify-between text-[10px]">
<span class="text-muted-foreground">Elements</span>
<span class="text-theme-100">{elements.length}</span>
</div>
<div class="flex items-center justify-between text-[10px]">
<span class="text-muted-foreground">Fonts</span>
<span class="text-theme-100">{(definition.fonts ?? []).length} custom</span>
</div>
<div class="flex items-center justify-between text-[10px]">
<span class="text-muted-foreground">Theme folder</span>
<span class="max-w-[150px] truncate font-mono text-theme-100">
{savedFolder ?? 'not saved yet'}
</span>
</div>
</div>
</ThemeSection>
<ThemeSection
title="Shortcuts"
class="mt-4"
bordered={false}
open={false}
contentClass="space-y-1 rounded-lg border border-theme-800 bg-black/20 p-3 pt-3 text-[10px] leading-relaxed text-muted-foreground"
>
<p><span class="font-mono text-theme-100">Cmd/Ctrl+Z</span> undo</p>
<p><span class="font-mono text-theme-100">Cmd/Ctrl+Shift+Z</span> redo</p>
<p><span class="font-mono text-theme-100">Cmd/Ctrl+S</span> save theme</p>
<p><span class="font-mono text-theme-100">Cmd/Ctrl+F</span> find layers and groups</p>
<p><span class="font-mono text-theme-100">Cmd/Ctrl + / -</span> zoom canvas</p>
<p><span class="font-mono text-theme-100">Cmd/Ctrl+0</span> fit canvas to window</p>
<p><span class="font-mono text-theme-100">Cmd/Ctrl+C / X / V</span> copy, cut, paste</p>
<p><span class="font-mono text-theme-100">Cmd/Ctrl+D</span> duplicate layer or group</p>
<p><span class="font-mono text-theme-100">Cmd/Ctrl+A</span> select all layers</p>
<p><span class="font-mono text-theme-100">Shift/Cmd+click</span> add to selection</p>
<p><span class="font-mono text-theme-100">Arrows</span> nudge selected layers</p>
<p>
<span class="font-mono text-theme-100">Alt/Option+↑ / ↓</span> jump to layers or groups
with logic
</p>
<p>
<span class="font-mono text-theme-100">Delete</span> remove selected layers or group
</p>
<Button
variant="ghost"
size="sm"
class="mt-2 h-6 w-full px-1 text-[10px] text-muted-foreground"
onclick={() => (shortcutsOpen = true)}
>
<Keyboard class="size-3" />
All shortcuts
</Button>
</ThemeSection>
<Button class="mt-4 w-full" onclick={() => saveTheme()} disabled={saving || loading}>
{#if saving}
<LoaderCircle class="animate-spin" />
{:else}
<Save />
{/if}
Save and apply
</Button>
</div>
{/if}
</aside>
</div>
<footer
class="flex h-7 shrink-0 items-center justify-between border-t border-theme-800 bg-black/40 px-4 text-[9px] text-muted-foreground"
>
<span class="flex items-center gap-1.5">
<span class="size-1.5 rounded-full bg-primary"></span>
{#if draftSavedAt}
Draft autosaved at {new Date(draftSavedAt).toLocaleTimeString()} · changes are local until you
save
{:else}
Changes are local until you save. WebM is recommended for video backgrounds and layers.
{/if}
</span>
<span>API 2.0 · advanced editor</span>
</footer>
{#if shortcutsOpen}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm"
role="presentation"
onclick={(event) => {
if (event.target === event.currentTarget) shortcutsOpen = false;
}}
>
<div
class="flex max-h-[80vh] w-full max-w-xl flex-col overflow-hidden rounded-xl border border-theme-700 bg-theme-950 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="shortcuts-title"
>
<div class="flex items-center gap-3 border-b border-theme-800 p-4">
<div
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/15 text-primary"
>
<Keyboard class="size-4" />
</div>
<div class="min-w-0 flex-1">
<h2 id="shortcuts-title" class="text-sm font-semibold">Keyboard shortcuts</h2>
<p class="mt-0.5 text-[10px] text-muted-foreground">
Everything in the editor is reachable from the keyboard.
</p>
</div>
<Button
variant="ghost"
size="icon"
class="size-7"
title="Close shortcuts"
aria-label="Close shortcuts"
onclick={() => (shortcutsOpen = false)}
>
<X class="size-3.5" />
</Button>
</div>
<div class="grid min-h-0 flex-1 gap-x-6 gap-y-0.5 overflow-y-auto p-4 sm:grid-cols-2">
{#each shortcutRows as shortcut (shortcut.label)}
<div
class="flex items-center justify-between gap-3 border-b border-theme-800/70 px-1 py-1.5 text-[11px] last:border-0"
>
<span class="min-w-0 truncate text-muted-foreground">{shortcut.label}</span>
<span class="flex shrink-0 items-center gap-1">
{#each shortcut.keys as key, index (key)}
{#if index > 0}<span class="text-theme-600">+</span>{/if}
<kbd
class="rounded border border-theme-700 bg-theme-900 px-1.5 py-0.5 font-mono text-[9px] text-theme-100"
>
{key}
</kbd>
{/each}
</span>
</div>
{/each}
</div>
<div class="border-t border-theme-800 px-4 py-2 text-[9px] text-muted-foreground">
Tip: press <kbd class="font-mono text-theme-100">?</kbd> anywhere in the editor to reopen this
list.
</div>
</div>
</div>
{/if}
{#if validationOpen && validationReport}
{@const errors = validationReport.issues.filter((issue) => issue.level === 'error')}
{@const warnings = validationReport.issues.filter((issue) => issue.level === 'warning')}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm"
role="presentation"
>
<div
class="flex max-h-[80vh] w-full max-w-lg flex-col overflow-hidden rounded-xl border border-theme-700 bg-theme-950 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="validation-title"
>
<div class="flex items-start gap-3 border-b border-theme-800 p-4">
<div
class="flex size-9 shrink-0 items-center justify-center rounded-lg {errors.length
? 'bg-destructive/15 text-destructive'
: 'bg-emerald-500/15 text-emerald-400'}"
>
{#if errors.length}
<AlertTriangle class="size-4" />
{:else}
<CheckCircle2 class="size-4" />
{/if}
</div>
<div class="min-w-0 flex-1">
<h2 id="validation-title" class="text-sm font-semibold">Theme check</h2>
<p class="mt-0.5 text-[10px] text-muted-foreground">
{validationReport.fileCount} referenced asset{validationReport.fileCount === 1
? ''
: 's'} · {formatBytes(validationReport.totalBytes)} estimated
</p>
</div>
<Button
variant="ghost"
size="icon"
class="size-7"
title="Close check"
onclick={() => {
validationOpen = false;
exportAfterValidation = false;
}}
>
<X class="size-3.5" />
</Button>
</div>
<div class="min-h-0 flex-1 space-y-2 overflow-y-auto p-4">
{#if validationReport.issues.length === 0}
<div class="rounded-lg border border-emerald-500/20 bg-emerald-500/10 p-3 text-xs">
Everything looks good. The theme is ready to export.
</div>
{:else}
{#each [...errors, ...warnings] as issue (issue.level + issue.message)}
<div
class="flex gap-2 rounded-md border px-2.5 py-2 text-[11px] leading-relaxed {issue.level ===
'error'
? 'border-destructive/25 bg-destructive/10 text-destructive'
: 'border-amber-500/20 bg-amber-500/10 text-amber-100'}"
>
{#if issue.level === 'error'}
<AlertTriangle class="mt-0.5 size-3.5 shrink-0" />
{:else}
<ShieldCheck class="mt-0.5 size-3.5 shrink-0" />
{/if}
<span>{issue.message}</span>
</div>
{/each}
{/if}
</div>
<div class="flex items-center justify-end gap-2 border-t border-theme-800 p-3">
<Button
variant="secondary"
size="sm"
onclick={() => {
validationOpen = false;
exportAfterValidation = false;
}}
>
Close
</Button>
{#if errors.length === 0 && exportAfterValidation}
<Button
size="sm"
onclick={() => exportCurrentTheme(true)}
disabled={exporting || saving}
>
{#if exporting}
<LoaderCircle class="animate-spin" />
{:else}
<Download />
{/if}
{warnings.length ? 'Export anyway' : 'Export theme'}
</Button>
{/if}
</div>
</div>
</div>
{/if}
{#if closeDialogOpen}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm"
role="presentation"
>
<div
class="w-full max-w-md rounded-xl border border-theme-700 bg-theme-950 p-5 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="close-title"
>
<h2 id="close-title" class="text-sm font-semibold">Save before closing?</h2>
<p class="mt-2 text-xs leading-relaxed text-muted-foreground">
This theme has unsaved changes. Save them, discard them, or keep editing.
</p>
<div class="mt-5 flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onclick={() => (closeDialogOpen = false)}
disabled={saving}
>
Keep editing
</Button>
<Button variant="destructive" size="sm" onclick={discardAndClose} disabled={saving}>
Discard
</Button>
<Button size="sm" onclick={saveAndClose} disabled={saving}>
{#if saving}
<LoaderCircle class="animate-spin" />
{:else}
<Save />
{/if}
Save and close
</Button>
</div>
</div>
</div>
{/if}
{#if deleteGroupTarget}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm"
role="presentation"
>
<div
class="w-full max-w-md rounded-xl border border-theme-700 bg-theme-950 p-5 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="delete-group-title"
>
<div class="flex items-start gap-3">
<div
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/15 text-destructive"
>
<Trash2 class="size-4" />
</div>
<div class="min-w-0 flex-1">
<h2 id="delete-group-title" class="text-sm font-semibold">
Delete “{groupLabel(deleteGroupTarget)}”?
</h2>
<p class="mt-2 text-xs leading-relaxed text-muted-foreground">
{#if deleteGroupLayerCount > 0}
<strong class="text-theme-100">{deleteGroupLayerLabel}</strong>
{#if deleteGroupNestedCount > 0}
and <strong class="text-theme-100">{deleteGroupNestedLabel}</strong>
{/if}
will be deleted with this group.
{:else if deleteGroupNestedCount > 0}
This group has no layers, but
<strong class="text-theme-100">{deleteGroupNestedLabel}</strong> will be deleted with
it.
{:else}
This group has no layers.
{/if}
</p>
<p class="mt-2 text-[10px] leading-relaxed text-muted-foreground/70">
You can undo this with Cmd/Ctrl+Z.
</p>
</div>
</div>
<div class="mt-5 flex justify-end gap-2">
<Button variant="ghost" size="sm" onclick={() => (deleteGroupId = undefined)}>
Cancel
</Button>
<Button variant="destructive" size="sm" onclick={confirmGroupDelete}>
<Trash2 class="size-3.5" />
Delete group
</Button>
</div>
</div>
</div>
{/if}
</div>