UNPKG

@fluritheme/fluri

Version:

Interactive snowfall for websites: DOM-aware snow accumulation, mouse interactions, snow physics, Canvas 2D rendering, and Christmas effects.

282 lines • 9.26 kB
import { z } from 'zod'; import { isSafeTextureSource } from './rendering/Texture.js'; const unit = z.number().finite().min(0).max(1); const positive = (max) => z.number().finite().min(0).max(max); const selector = z.string().min(1).max(512); const wind = z .object({ base: z.number().finite().min(-5).max(5), gusts: z.boolean(), variability: positive(5), }) .strict(); export const presetNames = [ 'gentle-snow', 'winter-magic', 'snowstorm', 'frosted-glass', 'christmas-classic', 'christmas-night', 'north-pole', 'minimal', 'playful', 'cinematic', ]; export const configSchema = z .object({ preset: z.enum(presetNames), autoStart: z.boolean(), seed: z.number().int().min(0).max(4294967295), container: z .custom((v) => typeof v === 'object' && v !== null && 'nodeType' in v && v.nodeType === 1 && 'style' in v, 'container must be an HTMLElement') .optional(), snow: z .object({ enabled: z.boolean(), intensity: unit, particleCount: z.number().int().min(0).max(4000), flakeStyle: z.enum(['soft', 'dot', 'crystal']), texture: z .string() .max(4 * 1024 * 1024) .refine(isSafeTextureSource, 'Use an HTTPS, blob, or PNG/JPEG/WebP data texture') .nullable() .optional(), color: z.string().regex(/^#(?:[\da-f]{3}|[\da-f]{6}|[\da-f]{8})$/i, 'Use a hex color'), minSize: z.number().min(0.2).max(20), maxSize: z.number().min(0.2).max(30), gravity: positive(1500), turbulence: positive(5), glow: z.boolean(), parallax: z.boolean(), wind, accumulation: z .object({ enabled: z.boolean(), maxDepth: positive(80), growthRate: positive(10), softness: unit, sliding: z.boolean(), transferToLowerSurfaces: z.boolean(), meltRate: positive(10), }) .strict(), }) .strict(), dom: z .object({ detection: z.enum(['auto', 'explicit']), respectLayers: z.boolean(), observeMutations: z.boolean(), include: z.array(selector).max(32), exclude: z.array(selector).max(32), maxSurfaces: z.number().int().min(0).max(500), removedSurface: z.enum(['shed', 'dispose']), }) .strict(), interactions: z .object({ brush: z .object({ enabled: z.boolean(), radius: z.number().min(1).max(200), strength: positive(5), activation: z.enum(['hover', 'press']), }) .strict(), snowballs: z .object({ enabled: z.boolean(), activation: z.enum(['press-and-drag', 'hover-circle']), minRadius: z.number().min(2).max(50), maxRadius: z.number().min(4).max(100), maxCount: z.number().int().min(0).max(32), }) .strict(), snowman: z.object({ enabled: z.boolean(), requiredBalls: z.literal(3) }).strict(), pointerVortex: z.object({ enabled: z.boolean() }).strict(), }) .strict(), performance: z .object({ adaptive: z.boolean(), maxFPS: z.number().int().min(10).max(120), preferredQuality: z.enum(['auto', 'low', 'medium', 'high']), maxDPR: z.number().min(1).max(3), }) .strict(), accessibility: z .object({ reducedMotion: z.enum(['respect', 'reduce', 'ignore']), controls: z.boolean() }) .strict(), debug: z.boolean(), }) .strict() .superRefine((v, ctx) => { if (v.snow.minSize > v.snow.maxSize) ctx.addIssue({ code: 'custom', message: 'minSize must not exceed maxSize', path: ['snow', 'minSize'], }); if (v.interactions.snowballs.minRadius > v.interactions.snowballs.maxRadius) ctx.addIssue({ code: 'custom', message: 'minRadius must not exceed maxRadius', path: ['interactions', 'snowballs'], }); }); export const defaults = { preset: 'gentle-snow', autoStart: false, seed: 20261105, snow: { enabled: true, intensity: 0.55, particleCount: 360, flakeStyle: 'soft', color: '#f3f7ff', minSize: 0.8, maxSize: 3.2, gravity: 85, turbulence: 0.55, glow: false, parallax: true, wind: { base: 0.12, gusts: true, variability: 0.3 }, accumulation: { enabled: true, maxDepth: 18, growthRate: 0.8, softness: 0.6, sliding: true, transferToLowerSurfaces: true, meltRate: 0, }, }, dom: { detection: 'auto', respectLayers: true, observeMutations: true, include: ['[data-weather-surface]'], exclude: ['input', 'textarea', 'select', '[contenteditable]', '[data-weather-ignore]'], maxSurfaces: 160, removedSurface: 'shed', }, interactions: { brush: { enabled: true, radius: 28, strength: 0.8, activation: 'hover' }, snowballs: { enabled: false, activation: 'press-and-drag', minRadius: 6, maxRadius: 36, maxCount: 12, }, snowman: { enabled: false, requiredBalls: 3 }, pointerVortex: { enabled: false }, }, performance: { adaptive: true, maxFPS: 60, preferredQuality: 'auto', maxDPR: 2 }, accessibility: { reducedMotion: 'respect', controls: false }, debug: false, }; export const defaultConfig = defaults; export const presets = { 'gentle-snow': { snow: { intensity: 0.4, wind: { base: 0.08 }, accumulation: { maxDepth: 14 } } }, 'winter-magic': { snow: { intensity: 0.7, glow: true, parallax: true, accumulation: { maxDepth: 22 } }, }, snowstorm: { snow: { intensity: 1, particleCount: 700, wind: { base: 1.3, variability: 1 }, turbulence: 1.3, accumulation: { growthRate: 1.8 }, }, }, 'frosted-glass': { snow: { intensity: 0.15, flakeStyle: 'crystal', accumulation: { maxDepth: 12 } }, }, 'christmas-classic': { snow: { intensity: 0.55, glow: true, wind: { base: 0.04 } } }, 'christmas-night': { snow: { intensity: 0.6, glow: true, color: '#dbeaff', wind: { base: 0.1 } }, }, 'north-pole': { snow: { intensity: 0.85, particleCount: 620, accumulation: { maxDepth: 30 } }, interactions: { snowballs: { enabled: true }, snowman: { enabled: true } }, }, minimal: { snow: { intensity: 0.2, particleCount: 120, glow: false, flakeStyle: 'dot', accumulation: { maxDepth: 8 }, }, interactions: { brush: { enabled: false } }, }, playful: { snow: { intensity: 0.7, accumulation: { maxDepth: 28, growthRate: 1.4 } }, interactions: { snowballs: { enabled: true }, snowman: { enabled: true }, pointerVortex: { enabled: true }, }, }, cinematic: { snow: { intensity: 0.8, particleCount: 480, glow: true, parallax: true, maxSize: 4.5, wind: { variability: 0.65 }, }, }, }; function isRecord(v) { return !!v && Object.getPrototypeOf(v) === Object.prototype; } function merge(base, patch, depth = 0) { if (depth > 10) throw new TypeError('Configuration nesting is too deep'); if (!isRecord(patch)) return Array.isArray(patch) ? [...patch] : patch; const result = isRecord(base) ? { ...base } : {}; for (const [key, value] of Object.entries(patch)) { if (['__proto__', 'constructor', 'prototype'].includes(key)) throw new TypeError('Unsafe configuration key'); if (value !== undefined) result[key] = merge(result[key], value, depth + 1); } return result; } export function resolveConfig(options = {}) { const p = options.preset ?? defaults.preset; if (!presetNames.includes(p)) throw new TypeError(`Unknown preset: ${String(p)}`); return validateConfig(merge(merge(defaults, presets[p]), options)); } export function patchConfig(current, patch) { return validateConfig(merge(current, patch)); } export function validateConfig(value) { return configSchema.parse(value); } export function cloneConfig(config) { return validateConfig(merge({}, config)); } export const surfaceOptionsSchema = z .object({ priority: z.number().finite().min(-10000).max(10000).optional(), accumulation: z.boolean().optional(), material: z.enum(['flat', 'slippery', 'sticky', 'no-accumulation']).optional(), edge: z.enum(['top', 'bottom']).optional(), layer: z.enum(['background', 'content', 'foreground']).optional(), clip: z.boolean().optional(), maxSnowDepth: z.number().finite().min(0).max(80).optional(), }) .strict(); //# sourceMappingURL=config.js.map