chore(themes): move theme libs into /theme

This commit is contained in:
HorizonCode committed 2026-11-04 13:39:45 +01:00
1 parent cfb6ec8cce
commit 83b007818d
57 files changed
+117 -103

No files matched your search

+174
View File
@@ -0,0 +1,174 @@
import type {
ThemeV2ClockElement,
ThemeV2CountdownElement,
ThemeV2ParticlesElement,
} from './themeV2';
/**
* Shared helpers for the dynamic API 2.0 layer types. The DOM renderer and the
* canvas thumbnail renderer both use these so a layer looks the same in the
* launcher, the editor canvas and the exported preview image.
*/
const pad = (value: number) => String(Math.max(0, Math.floor(value))).padStart(2, '0');
export const themeClockTime = (element: ThemeV2ClockElement, now: Date): string => {
const hours = now.getHours();
const minutes = now.getMinutes();
const seconds = now.getSeconds();
const showSeconds = element.showSeconds !== false;
if (element.hour12) {
const hour = hours % 12 || 12;
return `${hour}:${pad(minutes)}${showSeconds ? `:${pad(seconds)}` : ''} ${hours < 12 ? 'AM' : 'PM'}`;
}
return `${pad(hours)}:${pad(minutes)}${showSeconds ? `:${pad(seconds)}` : ''}`;
};
export const themeClockDate = (now: Date): string =>
now.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
export type ThemeCountdownParts = {
days: number;
hours: number;
minutes: number;
seconds: number;
/** True when the target is a valid date that has already passed. */
finished: boolean;
/** False for empty or unparsable targets; the layer then shows zeroes. */
valid: boolean;
};
export const themeCountdownParts = (target: string, now: Date): ThemeCountdownParts => {
const timestamp = target.trim() ? Date.parse(target) : Number.NaN;
if (!Number.isFinite(timestamp)) {
return { days: 0, hours: 0, minutes: 0, seconds: 0, finished: false, valid: false };
}
const remaining = Math.max(0, timestamp - now.getTime());
const totalSeconds = Math.floor(remaining / 1000);
return {
days: Math.floor(totalSeconds / 86_400),
hours: Math.floor((totalSeconds % 86_400) / 3_600),
minutes: Math.floor((totalSeconds % 3_600) / 60),
seconds: totalSeconds % 60,
finished: remaining <= 0,
valid: true,
};
};
export const themeCountdownText = (element: ThemeV2CountdownElement, now: Date): string => {
const parts = themeCountdownParts(element.target, now);
if (parts.finished) return element.finishedText?.trim() || 'The wait is over';
const template = element.text?.trim() ? element.text : '{days}d {hours}h {minutes}m {seconds}s';
return template
.replaceAll('{days}', String(parts.days))
.replaceAll('{hours}', pad(parts.hours))
.replaceAll('{minutes}', pad(parts.minutes))
.replaceAll('{seconds}', pad(parts.seconds));
};
export type ThemeParticleSeed = {
/** Horizontal position in percent of the layer. */
left: number;
/** Vertical offset in percent of the layer. */
top: number;
/** Full animation duration in seconds. */
duration: number;
/** Negative delay so particles start already spread along their path. */
delay: number;
/** Particle size multiplier, 0.45–1.4. */
size: number;
/** Horizontal drift in percent of the layer width. */
drift: number;
/** Total rotation in degrees. */
spin: number;
/** Use the secondary color. */
alternate: boolean;
/** Peak opacity, 0.35–1. */
opacity: number;
};
const seeded = (seed: number) => {
let state = seed || 1;
return () => {
state = (state * 9301 + 49297) % 233_280;
return state / 233_280;
};
};
export const themeParticleSeeds = (element: ThemeV2ParticlesElement): ThemeParticleSeed[] => {
const density = Math.max(4, Math.min(120, Math.round(element.density ?? 28)));
const speed = Math.max(0.1, Math.min(3, element.speed ?? 1));
const wind = Math.max(-1, Math.min(1, element.wind ?? 0));
const random = seeded(density * 7919 + 17);
return Array.from({ length: density }, () => {
const duration = (6 + random() * 10) / speed;
return {
left: random() * 100,
top: random() * 100,
duration,
delay: -duration * random(),
size: 0.45 + random() * 0.95,
drift: (random() * 2 - 1) * 10 + wind * 16,
spin: (random() * 2 - 1) * 720,
alternate: random() > 0.5,
opacity: 0.35 + random() * 0.65,
};
});
};
/** SVG linear-gradient vector for an angle, in 0–1 gradient units. */
export const themeSvgGradientVector = (angle: number) => {
const radians = (angle * Math.PI) / 180;
const x = Math.sin(radians);
const y = -Math.cos(radians);
return {
x1: 0.5 - x / 2,
y1: 0.5 - y / 2,
x2: 0.5 + x / 2,
y2: 0.5 + y / 2,
};
};
/**
* Arc geometry in a 0–100 view box. Angles start at 12 o'clock and run
* clockwise; the stroke is inset so it never clips at the layer edge.
*/
export const themeArcGeometry = (
startAngle: number,
endAngle: number,
thickness: number,
width: number,
height: number
) => {
const minSide = Math.max(1, Math.min(width, height));
const strokeWidth = Math.max(0.5, Math.min(60, (thickness / minSide) * 100));
const radius = Math.max(1, 50 - strokeWidth / 2);
const start = ((startAngle % 360) + 360) % 360;
let end = ((endAngle % 360) + 360) % 360;
if (end <= start) end += 360;
const span = Math.min(360, end - start);
const point = (angle: number) => {
const radians = (angle * Math.PI) / 180;
return {
x: 50 + radius * Math.sin(radians),
y: 50 - radius * Math.cos(radians),
};
};
const from = point(start);
if (span >= 359.99) {
const to = point(start + 180);
return {
d: `M ${from.x} ${from.y} A ${radius} ${radius} 0 1 1 ${to.x} ${to.y} A ${radius} ${radius} 0 1 1 ${from.x} ${from.y}`,
strokeWidth,
};
}
const to = point(start + span);
const largeArc = span > 180 ? 1 : 0;
return {
d: `M ${from.x} ${from.y} A ${radius} ${radius} 0 ${largeArc} 1 ${to.x} ${to.y}`,
strokeWidth,
};
};