Files
EZPPLauncher/src/lib/themeV2.ts
T

2304 lines
75 KiB
TypeScript
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.
/**
* 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<string, ThemeV2ScriptValue>;
/** 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 = <T extends string>(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<string>();
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<string>();
const fonts: ThemeV2Font[] = [];
for (const candidate of raw.slice(0, 48)) {
if (!candidate || typeof candidate !== 'object') continue;
const source = candidate as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>): 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<string>();
const groups: ThemeV2Group[] = [];
raw.slice(0, 50).forEach((candidate, index) => {
if (!candidate || typeof candidate !== 'object') return;
const source = candidate as Record<string, unknown>;
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<string>([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<string, unknown>;
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<string, unknown>): 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<string, unknown>;
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<string, unknown>;
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<string>();
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<string, ThemeV2ScriptValue> => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
const defaults: Record<string, ThemeV2ScriptValue> = {};
for (const [rawName, rawValue] of Object.entries(value as Record<string, unknown>).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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string>();
const behaviors: ThemeV2Behavior[] = [];
raw.slice(0, 100).forEach((candidate, index) => {
if (!candidate || typeof candidate !== 'object') return;
const source = candidate as Record<string, unknown>;
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>): ThemeV2Definition => {
const defaults = createDefaultThemeV2();
const sourceBackground = (raw.background ?? {}) as Record<string, unknown>;
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<string, unknown>;
const effectType = ['none', 'snow', 'stars', 'petals', 'bubbles'].includes(
String(sourceEffects.type)
)
? (String(sourceEffects.type) as ThemeV2EffectType)
: 'none';
const sourceOverlay = (raw.overlay ?? {}) as Record<string, unknown>;
const sourceAudio = raw.audio as Record<string, unknown> | undefined;
const sourceTypography = (raw.typography ?? {}) as Record<string, unknown>;
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<string, string> = {}
) =>
(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<ThemeV2Element, 'groupId'>
): ThemeV2Group[] => {
const byId = new Map((groups ?? []).map((group) => [group.id, group]));
const ancestors: ThemeV2Group[] = [];
const seen = new Set<string>();
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<string>
): 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<string, string> = {}
) => {
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}`;
};