PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.6
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.6
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / assets / js / snow-wallpaper.js

snow-wallpaper.js in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.6, at assets/js/snow-wallpaper.js

1,981 lines 65.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 (function() {
2 "use strict";
3 const TEXT_DOMAIN = "desktop-mode";
4 function i18n() {
5 return window.wp?.i18n;
6 }
7 function __(text, domain = TEXT_DOMAIN) {
8 return i18n()?.__(text, domain) ?? text;
9 }
10 function getWpHooks() {
11 const hooks = window.wp?.hooks;
12 if (!hooks) {
13 throw new Error(
14 "[desktop-mode] `window.wp.hooks` is not available. The plugin declares `wp-hooks` as a script dependency; if you are seeing this error, verify the enqueue order."
15 );
16 }
17 return hooks;
18 }
19 function addAction(hookName, namespace, callback, priority) {
20 getWpHooks().addAction(
21 hookName,
22 namespace,
23 callback,
24 priority
25 );
26 }
27 function removeAction(hookName, namespace) {
28 return getWpHooks().removeAction(hookName, namespace);
29 }
30 const HOOKS = {
31 /** Action mirroring document.visibilitychange for active canvas wallpapers. */
32 WALLPAPER_VISIBILITY: "desktop-mode.wallpaper.visibility",
33 /**
34 * Action, fires after a wallpaper's persisted settings change (the
35 * user edited them through the wallpaper's config dialog in OS
36 * Settings). Payload: `{ id, settings }` — the wallpaper id and the
37 * full post-merge settings object. A mounted wallpaper subscribes to
38 * live-apply changes without a remount.
39 *
40 * @since 0.9.5
41 */
42 WALLPAPER_SETTINGS_CHANGED: "desktop-mode.wallpaper.settings-changed",
43 /**
44 * Action, fires BEFORE the window's element is detached from the
45 * DOM but AFTER the manager has already removed it from the stack.
46 * Payload: `{ windowId: string, element: HTMLElement }`.
47 *
48 * Use this for cleanup that needs a reference to the live
49 * element (removing anchored snow, wallpaper particles pinned to
50 * window tops, measurement caches keyed by element). `WINDOW_CLOSED`
51 * fires immediately after and only carries the id, which means
52 * subscribers would otherwise have to re-query the DOM — by then
53 * the element is gone, so they can't match at all.
54 */
55 WINDOW_CLOSING: "desktop-mode.window.closing",
56 /**
57 * Action, fires when a window is minimized. Payload:
58 * `{ windowId: string, element: HTMLElement }`.
59 *
60 * The element ride-along matches {@link WINDOW_CLOSING}'s shape so
61 * wallpaper plugins anchored to window tops (snow, leaves, rain
62 * splash) can match stuck particles by element identity and run
63 * their teardown — minimized windows render at `opacity: 0` so
64 * `offsetParent === null` checks miss them.
65 */
66 WINDOW_MINIMIZED: "desktop-mode.window.minimized",
67 /**
68 * Action, fires when a window is restored from minimized. Payload:
69 * `{ windowId: string, element: HTMLElement }`.
70 */
71 WINDOW_RESTORED: "desktop-mode.window.restored",
72 /**
73 * Action, fires when a window is maximized (fills desktop area).
74 * Payload: `{ windowId: string, element: HTMLElement }`.
75 */
76 WINDOW_MAXIMIZED: "desktop-mode.window.maximized",
77 /**
78 * Action, fires when a window exits maximized state. Payload:
79 * `{ windowId: string, element: HTMLElement }`.
80 */
81 WINDOW_UNMAXIMIZED: "desktop-mode.window.unmaximized",
82 /**
83 * Action, fires when a window enters fullscreen / focus mode.
84 * Payload: `{ windowId: string, element: HTMLElement }`.
85 */
86 WINDOW_FULLSCREEN_ENTERED: "desktop-mode.window.fullscreen-entered",
87 /**
88 * Action, fires when a window exits fullscreen / focus mode.
89 * Payload: `{ windowId: string, element: HTMLElement }`.
90 */
91 WINDOW_FULLSCREEN_EXITED: "desktop-mode.window.fullscreen-exited",
92 /**
93 * Action, fires at most once per animation frame during an
94 * active drag or resize with the live geometry. Payload: `{
95 * windowId: string, x: number, y: number, width: number,
96 * height: number, state: WindowState, phase: 'drag' | 'resize' }`.
97 *
98 * Intended for per-frame collision-aware wallpapers (snow piling
99 * on window tops, rain splash on edges) that would otherwise
100 * poll `getBoundingClientRect` every rAF. Coalesced via
101 * `requestAnimationFrame` so a pointermove storm collapses to
102 * one fire per paint — matches the cadence a wallpaper's own
103 * ticker runs at.
104 *
105 * NOT fired at drag/resize end — `WINDOW_DRAG_END` /
106 * `WINDOW_RESIZE_END` handle the settled geometry. Subscribers
107 * that only want the final position should listen to those
108 * instead.
109 */
110 WINDOW_BOUNDS_CHANGED: "desktop-mode.window.bounds-changed",
111 /** Action before a widget tears down. Payload `{ id }`. */
112 WIDGET_UNMOUNTING: "desktop-mode.widget.unmounting"
113 };
114 function html(strings, ...values) {
115 return { __wpdHtml: true, strings, values };
116 }
117 function isTemplateResult(v) {
118 return !!v && v.__wpdHtml === true;
119 }
120 const MARKER_PREFIX = "$$wpd$$";
121 const MARKER_RE = /\$\$wpd\$\$(\d+)\$\$/g;
122 function joinWithMarkers(strings) {
123 let out = strings[0];
124 for (let i = 1; i < strings.length; i++) {
125 out += `${MARKER_PREFIX}${i - 1}$$` + strings[i];
126 }
127 return out;
128 }
129 const compiledCache = /* @__PURE__ */ new WeakMap();
130 function compile(strings) {
131 const cached = compiledCache.get(strings);
132 if (cached) {
133 return cached;
134 }
135 const template = document.createElement("template");
136 template.innerHTML = joinWithMarkers(strings);
137 const recipes = [];
138 const walk = (node, path) => {
139 if (node.nodeType === Node.ELEMENT_NODE) {
140 const el = node;
141 for (const attr of Array.from(el.attributes)) {
142 const rawName = attr.name;
143 const rawValue = attr.value;
144 const prefix = rawName[0];
145 if (MARKER_RE.test(rawValue)) {
146 MARKER_RE.lastIndex = 0;
147 if (prefix === "@") {
148 const match = MARKER_RE.exec(rawValue);
149 MARKER_RE.lastIndex = 0;
150 recipes.push({
151 path,
152 kind: "event",
153 name: rawName.slice(1),
154 valueIndex: match ? Number(match[1]) : 0
155 });
156 el.removeAttribute(rawName);
157 } else if (prefix === ".") {
158 const match = MARKER_RE.exec(rawValue);
159 MARKER_RE.lastIndex = 0;
160 recipes.push({
161 path,
162 kind: "prop",
163 name: rawName.slice(1),
164 valueIndex: match ? Number(match[1]) : 0
165 });
166 el.removeAttribute(rawName);
167 } else if (prefix === "?") {
168 const match = MARKER_RE.exec(rawValue);
169 MARKER_RE.lastIndex = 0;
170 recipes.push({
171 path,
172 kind: "bool",
173 name: rawName.slice(1),
174 valueIndex: match ? Number(match[1]) : 0
175 });
176 el.removeAttribute(rawName);
177 } else {
178 const fragments = [];
179 const indices = [];
180 let lastEnd = 0;
181 let m;
182 MARKER_RE.lastIndex = 0;
183 while ((m = MARKER_RE.exec(rawValue)) !== null) {
184 fragments.push(rawValue.slice(lastEnd, m.index));
185 indices.push(Number(m[1]));
186 lastEnd = m.index + m[0].length;
187 }
188 fragments.push(rawValue.slice(lastEnd));
189 recipes.push({
190 path,
191 kind: "attr",
192 name: rawName,
193 template: fragments,
194 valueIndices: indices
195 });
196 el.setAttribute(rawName, "");
197 }
198 }
199 }
200 }
201 const children = Array.from(node.childNodes);
202 let shift = 0;
203 for (let i = 0; i < children.length; i++) {
204 const child = children[i];
205 const liveIndex = i + shift;
206 if (child.nodeType === Node.TEXT_NODE) {
207 const text = child.textContent || "";
208 if (!MARKER_RE.test(text)) {
209 MARKER_RE.lastIndex = 0;
210 continue;
211 }
212 MARKER_RE.lastIndex = 0;
213 const parent = child.parentNode;
214 let lastEnd = 0;
215 let m;
216 const newNodes = [];
217 const newRecipes = [];
218 MARKER_RE.lastIndex = 0;
219 while ((m = MARKER_RE.exec(text)) !== null) {
220 if (m.index > lastEnd) {
221 newNodes.push(document.createTextNode(text.slice(lastEnd, m.index)));
222 }
223 const placeholder = document.createTextNode("");
224 newNodes.push(placeholder);
225 newRecipes.push({
226 path: [...path, liveIndex + newNodes.length - 1],
227 kind: "node",
228 valueIndex: Number(m[1])
229 });
230 lastEnd = m.index + m[0].length;
231 }
232 if (lastEnd < text.length) {
233 newNodes.push(document.createTextNode(text.slice(lastEnd)));
234 }
235 for (const nn of newNodes) {
236 parent.insertBefore(nn, child);
237 }
238 parent.removeChild(child);
239 shift += newNodes.length - 1;
240 recipes.push(...newRecipes);
241 } else {
242 walk(child, [...path, liveIndex]);
243 }
244 }
245 };
246 walk(template.content, []);
247 const buildParts = (fragment) => {
248 const out = [];
249 for (const r of recipes) {
250 let node = fragment;
251 for (const idx of r.path) {
252 node = node.childNodes[idx];
253 }
254 if (r.kind === "node") {
255 out.push({
256 kind: "node",
257 valueIndex: r.valueIndex,
258 child: {
259 anchor: node,
260 state: null
261 }
262 });
263 } else if (r.kind === "attr") {
264 out.push({
265 kind: "attr",
266 element: node,
267 name: r.name,
268 template: r.template,
269 valueIndices: r.valueIndices
270 });
271 } else if (r.kind === "event") {
272 out.push({
273 kind: "event",
274 valueIndex: r.valueIndex,
275 element: node,
276 name: r.name
277 });
278 } else if (r.kind === "prop") {
279 out.push({
280 kind: "prop",
281 valueIndex: r.valueIndex,
282 element: node,
283 name: r.name
284 });
285 } else if (r.kind === "bool") {
286 out.push({
287 kind: "bool",
288 valueIndex: r.valueIndex,
289 element: node,
290 name: r.name
291 });
292 }
293 }
294 return out;
295 };
296 const entry = { template, buildParts };
297 compiledCache.set(strings, entry);
298 return entry;
299 }
300 const mountState = /* @__PURE__ */ new WeakMap();
301 function mountIntact(state, container) {
302 for (const node of state.nodes) {
303 if (node.parentNode !== container) {
304 return false;
305 }
306 }
307 return true;
308 }
309 function render(result, container) {
310 const existing = mountState.get(container);
311 if (existing && existing.strings === result.strings && mountIntact(existing, container)) {
312 applyValues(existing.parts, result.values);
313 return;
314 }
315 const compiled = compile(result.strings);
316 const fragment = compiled.template.content.cloneNode(true);
317 const parts = compiled.buildParts(fragment);
318 const nodes = Array.from(fragment.childNodes);
319 while (container.firstChild) {
320 container.removeChild(container.firstChild);
321 }
322 container.appendChild(fragment);
323 applyValues(parts, result.values);
324 mountState.set(container, { strings: result.strings, parts, nodes });
325 }
326 function applyValues(parts, values) {
327 for (const part of parts) {
328 if (part.kind === "node") {
329 updateChildPart(part.child, values[part.valueIndex]);
330 } else if (part.kind === "attr") {
331 let composed = part.template[0];
332 for (let i = 0; i < part.valueIndices.length; i++) {
333 composed += formatText(values[part.valueIndices[i]]);
334 composed += part.template[i + 1];
335 }
336 if (composed !== part.last) {
337 part.last = composed;
338 if (composed === "") {
339 part.element.removeAttribute(part.name);
340 } else {
341 part.element.setAttribute(part.name, composed);
342 }
343 }
344 } else if (part.kind === "event") {
345 const next = values[part.valueIndex];
346 if (next !== part.current) {
347 if (part.current) {
348 part.element.removeEventListener(part.name, part.current);
349 }
350 if (next) {
351 part.element.addEventListener(part.name, next);
352 }
353 part.current = next;
354 }
355 } else if (part.kind === "prop") {
356 const next = values[part.valueIndex];
357 if (next !== part.last) {
358 part.last = next;
359 part.element[part.name] = next;
360 }
361 } else if (part.kind === "bool") {
362 const next = !!values[part.valueIndex];
363 if (next !== part.last) {
364 part.last = next;
365 if (next) {
366 part.element.setAttribute(part.name, "");
367 } else {
368 part.element.removeAttribute(part.name);
369 }
370 }
371 }
372 }
373 }
374 function updateChildPart(child, value) {
375 if (value === null || value === void 0 || value === false) {
376 if (child.state) {
377 disposeChildState(child.state);
378 child.state = null;
379 }
380 return;
381 }
382 if (Array.isArray(value)) {
383 updateArrayChild(child, value);
384 return;
385 }
386 if (isTemplateResult(value)) {
387 updateTemplateChild(child, value);
388 return;
389 }
390 if (value instanceof Node) {
391 updateNodeChild(child, value);
392 return;
393 }
394 updateTextChild(child, formatText(value));
395 }
396 function updateNodeChild(child, node) {
397 const old = child.state;
398 if (old?.shape === "node" && old.node === node) {
399 return;
400 }
401 if (old) {
402 disposeChildState(old);
403 }
404 insertBeforeAnchor(child, [node]);
405 child.state = { shape: "node", node };
406 }
407 function updateTextChild(child, text) {
408 const old = child.state;
409 if (old?.shape === "text") {
410 if (old.text !== text) {
411 old.node.textContent = text;
412 old.text = text;
413 }
414 return;
415 }
416 if (old) {
417 disposeChildState(old);
418 }
419 const node = document.createTextNode(text);
420 insertBeforeAnchor(child, [node]);
421 child.state = { shape: "text", node, text };
422 }
423 function updateTemplateChild(child, result) {
424 const old = child.state;
425 if (old?.shape === "template" && old.strings === result.strings) {
426 applyValues(old.parts, result.values);
427 return;
428 }
429 if (old) {
430 disposeChildState(old);
431 }
432 const compiled = compile(result.strings);
433 const fragment = compiled.template.content.cloneNode(true);
434 const parts = compiled.buildParts(fragment);
435 const topNodes = Array.from(fragment.childNodes);
436 insertBeforeAnchor(child, [fragment]);
437 applyValues(parts, result.values);
438 child.state = {
439 shape: "template",
440 strings: result.strings,
441 parts,
442 nodes: topNodes
443 };
444 }
445 function updateArrayChild(child, arr) {
446 const old = child.state;
447 if (old?.shape === "array" && old.entries.length === arr.length) {
448 for (let i = 0; i < arr.length; i++) {
449 updateChildPart(old.entries[i], arr[i]);
450 }
451 return;
452 }
453 if (old) {
454 disposeChildState(old);
455 }
456 const entries = [];
457 for (const v of arr) {
458 const entryAnchor = document.createTextNode("");
459 insertBeforeAnchor(child, [entryAnchor]);
460 const entry = { anchor: entryAnchor, state: null };
461 updateChildPart(entry, v);
462 entries.push(entry);
463 }
464 child.state = { shape: "array", entries };
465 }
466 function insertBeforeAnchor(child, nodes) {
467 const parent = child.anchor.parentNode;
468 if (!parent) {
469 return;
470 }
471 for (const node of nodes) {
472 parent.insertBefore(node, child.anchor);
473 }
474 }
475 function disposeChildState(state) {
476 if (state.shape === "text") {
477 state.node.remove();
478 return;
479 }
480 if (state.shape === "template") {
481 for (const node of state.nodes) {
482 if (node.parentNode) {
483 node.parentNode.removeChild(node);
484 }
485 }
486 return;
487 }
488 if (state.shape === "node") {
489 if (state.node.parentNode) {
490 state.node.parentNode.removeChild(state.node);
491 }
492 return;
493 }
494 for (const entry of state.entries) {
495 if (entry.state) {
496 disposeChildState(entry.state);
497 }
498 entry.anchor.remove();
499 }
500 }
501 function formatText(v) {
502 if (v === null || v === void 0 || v === false) {
503 return "";
504 }
505 return String(v);
506 }
507 const _Component = class _Component extends HTMLElement {
508 constructor() {
509 super();
510 this._renderScheduled = false;
511 this._propValues = {};
512 const ctor = this.constructor;
513 if (ctor.shadow) {
514 this.attachShadow({ mode: "open" });
515 this._renderRoot = this.shadowRoot;
516 } else {
517 this._renderRoot = this;
518 }
519 this._installPropAccessors();
520 }
521 static get observedAttributes() {
522 return this.props.map(kebab);
523 }
524 connectedCallback() {
525 this._adoptStyles();
526 this.requestUpdate();
527 }
528 attributeChangedCallback(name, oldValue, newValue) {
529 if (oldValue === newValue) {
530 return;
531 }
532 const prop = camel(name);
533 this._propValues[prop] = newValue;
534 this.requestUpdate();
535 }
536 /**
537 * Declarative class-name setter. Assign an array (or a
538 * space-separated string) and the host's `class` attribute is
539 * rewritten to match. Intended for programmatic styling — when
540 * a plugin has enqueued its own stylesheet and wants to apply
541 * one of those classes to a shell component:
542 *
543 * ```js
544 * element.classNames = [ 'my-plugin-brand', 'is-active' ];
545 * // → <wpd-select class="my-plugin-brand is-active">
546 * ```
547 *
548 * The plain HTML `class="…"` attribute works just the same and
549 * is always preferred when writing markup by hand — this setter
550 * exists for the JS-API case where the caller has an array of
551 * conditional classes in hand.
552 *
553 * Getter returns the current `classList` as a plain array for
554 * symmetric read/write.
555 *
556 * @since 0.5.0
557 */
558 get classNames() {
559 return Array.from(this.classList);
560 }
561 set classNames(next) {
562 if (next === null || next === void 0) {
563 this.removeAttribute("class");
564 return;
565 }
566 const list = Array.isArray(next) ? next : String(next).split(/\s+/);
567 const cleaned = list.map((s) => String(s).trim()).filter((s) => s !== "");
568 this.className = cleaned.join(" ");
569 }
570 /**
571 * Request a re-render explicitly. Components rarely need this —
572 * declare state via props + attribute observers and the render
573 * loop picks up changes automatically.
574 */
575 requestUpdate() {
576 this._scheduleRender();
577 }
578 /**
579 * Dispatch a `CustomEvent` with a `detail`. Bubbles + composed
580 * by default (matches typical WC UX — events cross shadow
581 * boundaries, parents can listen without knowing about internal
582 * structure).
583 */
584 emit(name, detail) {
585 return this.dispatchEvent(
586 new CustomEvent(name, {
587 detail,
588 bubbles: true,
589 composed: true
590 })
591 );
592 }
593 // ------------------------------------------------------------------
594 // Internals
595 // ------------------------------------------------------------------
596 /**
597 * Wire every `static props` entry to a matched property getter +
598 * setter on the element. Setting the property reflects into the
599 * attribute (so downstream observers + CSS selectors see it);
600 * reading the property falls back to the attribute.
601 */
602 _installPropAccessors() {
603 const ctor = this.constructor;
604 for (const prop of ctor.props) {
605 if (Object.getOwnPropertyDescriptor(this, prop)) {
606 continue;
607 }
608 const attr = kebab(prop);
609 Object.defineProperty(this, prop, {
610 get: () => {
611 if (prop in this._propValues) {
612 return this._propValues[prop];
613 }
614 return this.getAttribute(attr);
615 },
616 set: (value) => {
617 let str;
618 if (value === null || value === void 0 || value === false) {
619 str = null;
620 } else if (value === true) {
621 str = "";
622 } else {
623 str = String(value);
624 }
625 this._propValues[prop] = str;
626 if (str === null) {
627 this.removeAttribute(attr);
628 } else {
629 this.setAttribute(attr, str);
630 }
631 this.requestUpdate();
632 },
633 enumerable: true,
634 configurable: true
635 });
636 }
637 }
638 /**
639 * Schedule a render on the next microtask. Multiple property
640 * assignments in the same tick collapse into a single render.
641 */
642 _scheduleRender() {
643 if (this._renderScheduled || !this.isConnected) {
644 return;
645 }
646 this._renderScheduled = true;
647 queueMicrotask(() => {
648 this._renderScheduled = false;
649 if (!this.isConnected) {
650 return;
651 }
652 render(this.render(), this._renderRoot);
653 });
654 }
655 /**
656 * Mount adoptable stylesheets onto the shadow root (via
657 * `adoptedStyleSheets`) or the light DOM (via one `<style>`
658 * tag per def). No-op if `static styles` is empty.
659 */
660 _adoptStyles() {
661 const ctor = this.constructor;
662 if (ctor.styles.length === 0) {
663 return;
664 }
665 if (ctor.shadow && this.shadowRoot) {
666 const sheets = ctor.styles.map((s) => s.sheet).filter((s) => s !== null);
667 this.shadowRoot.adoptedStyleSheets = sheets;
668 if (sheets.length !== ctor.styles.length) {
669 for (const s of ctor.styles) {
670 if (!s.sheet) {
671 const tag = document.createElement("style");
672 tag.textContent = s.cssText;
673 this.shadowRoot.appendChild(tag);
674 }
675 }
676 }
677 } else {
678 this._adoptLightStyles(ctor);
679 }
680 }
681 _adoptLightStyles(ctor) {
682 if (_Component._lightStylesAdopted.has(ctor)) {
683 return;
684 }
685 _Component._lightStylesAdopted.add(ctor);
686 for (const s of ctor.styles) {
687 const tag = document.createElement("style");
688 tag.dataset.wpdUi = this.tagName.toLowerCase();
689 tag.textContent = s.cssText;
690 document.head.appendChild(tag);
691 }
692 }
693 };
694 _Component.props = [];
695 _Component.styles = [];
696 _Component.shadow = true;
697 _Component._lightStylesAdopted = /* @__PURE__ */ new WeakSet();
698 let Component = _Component;
699 function defineComponent(tag, ctor) {
700 if (customElements.get(tag)) {
701 return;
702 }
703 customElements.define(tag, ctor);
704 }
705 function kebab(s) {
706 return s.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase());
707 }
708 function camel(s) {
709 return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
710 }
711 const SUPPORTS_CONSTRUCTABLE_SHEETS = (() => {
712 try {
713 const s = new CSSStyleSheet();
714 return typeof s.replaceSync === "function";
715 } catch {
716 return false;
717 }
718 })();
719 function css(strings, ...values) {
720 let text = strings[0];
721 for (let i = 1; i < strings.length; i++) {
722 const v = values[i - 1];
723 if (typeof v === "string" || typeof v === "number") {
724 text += String(v);
725 } else if (v && v.__wpdCss) {
726 text += v.cssText;
727 } else {
728 throw new TypeError(
729 "[wpd-ui] css`` interpolations must be strings, numbers, or other css`` results. Got: " + typeof v
730 );
731 }
732 text += strings[i];
733 }
734 if (SUPPORTS_CONSTRUCTABLE_SHEETS) {
735 const sheet = new CSSStyleSheet();
736 sheet.replaceSync(text);
737 return { __wpdCss: true, sheet, cssText: text };
738 }
739 return { __wpdCss: true, sheet: null, cssText: text };
740 }
741 const styles$2 = css`:host{display:flex;align-items:center;gap:10px;font-size:12px;color:var( --desktop-mode-muted,#646970 )}input[ type='range' ]{flex:1;accent-color:var( --wp-admin-theme-color,#2271b1 )}.wpd-range-field__value{min-width:3ch;text-align:end;font-variant-numeric:tabular-nums;color:var( --desktop-mode-text,#1d2327 )}`;
742 const _WpdRangeField = class _WpdRangeField extends Component {
743 render() {
744 const label = this.label || "";
745 const value = this.value || "0";
746 const min = this.min || "0";
747 const max = this.max || "100";
748 const step = this.step || "1";
749 const suffix = this.suffix || "";
750 return html`
751 <label class="wpd-range-field__label">${label}</label>
752 <input
753 type="range"
754 min=${min}
755 max=${max}
756 step=${step}
757 .value=${value}
758 @input=${(e) => this._onInput(e)}
759 />
760 <span class="wpd-range-field__value">${value}${suffix}</span>
761 `;
762 }
763 _onInput(e) {
764 const input = e.target;
765 const n = parseFloat(input.value);
766 if (!Number.isFinite(n)) {
767 return;
768 }
769 this.value = String(n);
770 this.emit("wpd-range-change", { value: n });
771 }
772 };
773 _WpdRangeField.props = ["label", "value", "min", "max", "step", "suffix"];
774 _WpdRangeField.styles = [styles$2];
775 _WpdRangeField.help = {
776 title: "Range field",
777 summary: "Label + range slider + live numeric readout. Emits wpd-range-change with an already-parsed number.",
778 status: "stable",
779 since: "0.9.0",
780 props: [
781 {
782 name: "label",
783 type: "string",
784 description: "Visible label above the slider."
785 },
786 {
787 name: "value",
788 type: "number (string)",
789 default: "0",
790 description: "Current slider value."
791 },
792 {
793 name: "min",
794 type: "number (string)",
795 default: "0",
796 description: "Lower bound of the slider range."
797 },
798 {
799 name: "max",
800 type: "number (string)",
801 default: "100",
802 description: "Upper bound of the slider range."
803 },
804 {
805 name: "step",
806 type: "number (string)",
807 default: "1",
808 description: "Slider step granularity."
809 },
810 {
811 name: "suffix",
812 type: "string",
813 description: 'Text appended to the readout (e.g. "px", "%").'
814 }
815 ],
816 events: [
817 {
818 name: "wpd-range-change",
819 description: "Fires on every slider movement.",
820 detail: "{ value: number }"
821 }
822 ],
823 cssProps: [
824 { name: "--desktop-mode-text", description: "Readout + label colour." },
825 { name: "--desktop-mode-muted", description: "Secondary colour." }
826 ],
827 example: html`
828 <wpd-range-field
829 label="Dock size"
830 value="48"
831 min="32"
832 max="80"
833 step="4"
834 suffix="px"
835 ></wpd-range-field>
836 `
837 };
838 let WpdRangeField = _WpdRangeField;
839 defineComponent("wpd-range-field", WpdRangeField);
840 const styles$1 = css`:host{display:inline-flex;align-items:center;gap:8px;font-size:12px;color:var( --desktop-mode-muted,#646970 )}label{display:inline-flex;align-items:center;gap:8px}input[ type='color' ]{width:28px;height:28px;padding:0;border:1px solid var( --desktop-mode-border,#c3c4c7 );border-radius:6px;background:transparent;cursor:pointer}:host( [ variant='block' ] ){display:flex;width:100%}:host( [ variant='block' ] ) label{display:flex;flex:1;align-items:center}:host( [ variant='block' ] ) input[ type='color' ]{flex:1;width:auto;height:32px}input[ type='color' ]::-webkit-color-swatch-wrapper{padding:2px}input[ type='color' ]::-webkit-color-swatch{border:none;border-radius:2px}`;
841 const _WpdColorField = class _WpdColorField extends Component {
842 render() {
843 const label = this.label || "";
844 const value = this.value || "#000000";
845 return html`
846 <label>
847 <span class="wpd-color-field__label">${label}</span>
848 <input
849 type="color"
850 .value=${value}
851 @input=${(e) => this._onInput(e)}
852 />
853 </label>
854 `;
855 }
856 _onInput(e) {
857 const input = e.target;
858 this.value = input.value;
859 this.emit("wpd-color-change", { value: input.value });
860 }
861 };
862 _WpdColorField.props = ["label", "value", "variant"];
863 _WpdColorField.styles = [styles$1];
864 _WpdColorField.help = {
865 title: "Color field",
866 summary: "Label + native color input. Reflects the value attribute both ways and emits wpd-color-change live on every edit (no debounce — callers debounce upstream).",
867 status: "stable",
868 since: "0.9.0",
869 props: [
870 {
871 name: "label",
872 type: "string",
873 description: "Visible label rendered next to the swatch."
874 },
875 {
876 name: "value",
877 type: "CSS hex color",
878 default: "#000000",
879 description: "Current color. Two-way reflected with the native picker."
880 },
881 {
882 name: "variant",
883 type: "string",
884 description: "Optional visual variant hint for the stylesheet."
885 }
886 ],
887 events: [
888 {
889 name: "wpd-color-change",
890 description: "Fires on every user edit.",
891 detail: "{ value: string }"
892 }
893 ],
894 cssProps: [
895 { name: "--desktop-mode-border", description: "Swatch outline." },
896 { name: "--desktop-mode-muted", description: "Label colour." }
897 ],
898 example: html`
899 <wpd-color-field label="Accent" value="#8b5cf6"></wpd-color-field>
900 `
901 };
902 let WpdColorField = _WpdColorField;
903 defineComponent("wpd-color-field", WpdColorField);
904 const styles = css`:host{display:inline-flex}:host( [ fill-cell ] ){display:flex;width:100%}button{appearance:none;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:var( --wpd-button-padding,6px 12px );border-radius:var( --wpd-button-border-radius,6px );font:inherit;font-weight:500;cursor:pointer;transition:background-color 0.12s ease,color 0.12s ease,border-color 0.12s ease;background:var( --wpd-button-bg,transparent );color:var( --wpd-button-fg,var( --desktop-mode-text,#1d2327 ) );border:var( --wpd-button-border,1px solid var( --desktop-mode-border,#c3c4c7 ) )}:host( [ fill-cell ] ) button{width:100%;min-height:var( --wpd-button-min-height,44px )}button:disabled{opacity:0.5;cursor:not-allowed}button:hover:not(:disabled ){background:var( --wpd-button-bg-hover,rgba( 0,0,0,0.04 ) )}:host( [ variant='primary' ] ) button{background:var( --wpd-button-bg,var( --wp-admin-theme-color,#2271b1 ) );color:var( --wpd-button-fg,#fff );border:var( --wpd-button-border,1px solid transparent )}:host( [ variant='primary' ] ) button:hover:not(:disabled ){filter:brightness( 1.06 );background:var( --wpd-button-bg,var( --wp-admin-theme-color,#2271b1 ) )}:host( [ variant='secondary' ] ) button{background:var( --wpd-button-bg,rgba( 0,0,0,0.06 ) );color:var( --wpd-button-fg,var( --desktop-mode-text,#1d2327 ) );border:var( --wpd-button-border,1px solid transparent )}:host( [ variant='secondary' ] ) button:hover:not(:disabled ){background:var( --wpd-button-bg-hover,rgba( 0,0,0,0.1 ) )}:host( [ variant='danger' ] ) button{background:var( --wpd-button-bg,transparent );color:var( --wpd-button-fg,#d63638 );border:var( --wpd-button-border,1px solid currentColor )}:host( [ variant='danger' ] ) button:hover:not(:disabled ){background:#d63638;color:#fff}:host( [ variant='link' ] ) button{background:transparent;color:var( --wpd-button-fg,var( --wp-admin-theme-color,#2271b1 ) );border:0;padding:0;text-decoration:underline}:host( [ busy ] ) button{pointer-events:none;opacity:0.75}.wpd-button__spinner{box-sizing:border-box;display:inline-block;width:12px;height:12px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:wpd-button-spin 0.6s linear infinite;flex-shrink:0}@keyframes wpd-button-spin{to{transform:rotate( 360deg )}}`;
905 const _WpdButton = class _WpdButton extends Component {
906 render() {
907 const disabled = this.disabled !== null;
908 const busy = this.busy !== null;
909 const type = this.type || "button";
910 return html`
911 <button
912 part="button"
913 type=${type}
914 ?disabled=${disabled || busy}
915 aria-busy=${busy ? "true" : "false"}
916 >
917 ${busy ? html`<span class="wpd-button__spinner" aria-hidden="true"></span>` : ""}
918 <slot></slot>
919 </button>
920 `;
921 }
922 };
923 _WpdButton.props = ["variant", "disabled", "type", "busy", "fill-cell"];
924 _WpdButton.styles = [styles];
925 _WpdButton.help = {
926 title: "Button",
927 summary: "Thin wrapper around <button> with consistent variant styling and a slot for the label.",
928 status: "stable",
929 since: "0.9.0",
930 props: [
931 {
932 name: "variant",
933 type: "'primary' | 'secondary' | 'ghost' | 'danger' | 'link'",
934 default: "ghost",
935 description: "Visual weight of the button. Use primary for the single attention-grabbing action per surface."
936 },
937 {
938 name: "disabled",
939 type: "boolean attribute",
940 description: "Disable pointer + keyboard interaction and dim the chrome."
941 },
942 {
943 name: "type",
944 type: "'button' | 'submit' | 'reset'",
945 default: "button",
946 description: "Forwarded to the underlying native <button>."
947 },
948 {
949 name: "busy",
950 type: "boolean attribute",
951 description: "Marks the button as in-progress (e.g., awaiting a fetch)."
952 },
953 {
954 name: "fill-cell",
955 type: "boolean attribute",
956 description: "Grow to fill the parent flex/grid cell. Useful for tiled keypads."
957 }
958 ],
959 slots: [{ name: "(default)", description: "Button label." }],
960 parts: [{ name: "button", description: "Underlying <button> element." }],
961 cssProps: [
962 { name: "--wpd-button-bg", description: "Background color." },
963 {
964 name: "--wpd-button-bg-hover",
965 description: "Hover wash (ghost + secondary variants)."
966 },
967 { name: "--wpd-button-fg", description: "Text color." },
968 { name: "--wpd-button-border", description: "Border shorthand." },
969 { name: "--wpd-button-border-radius", default: "6px" },
970 { name: "--wpd-button-padding", default: "6px 12px" },
971 {
972 name: "--wpd-button-min-height",
973 description: "Minimum height when fill-cell is set."
974 }
975 ],
976 example: html`
977 <wpd-cluster gap="8">
978 <wpd-button variant="primary">Primary</wpd-button>
979 <wpd-button variant="secondary">Secondary</wpd-button>
980 <wpd-button variant="ghost">Ghost</wpd-button>
981 <wpd-button variant="danger">Danger</wpd-button>
982 <wpd-button variant="link">Link</wpd-button>
983 </wpd-cluster>
984 `
985 };
986 let WpdButton = _WpdButton;
987 defineComponent("wpd-button", WpdButton);
988 function getPixi() {
989 const pixi = window.PIXI;
990 return pixi ?? null;
991 }
992 const SNOW_DEFAULTS = {
993 wind: 22,
994 particleCount: 660,
995 flakeSize: 16,
996 background: "#0c1a36"
997 };
998 const SNOW_LIMITS = {
999 wind: { min: 0, max: 80 },
1000 particleCount: { min: 100, max: 2e3 },
1001 flakeSize: { min: 6, max: 40 }
1002 };
1003 function clampNumber(value, limits, fallback) {
1004 if (typeof value !== "number" || !Number.isFinite(value)) {
1005 return fallback;
1006 }
1007 return Math.min(limits.max, Math.max(limits.min, value));
1008 }
1009 function sanitizeSnowSettings(raw) {
1010 const bag = raw ?? {};
1011 return {
1012 wind: clampNumber(bag.wind, SNOW_LIMITS.wind, SNOW_DEFAULTS.wind),
1013 particleCount: Math.round(
1014 clampNumber(
1015 bag.particleCount,
1016 SNOW_LIMITS.particleCount,
1017 SNOW_DEFAULTS.particleCount
1018 )
1019 ),
1020 flakeSize: clampNumber(
1021 bag.flakeSize,
1022 SNOW_LIMITS.flakeSize,
1023 SNOW_DEFAULTS.flakeSize
1024 ),
1025 background: typeof bag.background === "string" && /^#[0-9a-f]{6}$/i.test(bag.background) ? bag.background.toLowerCase() : SNOW_DEFAULTS.background
1026 };
1027 }
1028 const STOP_55_DELTA = { h: -2.1538461538461604, s: -0.10790835181079084, l: 0.11176470588235296 };
1029 const STOP_100_DELTA = { h: -2.5, s: -0.2834224598930483, l: 0.2705882352941177 };
1030 function hexToHsl(hex) {
1031 const r = parseInt(hex.slice(1, 3), 16) / 255;
1032 const g = parseInt(hex.slice(3, 5), 16) / 255;
1033 const b = parseInt(hex.slice(5, 7), 16) / 255;
1034 const max = Math.max(r, g, b);
1035 const min = Math.min(r, g, b);
1036 const l = (max + min) / 2;
1037 const d = max - min;
1038 if (d === 0) {
1039 return { h: 0, s: 0, l };
1040 }
1041 const s = l < 0.5 ? d / (max + min) : d / (2 - max - min);
1042 let h;
1043 if (max === r) {
1044 h = (g - b) / d + (g < b ? 6 : 0);
1045 } else if (max === g) {
1046 h = (b - r) / d + 2;
1047 } else {
1048 h = (r - g) / d + 4;
1049 }
1050 return { h: h * 60, s, l };
1051 }
1052 function hslToHex(h, s, l) {
1053 h = (h % 360 + 360) % 360;
1054 s = Math.min(1, Math.max(0, s));
1055 l = Math.min(1, Math.max(0, l));
1056 const c = (1 - Math.abs(2 * l - 1)) * s;
1057 const x = c * (1 - Math.abs(h / 60 % 2 - 1));
1058 const m = l - c / 2;
1059 let r = 0;
1060 let g = 0;
1061 let b = 0;
1062 if (h < 60) {
1063 r = c;
1064 g = x;
1065 } else if (h < 120) {
1066 r = x;
1067 g = c;
1068 } else if (h < 180) {
1069 g = c;
1070 b = x;
1071 } else if (h < 240) {
1072 g = x;
1073 b = c;
1074 } else if (h < 300) {
1075 r = x;
1076 b = c;
1077 } else {
1078 r = c;
1079 b = x;
1080 }
1081 const channel = (v) => Math.round((v + m) * 255).toString(16).padStart(2, "0");
1082 return `#${channel(r)}${channel(g)}${channel(b)}`;
1083 }
1084 function backdropCss(background) {
1085 const base = hexToHsl(background);
1086 const mid = hslToHex(
1087 base.h + STOP_55_DELTA.h,
1088 base.s + STOP_55_DELTA.s,
1089 base.l + STOP_55_DELTA.l
1090 );
1091 const bottom = hslToHex(
1092 base.h + STOP_100_DELTA.h,
1093 base.s + STOP_100_DELTA.s,
1094 base.l + STOP_100_DELTA.l
1095 );
1096 return `linear-gradient(180deg, ${background} 0%, ${mid} 55%, ${bottom} 100%)`;
1097 }
1098 const TEXTURE_SIZE = 64;
1099 const TUNING = {
1100 /**
1101 * Spawn rate (flakes/s) while the field is unsaturated, at the
1102 * default particle count. Scaled linearly with the user's
1103 * particle count so the pool fills in the same wall-clock time at
1104 * every density. The pool cap is the real ceiling — once hit,
1105 * spawn pauses until something melts or recycles.
1106 */
1107 spawnPerSecondAtDefault: 90,
1108 /** The particle count `spawnPerSecondAtDefault` is calibrated for. */
1109 spawnCalibrationCount: 660,
1110 /**
1111 * Min / max vertical drift (px/s). Real snow falls slowly and
1112 * reaches terminal velocity fast — air resistance dominates over
1113 * gravity at flake mass — so velocity is modeled as a constant
1114 * per particle rather than accelerating. Range chosen for an
1115 * atmospheric feel rather than a hailstorm.
1116 */
1117 gravityMin: 28,
1118 gravityMax: 72,
1119 /** Period of the global wind sweep (seconds). */
1120 windPeriodSec: 11,
1121 /** Per-particle sway amplitude (px/s target). */
1122 driftAmplitude: 32,
1123 driftPeriodMin: 2.5,
1124 driftPeriodMax: 5.5,
1125 /**
1126 * Max rotation speed (rad/s). Spheres are rotation-invariant by
1127 * construction — kept tiny only so any minor bilinear-filter
1128 * asymmetries don't lock to a fixed orientation across the field.
1129 */
1130 rotationMax: 0.2,
1131 alphaMin: 0.7,
1132 alphaMax: 1,
1133 /** Melt duration once a stuck flake starts melting. */
1134 meltDurationSec: 1.8,
1135 /**
1136 * How long a flake stays stuck before it starts melting. Visible
1137 * piles take time to build — a short lifetime barely lets a
1138 * column reach 2–3 flakes before the bottom one melts. A small
1139 * jitter is applied per flake so an entire windowful doesn't melt
1140 * in lockstep.
1141 */
1142 stuckLifeSec: 9,
1143 stuckLifeJitter: 2.5,
1144 /**
1145 * A small inset on the window top so flakes don't visibly overlap
1146 * the title-bar drop shadow.
1147 */
1148 collisionMarginY: 2,
1149 /**
1150 * Width of one pile-height bucket in CSS px (surface-local X).
1151 * Each surface keeps a Float32Array of bucket heights; a falling
1152 * flake's bucket index is `floor((vpX - r.x) / bucket)`. 8 px is
1153 * roughly half a flake wide — narrow enough that two flakes
1154 * landing in the same column visibly stack rather than overlap,
1155 * wide enough that buckets feel continuous rather than discrete
1156 * bins.
1157 */
1158 pileBucketPx: 8,
1159 /**
1160 * Cap on per-column pile height in CSS px. Beyond this, further
1161 * flakes still stick but don't push the pile higher — surfaces
1162 * visually "saturate" with snow rather than growing unbounded
1163 * towers. ~3 flakes deep at the largest default flake size reads
1164 * as a small drift edge.
1165 */
1166 pileMaxPx: 48,
1167 /**
1168 * Fraction of a flake's size added to its bucket's pile height on
1169 * landing. Successive flakes' centres sit only
1170 * `pileContribution * size` apart, so 0.1 puts new flake centres
1171 * just 10% of a diameter above the previous one. The bright cores
1172 * (~30% of the size) heavily overlap, reading as a continuous
1173 * mass of snow rather than a stack of discrete dots with visible
1174 * interstitial halo. Lower = denser pile, slower vertical growth.
1175 */
1176 pileContribution: 0.1,
1177 /**
1178 * Fraction of `pileContribution` that bleeds into the two
1179 * neighbor buckets — gives piles a natural slope instead of
1180 * letting one column tower over its neighbors.
1181 */
1182 pileSpread: 0.4
1183 };
1184 const POOL_SIZE = SNOW_LIMITS.particleCount.max;
1185 function buildSnowflakeTexture(pixi) {
1186 const size = TEXTURE_SIZE;
1187 const canvas = document.createElement("canvas");
1188 canvas.width = size;
1189 canvas.height = size;
1190 const ctx = canvas.getContext("2d");
1191 if (!ctx) {
1192 return pixi.Texture.from(canvas);
1193 }
1194 const cx = size / 2;
1195 const cy = size / 2;
1196 const radius = size / 2 - 1;
1197 const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
1198 grad.addColorStop(0, "rgba(255, 255, 255, 1)");
1199 grad.addColorStop(0.3, "rgba(250, 252, 255, 0.9)");
1200 grad.addColorStop(0.6, "rgba(235, 245, 255, 0.32)");
1201 grad.addColorStop(0.88, "rgba(220, 232, 255, 0.07)");
1202 grad.addColorStop(1, "rgba(210, 228, 255, 0)");
1203 ctx.fillStyle = grad;
1204 ctx.fillRect(0, 0, size, size);
1205 return pixi.Texture.from(canvas);
1206 }
1207 function rand(a, b) {
1208 return a + Math.random() * (b - a);
1209 }
1210 async function mountSnowScene(opts) {
1211 const { container, pixi, getSurfaces } = opts;
1212 const priorBackground = container.style.background;
1213 container.style.background = backdropCss(opts.settings.background);
1214 const tunables = { ...opts.settings };
1215 const app = new pixi.Application();
1216 try {
1217 await app.init({
1218 resizeTo: container,
1219 backgroundAlpha: 0,
1220 antialias: true,
1221 autoDensity: true,
1222 resolution: Math.min(window.devicePixelRatio || 1, 2)
1223 });
1224 } catch (err) {
1225 container.style.background = priorBackground;
1226 throw err;
1227 }
1228 container.appendChild(app.canvas);
1229 app.canvas.style.position = "absolute";
1230 app.canvas.style.inset = "0";
1231 app.canvas.style.width = "100%";
1232 app.canvas.style.height = "100%";
1233 app.canvas.style.pointerEvents = "none";
1234 const texture = buildSnowflakeTexture(pixi);
1235 const stage = new pixi.ParticleContainer({
1236 dynamicProperties: {
1237 position: true,
1238 vertex: true,
1239 rotation: true,
1240 color: true
1241 }
1242 });
1243 app.stage.addChild(stage);
1244 const MAX = POOL_SIZE;
1245 const pX = new Float32Array(MAX);
1246 const pY = new Float32Array(MAX);
1247 const pVX = new Float32Array(MAX);
1248 const pVY = new Float32Array(MAX);
1249 const pSize = new Float32Array(MAX);
1250 const pRot = new Float32Array(MAX);
1251 const pRotVel = new Float32Array(MAX);
1252 const pDriftPhase = new Float32Array(MAX);
1253 const pDriftFreq = new Float32Array(MAX);
1254 const pDriftAmp = new Float32Array(MAX);
1255 const pBaseAlpha = new Float32Array(MAX);
1256 const pState = new Uint8Array(MAX);
1257 const pAnchor = new Array(MAX);
1258 const pAnchorDX = new Float32Array(MAX);
1259 const pAnchorDY = new Float32Array(MAX);
1260 const pStuckLife = new Float32Array(MAX);
1261 const pMelt = new Float32Array(MAX);
1262 const pSurfaceId = new Array(MAX);
1263 const pBucket = new Int32Array(MAX);
1264 const pPileAdd = new Float32Array(MAX);
1265 const pPileRemaining = new Float32Array(MAX);
1266 const particles = new Array(MAX);
1267 const freeList = new Array(MAX);
1268 for (let i = 0; i < MAX; i++) {
1269 const particle = new pixi.Particle({
1270 texture,
1271 anchorX: 0.5,
1272 anchorY: 0.5,
1273 // Free particles are hidden via alpha rather than removed
1274 // from the container — the mutation is already on the
1275 // GPU's dynamic-color path, so this is cheaper than
1276 // churning the particle list.
1277 alpha: 0,
1278 tint: 16777215
1279 });
1280 stage.addParticle(particle);
1281 particles[i] = particle;
1282 pState[i] = 0;
1283 pAnchor[i] = null;
1284 pSurfaceId[i] = null;
1285 pBucket[i] = -1;
1286 pPileAdd[i] = 0;
1287 pPileRemaining[i] = 0;
1288 freeList[i] = MAX - 1 - i;
1289 }
1290 let freeCount = MAX;
1291 const pileHeights = /* @__PURE__ */ new Map();
1292 const surfaces = [];
1293 let surfacesDirty = true;
1294 let canvasRect = app.canvas.getBoundingClientRect();
1295 function refreshCanvasRect() {
1296 canvasRect = app.canvas.getBoundingClientRect();
1297 }
1298 function refreshSurfacesIfDirty() {
1299 if (!surfacesDirty) {
1300 return;
1301 }
1302 surfaces.length = 0;
1303 if (!getSurfaces) {
1304 surfacesDirty = false;
1305 return;
1306 }
1307 const all = getSurfaces();
1308 let liveIds = null;
1309 for (let k = 0; k < all.length; k++) {
1310 const s = all[k];
1311 if (s.face !== "top") {
1312 continue;
1313 }
1314 if (s.rect.width <= 0 || s.rect.height <= 0) {
1315 continue;
1316 }
1317 surfaces.push(s);
1318 if (pileHeights.size > 0) {
1319 if (liveIds === null) {
1320 liveIds = /* @__PURE__ */ new Set();
1321 }
1322 liveIds.add(s.id);
1323 }
1324 }
1325 if (pileHeights.size > 0) {
1326 pileHeights.forEach((_arr, id) => {
1327 if (!liveIds || !liveIds.has(id)) {
1328 pileHeights.delete(id);
1329 }
1330 });
1331 }
1332 surfacesDirty = false;
1333 }
1334 function getPileForSurface(surface) {
1335 const bucketCount = Math.max(
1336 1,
1337 Math.ceil(surface.rect.width / TUNING.pileBucketPx)
1338 );
1339 const existing = pileHeights.get(surface.id);
1340 if (existing && existing.length === bucketCount) {
1341 return existing;
1342 }
1343 const fresh = new Float32Array(bucketCount);
1344 if (existing) {
1345 const copyLen = Math.min(existing.length, bucketCount);
1346 for (let i = 0; i < copyLen; i++) {
1347 fresh[i] = existing[i];
1348 }
1349 }
1350 pileHeights.set(surface.id, fresh);
1351 return fresh;
1352 }
1353 function spawn() {
1354 if (freeCount === 0) {
1355 return;
1356 }
1357 const idx = freeList[--freeCount];
1358 const w = app.canvas.clientWidth;
1359 const sizeMax = tunables.flakeSize;
1360 const sizeMin = sizeMax / 2;
1361 pX[idx] = Math.random() * w;
1362 pY[idx] = -rand(50, 180);
1363 pVX[idx] = rand(-8, 8);
1364 pVY[idx] = rand(TUNING.gravityMin, TUNING.gravityMax);
1365 pSize[idx] = rand(sizeMin, sizeMax);
1366 pRot[idx] = Math.random() * Math.PI * 2;
1367 pRotVel[idx] = rand(-0.2, TUNING.rotationMax);
1368 pDriftPhase[idx] = Math.random() * Math.PI * 2;
1369 pDriftFreq[idx] = 2 * Math.PI / rand(TUNING.driftPeriodMin, TUNING.driftPeriodMax);
1370 pDriftAmp[idx] = rand(6, TUNING.driftAmplitude);
1371 pBaseAlpha[idx] = rand(TUNING.alphaMin, TUNING.alphaMax);
1372 pMelt[idx] = 0;
1373 pStuckLife[idx] = 0;
1374 pAnchor[idx] = null;
1375 pSurfaceId[idx] = null;
1376 pBucket[idx] = -1;
1377 pPileAdd[idx] = 0;
1378 pState[idx] = 1;
1379 pPileRemaining[idx] = 0;
1380 const particle = particles[idx];
1381 if (!particle) {
1382 return;
1383 }
1384 const scale = pSize[idx] / TEXTURE_SIZE;
1385 particle.scaleX = scale;
1386 particle.scaleY = scale;
1387 particle.alpha = pBaseAlpha[idx];
1388 particle.rotation = pRot[idx];
1389 particle.x = pX[idx];
1390 particle.y = pY[idx];
1391 }
1392 function decrementPileFor(idx) {
1393 const sid = pSurfaceId[idx];
1394 if (sid === null) {
1395 return;
1396 }
1397 const pile = pileHeights.get(sid);
1398 if (!pile) {
1399 return;
1400 }
1401 const b = pBucket[idx];
1402 if (b < 0 || b >= pile.length) {
1403 return;
1404 }
1405 const add = pPileRemaining[idx];
1406 if (add <= 0) {
1407 return;
1408 }
1409 const spread = add * TUNING.pileSpread;
1410 pile[b] = Math.max(0, pile[b] - add);
1411 if (b > 0) {
1412 pile[b - 1] = Math.max(0, pile[b - 1] - spread);
1413 }
1414 if (b + 1 < pile.length) {
1415 pile[b + 1] = Math.max(0, pile[b + 1] - spread);
1416 }
1417 pPileRemaining[idx] = 0;
1418 }
1419 function release(idx) {
1420 decrementPileFor(idx);
1421 pState[idx] = 0;
1422 pAnchor[idx] = null;
1423 pSurfaceId[idx] = null;
1424 pBucket[idx] = -1;
1425 pPileAdd[idx] = 0;
1426 pPileRemaining[idx] = 0;
1427 const particle = particles[idx];
1428 if (particle) {
1429 particle.alpha = 0;
1430 }
1431 freeList[freeCount++] = idx;
1432 }
1433 function stick(idx, anchorEl, dx, pileHeight, surfaceId, bucket, pileAdd) {
1434 pState[idx] = 2;
1435 pAnchor[idx] = anchorEl;
1436 pAnchorDX[idx] = dx;
1437 pAnchorDY[idx] = TUNING.collisionMarginY - pileHeight;
1438 pSurfaceId[idx] = surfaceId;
1439 pBucket[idx] = bucket;
1440 pPileAdd[idx] = pileAdd;
1441 pPileRemaining[idx] = pileAdd;
1442 pVX[idx] = 0;
1443 pVY[idx] = 0;
1444 pRotVel[idx] = 0;
1445 pStuckLife[idx] = rand(
1446 TUNING.stuckLifeSec - TUNING.stuckLifeJitter,
1447 TUNING.stuckLifeSec + TUNING.stuckLifeJitter
1448 );
1449 }
1450 function startMelt(idx) {
1451 pState[idx] = 3;
1452 pMelt[idx] = 0;
1453 }
1454 function detachToFalling(idx) {
1455 decrementPileFor(idx);
1456 pState[idx] = 1;
1457 pAnchor[idx] = null;
1458 pSurfaceId[idx] = null;
1459 pBucket[idx] = -1;
1460 pPileAdd[idx] = 0;
1461 pPileRemaining[idx] = 0;
1462 pVX[idx] = rand(-6, 6);
1463 pVY[idx] = rand(TUNING.gravityMin, TUNING.gravityMax);
1464 pRotVel[idx] = rand(-0.2, TUNING.rotationMax);
1465 }
1466 function collideWithSurfaces(idx, prevY) {
1467 const vpX = pX[idx] + canvasRect.left;
1468 const vpY = pY[idx] + canvasRect.top;
1469 const prevVpY = prevY + canvasRect.top;
1470 for (let k = 0; k < surfaces.length; k++) {
1471 const s = surfaces[k];
1472 const r = s.rect;
1473 if (vpX < r.x || vpX > r.x + r.width) {
1474 continue;
1475 }
1476 const pile = getPileForSurface(s);
1477 let bucket = Math.floor((vpX - r.x) / TUNING.pileBucketPx);
1478 if (bucket < 0) {
1479 bucket = 0;
1480 } else if (bucket >= pile.length) {
1481 bucket = pile.length - 1;
1482 }
1483 const pileHeight = pile[bucket];
1484 const top = r.y + TUNING.collisionMarginY - pileHeight;
1485 if (prevVpY <= top && vpY >= top) {
1486 const add = pSize[idx] * TUNING.pileContribution;
1487 const spread = add * TUNING.pileSpread;
1488 stick(idx, s.element, vpX - r.x, pileHeight, s.id, bucket, add);
1489 pile[bucket] = Math.min(TUNING.pileMaxPx, pile[bucket] + add);
1490 if (bucket > 0) {
1491 pile[bucket - 1] = Math.min(
1492 TUNING.pileMaxPx,
1493 pile[bucket - 1] + spread
1494 );
1495 }
1496 if (bucket + 1 < pile.length) {
1497 pile[bucket + 1] = Math.min(
1498 TUNING.pileMaxPx,
1499 pile[bucket + 1] + spread
1500 );
1501 }
1502 return true;
1503 }
1504 }
1505 return false;
1506 }
1507 let elapsed = 0;
1508 let lastRectRefresh = -1;
1509 let spawnAccum = 0;
1510 let animating = !opts.prefersReducedMotion;
1511 function spawnPerSecond() {
1512 return TUNING.spawnPerSecondAtDefault * tunables.particleCount / TUNING.spawnCalibrationCount;
1513 }
1514 if (!animating) {
1515 const staticCount = tunables.particleCount * 0.35;
1516 for (let s = 0; s < staticCount; s++) {
1517 spawn();
1518 }
1519 }
1520 function tick(ticker) {
1521 let dt = ticker.deltaMS / 1e3;
1522 if (dt > 0.1) {
1523 dt = 0.1;
1524 }
1525 elapsed += dt;
1526 if (elapsed - lastRectRefresh > 0.05) {
1527 surfacesDirty = true;
1528 lastRectRefresh = elapsed;
1529 }
1530 refreshCanvasRect();
1531 refreshSurfacesIfDirty();
1532 const wind = Math.sin(elapsed / TUNING.windPeriodSec * Math.PI * 2) * tunables.wind;
1533 if (animating) {
1534 spawnAccum += dt * spawnPerSecond();
1535 while (spawnAccum >= 1) {
1536 if (MAX - freeCount >= tunables.particleCount) {
1537 spawnAccum = 0;
1538 break;
1539 }
1540 spawn();
1541 spawnAccum -= 1;
1542 }
1543 }
1544 const w = app.canvas.clientWidth;
1545 const h = app.canvas.clientHeight;
1546 for (let idx = 0; idx < MAX; idx++) {
1547 const st = pState[idx];
1548 if (st === 0) {
1549 continue;
1550 }
1551 const particle = particles[idx];
1552 if (!particle) {
1553 continue;
1554 }
1555 if (st === 1) {
1556 const prevY = pY[idx];
1557 const sway = Math.sin(elapsed * pDriftFreq[idx] + pDriftPhase[idx]) * pDriftAmp[idx];
1558 pVX[idx] += (wind + sway - pVX[idx]) * Math.min(1, dt * 1.5);
1559 pX[idx] += pVX[idx] * dt;
1560 pY[idx] += pVY[idx] * dt;
1561 pRot[idx] += pRotVel[idx] * dt;
1562 if (pX[idx] < -16) {
1563 pX[idx] += w + 32;
1564 } else if (pX[idx] > w + 16) {
1565 pX[idx] -= w + 32;
1566 }
1567 if (collideWithSurfaces(idx, prevY)) ;
1568 else if (pY[idx] > h + 24) {
1569 release(idx);
1570 continue;
1571 }
1572 if (pState[idx] === 1) {
1573 particle.x = pX[idx];
1574 particle.y = pY[idx];
1575 particle.rotation = pRot[idx];
1576 }
1577 }
1578 if (pState[idx] === 2) {
1579 const anchorEl = pAnchor[idx];
1580 if (anchorEl) {
1581 if (!anchorEl.isConnected) {
1582 detachToFalling(idx);
1583 } else if (anchorEl.offsetParent === null) {
1584 startMelt(idx);
1585 } else {
1586 const arect = anchorEl.getBoundingClientRect();
1587 if (pAnchorDX[idx] < 0 || pAnchorDX[idx] > arect.width) {
1588 detachToFalling(idx);
1589 continue;
1590 }
1591 const ax = arect.left - canvasRect.left + pAnchorDX[idx];
1592 const ay = arect.top - canvasRect.top + pAnchorDY[idx];
1593 pX[idx] = ax;
1594 pY[idx] = ay;
1595 particle.x = ax;
1596 particle.y = ay;
1597 }
1598 } else {
1599 particle.x = pX[idx];
1600 particle.y = pY[idx];
1601 }
1602 if (pState[idx] === 2) {
1603 pStuckLife[idx] -= dt;
1604 if (pStuckLife[idx] <= 0) {
1605 startMelt(idx);
1606 }
1607 }
1608 }
1609 if (pState[idx] === 3) {
1610 pMelt[idx] += dt / TUNING.meltDurationSec;
1611 const t = pMelt[idx] > 1 ? 1 : pMelt[idx];
1612 particle.alpha = pBaseAlpha[idx] * (1 - t);
1613 const meltScale = pSize[idx] / TEXTURE_SIZE * (1 - t * 0.6);
1614 particle.scaleX = meltScale;
1615 particle.scaleY = meltScale;
1616 if (pPileRemaining[idx] > 0 && pSurfaceId[idx] !== null) {
1617 const meltStep = dt / TUNING.meltDurationSec;
1618 const rawDelta = pPileAdd[idx] * meltStep;
1619 const delta = rawDelta < pPileRemaining[idx] ? rawDelta : pPileRemaining[idx];
1620 pPileRemaining[idx] -= delta;
1621 const pile = pileHeights.get(pSurfaceId[idx]);
1622 if (pile && pBucket[idx] >= 0 && pBucket[idx] < pile.length) {
1623 const bk = pBucket[idx];
1624 const spread = delta * TUNING.pileSpread;
1625 pile[bk] = Math.max(0, pile[bk] - delta);
1626 if (bk > 0) {
1627 pile[bk - 1] = Math.max(
1628 0,
1629 pile[bk - 1] - spread
1630 );
1631 }
1632 if (bk + 1 < pile.length) {
1633 pile[bk + 1] = Math.max(
1634 0,
1635 pile[bk + 1] - spread
1636 );
1637 }
1638 }
1639 const mySid = pSurfaceId[idx];
1640 const myBucket = pBucket[idx];
1641 const myDY = pAnchorDY[idx];
1642 for (let j = 0; j < MAX; j++) {
1643 if (pState[j] === 2 && pSurfaceId[j] === mySid && pBucket[j] === myBucket && pAnchorDY[j] < myDY) {
1644 pAnchorDY[j] += delta;
1645 }
1646 }
1647 }
1648 if (t >= 1) {
1649 release(idx);
1650 }
1651 }
1652 }
1653 }
1654 app.ticker.add(tick);
1655 if (!animating) {
1656 app.ticker.update();
1657 app.ticker.stop();
1658 }
1659 let destroyed = false;
1660 return {
1661 setAnimating(next) {
1662 if (destroyed) {
1663 return;
1664 }
1665 animating = next && !opts.prefersReducedMotion;
1666 if (animating) {
1667 app.ticker.start();
1668 } else {
1669 app.ticker.stop();
1670 }
1671 },
1672 applySettings(next) {
1673 if (destroyed) {
1674 return;
1675 }
1676 tunables.wind = next.wind;
1677 tunables.particleCount = next.particleCount;
1678 tunables.flakeSize = next.flakeSize;
1679 if (next.background !== tunables.background) {
1680 tunables.background = next.background;
1681 container.style.background = backdropCss(next.background);
1682 }
1683 },
1684 markSurfacesDirty() {
1685 surfacesDirty = true;
1686 },
1687 detachFlakesAnchoredTo(element) {
1688 if (destroyed) {
1689 return;
1690 }
1691 surfacesDirty = true;
1692 for (let i = 0; i < MAX; i++) {
1693 if (pState[i] === 2 && pAnchor[i] === element) {
1694 detachToFalling(i);
1695 }
1696 }
1697 },
1698 destroy() {
1699 if (destroyed) {
1700 return;
1701 }
1702 destroyed = true;
1703 app.ticker.stop();
1704 app.ticker.remove(tick);
1705 app.destroy(
1706 { removeView: true },
1707 { children: true, texture: true, textureSource: true }
1708 );
1709 for (let i = 0; i < MAX; i++) {
1710 particles[i] = null;
1711 pAnchor[i] = null;
1712 }
1713 container.style.background = priorBackground;
1714 }
1715 };
1716 }
1717 const WALLPAPER_ID = "wp-snow";
1718 const NAMESPACE = "desktop-mode/snow";
1719 const PREVIEW = backdropCss(SNOW_DEFAULTS.background);
1720 const PREVIEW_PARTICLES = 140;
1721 function surfacesSupplier() {
1722 const api = window.wp?.desktop;
1723 if (!api || typeof api.getWallpaperSurfaces !== "function") {
1724 return null;
1725 }
1726 return () => api.getWallpaperSurfaces();
1727 }
1728 function wireSceneHooks(scene) {
1729 const visibilityHandler = (...args) => {
1730 const detail = args[0];
1731 if (!detail || detail.id !== WALLPAPER_ID) {
1732 return;
1733 }
1734 scene.setAnimating(detail.state === "visible");
1735 };
1736 addAction(
1737 HOOKS.WALLPAPER_VISIBILITY,
1738 `${NAMESPACE}/visibility`,
1739 visibilityHandler
1740 );
1741 const detachHandler = (...args) => {
1742 const detail = args[0];
1743 if (!detail || !detail.element) {
1744 return;
1745 }
1746 scene.detachFlakesAnchoredTo(detail.element);
1747 };
1748 addAction(
1749 HOOKS.WINDOW_CLOSING,
1750 `${NAMESPACE}/window-closing`,
1751 detachHandler
1752 );
1753 addAction(
1754 HOOKS.WINDOW_MINIMIZED,
1755 `${NAMESPACE}/window-minimized`,
1756 detachHandler
1757 );
1758 const dirtyHandler = () => {
1759 scene.markSurfacesDirty();
1760 };
1761 addAction(
1762 HOOKS.WINDOW_BOUNDS_CHANGED,
1763 `${NAMESPACE}/bounds-changed`,
1764 dirtyHandler
1765 );
1766 addAction(
1767 HOOKS.WINDOW_RESTORED,
1768 `${NAMESPACE}/window-restored`,
1769 dirtyHandler
1770 );
1771 addAction(
1772 HOOKS.WINDOW_MAXIMIZED,
1773 `${NAMESPACE}/window-maximized`,
1774 dirtyHandler
1775 );
1776 addAction(
1777 HOOKS.WINDOW_UNMAXIMIZED,
1778 `${NAMESPACE}/window-unmaximized`,
1779 dirtyHandler
1780 );
1781 addAction(
1782 HOOKS.WINDOW_FULLSCREEN_ENTERED,
1783 `${NAMESPACE}/window-fullscreen-entered`,
1784 dirtyHandler
1785 );
1786 addAction(
1787 HOOKS.WINDOW_FULLSCREEN_EXITED,
1788 `${NAMESPACE}/window-fullscreen-exited`,
1789 dirtyHandler
1790 );
1791 const widgetUnmountingHandler = (...args) => {
1792 const detail = args[0];
1793 if (!detail || !detail.id) {
1794 return;
1795 }
1796 const safeId = window.CSS && typeof CSS.escape === "function" ? CSS.escape(detail.id) : String(detail.id).replace(/"/g, '\\"');
1797 const card = document.querySelector(
1798 `[data-widget-id="${safeId}"]`
1799 );
1800 if (!card) {
1801 return;
1802 }
1803 scene.detachFlakesAnchoredTo(card);
1804 };
1805 addAction(
1806 HOOKS.WIDGET_UNMOUNTING,
1807 `${NAMESPACE}/widget-unmounting`,
1808 widgetUnmountingHandler
1809 );
1810 const settingsHandler = (...args) => {
1811 const detail = args[0];
1812 if (!detail || detail.id !== WALLPAPER_ID) {
1813 return;
1814 }
1815 scene.applySettings(sanitizeSnowSettings(detail.settings));
1816 };
1817 addAction(
1818 HOOKS.WALLPAPER_SETTINGS_CHANGED,
1819 `${NAMESPACE}/settings-changed`,
1820 settingsHandler
1821 );
1822 return () => {
1823 removeAction(HOOKS.WALLPAPER_VISIBILITY, `${NAMESPACE}/visibility`);
1824 removeAction(HOOKS.WINDOW_CLOSING, `${NAMESPACE}/window-closing`);
1825 removeAction(HOOKS.WINDOW_MINIMIZED, `${NAMESPACE}/window-minimized`);
1826 removeAction(HOOKS.WINDOW_RESTORED, `${NAMESPACE}/window-restored`);
1827 removeAction(HOOKS.WINDOW_MAXIMIZED, `${NAMESPACE}/window-maximized`);
1828 removeAction(
1829 HOOKS.WINDOW_UNMAXIMIZED,
1830 `${NAMESPACE}/window-unmaximized`
1831 );
1832 removeAction(
1833 HOOKS.WINDOW_FULLSCREEN_ENTERED,
1834 `${NAMESPACE}/window-fullscreen-entered`
1835 );
1836 removeAction(
1837 HOOKS.WINDOW_FULLSCREEN_EXITED,
1838 `${NAMESPACE}/window-fullscreen-exited`
1839 );
1840 removeAction(
1841 HOOKS.WINDOW_BOUNDS_CHANGED,
1842 `${NAMESPACE}/bounds-changed`
1843 );
1844 removeAction(
1845 HOOKS.WIDGET_UNMOUNTING,
1846 `${NAMESPACE}/widget-unmounting`
1847 );
1848 removeAction(
1849 HOOKS.WALLPAPER_SETTINGS_CHANGED,
1850 `${NAMESPACE}/settings-changed`
1851 );
1852 };
1853 }
1854 function rangeField(label, limits, step, value, onChange) {
1855 const field = document.createElement("wpd-range-field");
1856 field.setAttribute("label", label);
1857 field.setAttribute("min", String(limits.min));
1858 field.setAttribute("max", String(limits.max));
1859 field.setAttribute("step", String(step));
1860 field.setAttribute("value", String(value));
1861 field.addEventListener("wpd-range-change", (e) => {
1862 onChange(e.detail.value);
1863 });
1864 return field;
1865 }
1866 function renderSnowConfig(container, ctx) {
1867 let current = sanitizeSnowSettings(ctx.settings);
1868 const set = (partial) => {
1869 current = { ...current, ...partial };
1870 ctx.setSettings(partial);
1871 };
1872 const windField = rangeField(
1873 __("Wind"),
1874 SNOW_LIMITS.wind,
1875 1,
1876 current.wind,
1877 (value) => set({ wind: value })
1878 );
1879 const particlesField = rangeField(
1880 __("Snowflakes"),
1881 SNOW_LIMITS.particleCount,
1882 10,
1883 current.particleCount,
1884 (value) => set({ particleCount: Math.round(value) })
1885 );
1886 const sizeField = rangeField(
1887 __("Flake size"),
1888 SNOW_LIMITS.flakeSize,
1889 1,
1890 current.flakeSize,
1891 (value) => set({ flakeSize: value })
1892 );
1893 const colorField = document.createElement("wpd-color-field");
1894 colorField.setAttribute("label", __("Background color"));
1895 colorField.setAttribute("value", current.background);
1896 colorField.addEventListener("wpd-color-change", (e) => {
1897 const value = e.detail.value;
1898 set({ background: sanitizeSnowSettings({ background: value }).background });
1899 });
1900 const reset = document.createElement("wpd-button");
1901 reset.setAttribute("variant", "ghost");
1902 reset.style.alignSelf = "flex-start";
1903 reset.style.marginTop = "4px";
1904 reset.textContent = __("Reset to defaults");
1905 reset.addEventListener("click", () => {
1906 set({ ...SNOW_DEFAULTS });
1907 windField.setAttribute("value", String(SNOW_DEFAULTS.wind));
1908 particlesField.setAttribute(
1909 "value",
1910 String(SNOW_DEFAULTS.particleCount)
1911 );
1912 sizeField.setAttribute("value", String(SNOW_DEFAULTS.flakeSize));
1913 colorField.setAttribute("value", SNOW_DEFAULTS.background);
1914 });
1915 container.appendChild(windField);
1916 container.appendChild(particlesField);
1917 container.appendChild(sizeField);
1918 container.appendChild(colorField);
1919 container.appendChild(reset);
1920 return () => {
1921 };
1922 }
1923 const def = {
1924 id: WALLPAPER_ID,
1925 label: __("Snow"),
1926 type: "canvas",
1927 preview: PREVIEW,
1928 previewParams: { particleCount: PREVIEW_PARTICLES },
1929 /**
1930 * Live tile preview for the OS Settings picker — the real
1931 * simulation at tile scale, minus surface collision (surface
1932 * rects are viewport-space and meaningless inside a tile) and at
1933 * a fraction of the field density.
1934 */
1935 renderPreview: async (container, ctx) => {
1936 const pixi = getPixi();
1937 if (!pixi) {
1938 return () => {
1939 };
1940 }
1941 const settings = sanitizeSnowSettings(ctx.settings);
1942 const rawCount = ctx.params.particleCount;
1943 const previewCount = typeof rawCount === "number" && Number.isFinite(rawCount) ? rawCount : PREVIEW_PARTICLES;
1944 const scene = await mountSnowScene({
1945 container,
1946 pixi,
1947 settings: sanitizeSnowSettings({
1948 ...settings,
1949 particleCount: previewCount
1950 }),
1951 prefersReducedMotion: ctx.prefersReducedMotion,
1952 getSurfaces: null
1953 });
1954 return () => scene.destroy();
1955 },
1956 needs: ["pixijs"],
1957 mount: async (container, ctx) => {
1958 const pixi = getPixi();
1959 if (!pixi) {
1960 return () => {
1961 };
1962 }
1963 const scene = await mountSnowScene({
1964 container,
1965 pixi,
1966 settings: sanitizeSnowSettings(ctx.settings),
1967 prefersReducedMotion: ctx.prefersReducedMotion,
1968 getSurfaces: surfacesSupplier()
1969 });
1970 const unwireHooks = wireSceneHooks(scene);
1971 return () => {
1972 unwireHooks();
1973 scene.destroy();
1974 };
1975 },
1976 renderConfig: renderSnowConfig
1977 };
1978 window.desktopModeWallpapers = window.desktopModeWallpapers || {};
1979 window.desktopModeWallpapers[WALLPAPER_ID] = def;
1980 })();
1981