/** * Declarative theme API 2.0. * * Version 1 themes are JavaScript bundles that mount their own UI. Version 2 * themes describe their look with data instead, which lets the launcher render * them consistently and lets the built-in editor create them without shipping * any code. A v2 theme package is a zip containing `theme.json` and an * `assets/` folder. */ import { DEFAULT_THEME_ICON_SET, isThemeIconName, isThemeIconSet, type ThemeIconName, type ThemeIconSet, } from './themeIcons'; import { DEFAULT_THEME_SVELTE_SOURCE } from './themeSvelteDefaults'; export type ThemeV2BackgroundType = 'color' | 'gradient' | 'image' | 'video'; export type ThemeV2Background = | { type: 'color'; color: string; opacity?: number; } | { type: 'gradient'; from: string; to: string; angle?: number; opacity?: number; } | { type: 'image'; src: string; fit?: 'cover' | 'contain'; opacity?: number; blur?: number; } | { type: 'video'; src: string; fit?: 'cover' | 'contain'; opacity?: number; blur?: number; /** Defaults to true so autoplay works without user interaction. */ muted?: boolean; }; export type ThemeV2EffectType = 'none' | 'snow' | 'stars' | 'petals' | 'bubbles'; export type ThemeV2Effects = { type: ThemeV2EffectType; density?: number; speed?: number; color?: string; }; export type ThemeV2Overlay = { color?: string; opacity?: number; vignette?: number; }; export type ThemeV2Audio = { src: string; loop?: boolean; }; export type ThemeV2Font = { /** The family name used by elements and the typography settings. */ family: string; /** Font file relative to the theme's assets folder. */ src: string; weight?: string; style?: 'normal' | 'italic'; }; export type ThemeV2Typography = { /** Font used by the launcher's normal UI text. */ body?: string; /** Font used by headings such as the launcher title. */ heading?: string; /** * Font used by monospace details such as paths, versions and telemetry. * Omit it to keep the launcher's built-in Space Mono stack. */ mono?: string; }; /** CSS-like filters shared by groups and individual elements. */ export type ThemeV2Filters = { /** Pixels, 0-40. */ blur?: number; /** Multiplier, 1 is unchanged. */ brightness?: number; contrast?: number; saturate?: number; /** Degrees, -360 to 360. */ hueRotate?: number; /** 0-1. */ grayscale?: number; sepia?: number; invert?: number; }; /** 2D CSS transform fields shared by layers and groups. */ export type ThemeV2Transform = { /** Degrees around the layer or group bounds centre. */ rotation?: number; /** Legacy uniform scale fallback; `scaleX` and `scaleY` take precedence. */ scale?: number; /** Horizontal scale around the bounds centre; 1 is unchanged. */ scaleX?: number; /** Vertical scale around the bounds centre; 1 is unchanged. */ scaleY?: number; /** Horizontal skew in degrees; 0 is unchanged. */ skewX?: number; /** Vertical skew in degrees; 0 is unchanged. */ skewY?: number; flipX?: boolean; flipY?: boolean; }; /** A group in the layer list. Groups may be nested through `parentId`. */ export type ThemeV2Group = ThemeV2Transform & { id: string; name?: string; parentId?: string; hidden?: boolean; locked?: boolean; /** Editor-only fold state; stored so the group stays folded on reopen. */ collapsed?: boolean; opacity?: number; /** Visual translation applied to the whole group in stage pixels. */ offsetX?: number; offsetY?: number; filters?: ThemeV2Filters; /** Clips every layer in the group to a shape or image alpha. */ mask?: ThemeV2Mask; /** Space-separated CSS classes inherited by every layer in the group. */ cssClasses?: string; }; /** A shape or image used to clip a layer or group. */ export type ThemeV2MaskShape = 'rectangle' | 'ellipse' | 'polygon' | 'star' | 'image'; export type ThemeV2Mask = { shape: ThemeV2MaskShape; /** Corner radius for rectangle masks. */ radius?: number; /** Sides for polygon masks and points for star masks. */ sides?: number; /** Inner radius of a star mask, as a fraction of the outer radius. */ innerRadius?: number; /** Asset used for image masks; its alpha channel clips the layer. */ src?: string; fit?: 'cover' | 'contain'; }; export type ThemeV2ElementAlign = 'left' | 'center' | 'right'; export type ThemeV2ElementVerticalAlign = 'top' | 'center' | 'bottom'; export type ThemeV2ElementBase = ThemeV2Transform & { id: string; /** Optional editor-facing name; does not affect rendering. */ name?: string; /** Stage coordinates: the launcher canvas is 1000 x 650. */ x: number; y: number; width: number; height: number; opacity?: number; /** Hidden layers stay in the package but are not rendered. */ hidden?: boolean; /** Locked layers can be selected in the layer list but not dragged on canvas. */ locked?: boolean; /** Opt in to clicks and hovers from theme logic. */ interactive?: boolean; /** Group this layer belongs to. */ groupId?: string; filters?: ThemeV2Filters; /** Clips this layer to a shape or image alpha. */ mask?: ThemeV2Mask; /** Space-separated CSS classes added to the rendered layer wrapper. */ cssClasses?: string; }; export type ThemeV2TextDecoration = | 'none' | 'underline' | 'line-through' | 'underline line-through'; export type ThemeV2TextTransform = 'none' | 'uppercase' | 'lowercase' | 'capitalize'; export type ThemeV2TextFillMode = 'solid' | 'gradient'; export type ThemeV2TextStyle = { fontFamily?: string; fontSize?: number; fontWeight?: number; fontStyle?: 'normal' | 'italic'; color?: string; align?: ThemeV2ElementAlign; verticalAlign?: ThemeV2ElementVerticalAlign; lineHeight?: number; letterSpacing?: number; textDecoration?: ThemeV2TextDecoration; textTransform?: ThemeV2TextTransform; fillMode?: ThemeV2TextFillMode; gradientFrom?: string; gradientTo?: string; /** CSS gradient angle in degrees; 90 is left to right. */ gradientAngle?: number; /** Text stroke width in pixels; 0 disables the outline. */ outlineWidth?: number; outlineColor?: string; shadowColor?: string; shadowBlur?: number; shadowOffsetX?: number; shadowOffsetY?: number; }; export type ThemeV2TextElement = ThemeV2ElementBase & ThemeV2TextStyle & { type: 'text'; text: string; }; /** Read-only launcher values a data layer can display. */ export const THEME_DATA_FIELDS = [ { value: 'user.name', label: 'Player name', group: 'Player' }, { value: 'user.country', label: 'Country code', group: 'Player' }, { value: 'user.pp', label: 'Performance points', group: 'Player' }, { value: 'user.rank', label: 'Global rank', group: 'Player' }, { value: 'user.plays', label: 'Play count', group: 'Player' }, { value: 'user.accuracy', label: 'Accuracy', group: 'Player' }, { value: 'user.playtime', label: 'Playtime', group: 'Player' }, { value: 'launcher.version', label: 'Launcher version', group: 'Launcher' }, { value: 'osu.build', label: 'osu! build', group: 'osu!' }, { value: 'osu.stream', label: 'osu! stream', group: 'osu!' }, { value: 'server.ping', label: 'Server ping', group: 'Launcher' }, { value: 'server.friends', label: 'Online friends', group: 'Launcher' }, { value: 'theme.name', label: 'Theme name', group: 'Theme' }, { value: 'theme.author', label: 'Theme author', group: 'Theme' }, { value: 'theme.version', label: 'Theme version', group: 'Theme' }, { value: 'screen', label: 'Current screen', group: 'Launcher' }, { value: 'clock.time', label: 'Clock (24h)', group: 'Clock' }, { value: 'clock.time12', label: 'Clock (12h)', group: 'Clock' }, { value: 'clock.date', label: 'Date', group: 'Clock' }, ] as const; export type ThemeV2DataField = (typeof THEME_DATA_FIELDS)[number]['value']; export type ThemeV2DataMode = 'osu' | 'taiko' | 'catch' | 'mania'; export type ThemeV2DataElement = ThemeV2ElementBase & ThemeV2TextStyle & { type: 'data'; /** Built-in launcher value, for example `user.pp` or `clock.time`. */ field: ThemeV2DataField; /** Which gamemode the player fields read from. */ mode?: ThemeV2DataMode; /** Template for the value; `{value}` is replaced. Defaults to `{value}`. */ text: string; }; export type ThemeV2ImageElement = ThemeV2ElementBase & { type: 'image'; src: string; fit?: 'cover' | 'contain'; radius?: number; /** Repaints the image's alpha silhouette with this color; empty keeps it. */ tint?: string; }; export type ThemeV2VideoElement = ThemeV2ElementBase & { type: 'video'; src: string; fit?: 'cover' | 'contain'; radius?: number; /** Defaults to true so autoplay works without user interaction. */ muted?: boolean; }; export type ThemeV2ShapeKind = 'rectangle' | 'ellipse' | 'line' | 'polygon' | 'star' | 'arc'; export type ThemeV2ShapeFillMode = 'solid' | 'gradient'; export type ThemeV2ShapeElement = ThemeV2ElementBase & { type: 'shape'; shape: ThemeV2ShapeKind; fill?: string; fillMode?: 'solid' | 'gradient'; gradientFrom?: string; gradientTo?: string; gradientAngle?: number; radius?: number; /** Top-left, top-right, bottom-right, bottom-left radii. */ cornerRadii?: [number, number, number, number]; borderWidth?: number; borderColor?: string; borderStyle?: 'solid' | 'dashed' | 'dotted'; /** Sides for polygon shapes and points for star shapes. */ points?: number; /** Star inner radius, as a fraction of the outer radius. */ innerRadius?: number; /** Frosted-glass blur behind the shape, in pixels. */ backdropBlur?: number; /** Arc start angle in degrees; 0 points up and angles run clockwise. */ startAngle?: number; /** Arc end angle in degrees; wraps past 360 when smaller than `startAngle`. */ endAngle?: number; /** Arc stroke width in stage pixels. */ thickness?: number; /** Whether arc ends use round caps. */ rounded?: boolean; }; export type ThemeV2PathElement = ThemeV2ElementBase & { type: 'path'; /** SVG path data authored in a 0–100 box, stretched to the layer bounds. */ path: string; fill?: string; fillMode?: ThemeV2ShapeFillMode; gradientFrom?: string; gradientTo?: string; gradientAngle?: number; /** Outline color; an empty value disables the outline. */ stroke?: string; /** Outline width in stage pixels. */ strokeWidth?: number; strokeStyle?: 'solid' | 'dashed' | 'dotted'; strokeLinecap?: 'butt' | 'round' | 'square'; }; export type ThemeV2ParticlePreset = | 'sparkles' | 'snow' | 'petals' | 'bubbles' | 'embers' | 'confetti'; export const THEME_PARTICLE_PRESETS = [ { value: 'sparkles', label: 'Sparkles' }, { value: 'snow', label: 'Snow' }, { value: 'petals', label: 'Petals' }, { value: 'bubbles', label: 'Bubbles' }, { value: 'embers', label: 'Embers' }, { value: 'confetti', label: 'Confetti' }, ] as const satisfies readonly { value: ThemeV2ParticlePreset; label: string }[]; export type ThemeV2ParticlesElement = ThemeV2ElementBase & { type: 'particles'; preset: ThemeV2ParticlePreset; /** Particle count, 4–120. */ density?: number; /** Animation speed multiplier, 0.1–3. */ speed?: number; /** Particle size in stage pixels, 1–24. */ size?: number; color?: string; /** Secondary color; presets blend between both. */ color2?: string; /** Horizontal drift, -1 (left) to 1 (right). */ wind?: number; /** Fade each particle towards the layer edges. */ fade?: boolean; }; export type ThemeV2ClockElement = ThemeV2ElementBase & { type: 'clock'; mode?: 'digital' | 'analog'; /** Digital clock uses 12-hour time. Analog clocks always use the local hour. */ hour12?: boolean; showSeconds?: boolean; /** Digital clock adds the local weekday and date below the time. */ showDate?: boolean; /** Optional caption shown above the time. */ label?: string; color?: string; /** Second hand, dial accents and the date text. */ accentColor?: string; fontFamily?: string; fontSize?: number; fontWeight?: number; align?: ThemeV2ElementAlign; }; export type ThemeV2CountdownElement = ThemeV2ElementBase & { type: 'countdown'; /** ISO date-time the layer counts down to. */ target: string; /** Template with `{days}`, `{hours}`, `{minutes}` and `{seconds}`. */ text: string; /** Shown once the target has passed. */ finishedText?: string; fontFamily?: string; fontSize?: number; fontWeight?: number; color?: string; align?: ThemeV2ElementAlign; }; export type ThemeV2IconElement = ThemeV2ElementBase & { type: 'icon'; icon: ThemeIconName; color?: string; /** Icon size as a percentage of the layer's shortest side. */ scale?: number; }; export type ThemeV2RepeaterElement = ThemeV2ElementBase & { type: 'repeater'; source: 'image' | 'icon'; src?: string; icon?: ThemeIconName; tileWidth?: number; tileHeight?: number; gapX?: number; gapY?: number; /** Tile counts; 0 or omitted fills the layer bounds. */ columns?: number; rows?: number; radius?: number; color?: string; }; export type ThemeV2GaugeElement = ThemeV2ElementBase & { type: 'gauge'; mode?: 'bar' | 'ring'; /** Fallback value from 0 to 1 when no variable is bound. */ value?: number; /** Theme variable whose 0-1 numeric value drives the gauge. */ variable?: string; trackColor?: string; fillColor?: string; thickness?: number; rounded?: boolean; /** Template shown in the middle; `{percent}`, `{value}` and `{variable}` work. */ label?: string; labelColor?: string; fontSize?: number; fontFamily?: string; }; export type ThemeV2TickerElement = ThemeV2ElementBase & { type: 'ticker'; text: string; fontFamily?: string; fontSize?: number; fontWeight?: number; color?: string; background?: string; /** Seconds for one full loop. */ speed?: number; gap?: number; textTransform?: ThemeV2TextTransform; }; export type ThemeV2LottieElement = ThemeV2ElementBase & { type: 'lottie'; /** Lottie JSON file relative to the theme's assets folder. */ src: string; loop?: boolean; autoplay?: boolean; speed?: number; }; export type ThemeV2ButtonAction = | { type: 'navigate'; screen: ThemeV2ScriptScreen } | { type: 'launch' } | { type: 'url'; url: string }; export type ThemeV2ButtonElement = ThemeV2ElementBase & { type: 'button'; label: string; icon?: ThemeIconName; action: ThemeV2ButtonAction; background?: string; color?: string; hoverBackground?: string; borderColor?: string; borderWidth?: number; radius?: number; fontSize?: number; fontWeight?: number; fontFamily?: string; }; /** * A self-contained Svelte 5 component authored in the theme editor. The source * is compiled in the WebView and mounted inside the layer bounds. Because this * executes theme JavaScript, API 2.0 themes with a Svelte layer require the * same explicit trust confirmation as API 1.0 themes. */ export type ThemeV2SvelteElement = ThemeV2ElementBase & { type: 'svelte'; /** Plain-JavaScript Svelte 5 component source (no TypeScript preprocessor). */ source: string; }; export type ThemeV2Element = | ThemeV2TextElement | ThemeV2DataElement | ThemeV2ImageElement | ThemeV2VideoElement | ThemeV2ShapeElement | ThemeV2IconElement | ThemeV2RepeaterElement | ThemeV2GaugeElement | ThemeV2TickerElement | ThemeV2LottieElement | ThemeV2ButtonElement | ThemeV2PathElement | ThemeV2ParticlesElement | ThemeV2ClockElement | ThemeV2CountdownElement | ThemeV2SvelteElement; export type ThemeV2ScriptScreen = 'home' | 'themes' | 'settings' | 'login'; export type ThemeV2ScriptValue = string | number | boolean; export type ThemeV2BehaviorOperator = | 'equals' | 'notEquals' | 'greaterThan' | 'lessThan' | 'greaterOrEqual' | 'lessOrEqual'; export type ThemeV2BehaviorTrigger = | { type: 'load' } | { type: 'screen'; screen: ThemeV2ScriptScreen } | { type: 'click' | 'hover' | 'leave'; elementId?: string } | { type: 'timer'; interval: number }; /** Attach a rule to a layer or group so it can be edited from that inspector. */ export type ThemeV2BehaviorTarget = { type: 'element' | 'group'; id: string }; export type ThemeV2BehaviorCondition = | { type: 'variable'; variable: string; operator: ThemeV2BehaviorOperator; value: ThemeV2ScriptValue; } | { type: 'visible'; elementId: string; value: boolean } | { type: 'groupVisible'; groupId: string; value: boolean } | { type: 'screen'; screen: ThemeV2ScriptScreen } | { type: 'chance'; percent: number }; export type ThemeV2BehaviorAction = | { type: 'setVariable'; variable: string; value: ThemeV2ScriptValue } | { type: 'changeVariable'; variable: string; amount: number } | { type: 'toggleVariable'; variable: string } | { type: 'showElement' | 'hideElement' | 'toggleElement'; elementId: string } | { type: 'showGroup' | 'hideGroup' | 'toggleGroup'; groupId: string } | { type: 'navigate'; screen: ThemeV2ScriptScreen } | { type: 'launch' } | { type: 'openUrl'; url: string }; /** A Scratch-style WHEN / IF / THEN rule. */ export type ThemeV2Behavior = { id: string; name?: string; enabled?: boolean; /** Fire at most once per theme session. */ once?: boolean; /** Optional layer or group this rule belongs to. */ target?: ThemeV2BehaviorTarget; trigger: ThemeV2BehaviorTrigger; conditions?: ThemeV2BehaviorCondition[]; /** Whether every condition or at least one must match. */ conditionMode?: 'all' | 'any'; actions: ThemeV2BehaviorAction[]; /** * Optional advanced script body written in JavaScript syntax. When defined, * it replaces `actions` for this rule. The script is compiled into a safe * instruction set and executed without eval(). */ script?: string; }; export type ThemeV2Definition = { name: string; version: string; apiVersion: '2.0'; author: string; description?: string; /** Marks the theme as adult content in the theme manager. */ nsfw?: boolean; /** Optional preview image, relative to the theme's assets folder. */ preview?: string; background: ThemeV2Background; overlay: ThemeV2Overlay; effects: ThemeV2Effects; audio?: ThemeV2Audio; /** Theme-wide fonts and freeform layers rendered over the launcher UI. */ typography: ThemeV2Typography; /** Built-in icon family used by launcher chrome. Defaults to Lucide. */ iconSet?: ThemeIconSet; fonts: ThemeV2Font[]; elements: ThemeV2Element[]; /** Optional layer groups. Elements reference them through `groupId`. */ groups: ThemeV2Group[]; /** Declarative interaction rules; API 2.0 themes never run arbitrary JS. */ behaviors?: ThemeV2Behavior[]; /** * Starting values for `{name}` text placeholders and rule variables. Any * variable without an entry starts at `0`. */ variables?: Record; /** Used for focus rings, active states and effect accents. */ accentColor?: string; /** Optional CSS appended after the launcher's own styles. */ css?: string; }; export type ThemeV2Asset = { name: string; source: string; }; const clamp = (value: unknown, fallback: number, min: number, max: number) => { const parsed = typeof value === 'number' ? value : Number(value); if (!Number.isFinite(parsed)) return fallback; return Math.min(max, Math.max(min, parsed)); }; const text = (value: unknown, fallback = '') => typeof value === 'string' && value.trim().length > 0 ? value.trim() : fallback; const color = (value: unknown, fallback: string) => { const result = text(value, fallback); return /^#(?:[0-9a-f]{3,8})$/i.test(result) ? result : fallback; }; const oneOf = (value: unknown, options: readonly T[], fallback: T): T => options.includes(value as T) ? (value as T) : fallback; const CSS_CLASS_TOKEN = /^-?[_a-zA-Z][_a-zA-Z0-9-]*$/; /** * Split a space-separated class list into safe, unique tokens. Theme CSS is * injected globally, so this keeps selectors predictable and prevents a layer * from carrying malformed attribute content. */ export const themeClassTokens = (value: unknown): string[] => { if (typeof value !== 'string') return []; const seen = new Set(); const tokens: string[] = []; for (const token of value.trim().split(/\s+/)) { if (!token || token.length > 64 || !CSS_CLASS_TOKEN.test(token) || seen.has(token)) continue; seen.add(token); tokens.push(token); if (tokens.length === 20) break; } return tokens; }; /** Sanitized class attribute value for a layer or group. */ export const themeClassNames = (value: unknown): string => themeClassTokens(value).join(' '); const clampInteger = (value: unknown, fallback: number, min: number, max: number) => Math.round(clamp(value, fallback, min, max)); const fontFamilyCss = (family: string) => `"${family.replace(/["\\]/g, '\\$&')}"`; const fontFormat = (src: string) => { const extension = src.split('.').pop()?.toLowerCase(); if (extension === 'woff2') return 'woff2'; if (extension === 'woff') return 'woff'; if (extension === 'otf') return 'opentype'; if (extension === 'ttf') return 'truetype'; return 'woff2'; }; const normalizeFonts = (raw: unknown): ThemeV2Font[] => { if (!Array.isArray(raw)) return []; const seen = new Set(); const fonts: ThemeV2Font[] = []; for (const candidate of raw.slice(0, 48)) { if (!candidate || typeof candidate !== 'object') continue; const source = candidate as Record; const family = text(source.family).slice(0, 80); const src = text(source.src).slice(0, 260); const weight = text(typeof source.weight === 'number' ? String(source.weight) : source.weight, '400') .replace(/[^0-9,\s]/g, '') .slice(0, 40) || '400'; const style = source.style === 'italic' ? 'italic' : 'normal'; const key = `${family}|${weight}|${style}|${src}`; if (!family || !src || seen.has(key)) continue; seen.add(key); fonts.push({ family, src, weight, style }); } return fonts; }; const normalizeFilters = (raw: unknown): ThemeV2Filters | undefined => { if (!raw || typeof raw !== 'object') return undefined; const source = raw as Record; const filters: ThemeV2Filters = {}; let hasValue = false; const add = ( key: keyof ThemeV2Filters, min: number, max: number, fallback: number, present: boolean ) => { if (!present) return; filters[key] = clamp(source[key], fallback, min, max); hasValue = true; }; add('blur', 0, 40, 0, source.blur !== undefined); add('brightness', 0, 3, 1, source.brightness !== undefined); add('contrast', 0, 3, 1, source.contrast !== undefined); add('saturate', 0, 4, 1, source.saturate !== undefined); add('hueRotate', -360, 360, 0, source.hueRotate !== undefined); add('grayscale', 0, 1, 0, source.grayscale !== undefined); add('sepia', 0, 1, 0, source.sepia !== undefined); add('invert', 0, 1, 0, source.invert !== undefined); return hasValue ? filters : undefined; }; const normalizeTransform = (source: Record): ThemeV2Transform => { const scale = clamp(source.scale, 1, 0.05, 10); return { rotation: clamp(source.rotation, 0, -360, 360), scale, scaleX: clamp(source.scaleX, scale, 0.05, 10), scaleY: clamp(source.scaleY, scale, 0.05, 10), skewX: clamp(source.skewX, 0, -89, 89), skewY: clamp(source.skewY, 0, -89, 89), flipX: source.flipX === true, flipY: source.flipY === true, }; }; const normalizeGroups = (raw: unknown): ThemeV2Group[] => { if (!Array.isArray(raw)) return []; const seen = new Set(); const groups: ThemeV2Group[] = []; raw.slice(0, 50).forEach((candidate, index) => { if (!candidate || typeof candidate !== 'object') return; const source = candidate as Record; let id = text(source.id, `group_${index + 1}`).slice(0, 64); let suffix = 2; while (seen.has(id)) id = `${id}_${suffix++}`; seen.add(id); const filters = normalizeFilters(source.filters); const cssClasses = themeClassNames(source.cssClasses); const mask = normalizeMask(source.mask); const group: ThemeV2Group = { id, name: typeof source.name === 'string' ? source.name.slice(0, 80) : undefined, parentId: typeof source.parentId === 'string' ? source.parentId.slice(0, 64) : undefined, hidden: source.hidden === true, locked: source.locked === true, collapsed: source.collapsed === true, opacity: clamp(source.opacity, 1, 0, 1), offsetX: clamp(source.offsetX, 0, -2000, 2000), offsetY: clamp(source.offsetY, 0, -2000, 2000), ...normalizeTransform(source), ...(filters ? { filters } : {}), ...(mask ? { mask } : {}), ...(cssClasses ? { cssClasses } : {}), }; groups.push(group); }); // Drop missing/self parents and break accidental cycles so rendering and // the layer tree can safely walk upwards. const ids = new Set(groups.map((group) => group.id)); for (const group of groups) { if (!group.parentId || !ids.has(group.parentId) || group.parentId === group.id) { group.parentId = undefined; } } for (const group of groups) { const seenParents = new Set([group.id]); let parent = group.parentId ? groups.find((candidate) => candidate.id === group.parentId) : undefined; while (parent) { if (seenParents.has(parent.id)) { group.parentId = undefined; break; } seenParents.add(parent.id); parent = parent.parentId ? groups.find((candidate) => candidate.id === parent?.parentId) : undefined; } } return groups; }; const normalizeMask = (raw: unknown): ThemeV2Mask | undefined => { if (!raw || typeof raw !== 'object') return undefined; const source = raw as Record; const shape = oneOf( source.shape, ['rectangle', 'ellipse', 'polygon', 'star', 'image'] as const, 'rectangle' ); const src = text(source.src).slice(0, 500); if (shape === 'image' && !src) return undefined; return { shape, radius: clamp(source.radius, 0, 0, 999), sides: clampInteger(source.sides, shape === 'star' ? 5 : 6, 3, 24), innerRadius: clamp(source.innerRadius, 0.5, 0.1, 0.95), ...(src ? { src } : {}), fit: source.fit === 'contain' ? 'contain' : 'cover', }; }; const DATA_FIELD_VALUES = THEME_DATA_FIELDS.map((field) => field.value); const normalizeTextStyle = (source: Record): ThemeV2TextStyle => ({ fontFamily: text(source.fontFamily, 'Sora Variable'), fontSize: clamp(source.fontSize, 28, 6, 240), fontWeight: clampInteger(source.fontWeight, 600, 100, 900), fontStyle: source.fontStyle === 'italic' ? 'italic' : 'normal', color: color(source.color, '#ffffff'), align: oneOf(source.align, ['left', 'center', 'right'] as const, 'left'), verticalAlign: oneOf(source.verticalAlign, ['top', 'center', 'bottom'] as const, 'top'), lineHeight: clamp(source.lineHeight, 1.2, 0.7, 3), letterSpacing: clamp(source.letterSpacing, 0, -20, 40), textDecoration: oneOf( source.textDecoration, ['none', 'underline', 'line-through', 'underline line-through'] as const, 'none' ), textTransform: oneOf( source.textTransform, ['none', 'uppercase', 'lowercase', 'capitalize'] as const, 'none' ), fillMode: source.fillMode === 'gradient' ? 'gradient' : 'solid', gradientFrom: color(source.gradientFrom, '#ff006e'), gradientTo: color(source.gradientTo, '#7c3aed'), gradientAngle: clamp(source.gradientAngle, 90, 0, 360), outlineWidth: clamp(source.outlineWidth, 0, 0, 20), outlineColor: color(source.outlineColor, '#000000'), shadowColor: color(source.shadowColor, '#000000'), shadowBlur: clamp(source.shadowBlur, 0, 0, 40), shadowOffsetX: clamp(source.shadowOffsetX, 0, -40, 40), shadowOffsetY: clamp(source.shadowOffsetY, 0, -40, 40), }); const normalizeButtonAction = (raw: unknown): ThemeV2ButtonAction => { if (!raw || typeof raw !== 'object') return { type: 'navigate', screen: 'home' }; const source = raw as Record; const type = oneOf(source.type, ['navigate', 'launch', 'url'] as const, 'navigate'); if (type === 'launch') return { type: 'launch' }; if (type === 'url') { return { type: 'url', url: text(source.url).slice(0, 2000) }; } return { type: 'navigate', screen: oneOf(source.screen, SCRIPT_SCREENS, 'home') }; }; const PATH_DATA_PATTERN = /^[MmZzLlHhVvCcSsQqTtAa0-9eE.,+\-\s]*$/; const normalizePathData = (value: unknown) => { const raw = text(value).slice(0, 4000); return raw && PATH_DATA_PATTERN.test(raw) ? raw : ''; }; const normalizeElement = (raw: unknown, index: number): ThemeV2Element | undefined => { if (!raw || typeof raw !== 'object') return undefined; const source = raw as Record; const type = oneOf( source.type, [ 'text', 'data', 'image', 'video', 'shape', 'icon', 'repeater', 'gauge', 'ticker', 'lottie', 'button', 'path', 'particles', 'clock', 'countdown', 'svelte', ] as const, 'text' ); const cssClasses = themeClassNames(source.cssClasses); const base = { id: text(source.id, `element_${index + 1}`).slice(0, 64), name: typeof source.name === 'string' ? source.name.slice(0, 80) : undefined, x: clamp(source.x, 0, 0, 1000), y: clamp(source.y, 0, 0, 650), width: clamp(source.width, 320, 24, 1000), height: clamp(source.height, 120, 24, 650), ...normalizeTransform(source), opacity: clamp(source.opacity, 1, 0, 1), hidden: source.hidden === true, locked: source.locked === true, interactive: source.interactive === true, groupId: typeof source.groupId === 'string' ? source.groupId.slice(0, 64) : undefined, filters: normalizeFilters(source.filters), mask: normalizeMask(source.mask), ...(cssClasses ? { cssClasses } : {}), }; if (type === 'text') { return { ...base, type, text: typeof source.text === 'string' ? source.text.slice(0, 2000) : 'Your text', ...normalizeTextStyle(source), }; } if (type === 'data') { return { ...base, type, field: oneOf(source.field, DATA_FIELD_VALUES, 'user.name' as ThemeV2DataField), mode: oneOf(source.mode, ['osu', 'taiko', 'catch', 'mania'] as const, 'osu'), text: typeof source.text === 'string' ? source.text.slice(0, 400) : '{value}', ...normalizeTextStyle(source), }; } if (type === 'shape') { const cornerRadii = Array.isArray(source.cornerRadii) ? source.cornerRadii.slice(0, 4) : []; const radius = clamp(source.radius, 16, 0, 999); return { ...base, type, shape: oneOf( source.shape, ['rectangle', 'ellipse', 'line', 'polygon', 'star', 'arc'] as const, 'rectangle' ), fill: color(source.fill, '#ff006e'), fillMode: source.fillMode === 'gradient' ? 'gradient' : 'solid', gradientFrom: color(source.gradientFrom, '#ff006e'), gradientTo: color(source.gradientTo, '#7c3aed'), gradientAngle: clamp(source.gradientAngle, 135, 0, 360), radius, ...(cornerRadii.length > 0 ? { cornerRadii: [ clamp(cornerRadii[0], radius, 0, 999), clamp(cornerRadii[1], radius, 0, 999), clamp(cornerRadii[2], radius, 0, 999), clamp(cornerRadii[3], radius, 0, 999), ] as [number, number, number, number], } : {}), borderWidth: clamp(source.borderWidth, 0, 0, 40), borderColor: color(source.borderColor, '#ffffff'), borderStyle: oneOf(source.borderStyle, ['solid', 'dashed', 'dotted'] as const, 'solid'), points: clampInteger(source.points, 6, 3, 24), innerRadius: clamp(source.innerRadius, 0.5, 0.1, 0.95), backdropBlur: clamp(source.backdropBlur, 0, 0, 60), startAngle: clamp(source.startAngle, 0, -360, 360), endAngle: clamp(source.endAngle, 270, -360, 720), thickness: clamp(source.thickness, 12, 1, 120), rounded: source.rounded !== false, }; } if (type === 'icon') { return { ...base, type, icon: isThemeIconName(source.icon) ? source.icon : 'brush', color: color(source.color, '#ffffff'), scale: clamp(source.scale, 100, 10, 100), }; } if (type === 'repeater') { return { ...base, type, source: oneOf(source.source, ['image', 'icon'] as const, 'icon'), ...(text(source.src) ? { src: text(source.src).slice(0, 500) } : {}), icon: isThemeIconName(source.icon) ? source.icon : 'music', tileWidth: clamp(source.tileWidth, 28, 4, 400), tileHeight: clamp(source.tileHeight, 28, 4, 400), gapX: clamp(source.gapX, 8, 0, 200), gapY: clamp(source.gapY, 8, 0, 200), columns: clampInteger(source.columns, 0, 0, 60), rows: clampInteger(source.rows, 0, 0, 60), radius: clamp(source.radius, 0, 0, 999), color: color(source.color, '#ffffff'), }; } if (type === 'gauge') { return { ...base, type, mode: oneOf(source.mode, ['bar', 'ring'] as const, 'bar'), value: clamp(source.value, 0.5, 0, 1), ...(text(source.variable) ? { variable: text(source.variable).slice(0, 64) } : {}), trackColor: color(source.trackColor, '#000000'), fillColor: color(source.fillColor, '#ff006e'), thickness: clamp(source.thickness, 12, 2, 80), rounded: source.rounded !== false, label: typeof source.label === 'string' ? source.label.slice(0, 200) : '', labelColor: color(source.labelColor, '#ffffff'), fontSize: clamp(source.fontSize, 20, 6, 200), fontFamily: text(source.fontFamily, 'Sora Variable'), }; } if (type === 'ticker') { return { ...base, type, text: typeof source.text === 'string' ? source.text.slice(0, 500) : 'Now playing', fontFamily: text(source.fontFamily, 'Sora Variable'), fontSize: clamp(source.fontSize, 24, 6, 200), fontWeight: clampInteger(source.fontWeight, 600, 100, 900), color: color(source.color, '#ffffff'), background: /^#(?:[0-9a-f]{3,8})$/i.test(text(source.background)) ? text(source.background) : '', speed: clamp(source.speed, 18, 2, 240), gap: clamp(source.gap, 48, 0, 400), textTransform: oneOf( source.textTransform, ['none', 'uppercase', 'lowercase', 'capitalize'] as const, 'none' ), }; } if (type === 'lottie') { const src = text(source.src).slice(0, 500); if (!src) return undefined; return { ...base, type, src, loop: source.loop !== false, autoplay: source.autoplay !== false, speed: clamp(source.speed, 1, 0.1, 4), }; } if (type === 'button') { return { ...base, type, label: typeof source.label === 'string' ? source.label.slice(0, 80) : 'Click me', ...(isThemeIconName(source.icon) ? { icon: source.icon } : {}), action: normalizeButtonAction(source.action), background: color(source.background, '#ff006e'), color: color(source.color, '#ffffff'), hoverBackground: color(source.hoverBackground, '#ff4d94'), borderColor: color(source.borderColor, '#ffffff'), borderWidth: clamp(source.borderWidth, 0, 0, 20), radius: clamp(source.radius, 10, 0, 999), fontSize: clamp(source.fontSize, 16, 6, 120), fontWeight: clampInteger(source.fontWeight, 600, 100, 900), fontFamily: text(source.fontFamily, 'Sora Variable'), }; } if (type === 'path') { return { ...base, type, path: normalizePathData(source.path) || 'M 5 95 L 50 5 L 95 95 Z', fill: color(source.fill, '#ff006e'), fillMode: source.fillMode === 'gradient' ? 'gradient' : 'solid', gradientFrom: color(source.gradientFrom, '#ff006e'), gradientTo: color(source.gradientTo, '#7c3aed'), gradientAngle: clamp(source.gradientAngle, 135, 0, 360), stroke: text(source.stroke) ? color(source.stroke, '#ffffff') : '', strokeWidth: clamp(source.strokeWidth, 0, 0, 60), strokeStyle: oneOf(source.strokeStyle, ['solid', 'dashed', 'dotted'] as const, 'solid'), strokeLinecap: oneOf(source.strokeLinecap, ['butt', 'round', 'square'] as const, 'round'), }; } if (type === 'particles') { return { ...base, type, preset: oneOf( source.preset, ['sparkles', 'snow', 'petals', 'bubbles', 'embers', 'confetti'] as const, 'sparkles' ), density: clampInteger(source.density, 28, 4, 120), speed: clamp(source.speed, 1, 0.1, 3), size: clamp(source.size, 6, 1, 24), color: color(source.color, '#ffffff'), color2: color(source.color2, '#ff006e'), wind: clamp(source.wind, 0, -1, 1), fade: source.fade !== false, }; } if (type === 'clock') { return { ...base, type, mode: oneOf(source.mode, ['digital', 'analog'] as const, 'digital'), hour12: source.hour12 === true, showSeconds: source.showSeconds !== false, showDate: source.showDate === true, label: typeof source.label === 'string' ? source.label.slice(0, 80) : '', color: color(source.color, '#ffffff'), accentColor: color(source.accentColor, '#ff006e'), fontFamily: text(source.fontFamily, 'Sora Variable'), fontSize: clamp(source.fontSize, 40, 6, 240), fontWeight: clampInteger(source.fontWeight, 700, 100, 900), align: oneOf(source.align, ['left', 'center', 'right'] as const, 'center'), }; } if (type === 'countdown') { return { ...base, type, target: text(source.target).slice(0, 64), text: typeof source.text === 'string' ? source.text.slice(0, 200) : '{days}d {hours}h {minutes}m {seconds}s', finishedText: typeof source.finishedText === 'string' ? source.finishedText.slice(0, 200) : 'The wait is over', fontFamily: text(source.fontFamily, 'Sora Variable'), fontSize: clamp(source.fontSize, 40, 6, 240), fontWeight: clampInteger(source.fontWeight, 700, 100, 900), color: color(source.color, '#ffffff'), align: oneOf(source.align, ['left', 'center', 'right'] as const, 'center'), }; } if (type === 'svelte') { return { ...base, type, source: typeof source.source === 'string' ? source.source.slice(0, 100_000) : DEFAULT_THEME_SVELTE_SOURCE, }; } const src = text(source.src).slice(0, 500); if (!src) return undefined; return { ...base, type, src, fit: source.fit === 'contain' ? 'contain' : 'cover', radius: clamp(source.radius, 0, 0, 999), ...(type === 'video' ? { muted: source.muted !== false } : {}), ...(type === 'image' ? { tint: text(source.tint).slice(0, 9) } : {}), }; }; const normalizeElements = (raw: unknown): ThemeV2Element[] => { if (!Array.isArray(raw)) return []; const seen = new Set(); const elements: ThemeV2Element[] = []; raw.slice(0, 100).forEach((candidate, index) => { const element = normalizeElement(candidate, index); if (!element) return; let id = element.id; let suffix = 2; while (seen.has(id)) id = `${element.id}_${suffix++}`; seen.add(id); elements.push({ ...element, id }); }); return elements; }; const SCRIPT_SCREENS = ['home', 'themes', 'settings', 'login'] as const; const SCRIPT_OPERATORS = [ 'equals', 'notEquals', 'greaterThan', 'lessThan', 'greaterOrEqual', 'lessOrEqual', ] as const; const normalizeScriptValue = ( value: unknown, fallback: ThemeV2ScriptValue = 0 ): ThemeV2ScriptValue => { if (typeof value === 'number' && Number.isFinite(value)) return value; if (typeof value === 'boolean') return value; if (typeof value === 'string') return value.slice(0, 200); return fallback; }; const VARIABLE_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; const RESERVED_VARIABLE_NAMES = new Set(['__proto__', 'prototype', 'constructor', 'screen']); /** Keep theme-declared variable defaults safe and bounded. */ const normalizeVariableDefaults = (value: unknown): Record => { if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; const defaults: Record = {}; for (const [rawName, rawValue] of Object.entries(value as Record).slice(0, 64)) { const name = rawName.trim().slice(0, 64); if (!VARIABLE_NAME_PATTERN.test(name) || RESERVED_VARIABLE_NAMES.has(name)) continue; defaults[name] = normalizeScriptValue(rawValue); } return defaults; }; const normalizeBehaviorTarget = (raw: unknown): ThemeV2BehaviorTarget | undefined => { if (!raw || typeof raw !== 'object') return undefined; const source = raw as Record; const type = oneOf(source.type, ['element', 'group'] as const, 'element'); const id = text(source.id).slice(0, 64); return id ? { type, id } : undefined; }; const normalizeBehaviorTrigger = (raw: unknown): ThemeV2BehaviorTrigger | undefined => { if (!raw || typeof raw !== 'object') return undefined; const source = raw as Record; const type = oneOf( source.type, ['load', 'screen', 'click', 'hover', 'leave', 'timer'] as const, 'load' ); if (type === 'screen') { return { type, screen: oneOf(source.screen, SCRIPT_SCREENS, 'home') }; } if (type === 'click' || type === 'hover' || type === 'leave') { const elementId = typeof source.elementId === 'string' ? source.elementId.slice(0, 64) : ''; return elementId ? { type, elementId } : { type }; } if (type === 'timer') { return { type, interval: clamp(source.interval, 5, 0.5, 3600) }; } return { type: 'load' }; }; const normalizeBehaviorCondition = (raw: unknown): ThemeV2BehaviorCondition | undefined => { if (!raw || typeof raw !== 'object') return undefined; const source = raw as Record; const type = oneOf( source.type, ['variable', 'visible', 'groupVisible', 'screen', 'chance'] as const, 'variable' ); if (type === 'variable') { const variable = text(source.variable).slice(0, 64); if (!variable) return undefined; return { type, variable, operator: oneOf(source.operator, SCRIPT_OPERATORS, 'equals'), value: normalizeScriptValue(source.value), }; } if (type === 'visible') { const elementId = text(source.elementId).slice(0, 64); if (!elementId) return undefined; return { type, elementId, value: source.value !== false }; } if (type === 'groupVisible') { const groupId = text(source.groupId).slice(0, 64); if (!groupId) return undefined; return { type, groupId, value: source.value !== false }; } if (type === 'screen') { return { type, screen: oneOf(source.screen, SCRIPT_SCREENS, 'home') }; } return { type, percent: clamp(source.percent, 25, 0, 100) }; }; const normalizeBehaviorAction = (raw: unknown): ThemeV2BehaviorAction | undefined => { if (!raw || typeof raw !== 'object') return undefined; const source = raw as Record; const type = oneOf( source.type, [ 'setVariable', 'changeVariable', 'toggleVariable', 'showElement', 'hideElement', 'toggleElement', 'showGroup', 'hideGroup', 'toggleGroup', 'navigate', 'launch', 'openUrl', ] as const, 'setVariable' ); if (type === 'setVariable') { const variable = text(source.variable).slice(0, 64); if (!variable) return undefined; return { type, variable, value: normalizeScriptValue(source.value) }; } if (type === 'changeVariable') { const variable = text(source.variable).slice(0, 64); if (!variable) return undefined; return { type, variable, amount: clamp(source.amount, 1, -1000000, 1000000) }; } if (type === 'toggleVariable') { const variable = text(source.variable).slice(0, 64); return variable ? { type, variable } : undefined; } if (type === 'showElement' || type === 'hideElement' || type === 'toggleElement') { const elementId = text(source.elementId).slice(0, 64); return elementId ? { type, elementId } : undefined; } if (type === 'showGroup' || type === 'hideGroup' || type === 'toggleGroup') { const groupId = text(source.groupId).slice(0, 64); return groupId ? { type, groupId } : undefined; } if (type === 'navigate') { return { type, screen: oneOf(source.screen, SCRIPT_SCREENS, 'home') }; } if (type === 'openUrl') { const url = text(source.url).slice(0, 2000); return /^https?:\/\//i.test(url) ? { type, url } : undefined; } return { type: 'launch' }; }; const normalizeBehaviors = (raw: unknown): ThemeV2Behavior[] => { if (!Array.isArray(raw)) return []; const seen = new Set(); const behaviors: ThemeV2Behavior[] = []; raw.slice(0, 100).forEach((candidate, index) => { if (!candidate || typeof candidate !== 'object') return; const source = candidate as Record; const trigger = normalizeBehaviorTrigger(source.trigger); if (!trigger) return; let id = text(source.id, `rule_${index + 1}`).slice(0, 64); let suffix = 2; while (seen.has(id)) id = `${id}_${suffix++}`; seen.add(id); const conditions = Array.isArray(source.conditions) ? source.conditions .slice(0, 12) .map(normalizeBehaviorCondition) .filter((condition): condition is ThemeV2BehaviorCondition => Boolean(condition)) : []; const actions = Array.isArray(source.actions) ? source.actions .slice(0, 20) .map(normalizeBehaviorAction) .filter((action): action is ThemeV2BehaviorAction => Boolean(action)) : []; behaviors.push({ id, name: typeof source.name === 'string' ? source.name.slice(0, 80) : `Rule ${index + 1}`, enabled: source.enabled !== false, once: source.once === true, target: normalizeBehaviorTarget(source.target), trigger, conditions, conditionMode: source.conditionMode === 'any' ? 'any' : 'all', actions, ...(typeof source.script === 'string' ? { script: source.script.slice(0, 20000) } : {}), }); }); return behaviors; }; /** Create a blank rule, optionally linked to a layer or group. */ export const createThemeV2Behavior = ( index = 0, target?: ThemeV2BehaviorTarget ): ThemeV2Behavior => { const base = { id: `rule_${Date.now().toString(36)}_${index}`, name: `Rule ${index + 1}`, enabled: true, once: false, conditions: [], conditionMode: 'all' as const, }; if (target?.type === 'element') { return { ...base, target, trigger: { type: 'click', elementId: target.id }, actions: [{ type: 'toggleElement', elementId: target.id }], }; } if (target?.type === 'group') { return { ...base, target, trigger: { type: 'click' }, actions: [{ type: 'toggleGroup', groupId: target.id }], }; } return { ...base, trigger: { type: 'load' }, actions: [] }; }; /** Create a sensible element for the advanced editor's add buttons. */ export const createThemeV2Element = (type: ThemeV2Element['type'], index = 0): ThemeV2Element => { const id = `element_${Date.now().toString(36)}_${index}`; const base = { id, x: 340, y: 250, width: 320, height: 120 }; if (type === 'image') { return normalizeElement( { ...base, type, src: 'placeholder.png' }, index )! as ThemeV2ImageElement; } if (type === 'video') { return normalizeElement( { ...base, type, src: 'placeholder.webm' }, index )! as ThemeV2VideoElement; } if (type === 'data') { return normalizeElement( { ...base, type, field: 'user.name', text: 'Welcome, {value}!', fontSize: 26, width: 420, height: 60, }, index )! as ThemeV2DataElement; } if (type === 'shape') { return normalizeElement( { ...base, type, shape: 'rectangle', width: 220, height: 120 }, index )! as ThemeV2ShapeElement; } if (type === 'icon') { return normalizeElement( { ...base, type, icon: 'brush', color: '#ffffff', width: 96, height: 96, x: 450, y: 280 }, index )! as ThemeV2IconElement; } if (type === 'repeater') { return normalizeElement( { ...base, type, source: 'icon', icon: 'music', width: 320, height: 180 }, index )! as ThemeV2RepeaterElement; } if (type === 'gauge') { return normalizeElement( { ...base, type, mode: 'bar', label: '{percent}%', width: 320, height: 44, y: 300 }, index )! as ThemeV2GaugeElement; } if (type === 'ticker') { return normalizeElement( { ...base, type, text: 'Now playing · Your favourite track · ', width: 420, height: 48 }, index )! as ThemeV2TickerElement; } if (type === 'lottie') { return normalizeElement( { ...base, type, src: 'animation.json', width: 320, height: 320 }, index )! as ThemeV2LottieElement; } if (type === 'button') { return normalizeElement( { ...base, type, label: 'Open themes', width: 220, height: 52, y: 320 }, index )! as ThemeV2ButtonElement; } if (type === 'path') { return normalizeElement( { ...base, type, path: 'M 5 95 L 50 5 L 95 95 Z', fillMode: 'gradient', width: 220, height: 200, }, index )! as ThemeV2PathElement; } if (type === 'particles') { return normalizeElement( { ...base, type, preset: 'sparkles', width: 520, height: 360, density: 36 }, index )! as ThemeV2ParticlesElement; } if (type === 'clock') { return normalizeElement( { ...base, type, mode: 'digital', width: 300, height: 110, fontSize: 48 }, index )! as ThemeV2ClockElement; } if (type === 'countdown') { return normalizeElement( { ...base, type, target: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), width: 380, height: 90, fontSize: 36, }, index )! as ThemeV2CountdownElement; } if (type === 'svelte') { return normalizeElement( { ...base, type, source: DEFAULT_THEME_SVELTE_SOURCE, width: 360, height: 190, }, index )! as ThemeV2SvelteElement; } return normalizeElement( { ...base, type, text: 'Your text', fontSize: 40, width: 420, height: 90 }, index )! as ThemeV2TextElement; }; export const isThemeV2 = (apiVersion: unknown) => typeof apiVersion === 'string' && apiVersion.split('.')[0] === '2'; export const createDefaultThemeV2 = (): ThemeV2Definition => ({ name: 'Untitled theme', version: '1.0.0', apiVersion: '2.0', author: '', description: '', nsfw: false, background: { type: 'gradient', from: '#181825', to: '#3b1d4f', angle: 135, opacity: 1, }, overlay: { color: '#000000', opacity: 0.2, vignette: 0.35, }, effects: { type: 'none', density: 30, speed: 1, color: '#ffffff', }, typography: { body: 'Sora Variable', heading: 'Sora Variable', }, iconSet: DEFAULT_THEME_ICON_SET, fonts: [], elements: [], groups: [], behaviors: [], accentColor: '#ff006e', css: '', }); /** Coerce a theme.json payload into the supported v2 shape. */ export const normalizeThemeV2 = (raw: Partial): ThemeV2Definition => { const defaults = createDefaultThemeV2(); const sourceBackground = (raw.background ?? {}) as Record; const backgroundType = ['color', 'gradient', 'image', 'video'].includes( String(sourceBackground.type) ) ? (String(sourceBackground.type) as ThemeV2BackgroundType) : 'gradient'; let background: ThemeV2Background; switch (backgroundType) { case 'color': background = { type: 'color', color: color(sourceBackground.color, '#181825'), opacity: clamp(sourceBackground.opacity, 1, 0, 1), }; break; case 'image': background = { type: 'image', src: text(sourceBackground.src), fit: sourceBackground.fit === 'contain' ? 'contain' : 'cover', opacity: clamp(sourceBackground.opacity, 1, 0, 1), blur: clamp(sourceBackground.blur, 0, 0, 40), }; break; case 'video': background = { type: 'video', src: text(sourceBackground.src), fit: sourceBackground.fit === 'contain' ? 'contain' : 'cover', opacity: clamp(sourceBackground.opacity, 1, 0, 1), blur: clamp(sourceBackground.blur, 0, 0, 40), muted: sourceBackground.muted !== false, }; break; default: background = { type: 'gradient', from: color( sourceBackground.from, defaults.background.type === 'gradient' ? defaults.background.from : '#181825' ), to: color(sourceBackground.to, '#3b1d4f'), angle: clamp(sourceBackground.angle, 135, 0, 360), opacity: clamp(sourceBackground.opacity, 1, 0, 1), }; } const sourceEffects = (raw.effects ?? {}) as Record; const effectType = ['none', 'snow', 'stars', 'petals', 'bubbles'].includes( String(sourceEffects.type) ) ? (String(sourceEffects.type) as ThemeV2EffectType) : 'none'; const sourceOverlay = (raw.overlay ?? {}) as Record; const sourceAudio = raw.audio as Record | undefined; const sourceTypography = (raw.typography ?? {}) as Record; const bodyFont = text(sourceTypography.body).slice(0, 80); const headingFont = text(sourceTypography.heading).slice(0, 80); const monoFont = text(sourceTypography.mono).slice(0, 80); const groups = normalizeGroups(raw.groups); const groupIds = new Set(groups.map((group) => group.id)); const elements = normalizeElements(raw.elements).map((element) => element.groupId && !groupIds.has(element.groupId) ? { ...element, groupId: undefined } : element ); return { name: text(raw.name, 'Untitled theme'), version: text(raw.version, '1.0.0'), apiVersion: '2.0', author: text(raw.author, 'Unknown'), description: text(raw.description), nsfw: raw.nsfw === true, preview: text(raw.preview), background, overlay: { color: color(sourceOverlay.color, '#000000'), opacity: clamp(sourceOverlay.opacity, 0.2, 0, 1), vignette: clamp(sourceOverlay.vignette, 0.35, 0, 1), }, effects: { type: effectType, density: Math.round(clamp(sourceEffects.density, 30, 0, 120)), speed: clamp(sourceEffects.speed, 1, 0.2, 3), color: color(sourceEffects.color, '#ffffff'), }, // A video background owns the soundtrack; combining it with theme music // would play two sources at once. audio: background.type !== 'video' && sourceAudio && text(sourceAudio.src) ? { src: text(sourceAudio.src), loop: sourceAudio.loop !== false, } : undefined, typography: { body: bodyFont || defaults.typography?.body || 'Sora Variable', heading: headingFont || bodyFont || defaults.typography?.heading || 'Sora Variable', ...(monoFont ? { mono: monoFont } : {}), }, iconSet: isThemeIconSet(raw.iconSet) ? raw.iconSet : DEFAULT_THEME_ICON_SET, fonts: normalizeFonts(raw.fonts), elements, groups, behaviors: normalizeBehaviors(raw.behaviors), variables: normalizeVariableDefaults(raw.variables), accentColor: color(raw.accentColor, '#ff006e'), css: typeof raw.css === 'string' ? raw.css : '', }; }; /** Resolve a path inside a theme package to an asset URL. */ export const themeAssetUrl = (base: string, path: string | undefined) => { if (!path) return ''; if (/^(?:[a-z]+:)?\/\//i.test(path) || /^(?:data|blob|asset):/i.test(path)) return path; const relative = path .replace(/\\/g, '/') .split('/') .filter(Boolean) .map((segment) => encodeURIComponent(segment)) .join('/'); // Keep a cache-busting query (used when reloading a theme from disk) on the // base URL, after the asset path rather than inside it. const match = /^([^?#]*)([?#].*)?$/.exec(base); const basePath = (match?.[1] ?? base).replace(/\/+$/, ''); const suffix = match?.[2] ?? ''; return basePath ? `${basePath}/${relative}${suffix}` : `${relative}${suffix}`; }; /** A CSS background value used for theme cards in the manager. */ export const themeV2ThumbnailStyle = (definition: ThemeV2Definition, assets = '') => { const background = definition.background; switch (background.type) { case 'color': return `background-color:${background.color};`; case 'image': return background.src ? `background-image:url("${themeAssetUrl(assets, background.src)}");background-size:cover;background-position:center;` : `background-color:#181825;`; case 'video': return `background:linear-gradient(135deg, ${definition.accentColor ?? '#ff006e'}55, #181825);`; default: return `background:linear-gradient(${background.angle ?? 135}deg, ${background.from}, ${background.to});`; } }; const channel = (value: string) => parseInt(value, 16) / 255; const relativeLuminance = (hex: string) => { const match = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex); if (!match) return 0; let value = match[1]; if (value.length === 3) value = value .split('') .map((part) => part + part) .join(''); const [r, g, b] = [value.slice(0, 2), value.slice(2, 4), value.slice(4, 6)].map(channel); return 0.2126 * r + 0.7152 * g + 0.0722 * b; }; export const readableTextColor = (hex: string) => relativeLuminance(hex) > 0.55 ? '#181825' : '#ffffff'; /** @font-face declarations for fonts bundled in a theme package. */ export const themeFontFaceCss = ( definition: ThemeV2Definition, assets = '', assetOverrides: Record = {} ) => (definition.fonts ?? []) .map( (font) => `@font-face{font-family:${fontFamilyCss(font.family)};src:url("${ assetOverrides[font.src] ?? themeAssetUrl(assets, font.src) }") format("${fontFormat(font.src)}");font-weight:${font.weight ?? '400'};font-style:${ font.style ?? 'normal' };font-display:swap;}` ) .join('\n'); /** Compose CSS filter functions for a layer and its ancestor groups. */ export const themeFilterCss = (...filters: (ThemeV2Filters | undefined)[]) => { const value = (candidate: unknown, fallback: number) => typeof candidate === 'number' && Number.isFinite(candidate) ? candidate : fallback; const parts: string[] = []; for (const filter of filters) { if (!filter) continue; const blur = value(filter.blur, 0); const brightness = value(filter.brightness, 1); const contrast = value(filter.contrast, 1); const saturate = value(filter.saturate, 1); const hueRotate = value(filter.hueRotate, 0); const grayscale = value(filter.grayscale, 0); const sepia = value(filter.sepia, 0); const invert = value(filter.invert, 0); if (blur > 0) parts.push(`blur(${blur}px)`); if (brightness !== 1) parts.push(`brightness(${brightness})`); if (contrast !== 1) parts.push(`contrast(${contrast})`); if (saturate !== 1) parts.push(`saturate(${saturate})`); if (hueRotate !== 0) parts.push(`hue-rotate(${hueRotate}deg)`); if (grayscale > 0) parts.push(`grayscale(${grayscale})`); if (sepia > 0) parts.push(`sepia(${sepia})`); if (invert > 0) parts.push(`invert(${invert})`); } return parts.join(' '); }; /** Groups from the element's immediate parent up to the root. */ export const themeGroupAncestors = ( groups: ThemeV2Group[] | undefined, element: Pick ): ThemeV2Group[] => { const byId = new Map((groups ?? []).map((group) => [group.id, group])); const ancestors: ThemeV2Group[] = []; const seen = new Set(); let current = element.groupId ? byId.get(element.groupId) : undefined; while (current && !seen.has(current.id)) { seen.add(current.id); ancestors.push(current); current = current.parentId ? byId.get(current.parentId) : undefined; } return ancestors; }; /** Group path from the outermost ancestor down to the given group. */ export const themeGroupPath = ( groups: ThemeV2Group[] | undefined, groupId: string ): ThemeV2Group[] => themeGroupAncestors(groups, { groupId }).slice().reverse(); export type ThemeV2TransformMatrix = { a: number; b: number; c: number; d: number; e: number; f: number; }; const IDENTITY_MATRIX: ThemeV2TransformMatrix = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; const finite = (value: number | undefined, fallback: number) => typeof value === 'number' && Number.isFinite(value) ? value : fallback; const multiplyMatrices = ( left: ThemeV2TransformMatrix, right: ThemeV2TransformMatrix ): ThemeV2TransformMatrix => ({ a: left.a * right.a + left.c * right.b, b: left.b * right.a + left.d * right.b, c: left.a * right.c + left.c * right.d, d: left.b * right.c + left.d * right.d, e: left.a * right.e + left.c * right.f + left.e, f: left.b * right.e + left.d * right.f + left.f, }); const translateMatrix = (x: number, y: number): ThemeV2TransformMatrix => ({ a: 1, b: 0, c: 0, d: 1, e: x, f: y, }); const rotateMatrix = (degrees: number): ThemeV2TransformMatrix => { const radians = (degrees * Math.PI) / 180; return { a: Math.cos(radians), b: Math.sin(radians), c: -Math.sin(radians), d: Math.cos(radians), e: 0, f: 0, }; }; const scaleMatrix = (x: number, y: number): ThemeV2TransformMatrix => ({ a: x, b: 0, c: 0, d: y, e: 0, f: 0, }); const aroundPoint = ( x: number, y: number, matrix: ThemeV2TransformMatrix ): ThemeV2TransformMatrix => multiplyMatrices(translateMatrix(x, y), multiplyMatrices(matrix, translateMatrix(-x, -y))); const cleanMatrix = (matrix: ThemeV2TransformMatrix): ThemeV2TransformMatrix => { const clean = (value: number) => { const rounded = Math.round(value * 100000) / 100000; return Object.is(rounded, -0) ? 0 : rounded; }; return { a: clean(matrix.a), b: clean(matrix.b), c: clean(matrix.c), d: clean(matrix.d), e: clean(matrix.e), f: clean(matrix.f), }; }; /** Bounds of every layer inside a group, including nested groups. */ export const themeGroupBounds = ( groups: ThemeV2Group[] | undefined, elements: ThemeV2Element[], groupId: string ) => { const members = elements.filter((element) => themeGroupAncestors(groups, element).some((group) => group.id === groupId) ); if (members.length === 0) return undefined; const left = Math.min(...members.map((element) => element.x)); const top = Math.min(...members.map((element) => element.y)); const right = Math.max(...members.map((element) => element.x + element.width)); const bottom = Math.max(...members.map((element) => element.y + element.height)); return { left, top, width: right - left, height: bottom - top, centerX: (left + right) / 2, centerY: (top + bottom) / 2, }; }; export type ThemeV2Point = { x: number; y: number }; /** Applies an affine transform to a point in stage coordinates. */ export const themeTransformPoint = ( matrix: ThemeV2TransformMatrix, x: number, y: number ): ThemeV2Point => ({ x: matrix.a * x + matrix.c * y + matrix.e, y: matrix.b * x + matrix.d * y + matrix.f, }); const transformScaleX = (transform: ThemeV2Transform) => Math.max(0.05, finite(transform.scaleX, finite(transform.scale, 1))); const transformScaleY = (transform: ThemeV2Transform) => Math.max(0.05, finite(transform.scaleY, finite(transform.scale, 1))); const skewMatrix = (skewX: number, skewY: number): ThemeV2TransformMatrix => { const x = Math.tan((finite(skewX, 0) * Math.PI) / 180); const y = Math.tan((finite(skewY, 0) * Math.PI) / 180); return { a: 1, b: y, c: x, d: 1, e: 0, f: 0 }; }; /** Skew and scale, without rotation; useful for gesture math. */ export const themeTransformScaleMatrix = (transform: ThemeV2Transform): ThemeV2TransformMatrix => multiplyMatrices( skewMatrix(finite(transform.skewX, 0), finite(transform.skewY, 0)), scaleMatrix( transformScaleX(transform) * (transform.flipX ? -1 : 1), transformScaleY(transform) * (transform.flipY ? -1 : 1) ) ); /** Complete 2D linear transform: rotate, then skew, then scale and flips. */ export const themeTransformLinearMatrix = (transform: ThemeV2Transform): ThemeV2TransformMatrix => multiplyMatrices( rotateMatrix(finite(transform.rotation, 0)), themeTransformScaleMatrix(transform) ); const themeGroupOwnMatrix = ( group: ThemeV2Group, bounds: { centerX: number; centerY: number } ): ThemeV2TransformMatrix => { return multiplyMatrices( translateMatrix( finite(group.offsetX, 0) + bounds.centerX, finite(group.offsetY, 0) + bounds.centerY ), multiplyMatrices( themeTransformLinearMatrix(group), translateMatrix(-bounds.centerX, -bounds.centerY) ) ); }; const themeGroupMatrix = ( group: ThemeV2Group, elements: ThemeV2Element[], groups: ThemeV2Group[], seen: Set ): ThemeV2TransformMatrix => { if (seen.has(group.id)) return IDENTITY_MATRIX; seen.add(group.id); const bounds = themeGroupBounds(groups, elements, group.id); const own = bounds ? themeGroupOwnMatrix(group, bounds) : IDENTITY_MATRIX; const parent = group.parentId ? groups.find((candidate) => candidate.id === group.parentId) : undefined; const parentMatrix = parent ? themeGroupMatrix(parent, elements, groups, seen) : IDENTITY_MATRIX; return cleanMatrix(multiplyMatrices(parentMatrix, own)); }; /** * Stage transform for a group itself, including nested ancestor groups. Use * this to draw a group's selection frame; layer matrices include the layer's * local transform as well. */ export const themeGroupTransformMatrix = ( group: ThemeV2Group, elements: ThemeV2Element[], groups: ThemeV2Group[] = [] ): ThemeV2TransformMatrix => themeGroupMatrix(group, elements, groups, new Set()); /** * Transform applied by a group's ancestors. Offsets are expressed in this * parent space, so screen-space drag deltas must be mapped through its inverse. */ export const themeGroupParentMatrix = ( group: ThemeV2Group, elements: ThemeV2Element[], groups: ThemeV2Group[] = [] ): ThemeV2TransformMatrix => { const parent = group.parentId ? groups.find((candidate) => candidate.id === group.parentId) : undefined; return parent ? themeGroupTransformMatrix(parent, elements, groups) : IDENTITY_MATRIX; }; /** * Local affine transform for a layer, including its own rotation, skew, * non-uniform scale and flips, plus every ancestor group's transform around * that group's bounds. */ export const themeElementTransformMatrix = ( element: ThemeV2Element, elements: ThemeV2Element[], groups: ThemeV2Group[] = [] ): ThemeV2TransformMatrix => { const elementTransform = aroundPoint( element.width / 2, element.height / 2, themeTransformLinearMatrix(element) ); let matrix = elementTransform; for (const group of themeGroupAncestors(groups, element)) { const bounds = themeGroupBounds(groups, elements, group.id); if (!bounds) continue; const centerX = bounds.centerX - element.x; const centerY = bounds.centerY - element.y; const local = multiplyMatrices( translateMatrix(finite(group.offsetX, 0) + centerX, finite(group.offsetY, 0) + centerY), multiplyMatrices(themeTransformLinearMatrix(group), translateMatrix(-centerX, -centerY)) ); matrix = multiplyMatrices(local, matrix); } return cleanMatrix(matrix); }; /** CSS transform value for a layer, using the matrix computed above. */ export const themeElementTransformCss = ( element: ThemeV2Element, elements: ThemeV2Element[], groups: ThemeV2Group[] = [] ) => { const matrix = themeElementTransformMatrix(element, elements, groups) ?? IDENTITY_MATRIX; return `matrix(${matrix.a}, ${matrix.b}, ${matrix.c}, ${matrix.d}, ${matrix.e}, ${matrix.f})`; }; /** CSS declarations for text decoration, fill and visual effects. */ export const themeTextStyleCss = (element: ThemeV2TextStyle) => { const color = element.color ?? '#ffffff'; const gradient = element.fillMode === 'gradient'; const from = element.gradientFrom ?? '#ff006e'; const to = element.gradientTo ?? '#7c3aed'; const decoration = element.textDecoration ?? 'none'; const fontSize = element.fontSize ?? 28; const parts = [ `font-style:${element.fontStyle === 'italic' ? 'italic' : 'normal'}`, `text-transform:${element.textTransform ?? 'none'}`, `text-decoration-line:${decoration}`, ]; if (gradient) { parts.push( `background-image:linear-gradient(${element.gradientAngle ?? 90}deg, ${from}, ${to})`, 'background-clip:text', '-webkit-background-clip:text', '-webkit-text-fill-color:transparent', 'color:transparent' ); } else { parts.push(`color:${color}`); } if (decoration !== 'none') { parts.push( `text-decoration-color:${gradient ? from : color}`, `text-decoration-thickness:${Math.max(1, Math.round(fontSize * 0.06))}px`, `text-underline-offset:${Math.max(1, Math.round(fontSize * 0.08))}px` ); } const outlineWidth = element.outlineWidth ?? 0; if (outlineWidth > 0) { parts.push( `-webkit-text-stroke:${outlineWidth}px ${element.outlineColor ?? '#000000'}`, 'paint-order:stroke fill' ); } const shadowBlur = element.shadowBlur ?? 0; const shadowX = element.shadowOffsetX ?? 0; const shadowY = element.shadowOffsetY ?? 0; if (shadowBlur > 0 || shadowX !== 0 || shadowY !== 0) { parts.push( `text-shadow:${shadowX}px ${shadowY}px ${shadowBlur}px ${element.shadowColor ?? '#000000'}` ); } return parts.join(';'); }; /** Regular polygon or star points, in percentages of the layer bounds. */ export const themeMaskPolygonPoints = ( sides = 6, star = false, innerRadius = 0.5 ): ThemeV2Point[] => { const count = Math.max(3, Math.min(24, Math.round(sides))) * (star ? 2 : 1); const points: ThemeV2Point[] = []; for (let index = 0; index < count; index += 1) { const angle = -Math.PI / 2 + (index * Math.PI * 2) / count; const radius = star && index % 2 === 1 ? Math.min(0.95, Math.max(0.1, innerRadius)) : 1; points.push({ x: 50 + Math.cos(angle) * 50 * radius, y: 50 + Math.sin(angle) * 50 * radius }); } return points; }; /** CSS `clip-path` for a shape mask, or an empty string for image masks. */ export const themeMaskClipPath = (mask: ThemeV2Mask | undefined) => { if (!mask) return ''; if (mask.shape === 'rectangle') { return `inset(0 round ${Math.max(0, Math.round(mask.radius ?? 0))}px)`; } if (mask.shape === 'ellipse') return 'ellipse(50% 50% at 50% 50%)'; if (mask.shape === 'image') return ''; const points = themeMaskPolygonPoints( mask.sides ?? 6, mask.shape === 'star', mask.innerRadius ?? 0.5 ); return `polygon(${points.map((point) => `${point.x}% ${point.y}%`).join(', ')})`; }; /** CSS declarations for an image mask. */ export const themeMaskImageCss = (mask: ThemeV2Mask | undefined, url: string | undefined) => { if (!mask || mask.shape !== 'image' || !url) return ''; const safeUrl = url.replace(/["\\)]/g, ''); return [ `-webkit-mask-image:url("${safeUrl}")`, `mask-image:url("${safeUrl}")`, `-webkit-mask-size:${mask.fit === 'contain' ? 'contain' : '100% 100%'}`, `mask-size:${mask.fit === 'contain' ? 'contain' : '100% 100%'}`, '-webkit-mask-position:center', 'mask-position:center', '-webkit-mask-repeat:no-repeat', 'mask-repeat:no-repeat', ].join(';'); }; /** * Clip/mask CSS for a group, expressed in stage coordinates. Groups render * their layers in a stage-sized wrapper, so the clip path is transformed with * the group's own matrix and the members keep their absolute positions. */ export const themeGroupMaskCss = ( group: ThemeV2Group, elements: ThemeV2Element[], groups: ThemeV2Group[], assetUrl: (path: string) => string ) => { const mask = group.mask; if (!mask) return ''; const bounds = themeGroupBounds(groups, elements, group.id); if (!bounds) return ''; if (mask.shape === 'image') { const url = mask.src ? assetUrl(mask.src) : ''; if (!url) return ''; const safeUrl = url.replace(/["\\)]/g, ''); return [ `-webkit-mask-image:url("${safeUrl}")`, `mask-image:url("${safeUrl}")`, `-webkit-mask-size:${mask.fit === 'contain' ? 'contain' : `${bounds.width}px ${bounds.height}px`}`, `mask-size:${mask.fit === 'contain' ? 'contain' : `${bounds.width}px ${bounds.height}px`}`, `-webkit-mask-position:${bounds.left}px ${bounds.top}px`, `mask-position:${bounds.left}px ${bounds.top}px`, '-webkit-mask-repeat:no-repeat', 'mask-repeat:no-repeat', ].join(';'); } const matrix = themeGroupTransformMatrix(group, elements, groups); const toStage = (x: number, y: number) => themeTransformPoint(matrix, bounds.left + x, bounds.top + y); const points: ThemeV2Point[] = []; if (mask.shape === 'rectangle') { points.push( toStage(0, 0), toStage(bounds.width, 0), toStage(bounds.width, bounds.height), toStage(0, bounds.height) ); } else if (mask.shape === 'ellipse') { const steps = 48; for (let index = 0; index < steps; index += 1) { const angle = (index / steps) * Math.PI * 2; points.push( toStage( bounds.width / 2 + Math.cos(angle) * (bounds.width / 2), bounds.height / 2 + Math.sin(angle) * (bounds.height / 2) ) ); } } else { const shapePoints = themeMaskPolygonPoints( mask.sides ?? 6, mask.shape === 'star', mask.innerRadius ?? 0.5 ); for (const point of shapePoints) { points.push(toStage((point.x / 100) * bounds.width, (point.y / 100) * bounds.height)); } } return `clip-path:polygon(${points .map((point) => `${Math.round(point.x * 100) / 100}px ${Math.round(point.y * 100) / 100}px`) .join(', ')})`; }; /** CSS injected while a v2 theme is active. */ export const themeV2Css = ( definition: ThemeV2Definition, assets = '', assetOverrides: Record = {} ) => { const accent = definition.accentColor; // `.dark` also defines the primary variables, so the override needs a // selector that is at least as specific as `html.dark`. const accentCss = accent ? `:root,:root.dark,:root.light{--primary:${accent};--primary-foreground:${readableTextColor(accent)};--ring:${accent};--color-primary:${accent};--color-primary-500:${accent};--color-primary-foreground:${readableTextColor(accent)};}` : ''; const body = definition.typography?.body?.trim(); const heading = definition.typography?.heading?.trim(); const mono = definition.typography?.mono?.trim(); const fontFaces = themeFontFaceCss(definition, assets, assetOverrides); const typographyCss = body ? `:root,:root.dark{--font-sans:${fontFamilyCss(body)},ui-sans-serif,system-ui,sans-serif;}body{font-family:${fontFamilyCss(body)},ui-sans-serif,system-ui,sans-serif;}` : ''; const headingCss = heading ? `:root,:root.dark{--font-heading:${fontFamilyCss(heading)},ui-sans-serif,system-ui,sans-serif;}h1,h2,h3,h4,h5,h6,.theme-heading{font-family:${fontFamilyCss(heading)},ui-sans-serif,system-ui,sans-serif;}` : ''; const monoCss = mono ? `:root,:root.dark{--font-mono:${fontFamilyCss(mono)},ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;}` : ''; return [accentCss, fontFaces, typographyCss, headingCss, monoCss, definition.css?.trim()] .filter(Boolean) .join('\n'); }; /** A safe, predictable asset filename for the theme editor. */ export const themeAssetName = (sourcePath: string, prefix: string) => { const base = sourcePath.split(/[\\/]/).pop() ?? 'asset'; const dot = base.lastIndexOf('.'); const extension = dot > 0 ? base .slice(dot) .toLowerCase() .replace(/[^a-z0-9.]/g, '') : ''; const stem = (dot > 0 ? base.slice(0, dot) : base) .toLowerCase() .replace(/[^a-z0-9_-]+/g, '_') .replace(/_+/g, '_') .replace(/^_|_$/g, '') .slice(0, 48) || 'asset'; return `${prefix}_${stem}${extension}`; };