domma-cms 0.103.0 → 0.104.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -943,7 +943,7 @@ Body content in **Markdown** is supported.
943
943
  <code>[ticker-tape]</code>, <code>[butterflies]</code>, <code>[strobe]</code>,
944
944
  <code>[animate]</code>, <code>[ambient]</code>, <code>[firework]</code>,
945
945
  <code>[fireworks]</code> and <code>[celebrate]</code> (with shorthands such as
946
- <code>[christmas /]</code>). Many can also be added to a card, box, hero, banner, column or
946
+ <code>[christmas /]</code>; <code>[christmas snow-only="true" /]</code> is just the falling snow). Many can also be added to a card, box, hero, banner, column or
947
947
  button as attributes: <code>[card reveal reveal-animation="zoom"]</code>. Settings are at
948
948
  <strong>System &gt; Effects</strong>.</p>
949
949
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.103.0",
3
+ "version": "0.104.1",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -218,6 +218,23 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
218
218
  return room ? {in: admitted(room).length, waiting: waitingList(room).length} : {in: 0, waiting: 0};
219
219
  },
220
220
 
221
+ /**
222
+ * Take one person out of a call - everyone joined with `identity` (someone who left the conversation
223
+ * the call belongs to). They are told the call ended for them; the others see them leave.
224
+ */
225
+ kick(key, identity) {
226
+ const room = rooms.get(key);
227
+ if (!room || !identity) return 0;
228
+ let n = 0;
229
+ for (const p of [...room.people.values()]) {
230
+ if (p.identity !== identity) continue;
231
+ push(p, {type: 'ended'});
232
+ remove(room, p, 'removed');
233
+ n += 1;
234
+ }
235
+ return n;
236
+ },
237
+
221
238
  /** End a call for everyone (a booking cancelled, an event deleted). */
222
239
  close(key) {
223
240
  const room = rooms.get(key);
@@ -84,5 +84,6 @@ export function mountCallRoutes(fastify, {prefix = '/call', ns, resolve, rooms =
84
84
  action('recording', (k, id, s, b) => rooms.recording(k, id, s, b.on === true));
85
85
  action('leave', (k, id, s) => rooms.leave(k, id, s));
86
86
 
87
- return {...rooms, closeRoom: (room) => rooms.close(keyOf(room)), countRoom: (room) => rooms.count(keyOf(room))};
87
+ return {...rooms, closeRoom: (room) => rooms.close(keyOf(room)), countRoom: (room) => rooms.count(keyOf(room)),
88
+ kickFromRoom: (room, identity) => rooms.kick(keyOf(room), identity)};
88
89
  }
@@ -1 +1 @@
1
- .dmc{--dmc-gap: .75rem;display:flex;flex-direction:column;gap:1rem;min-height:70vh;color:var(--dm-text, inherit)}.dmc [hidden]{display:none!important}.dmc-head{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}.dmc-title{font-size:1.35rem;margin:0;flex:1 1 auto}.dmc-rec{color:#fff;background:var(--dm-danger, #c62828);border-radius:999px;padding:.2rem .7rem;font-size:.85rem;font-weight:600}.dmc-count{color:var(--dm-text-muted, #666);font-size:.9rem}.dmc-panel{display:grid;grid-template-columns:minmax(0,3fr) minmax(0,2fr);gap:1.5rem;align-items:start}.dmc-panel--msg{display:block;text-align:center;padding:3rem 1rem}.dmc-preview{position:relative;aspect-ratio:16 / 9;background:#111;border-radius:.75rem;overflow:hidden}.dmc-preview video,.dmc-tile video{width:100%;height:100%;object-fit:cover;display:block}.dmc-self,.dmc-self-preview{transform:scaleX(-1)}.dmc-nocam{position:absolute;inset:0;display:grid;place-items:center;color:#ddd;margin:0}.dmc-check h2{margin-top:0}.dmc-label{display:grid;gap:.35rem;margin:1rem 0;font-weight:600}.dmc-input{font:inherit;padding:.55rem .7rem;border:1px solid var(--dm-border, #ccc);border-radius:.5rem;background:var(--dm-surface, #fff);color:inherit;min-width:0}.dmc-row{display:flex;gap:.5rem;flex-wrap:wrap;margin:1rem 0}.dmc-note{color:var(--dm-text-muted, #666)}.dmc-error{color:var(--dm-danger, #c62828);font-weight:600}.dmc-btn{font:inherit;cursor:pointer;padding:.55rem 1rem;border-radius:.5rem;border:1px solid var(--dm-border, #ccc);background:var(--dm-surface, #fff);color:inherit}.dmc-btn:hover{border-color:var(--dm-primary, #555)}.dmc-btn:disabled{opacity:.6;cursor:default}.dmc-btn.is-off{background:var(--dm-danger, #c62828);border-color:transparent;color:#fff}.dmc-btn--go{background:var(--dm-primary, #1f6feb);border-color:transparent;color:#fff}.dmc-btn--leave{background:var(--dm-danger, #c62828);border-color:transparent;color:#fff}.dmc-btn--rec{border-color:var(--dm-danger, #c62828)}.dmc-btn--small{padding:.3rem .65rem;font-size:.9rem}.dmc-call{display:grid;grid-template-columns:minmax(0,1fr) auto;grid-template-rows:1fr auto;gap:var(--dmc-gap)}.dmc-main{display:grid;gap:var(--dmc-gap);grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr));align-content:start}.dmc-main[data-people="1"]{grid-template-columns:minmax(0,1fr)}.dmc-main[data-people="2"]{grid-template-columns:repeat(auto-fit,minmax(min(100%,360px),1fr))}.dmc-grid,.dmc-remote{display:contents}.dmc-tile{position:relative;margin:0;aspect-ratio:16 / 9;background:#111;border-radius:.75rem;overflow:hidden}.dmc-tile figcaption{position:absolute;left:.5rem;bottom:.5rem;padding:.15rem .5rem;border-radius:.35rem;background:#0009;color:#fff;font-size:.85rem}.dmc-side{width:300px;display:flex;flex-direction:column;gap:1rem;grid-row:1 / span 1;grid-column:2}.dmc-side h3{margin:0 0 .5rem;font-size:1rem}.dmc-waiting ul{list-style:none;margin:0;padding:0;display:grid;gap:.5rem}.dmc-waiting li{display:flex;align-items:center;gap:.4rem;flex-wrap:wrap}.dmc-waiting li span{flex:1 1 100%;font-weight:600}.dmc-chat{display:flex;flex-direction:column;min-height:0;flex:1}.dmc-messages{list-style:none;margin:0 0 .5rem;padding:0;overflow-y:auto;max-height:50vh;display:grid;gap:.4rem}.dmc-messages li.is-mine strong{color:var(--dm-primary, #1f6feb)}.dmc-send{display:flex;gap:.4rem}.dmc-send .dmc-input{flex:1}.dmc-bar{grid-column:1 / -1;display:flex;gap:.5rem;flex-wrap:wrap;justify-content:center}@media(max-width:760px){.dmc-panel,.dmc-call{grid-template-columns:1fr}.dmc-side{width:auto;grid-column:1;grid-row:auto}.dmc-bar{position:sticky;bottom:0;background:var(--dm-background, #fff);padding:.5rem 0}.dmc-bar .dmc-btn{flex:1 1 30%}}
1
+ .dmc{--dmc-gap: .75rem;display:flex;flex-direction:column;gap:1rem;min-height:70vh;color:var(--dm-text, inherit)}.dmc [hidden]{display:none!important}.dmc-head{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}.dmc-title{font-size:1.35rem;margin:0;flex:1 1 auto}.dmc-rec{color:#fff;background:var(--dm-danger, #c62828);border-radius:999px;padding:.2rem .7rem;font-size:.85rem;font-weight:600}.dmc-count{color:var(--dm-text-muted, #666);font-size:.9rem}.dmc-panel{display:grid;grid-template-columns:minmax(0,3fr) minmax(0,2fr);gap:1.5rem;align-items:start}.dmc-panel--msg{display:block;text-align:center;padding:3rem 1rem}.dmc-preview{position:relative;aspect-ratio:16 / 9;background:#111;border-radius:.75rem;overflow:hidden}.dmc-preview video,.dmc-tile video{width:100%;height:100%;object-fit:cover;display:block}.dmc-self,.dmc-self-preview{transform:scaleX(-1)}.dmc-nocam{position:absolute;inset:0;display:grid;place-items:center;color:#ddd;margin:0}.dmc-check h2{margin-top:0}.dmc-label{display:grid;gap:.35rem;margin:1rem 0;font-weight:600}.dmc-input{font:inherit;padding:.55rem .7rem;border:1px solid var(--dm-border, #ccc);border-radius:.5rem;background:var(--dm-surface, #fff);color:inherit;min-width:0}.dmc-row{display:flex;gap:.5rem;flex-wrap:wrap;margin:1rem 0}.dmc-note{color:var(--dm-text-muted, #666)}.dmc-error{color:var(--dm-danger, #c62828);font-weight:600}.dmc-btn{font:inherit;cursor:pointer;padding:.55rem 1rem;border-radius:.5rem;border:1px solid var(--dm-border, #ccc);background:var(--dm-surface, #fff);color:inherit;display:inline-flex;align-items:center;justify-content:center;gap:.45rem;position:relative}.dmc-i{display:inline-flex}.dmc-btn svg,.dmc-rec svg,.dmc-count svg{width:18px;height:18px;flex:none}.dmc-rec svg{width:10px;height:10px;fill:currentColor}.dmc-count{display:inline-flex;align-items:center;gap:.35rem}.dmc-tool{flex-direction:column;gap:.2rem;min-width:5.5rem;padding:.5rem .75rem;font-size:.85rem}.dmc-btn.is-on{border-color:var(--dm-primary, #1f6feb);color:var(--dm-primary, #1f6feb)}.dmc-btn--rec.is-on{border-color:var(--dm-danger, #c62828);color:var(--dm-danger, #c62828)}.dmc-btn--rec.is-on svg{fill:currentColor}.dmc-badge{position:absolute;top:.2rem;right:.35rem;min-width:1.1rem;height:1.1rem;padding:0 .25rem;border-radius:999px;background:var(--dm-danger, #c62828);color:#fff;font-size:.7rem;line-height:1.1rem;text-align:center}.dmc-btn:hover{border-color:var(--dm-primary, #555)}.dmc-btn:disabled{opacity:.6;cursor:default}.dmc-btn.is-off{background:var(--dm-danger, #c62828);border-color:transparent;color:#fff}.dmc-btn--go{background:var(--dm-primary, #1f6feb);border-color:transparent;color:#fff}.dmc-btn--leave{background:var(--dm-danger, #c62828);border-color:transparent;color:#fff}.dmc-btn--rec{border-color:var(--dm-danger, #c62828)}.dmc-btn--small{padding:.3rem .65rem;font-size:.9rem}.dmc-call{display:grid;grid-template-columns:minmax(0,1fr) auto;grid-template-rows:1fr auto;gap:var(--dmc-gap)}.dmc-main{display:grid;gap:var(--dmc-gap);grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr));align-content:start}.dmc-main[data-people="1"]{grid-template-columns:minmax(0,1fr)}.dmc-main[data-people="2"]{grid-template-columns:repeat(auto-fit,minmax(min(100%,360px),1fr))}.dmc-grid,.dmc-remote{display:contents}.dmc-tile{position:relative;margin:0;aspect-ratio:16 / 9;background:#111;border-radius:.75rem;overflow:hidden}.dmc-tile figcaption{position:absolute;left:.5rem;bottom:.5rem;padding:.15rem .5rem;border-radius:.35rem;background:#0009;color:#fff;font-size:.85rem}.dmc-side{width:300px;display:flex;flex-direction:column;gap:1rem;grid-row:1 / span 1;grid-column:2}.dmc-side h3{margin:0 0 .5rem;font-size:1rem}.dmc-waiting ul{list-style:none;margin:0;padding:0;display:grid;gap:.5rem}.dmc-waiting li{display:flex;align-items:center;gap:.4rem;flex-wrap:wrap}.dmc-waiting li span{flex:1 1 100%;font-weight:600}.dmc-chat{display:flex;flex-direction:column;min-height:0;flex:1}.dmc-messages{list-style:none;margin:0 0 .5rem;padding:0;overflow-y:auto;max-height:50vh;display:grid;gap:.4rem}.dmc-messages li.is-mine strong{color:var(--dm-primary, #1f6feb)}.dmc-send{display:flex;gap:.4rem}.dmc-send .dmc-input{flex:1}.dmc-bar{grid-column:1 / -1;display:flex;gap:.5rem;flex-wrap:wrap;justify-content:center}@media(max-width:760px){.dmc-panel,.dmc-call{grid-template-columns:1fr}.dmc-side{width:auto;grid-column:1;grid-row:auto}.dmc-bar{position:sticky;bottom:0;background:var(--dm-background, #fff);padding:.5rem 0}.dmc-bar .dmc-btn{flex:1 1 auto;min-width:3rem}.dmc-lbl{display:none}.dmc-tool{padding:.6rem}}
@@ -19,12 +19,17 @@
19
19
 
20
20
  const M = window.M;
21
21
 
22
+ /** An icon (Domma's I.scan turns it into an SVG after binding - so no binding ever sits on it). */
23
+ const ico = (name) => `<span class="dmc-ico" data-icon="${name}" data-icon-size="18" aria-hidden="true"></span>`;
24
+ /** Two icons, one shown by a condition: the bindings are on the wrappers, never on the icons. */
25
+ const pair = (cond, on, off) => `<span class="dmc-i" data-bind-hidden="!(${cond})">${ico(on)}</span><span class="dmc-i" data-bind-hidden="${cond}">${ico(off)}</span>`;
26
+
22
27
  const TEMPLATE = `
23
28
  <div class="dmc">
24
29
  <header class="dmc-head">
25
30
  <h1 class="dmc-title" data-bind-text="title.value"></h1>
26
- <span class="dmc-rec" data-bind-hidden="!recording.value" role="status">● Recording</span>
27
- <span class="dmc-count" data-bind-text="countText.value"></span>
31
+ <span class="dmc-rec" data-bind-hidden="!recording.value" role="status">${ico('circle')} Recording</span>
32
+ <span class="dmc-count" data-bind-hidden="!countText.value">${ico('users')} <span data-bind-text="countText.value"></span></span>
28
33
  </header>
29
34
 
30
35
  <section class="dmc-panel" data-bind-hidden="stage.value !== 'check'">
@@ -36,18 +41,18 @@ const TEMPLATE = `
36
41
  <label class="dmc-label" data-bind-hidden="!askName">Your name
37
42
  <input class="dmc-input" data-model="name.value" maxlength="60" autocomplete="name"></label>
38
43
  <div class="dmc-row">
39
- <button type="button" class="dmc-btn" data-on-click="toggleMic" data-bind-class="!micOn.value && 'is-off'" data-bind-text="micOn.value ? 'Mic on' : 'Mic off'"></button>
40
- <button type="button" class="dmc-btn" data-on-click="toggleCam" data-bind-class="!camOn.value && 'is-off'" data-bind-text="camOn.value ? 'Camera on' : 'Camera off'"></button>
44
+ <button type="button" class="dmc-btn" data-on-click="toggleMic" data-bind-class="!micOn.value && 'is-off'" data-bind-aria-pressed="!micOn.value">${pair('micOn.value', 'mic', 'mic-off')}<span data-bind-text="micOn.value ? 'Mic on' : 'Mic off'"></span></button>
45
+ <button type="button" class="dmc-btn" data-on-click="toggleCam" data-bind-class="!camOn.value && 'is-off'" data-bind-aria-pressed="!camOn.value">${pair('camOn.value', 'video', 'video-off')}<span data-bind-text="camOn.value ? 'Camera on' : 'Camera off'"></span></button>
41
46
  </div>
42
47
  <p class="dmc-note" data-bind-hidden="!recordingAllowed.value">The host may record this call. You will see a red Recording notice while they do.</p>
43
48
  <p class="dmc-error" data-bind-hidden="!error.value" data-bind-text="error.value" role="alert"></p>
44
- <button type="button" class="dmc-btn dmc-btn--go" data-on-click="join" data-bind-disabled="busy.value">Join the call</button>
49
+ <button type="button" class="dmc-btn dmc-btn--go" data-on-click="join" data-bind-disabled="busy.value">${ico('phone')}<span>Join the call</span></button>
45
50
  </div>
46
51
  </section>
47
52
 
48
53
  <section class="dmc-panel dmc-panel--msg" data-bind-hidden="stage.value !== 'waiting'">
49
54
  <h2>Waiting for the host</h2><p>They know you are here and will let you in shortly.</p>
50
- <button type="button" class="dmc-btn" data-on-click="leave">Leave</button>
55
+ <button type="button" class="dmc-btn" data-on-click="leave">${ico('log-out')}<span>Leave</span></button>
51
56
  </section>
52
57
 
53
58
  <section class="dmc-panel dmc-panel--msg" data-bind-hidden="!(stage.value === 'ended' || stage.value === 'away' || stage.value === 'error')">
@@ -71,8 +76,8 @@ const TEMPLATE = `
71
76
  <h3>Waiting to join</h3>
72
77
  <ul data-each="waiting key=id">
73
78
  <li><span data-bind-text="name"></span>
74
- <button type="button" class="dmc-btn dmc-btn--small dmc-btn--go" data-on-click="$parent.admit($data)">Let in</button>
75
- <button type="button" class="dmc-btn dmc-btn--small" data-on-click="$parent.turnAway($data)">Turn away</button></li>
79
+ <button type="button" class="dmc-btn dmc-btn--small dmc-btn--go" data-on-click="$parent.admit($data)">${ico('check')}<span>Let in</span></button>
80
+ <button type="button" class="dmc-btn dmc-btn--small" data-on-click="$parent.turnAway($data)">${ico('x')}<span>Turn away</span></button></li>
76
81
  </ul>
77
82
  </div>
78
83
  <div class="dmc-chat" data-bind-hidden="!sideOpen.value">
@@ -82,18 +87,18 @@ const TEMPLATE = `
82
87
  </ol>
83
88
  <form class="dmc-send" data-on-submit="sendChat">
84
89
  <input class="dmc-input" data-model="draft.value" maxlength="1000" placeholder="Message everyone" aria-label="Message everyone">
85
- <button type="submit" class="dmc-btn dmc-btn--small">Send</button>
90
+ <button type="submit" class="dmc-btn dmc-btn--small" aria-label="Send">${ico('message-circle')}<span>Send</span></button>
86
91
  </form>
87
92
  </div>
88
93
  </aside>
89
94
 
90
95
  <nav class="dmc-bar" aria-label="Call controls">
91
- <button type="button" class="dmc-btn" data-on-click="toggleMic" data-bind-class="!micOn.value && 'is-off'" data-bind-text="micOn.value ? 'Mute' : 'Unmute'"></button>
92
- <button type="button" class="dmc-btn" data-on-click="toggleCam" data-bind-class="!camOn.value && 'is-off'" data-bind-text="camOn.value ? 'Stop video' : 'Start video'"></button>
93
- <button type="button" class="dmc-btn" data-on-click="toggleShare" data-bind-hidden="!canShare" data-bind-text="sharing.value ? 'Stop sharing' : 'Share screen'"></button>
94
- <button type="button" class="dmc-btn" data-on-click="toggleSide" data-bind-text="chatLabel.value"></button>
95
- <button type="button" class="dmc-btn dmc-btn--rec" data-on-click="toggleRecord" data-bind-hidden="!(host.value && recordingAllowed.value && canRecord)" data-bind-text="iRecord.value ? 'Stop recording' : 'Record'"></button>
96
- <button type="button" class="dmc-btn dmc-btn--leave" data-on-click="leave">Leave</button>
96
+ <button type="button" class="dmc-btn dmc-tool" data-on-click="toggleMic" data-bind-class="!micOn.value && 'is-off'" data-bind-aria-pressed="!micOn.value" data-bind-title="micOn.value ? 'Mute' : 'Unmute'">${pair('micOn.value', 'mic', 'mic-off')}<span class="dmc-lbl" data-bind-text="micOn.value ? 'Mute' : 'Unmute'"></span></button>
97
+ <button type="button" class="dmc-btn dmc-tool" data-on-click="toggleCam" data-bind-class="!camOn.value && 'is-off'" data-bind-aria-pressed="!camOn.value" data-bind-title="camOn.value ? 'Stop video' : 'Start video'">${pair('camOn.value', 'video', 'video-off')}<span class="dmc-lbl" data-bind-text="camOn.value ? 'Stop video' : 'Start video'"></span></button>
98
+ <button type="button" class="dmc-btn dmc-tool" data-on-click="toggleShare" data-bind-hidden="!canShare" data-bind-class="sharing.value && 'is-on'" data-bind-aria-pressed="sharing.value" data-bind-title="sharing.value ? 'Stop sharing' : 'Share your screen'">${ico('monitor')}<span class="dmc-lbl" data-bind-text="sharing.value ? 'Stop sharing' : 'Share screen'"></span></button>
99
+ <button type="button" class="dmc-btn dmc-tool" data-on-click="toggleSide" data-bind-class="sideOpen.value && 'is-on'" data-bind-aria-pressed="sideOpen.value" data-bind-title="chatLabel.value">${ico('message-circle')}<span class="dmc-lbl" data-bind-text="chatLabel.value"></span><span class="dmc-badge" data-bind-hidden="!unread.value || sideOpen.value" data-bind-text="unread.value"></span></button>
100
+ <button type="button" class="dmc-btn dmc-tool dmc-btn--rec" data-on-click="toggleRecord" data-bind-hidden="!(host.value && recordingAllowed.value && canRecord)" data-bind-class="iRecord.value && 'is-on'" data-bind-aria-pressed="iRecord.value" data-bind-title="iRecord.value ? 'Stop recording' : 'Record the call'">${ico('circle')}<span class="dmc-lbl" data-bind-text="iRecord.value ? 'Stop recording' : 'Record'"></span></button>
101
+ <button type="button" class="dmc-btn dmc-tool dmc-btn--leave" data-on-click="leave" title="Leave the call">${ico('phone-off')}<span class="dmc-lbl">Leave</span></button>
97
102
  </nav>
98
103
  </section>
99
104
  </div>`;
@@ -130,7 +135,7 @@ function mount(el) {
130
135
  const meCaption = M.computed(() => `${name.value || 'You'} (you)${micOn.value ? '' : ' · muted'}`);
131
136
  const countText = M.computed(() => (stage.value === 'in' ? `${peers.value.length + 1} of ${max.value}` : ''));
132
137
  const people = M.computed(() => String(Math.min(6, peers.value.length + 1)));
133
- const chatLabel = M.computed(() => (sideOpen.value ? 'Hide chat' : unread.value ? `Chat (${unread.value})` : 'Chat'));
138
+ const chatLabel = M.computed(() => (sideOpen.value ? 'Hide chat' : 'Chat'));
134
139
  const title = M.observable(cfg.title || 'Call');
135
140
 
136
141
  // ---- The connection to the site ---------------------------------------------------
@@ -460,9 +465,13 @@ function mount(el) {
460
465
 
461
466
  el.innerHTML = TEMPLATE;
462
467
  const handleBindings = M.applyBindings(model, el.firstElementChild);
468
+ // Icons after binding (I.scan swaps each span for an SVG); new waiting rows get theirs as they arrive.
469
+ const scan = () => { try { window.I?.scan?.(el); } catch { /* no icons: the labels still read */ } };
470
+ scan();
471
+ const stopScan = M.effect(() => { waiting.value; requestAnimationFrame(scan); });
463
472
  window.addEventListener('pagehide', () => { if (me) post('leave', {}, {keepalive: true}); });
464
473
  getDevices().then(setTracks);
465
- return () => { stopAttach?.(); handleBindings?.dispose?.(); };
474
+ return () => { stopAttach?.(); stopScan?.(); handleBindings?.dispose?.(); };
466
475
  }
467
476
 
468
477
  document.querySelectorAll('[data-dm-call]').forEach(mount);
@@ -20,6 +20,19 @@ service - no import of the Calendar, which is a separate paid Tool).
20
20
  Booking Pro (not yet built) is an ADD-ON that registers into this one (`lib/extensions.js`), the Cart / Cart Pro
21
21
  pattern - it does not supersede it. Payment is Pro only.
22
22
 
23
+ 1.4.0 (2026-09-30): "Call now" hooks for Booking Pro. Registry `version: 4`: `actions: [{id, icon, title, module}]`
24
+ (module under `/plugins/<owner>/`; ids `[a-z0-9-]`, never a screen's or tab's key, first owner keeps an id) ->
25
+ `registry.actions()` -> bootstrap `actions` -> a banner button each (`booking-x-<id>`) that imports the module and calls
26
+ `run(ctx, prefill)` (ctx = the screen's ctx + `owner`, `api` (sectionApi), `bookables`, `services`, `helpPopovers`).
27
+ `#/plugins/booking/<action-id>?contact=|email=&name=` is the Diary + that action with the prefill resolved like `new`
28
+ (the address goes back to `diary`). `callOpenedAt` on an online booking (`changeBooking` patch, online only, history
29
+ `call-opened`; a move clears it): `callWindow` opens at min(start - 10 min, callOpenedAt) and closes at max(end + 60
30
+ min, callOpenedAt + 2 h), so the customer's EXISTING link works at once - Booking (free) honours it after a lapse.
31
+ `registry.services` adds `openCall(id, {by})` (every online HOLDING booking of the session; throws `code` none |
32
+ not-online | cancelled), `callLinks(b, request?)` (absolute join/host URLs from a RAW booking), `adminBooking`,
33
+ `ownContact(id, userId)`, `siteBase(request?)`; `book()` takes `contactId` + `userId` (checked with `ownContact`) and
34
+ `request` (for the email links). An older Pro registers no actions; an older Booking ignores them.
35
+
23
36
  1.3.0 (2026-09-30): online bookings (plan: `dcms-marketplace/docs/online-meetings-plan.md`). An extension's
24
37
  `rules()` may say `online` (+ `recordingAllowed`); `placeBooking` then stores `online: true`, `hostKey`,
25
38
  `recordingAllowed` ON THE BOOKING, so Booking (free) serves the call for it even after Pro lapses. `lib/call.js`:
@@ -73,7 +86,8 @@ completed, no-show (`HOLDING`); cancelled frees it.
73
86
  writes observables, and moves focus / scroll. Without `window.M` the server's page is left as it is.
74
87
  - `docs/guide.md` - the user guide in the admin Documentation folder.
75
88
  - `tests/pure.test.js` (time, slots, cleaning), `tests/api.test.js` (the whole API against the real collection
76
- service; restores config/plugins.json and contacts-contacts), `tests/public.test.js` (the page's paths).
89
+ service; restores config/plugins.json and contacts-contacts; registry actions, callOpenedAt), `tests/public.test.js`
90
+ (the page's paths; `call-now/<id>` is left to Booking Pro).
77
91
 
78
92
  ## Data
79
93
 
@@ -83,13 +97,13 @@ all off - bookings hold customers' details). Settings: `config/plugins.json` `bo
83
97
  A booking: `serviceId`, `serviceName`, `duration`, `bookableId`, `bookableName`, `startAt`, `endAt`, `blockStart`,
84
98
  `blockEnd`, `status`, `name`, `email`, `phone`, `notes`, `staffNote`, `contactId`, `source` (public | admin),
85
99
  `createdBy`, `token` (the customer's link; never sent to the admin), `reminderSentAt`, `history` [{at, what, by,
86
- from?, reason?}], `cancelledAt`, `ext` (Pro's). Services and bookables carry `ext` too (Pro's fields, kept by the
100
+ from?, reason?}], `cancelledAt`, `ext` (Pro's); online ones also `online`, `hostKey`, `recordingAllowed`, `callOpenedAt`. Services and bookables carry `ext` too (Pro's fields, kept by the
87
101
  free Tool untouched).
88
102
 
89
103
  ## Extensions (Booking Pro)
90
104
 
91
- `bookingRegistry()` (`lib/extensions.js`, `version: 2`): `register(owner, {rules, form, answers, check, admin, waitlistUrl,
92
- on, tabs})` - every hook optional and guarded (one that throws counts as absent). `rules(service)` ->
105
+ `bookingRegistry()` (`lib/extensions.js`, `version: 4`): `register(owner, {rules, form, answers, check, admin, waitlistUrl,
106
+ on, tabs, actions})` - every hook optional and guarded (one that throws counts as absent). `rules(service)` ->
93
107
  {capacity, companions: [[ids]], maxPerDay, waitlist}, resolved to bookable objects by `registry.rules(service,
94
108
  bookables)` and applied by `slotsFor` / `whoCanTake` / `placeBooking` (same session = same service + start +
95
109
  primary bookable: shares places, never clashes with itself; a day's limit counts sessions). `form` / `answers`: extra
@@ -13,7 +13,7 @@ export {fmtDate, fmtDateTime};
13
13
 
14
14
  export const SIDEBAR_URL = '#/plugins/booking';
15
15
  export const BASE = '/api/plugins/booking';
16
- export const V = '1.1.0';
16
+ export const V = '1.4.0';
17
17
 
18
18
  let kit = null;
19
19
 
@@ -11,11 +11,15 @@
11
11
  * #/plugins/booking/new?contact=<id> the Diary with New booking open, filled from a contact
12
12
  * #/plugins/booking/new?email=&name= ... or from an email's sender (Email's "Book Sam…")
13
13
  * #/plugins/booking/<ext-key> an extension's screen
14
+ * #/plugins/booking/<action-id>?contact=<id> | ?email=&name=
15
+ * the Diary, then an extension's banner action run with that prefill
16
+ * (Booking Pro's "Call now" from a contact's or an email's menu)
14
17
  *
15
18
  * Settings are behind the banner's cog, in a slideover.
16
19
  */
17
20
 
18
21
  import {V, api, errText, loadKit, store} from '../lib/kit.js';
22
+ import {sectionApi} from '../lib/extension-sections.js';
19
23
 
20
24
  const ROUTE = /^#\/plugins\/booking(\/|\?|$)/;
21
25
  const TABS = [
@@ -65,8 +69,10 @@ export const bookingView = {
65
69
  if (!here()) return;
66
70
 
67
71
  const tabs = [...TABS, ...(boot.tabs || [])];
72
+ const actions = (boot.actions || []).filter(a => a && typeof a.module === 'string' && a.module.startsWith(`/plugins/${a.owner}/`));
68
73
  const wantNew = tab === 'new';
69
- const current = tabs.find(t => t.key === (wantNew ? 'diary' : tab)) || TABS[0];
74
+ const wantAction = actions.find(a => a.id === tab) || null;
75
+ const current = tabs.find(t => t.key === (wantNew || wantAction ? 'diary' : tab)) || TABS[0];
70
76
 
71
77
  // "Booking › Diary" in the banner.
72
78
  const name = container.querySelector('.dm-plugin-banner-name');
@@ -138,6 +144,28 @@ export const bookingView = {
138
144
  kit.addBannerAction($container, {id: 'booking-new', icon: 'plus', title: 'New booking',
139
145
  onClick: () => ctx.newBooking({})});
140
146
 
147
+ /**
148
+ * An extension's banner action (Booking 1.4): its module's `run(ctx, prefill)`, imported on the click.
149
+ * ctx is the screen's (boot, openBooking, refresh, go...) plus what sections get (api, owner, helpPopovers).
150
+ * Anything that must open a new tab does so on a click of its own, never after this import.
151
+ */
152
+ const runAction = async (a, prefill = {}) => {
153
+ try {
154
+ const mod = await import(a.module);
155
+ const run = mod.run || mod.default?.run;
156
+ if (typeof run !== 'function') throw new Error('no run()');
157
+ return await run({...ctx, owner: a.owner, api: sectionApi, bookables: boot.bookables, services: boot.services,
158
+ helpPopovers: (el) => kit.helpPopovers(el)}, prefill);
159
+ } catch (err) {
160
+ console.warn(`[booking] ${a.owner} action ${a.id}:`, err);
161
+ E.toast(`${a.title} could not start: ${errText(err)}`, {type: 'error'});
162
+ return null;
163
+ }
164
+ };
165
+ for (const a of actions) {
166
+ kit.addBannerAction($container, {id: `booking-x-${a.id}`, icon: a.icon, title: a.title, onClick: () => runAction(a)});
167
+ }
168
+
141
169
  try {
142
170
  const mod = await import(current.module);
143
171
  if (!here()) return;
@@ -154,9 +182,9 @@ export const bookingView = {
154
182
  }
155
183
  store.set('booking.last-tab', current.key);
156
184
 
157
- // From Contacts or Email: the New booking slideover, filled in. The
185
+ // From Contacts or Email: the New booking slideover (or an extension's action), filled in. The
158
186
  // address goes back to the Diary so a reload does not open it again.
159
- if (wantNew && here()) {
187
+ if ((wantNew || wantAction) && here()) {
160
188
  ctx.replaceHash('#/plugins/booking/diary');
161
189
  const prefill = {email: query.email || '', name: query.name || ''};
162
190
  if (query.contact) {
@@ -171,7 +199,9 @@ export const bookingView = {
171
199
  const c = await api.contactByEmail(query.email).catch(() => null);
172
200
  if (c?.id) Object.assign(prefill, {contactId: c.id, name: c.name || prefill.name, phone: c.phone});
173
201
  }
174
- if (ROUTE.test(location.hash)) ctx.newBooking(prefill);
202
+ if (!ROUTE.test(location.hash)) return;
203
+ if (wantAction) runAction(wantAction, prefill);
204
+ else ctx.newBooking(prefill);
175
205
  }
176
206
  }
177
207
  };
@@ -436,7 +436,7 @@ export async function openBooking(ctx, id) {
436
436
  const saved = M.observable(b.staffNote || '');
437
437
  const tone = `is-${STATUS_TONE[b.status] || 'muted'}`;
438
438
  const history = (b.history || []).slice().reverse().map(h => {
439
- const what = {made: 'Booked', moved: 'Moved', cancelled: 'Cancelled', completed: 'Marked done', 'no-show': 'Marked no-show', confirmed: 'Back to booked', pending: 'Pending'}[h.what] || h.what;
439
+ const what = {made: 'Booked', moved: 'Moved', cancelled: 'Cancelled', completed: 'Marked done', 'no-show': 'Marked no-show', confirmed: 'Back to booked', pending: 'Pending', 'call-opened': 'Call opened early'}[h.what] || h.what;
440
440
  const was = h.from ? wallClock(h.from, ctx.tz) : null;
441
441
  const extra = h.what === 'moved' && was ? ` (was ${dateLabel(was.date)}, ${was.time})` : h.reason ? `: ${h.reason}` : '';
442
442
  return `<li><strong>${esc(what)}</strong>${esc(extra)}<small>${esc(fmtDateTime(h.at))}${h.by ? ` · ${esc(h.by)}` : ''}</small></li>`;
@@ -477,7 +477,7 @@ export async function openBooking(ctx, id) {
477
477
  <div class="bk-ext-host" data-part="ext"></div>
478
478
  ${b.hostUrl && live ? `<div class="bk-actions bk-actions-start bk-call-row">
479
479
  <a class="btn btn-sm btn-primary" href="${esc(b.hostUrl)}" target="_blank" rel="noopener"><span data-icon="video" data-icon-size="13"></span> Start the call</a>
480
- <span class="bk-muted">You are the host: let ${esc(b.name || 'them')} in when they arrive. It opens 10 minutes before the start.</span></div>` : ''}
480
+ <span class="bk-muted">You are the host: let ${esc(b.name || 'them')} in when they arrive. ${Date.parse(b.callOpenedAt || '') > Date.now() - 2 * 3600000 ? 'It was opened early, so it is open now.' : 'It opens 10 minutes before the start.'}</span></div>` : ''}
481
481
  <div class="bk-actions bk-actions-start">
482
482
  <button type="button" class="btn btn-sm btn-outline" data-on-click="move" ${live ? '' : 'disabled'}><span data-icon="move" data-icon-size="13"></span> Move</button>
483
483
  <button type="button" class="btn btn-sm btn-outline" data-on-click="done" ${live && b.status !== 'completed' ? '' : 'disabled'}><span data-icon="check" data-icon-size="13"></span> Done</button>
@@ -58,6 +58,7 @@ Booking has its own permission, **Booking**, which admins have by default. **See
58
58
 
59
59
  Booking Pro adds the following to the free Tool:
60
60
  - Online appointments, held as a video call on your site.
61
+ - **Call now**: open an online booking's call early, or start a video call with anyone straight away - from Booking, a contact or an email.
61
62
  - Services that need more than one person or room at once.
62
63
  - Classes and group sessions with places and a waiting list.
63
64
  - Recurring bookings.
@@ -33,12 +33,26 @@ export const joinPath = (settings, b) => `${settings.bookingPath}/call/${session
33
33
  /** The host's (the person booked) - never shown to the customer. */
34
34
  export const hostPath = (settings, b) => `${settings.bookingPath}/call/${sessionOf(b)}?b=${encodeURIComponent(b.id)}&h=${encodeURIComponent(b.hostKey)}`;
35
35
 
36
- /** When the call opens and closes, for a booking. */
37
- export function callWindow(b) {
38
- return {
39
- opensAt: new Date(Date.parse(b.startAt) - OPENS_BEFORE_MIN * 60000).toISOString(),
40
- closesAt: new Date(Date.parse(b.endAt) + CLOSES_AFTER_MIN * 60000).toISOString()
41
- };
36
+ /** How long a call opened early ("Call now", Booking 1.4) stays open at least. */
37
+ export const OPENED_EARLY_HOURS = 2;
38
+
39
+ /**
40
+ * When the call opens and closes, for a booking. Staff may open it early
41
+ * (`callOpenedAt`, "Call now"): it opens then, and stays open at least
42
+ * OPENED_EARLY_HOURS after, even when the booking itself is later.
43
+ */
44
+ export function callWindow(b, now = Date.now()) {
45
+ const opens = Date.parse(b.startAt) - OPENS_BEFORE_MIN * 60000;
46
+ const closes = Date.parse(b.endAt) + CLOSES_AFTER_MIN * 60000;
47
+ const early = Date.parse(b.callOpenedAt || '');
48
+ const iso = (a, z) => ({opensAt: new Date(a).toISOString(), closesAt: new Date(z).toISOString()});
49
+ // Opened early ("Call now"): open for OPENED_EARLY_HOURS from then - not all the way to the booking,
50
+ // which may be days off - joined up with the booking's own window when the two meet.
51
+ if (Number.isFinite(early) && now >= early && now <= early + OPENED_EARLY_HOURS * 3600000) {
52
+ const earlyEnd = early + OPENED_EARLY_HOURS * 3600000;
53
+ return iso(Math.min(early, opens), earlyEnd >= opens ? Math.max(earlyEnd, closes) : earlyEnd);
54
+ }
55
+ return iso(opens, closes);
42
56
  }
43
57
 
44
58
  /**
@@ -48,9 +62,9 @@ export function callWindow(b) {
48
62
  * @param {string} room - the room in the address
49
63
  * @param {{t?: string, h?: string}} cred
50
64
  */
51
- export function whoIsCalling(b, room, cred = {}) {
65
+ export function whoIsCalling(b, room, cred = {}, now = Date.now()) {
52
66
  if (!b || b.online !== true || !HOLDING.has(b.status) || sessionOf(b) !== room) return null;
53
- const base = {title: b.serviceName, recordingAllowed: b.recordingAllowed === true, max: 6, ...callWindow(b)};
67
+ const base = {title: b.serviceName, recordingAllowed: b.recordingAllowed === true, max: 6, ...callWindow(b, now)};
54
68
  // identity: a link the host turns away cannot knock again (the engine remembers it per room).
55
69
  if (cred.h && same(cred.h, b.hostKey)) return {...base, host: true, name: b.bookableName || 'Host', identity: `${b.id}:host`};
56
70
  if (cred.t && same(cred.t, b.token)) return {...base, host: false, name: b.name || 'Guest', identity: `${b.id}:guest`};
@@ -14,6 +14,9 @@
14
14
  * waitlistUrl - where the booking page posts "join the waiting list"
15
15
  * on: {made, moved, cancelled, status} - told after the fact, never waited on
16
16
  * tabs: [{key, label, icon, module}] - admin screens beside Booking's own
17
+ * actions: [{id, icon, title, module}] - banner buttons on Booking's screens (Booking 1.4); the module's
18
+ * `run(ctx, prefill)` is called on the click - and for `#/plugins/booking/<id>?...`
19
+ * (a deep link from another Tool), with the prefill from the address
17
20
  *
18
21
  * Every hook is optional and guarded: one that throws counts as absent, so an
19
22
  * extension can never stop a booking being made. A lapsed Pro licence means
@@ -35,7 +38,8 @@ export function bookingRegistry() {
35
38
  globalThis[KEY] = {
36
39
  // 2: rules, form, answers, check, admin sections, waitlistUrl, services.book / cancel (Booking 1.1).
37
40
  // 3: rules may say online (+ recordingAllowed): held as a video call (Booking 1.3).
38
- version: 3,
41
+ // 4: actions (banner buttons), services.openCall / callLinks, book({contactId, request}) (Booking 1.4).
42
+ version: 4,
39
43
  services: {},
40
44
  register(owner, ext = {}) {
41
45
  if (typeof owner !== 'string' || !owner) return false;
@@ -136,6 +140,21 @@ export function bookingRegistry() {
136
140
  return out;
137
141
  },
138
142
 
143
+ /** Banner buttons extensions add to Booking's screens. Ids are unique; the first to claim one keeps it. */
144
+ actions() {
145
+ const out = [];
146
+ // A deep link is #/plugins/booking/<id>: never a screen's own address.
147
+ const taken = new Set(['diary', 'bookings', 'services', 'people', 'new', 'settings', ...this.tabs().map(t => t.key)]);
148
+ for (const [owner, ext] of owners) {
149
+ for (const a of Array.isArray(ext.actions) ? ext.actions : []) {
150
+ if (typeof a?.id !== 'string' || !/^[a-z0-9-]{1,40}$/.test(a.id) || taken.has(a.id) || !underOwner(owner, a.module)) continue;
151
+ taken.add(a.id);
152
+ out.push({id: a.id, title: String(a.title || a.id).slice(0, 80), icon: String(a.icon || 'puzzle'), module: a.module, owner});
153
+ }
154
+ }
155
+ return out;
156
+ },
157
+
139
158
  /** Tell every extension; never throws, never waits. */
140
159
  emit(event, ...args) {
141
160
  for (const ext of owners.values()) {
@@ -260,7 +260,8 @@ export function placeBooking({service, candidates, startAt, fields, moveId = '',
260
260
  const current = await getEntry(BOOKINGS, moveId);
261
261
  if (!current) throw new Error('That booking no longer exists.');
262
262
  const history = [...(current.data.history || []), {at: new Date().toISOString(), what: 'moved', from: current.data.startAt, by: fields.by || ''}].slice(-50);
263
- return flat(await updateEntry(BOOKINGS, moveId, {...current.data, ...times, history}));
263
+ // A call opened early ("Call now") was for the old time; the new one opens as usual.
264
+ return flat(await updateEntry(BOOKINGS, moveId, {...current.data, ...times, callOpenedAt: '', history}));
264
265
  }
265
266
  const {by, ...rest} = fields;
266
267
  return flat(await createEntry(BOOKINGS, {
@@ -307,6 +308,11 @@ export function changeBooking(id, patch, by = '') {
307
308
  }
308
309
  if (patch.reminderSentAt !== undefined) data.reminderSentAt = patch.reminderSentAt;
309
310
  if (patch.contactId !== undefined) data.contactId = patch.contactId;
311
+ // "Call now" (1.4): an online booking's call opened before its time.
312
+ if (patch.callOpenedAt !== undefined && data.online === true) {
313
+ data.callOpenedAt = String(patch.callOpenedAt || '');
314
+ if (data.callOpenedAt) data.history = [...(data.history || []), {at: data.callOpenedAt, what: 'call-opened', by}].slice(-50);
315
+ }
310
316
  if (patch.ext && typeof patch.ext === 'object') data.ext = {...(data.ext || {}), ...patch.ext};
311
317
  return {booking: flat(await updateEntry(BOOKINGS, id, data)), from};
312
318
  });
@@ -119,7 +119,7 @@ function registerShortcodes(hooks) {
119
119
  });
120
120
  }
121
121
 
122
- export const ASSET_V = '1.3.1';
122
+ export const ASSET_V = '1.4.0';
123
123
 
124
124
  export default async function booking(fastify, options) {
125
125
  const {authenticate, requirePermission} = options.auth;
@@ -271,7 +271,13 @@ export default async function booking(fastify, options) {
271
271
 
272
272
  registry.services = {
273
273
  name: NAME, liveSettings, allBookings, allServices, allBookables, getBooking, getService, getBookable,
274
- changeBooking, sendReminders, siteBase: () => siteBase(null),
274
+ changeBooking, sendReminders,
275
+ /** The site's address: `site.baseUrl`, else the request's origin (a signed-in one), else the last admin's. */
276
+ siteBase: (request = null) => siteBase(request),
277
+ /** A booking as the admin may see it (no customer token; an online one's host key as `hostUrl`). */
278
+ adminBooking,
279
+ /** The contact `id` when it is `userId`'s own (Contacts on), else null. */
280
+ ownContact: async (id, userId) => (contactsOn() && id && userId ? ownContact(String(id), String(userId)).catch(() => null) : null),
275
281
  manageUrl: (b) => liveSettings().then(s => manageUrl(s, b, siteBase(null))),
276
282
  /** Make a booking the whole way (contacts, emails, notices, events) - a waiting list's place, a series. Throws SlotTaken. */
277
283
  book: (p) => bookForExtension(p),
@@ -286,6 +292,30 @@ export default async function booking(fastify, options) {
286
292
  }
287
293
  return res.booking;
288
294
  },
295
+ /**
296
+ * "Call now" (1.4): open an online booking's call now, even when it is booked for later - for everyone
297
+ * booked into that session (a class shares one call). The customer's own link then works at once.
298
+ * Throws with `code`: 'none' | 'not-online' | 'cancelled'.
299
+ */
300
+ openCall: async (id, {by = ''} = {}) => {
301
+ const b = await getBooking(String(id || ''));
302
+ const refuse = (code, message) => Object.assign(new Error(message), {code});
303
+ if (!b) throw refuse('none', 'No such booking.');
304
+ if (b.online !== true || !b.hostKey) throw refuse('not-online', 'That booking is not an online one, so it has no call.');
305
+ if (!HOLDING.has(b.status) || b.status === 'cancelled') throw refuse('cancelled', 'That booking is cancelled, so it has no call.');
306
+ const now = new Date().toISOString();
307
+ const room = sessionOf(b);
308
+ const session = (await allBookings()).filter(x => x.online === true && HOLDING.has(x.status) && sessionOf(x) === room);
309
+ for (const x of session) await changeBooking(x.id, {callOpenedAt: now}, by);
310
+ return getBooking(b.id);
311
+ },
312
+ /** An online booking's call addresses, whole: the customer's (`joinUrl`) and the host's (`hostUrl`). */
313
+ callLinks: (b, request = null) => {
314
+ if (b?.online !== true) return {joinUrl: '', hostUrl: ''};
315
+ const s = settingsNow();
316
+ const base = siteBase(request);
317
+ return {joinUrl: b.token ? `${base}${joinPath(s, b)}` : '', hostUrl: b.hostKey ? `${base}${hostPath(s, b)}` : ''};
318
+ },
289
319
  SlotTaken,
290
320
  // Booking's own helpers, so an extension never imports Booking's files.
291
321
  mail: {send: (msg) => send(msg, {log}), compose, mailReady},
@@ -293,20 +323,27 @@ export default async function booking(fastify, options) {
293
323
  timeZone: () => settingsNow().timeZone
294
324
  };
295
325
 
296
- async function bookForExtension({serviceId, bookableId = '', startAt, customer: raw = {}, source = 'extension', ext = {}, force = false, notify = true, by = ''}) {
326
+ /**
327
+ * `contactId` (1.4): the caller's own contact (`userId` = the caller) to book as - checked, dropped when it is
328
+ * not theirs. `request`: the admin request it came from, for the links in the emails.
329
+ */
330
+ async function bookForExtension({serviceId, bookableId = '', startAt, customer: raw = {}, source = 'extension', ext = {}, force = false, notify = true, by = '',
331
+ contactId: askedContact = '', userId = '', request = null}) {
297
332
  const s = await liveSettings();
298
333
  const {service, candidates, rules} = await candidatesFor(String(serviceId || ''), String(bookableId || ''), {includeInactive: true});
299
334
  if (!service || !candidates.length) throw new Error('That service cannot be booked.');
300
335
  const {data: customer, errors} = cleanCustomer(raw, {askPhone: true});
301
336
  if (Object.keys(errors).length) throw new Error(Object.values(errors)[0]);
337
+ const contactId = askedContact && userId && await ownContact(String(askedContact), String(userId)).catch(() => null) ? String(askedContact) : '';
302
338
  const b = await placeBooking({service, candidates, rules, startAt: new Date(startAt).toISOString(), tz: s.timeZone, force,
303
- fields: {...customer, source, by: by || customer.name, ext: extOf(ext)}});
304
- if (s.addToContacts) {
339
+ fields: {...customer, source, by: by || customer.name, ext: extOf(ext), ...(contactId ? {contactId} : {}), ...(userId ? {createdBy: String(userId)} : {})}});
340
+ if (!contactId && s.addToContacts) {
305
341
  const bk = candidates.find(c => c.id === b.bookableId);
306
342
  const id = await linkContact(customer, bk?.userId || s.contactsOwnerId, contactsOn()).catch(() => '');
307
343
  if (id) Object.assign(b, (await changeBooking(b.id, {contactId: id})).booking);
308
344
  }
309
- if (notify) tell(b, 'made', {toOwner: source !== 'admin'});
345
+ if (request) siteBase(request);
346
+ if (notify) tell(b, 'made', {request, toOwner: source !== 'admin'});
310
347
  registry.emit('made', b);
311
348
  return b;
312
349
  }
@@ -358,6 +395,7 @@ export default async function booking(fastify, options) {
358
395
  calendar: Boolean(getPluginStates().calendar?.enabled),
359
396
  tabs: registry.tabs(),
360
397
  sections: registry.admin(),
398
+ actions: typeof registry.actions === 'function' ? registry.actions() : [],
361
399
  now: new Date().toISOString(),
362
400
  today: wallClock(Date.now(), s.timeZone).date
363
401
  };
@@ -752,6 +790,14 @@ export default async function booking(fastify, options) {
752
790
  const {mountCallRoutes} = await import('../_lib/call/routes.js');
753
791
  calls = mountCallRoutes(fastify, {prefix: '/call', ns: 'booking', resolve: async (request, room, body) => {
754
792
  const b = await getBooking(String(body?.b || '')).catch(() => null);
793
+ // A class opened early ("Call now") is open for everyone in the session - someone who booked in
794
+ // after the click too: take the session's latest opening when this booking has none of its own.
795
+ if (b && b.online === true && !b.callOpenedAt) {
796
+ const room0 = sessionOf(b);
797
+ const opened = (await allBookings()).filter(x => x.id !== b.id && x.online === true && x.callOpenedAt && sessionOf(x) === room0)
798
+ .map(x => x.callOpenedAt).sort().at(-1);
799
+ if (opened) b.callOpenedAt = opened;
800
+ }
755
801
  return whoIsCalling(b, room, {t: body?.t, h: body?.h});
756
802
  }});
757
803
  } catch (err) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "booking",
3
3
  "displayName": "Booking",
4
- "version": "1.3.1",
4
+ "version": "1.4.0",
5
5
  "tier": "free",
6
6
  "description": "Let people book appointments with you or your team, and rooms or equipment, from a booking page on your site. Opening hours, days off and buffers; confirmation and reminder emails with a link to change or cancel; a diary in the admin. Book straight from a contact or an email.",
7
7
  "author": "Domma CMS",
@@ -72,7 +72,7 @@
72
72
  ],
73
73
  "views": {
74
74
  "plugin-booking": {
75
- "entry": "booking/admin/views/booking.js?v=1.1.0",
75
+ "entry": "booking/admin/views/booking.js?v=1.4.0",
76
76
  "exportName": "bookingView"
77
77
  }
78
78
  }
@@ -354,3 +354,92 @@ test('online: a service an extension makes online gives a call - the customer an
354
354
  reg.unregister('test-online');
355
355
  }
356
356
  });
357
+
358
+ test('extensions: banner actions reach the bootstrap, only under their owner, one per id', async () => {
359
+ const reg = bookingRegistry();
360
+ assert.ok(reg.version >= 4);
361
+ reg.register('test-actions', {
362
+ tabs: [{key: 'extra', label: 'Extra', module: '/plugins/test-actions/admin/extra.js'}],
363
+ actions: [
364
+ {id: 'ring', icon: 'video', title: 'Ring now', module: '/plugins/test-actions/admin/ring.js?v=1'},
365
+ {id: 'steal', title: 'Not mine', module: '/plugins/someone-else/admin/x.js'},
366
+ {id: 'Bad Id', title: 'Bad', module: '/plugins/test-actions/admin/x.js'},
367
+ {id: 'bookings', title: 'A screen of Booking\'s', module: '/plugins/test-actions/admin/x.js'},
368
+ {id: 'extra', title: 'A tab\'s address', module: '/plugins/test-actions/admin/x.js'},
369
+ {id: 'ring', title: 'Twice', module: '/plugins/test-actions/admin/ring2.js'},
370
+ null
371
+ ]
372
+ });
373
+ reg.register('test-actions-2', {actions: [{id: 'ring', title: 'Taken already', module: '/plugins/test-actions-2/admin/ring.js'}], tabs: 'nope'});
374
+ try {
375
+ const r = await call('GET', '/bootstrap');
376
+ assert.equal(r.status, 200);
377
+ assert.deepEqual(r.body.actions, [{id: 'ring', title: 'Ring now', icon: 'video', module: '/plugins/test-actions/admin/ring.js?v=1', owner: 'test-actions'}]);
378
+ } finally {
379
+ reg.unregister('test-actions');
380
+ reg.unregister('test-actions-2');
381
+ }
382
+ assert.deepEqual((await call('GET', '/bootstrap')).body.actions, []);
383
+ });
384
+
385
+ test('Call now on a booking: its call opens early for the customer\'s own link; moving it closes it again', async () => {
386
+ const reg = bookingRegistry();
387
+ reg.register('test-online', {rules: (s) => (s?.slug === 'early-call' ? {online: true} : {})});
388
+ try {
389
+ const vic = (await call('GET', '/bookables')).body.find(b => b.name === 'Vic')
390
+ || (await call('POST', '/bookables', {name: 'Vic', kind: 'person'})).body;
391
+ const svc = (await call('POST', '/services', {name: 'Early call', duration: 30, bookables: [vic.id]})).body;
392
+ const plain = (await call('GET', '/services')).body.find(s => s.slug === 'consultation');
393
+ const day = aheadWeekday();
394
+ let r = await call('POST', '/bookings', {serviceId: svc.id, startAt: atWallClock(day, '10:00', TZ).toISOString(), customer: {name: 'Early Bird', email: 'early@example.com'}, force: true, notify: false});
395
+ assert.equal(r.status, 201, JSON.stringify(r.body));
396
+ const b = r.body;
397
+ const raw = await reg.services.getBooking(b.id);
398
+ const links = reg.services.callLinks(raw);
399
+ assert.match(links.joinUrl, new RegExp(`/booking/call/[\\w-]+\\?b=${b.id}&t=${raw.token}$`));
400
+ assert.match(links.hostUrl, /&h=/);
401
+ const room = b.hostUrl.split('/call/')[1].split('?')[0];
402
+ const join = () => call('POST', `/call/${room}/join`, {b: b.id, t: raw.token});
403
+ assert.equal((await join()).status, 425, 'a fortnight ahead: not open yet');
404
+
405
+ const opened = await reg.services.openCall(b.id, {by: 'Sam'});
406
+ assert.ok(opened.callOpenedAt);
407
+ assert.equal(opened.history.at(-1).what, 'call-opened');
408
+ r = await join();
409
+ assert.equal(r.status, 200, JSON.stringify(r.body));
410
+ assert.equal(r.body.state, 'waiting', 'the customer knocks at once');
411
+ const host = await call('POST', `/call/${room}/join`, {b: b.id, h: raw.hostKey});
412
+ assert.equal(host.status, 200);
413
+ assert.equal(host.body.host, true);
414
+ assert.equal(reg.services.adminBooking(opened).token, undefined);
415
+ assert.equal(reg.services.adminBooking(opened).hostKey, undefined);
416
+
417
+ // Moved: the new time opens as usual again.
418
+ r = await call('PUT', `/bookings/${b.id}/move`, {startAt: atWallClock(day, '11:00', TZ).toISOString(), force: true, notify: false});
419
+ assert.equal(r.status, 200, JSON.stringify(r.body));
420
+ assert.equal(r.body.callOpenedAt, '');
421
+ const moved = await reg.services.getBooking(b.id);
422
+ const room2 = r.body.hostUrl.split('/call/')[1].split('?')[0];
423
+ assert.equal((await call('POST', `/call/${room2}/join`, {b: b.id, t: moved.token})).status, 425);
424
+
425
+ // Refused: not online, cancelled, missing.
426
+ const other = (await call('POST', '/bookings', {serviceId: plain.id, startAt: atWallClock(day, '15:00', TZ).toISOString(), customer: {name: 'In Person', email: 'ip@example.com'}, force: true, notify: false})).body;
427
+ await assert.rejects(reg.services.openCall(other.id), {code: 'not-online'});
428
+ await call('PUT', `/bookings/${b.id}`, {status: 'cancelled', notify: false});
429
+ await assert.rejects(reg.services.openCall(b.id), {code: 'cancelled'});
430
+ await assert.rejects(reg.services.openCall('nope'), {code: 'none'});
431
+ for (const id of [b.id, other.id]) await call('DELETE', `/bookings/${id}`);
432
+ } finally {
433
+ reg.unregister('test-online');
434
+ }
435
+ });
436
+
437
+ test('an early-opened call lasts two hours from the click, not all the way to a booking days off', async () => {
438
+ const {callWindow} = await import('../lib/call.js');
439
+ const b = {startAt: '2031-01-10T10:00:00.000Z', endAt: '2031-01-10T11:00:00.000Z', callOpenedAt: '2031-01-03T09:00:00.000Z'};
440
+ const at = (iso) => Date.parse(iso);
441
+ assert.deepEqual(callWindow(b, at('2031-01-03T09:30:00.000Z')), {opensAt: '2031-01-03T09:00:00.000Z', closesAt: '2031-01-03T11:00:00.000Z'});
442
+ assert.deepEqual(callWindow(b, at('2031-01-05T09:00:00.000Z')), {opensAt: '2031-01-10T09:50:00.000Z', closesAt: '2031-01-10T12:00:00.000Z'}, "days later: back to the booking's own window");
443
+ const soon = {...b, callOpenedAt: '2031-01-10T09:00:00.000Z'};
444
+ assert.deepEqual(callWindow(soon, at('2031-01-10T09:05:00.000Z')), {opensAt: '2031-01-10T09:00:00.000Z', closesAt: '2031-01-10T12:00:00.000Z'}, 'joined up when they meet');
445
+ });
@@ -16,6 +16,7 @@ test('addresses under the booking page', () => {
16
16
  assert.equal(matchBookingPath('/booking/manage', b), null, '"manage" alone is not a service');
17
17
  assert.equal(matchBookingPath('/booking/manage/short', b), null);
18
18
  assert.equal(matchBookingPath('/booking/a/b', b), null);
19
+ assert.equal(matchBookingPath('/booking/call-now/AbCdEf0123456789', b), null, 'an instant call is Booking Pro\'s page');
19
20
  assert.equal(matchBookingPath('/booking/Hair_Cut', b), null);
20
21
  assert.equal(matchBookingPath('/bookings', b), null, 'a neighbour address is not ours');
21
22
  assert.equal(matchBookingPath('/booking/%E0%A4%A', b), null, 'bad encoding');
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "note": "Written by `make sync-free-tier` in dcms-marketplace. Do not edit these plugins here - edit them there and sync.",
3
- "source": "dcms-marketplace@7098cbc",
3
+ "source": "dcms-marketplace@4cdfbe1",
4
4
  "plugins": {
5
5
  "blog": {
6
6
  "version": "1.9.4",
@@ -73,19 +73,19 @@
73
73
  }
74
74
  },
75
75
  "booking": {
76
- "version": "1.3.1",
76
+ "version": "1.4.0",
77
77
  "files": {
78
- "CLAUDE.md": "66c75435a64162573e9dd20bd6c28118f4b5c5505d56cf9ad73891e1e91f79fe",
78
+ "CLAUDE.md": "8e96d41dcbba3adcebf8456a6638e266f559d6fbfb4ba1406645a82cb40ce146",
79
79
  "admin/css/index.css": "4f7731921c0c5038cb1566a6ab2d43ffc35bf9b161e58f87a0cff423e81b47fa",
80
80
  "admin/lib/details.js": "8b37536bfa9ccfcc5265cc957ba8266d3f5f777133e7c0946ae49540f1c80e3c",
81
81
  "admin/lib/diary-layout.js": "d818c79001fb9a9ecc15c96239ba961b0a1d98b9e8fce903eefb57d85adf2a73",
82
82
  "admin/lib/extension-sections.js": "0588ad47660e511b0dd8077bb9c3999c775d97597333a75e87ee12d915bbf00b",
83
- "admin/lib/kit.js": "f1935abbfebb5c5054ac1691e92d5af622db50ea1b17251d99089a765e4f6997",
83
+ "admin/lib/kit.js": "661e9c407bf398694dfa08105590065a6352594d526f3c09c31b180c840045bf",
84
84
  "admin/templates/booking.html": "20b9a3bfe3e5d5bbee613f29be99551b12790fb38f80d119585360cc38e9ec57",
85
- "admin/views/booking.js": "562f7212d4f6074e6154afd2764c374eba8c1aed46dfc6c876c9fb08d04da938",
85
+ "admin/views/booking.js": "4eddf6c0b60dbbba4c6e35352df247a3d34b66ee1d43c51fe2d53f894707348f",
86
86
  "admin/views/bookings.js": "bf17db82ed4d2f5c29ac924dd60dc0b84c8bd9c8ce32da6cbb011527699a56a6",
87
87
  "admin/views/diary.js": "9e8976e67c2232d353b3d9a1ef0d0503cc4d06b710d54d0cba84fb5b796fdfe9",
88
- "admin/views/panels.js": "7cc00b24f780e6364be7c5cb929d0a6e71ee820b207b4be1f5a87cf07ea1b1ea",
88
+ "admin/views/panels.js": "9d1c45e56d2a7a8caa12e9af29545c808ffb88da3ea48397d55aae19e802ec69",
89
89
  "admin/views/people.js": "58cb9cc550b48c959a5572a4d25650ea2b32b658c9d0bf4c244e4abe0277c211",
90
90
  "admin/views/services.js": "122326d222ed16dc8ec903fef6f62f6a7638c31d73c4612b7a8c2ced38358068",
91
91
  "admin/views/settings.js": "299297b47a6509986276a859807a33ea225681d28e0e9cd06cdf36b0f6ec0c11",
@@ -93,14 +93,14 @@
93
93
  "collections/booking-bookings.json": "fa0b15cc81b4a2114734a0bc8adc4b4d25049d16cf62549b83634cdbeb543b52",
94
94
  "collections/booking-services.json": "cecb500a9215a0eba894e6b4c9ae78f5fbeb209830d07d47050c56c75143f66a",
95
95
  "config.js": "5565fd627481cf809322257cc300a7cbb7ccbf6cae076b4cf8a7453fcf554bd3",
96
- "docs/guide.md": "885a4bb9ffde34309a9d795232c376104e550f84ea0ea997aaba215c88cb5bcc",
96
+ "docs/guide.md": "545b050500a09ffdf7bc620e01f0b63bf59e92dfe855ba3201419b0bcc92a1fc",
97
97
  "inject/body.html": "ab55ce945eec2302eb0c836a4626cffa5e091d050c6a59c6f2af332b05570777",
98
- "lib/call.js": "ce14cc92ac150f25893b60e7d5a20b5627067efbf4b776eae8ccfa534e3ab061",
99
- "lib/extensions.js": "ccb8a1c451f111ccee8b7f71c92d5ef54ee7771ec35cbfefa5632bddee906457",
98
+ "lib/call.js": "7dfa0ae60be87d8f7f26b4360d5c1144322abe66eb61b233ed812e35bb8e59bd",
99
+ "lib/extensions.js": "8cec28ff8685daad8083b23f034f1c3bf82fd2322943dd19d46570ccdc311105",
100
100
  "lib/mail.js": "cad8f409df62d6e42f0f4024ec364bbe018a62b842fa3f7c38786fd4e7f92408",
101
- "lib/store.js": "1b8d625c16fc4fd1dd233e9a5157de6e5b25ffb6f6fe05fc49749e4008e3ba82",
102
- "plugin.js": "ae3ea8f5a63a1af7818064e9f9b83fbb16502266510d24216f3e65c93919a5f8",
103
- "plugin.json": "2f973abbdcf0b1b148e89295b1b6469cc48b9d5585e5e8ffc2b7099f3ff4c018",
101
+ "lib/store.js": "270161f907c68de39d32758931e86c290582524af4650e722c2c3be209acc875",
102
+ "plugin.js": "963e8190dbcaec71915d73402648899bb38135b25ec869a659d0938d03868682",
103
+ "plugin.json": "0ef595cd1925b41194821f97d0e0e0fca5edb20a98a0f97a8a0efd52e8c50c16",
104
104
  "plugin.public.js": "cd607de84ba7cffe384aa40a1a5976277896bf3973baa6415a01a1a4c9e3aca1",
105
105
  "public/booking.css": "8b88989fdd04e50efc1688a2f32fe2e422299d7d13b81bb8fce0cc162fa99df6",
106
106
  "public/booking.js": "7dfc3d205696967013886567f67a867443c2e370bac29019d69911d695fdbade",
@@ -110,8 +110,8 @@
110
110
  "public/lib/time.js": "48e13a23e3d7a4916e2097e8f87c03a87d37b71c4ef037a5a16143c6a4185c41",
111
111
  "templates/booking.html": "a4995de46dfdab7e692b56f21347f29c76320cf3b0477889dc3c5d14e0b6dcd7",
112
112
  "tests/admin-diary.test.js": "4e34b447d62b41272ceec49bad9d491ae00dd04f4760d8a7a3046be1912f4abe",
113
- "tests/api.test.js": "f97b3b82370dd7eb3001fea7f4f806fa56f25e77605f1c6f843fe89244f9e86d",
114
- "tests/public.test.js": "0d14c2fe47877ccb7aeae45210cec977c78973be4d2edc323a9315bb985d1f8b",
113
+ "tests/api.test.js": "57ce73f48776e15454794405bae5b9674baeda46a96e4a11d0e253c6a977a764",
114
+ "tests/public.test.js": "0b7b626d60df5a3d73d7c0077b73f59b62a9fded98ce84a8d93cd32cd0333159",
115
115
  "tests/pure.test.js": "8ba0c8f76b4c9efcc422f39ff29509612544d81f760025a4b9a5594af14e7bcf"
116
116
  }
117
117
  },
@@ -1 +1 @@
1
- var g=Object.defineProperty;var f=(h,e,a)=>e in h?g(h,e,{enumerable:!0,configurable:!0,writable:!0,value:a}):h[e]=a;var d=(h,e,a)=>f(h,typeof e!="symbol"?e+"":e,a);import{CanvasManager as y}from"./core/canvas.js";import{PhysicsEngine as p,updateMovingParticle as v,updateParticlePhysics as w}from"./core/physics.js";import{createParticle as M}from"./core/particles.js";const l=class l{static isDateInRange(e,a,i,n){const o=new Date,s=o.getMonth()+1,r=o.getDate();return i<e?s===e&&r>=a||s>e||s<e&&s<=i||s===i&&r<=n:(s>e||s===e&&r>=a)&&(s<i||s===i&&r<=n)}static getCurrentTheme(){for(const[e,a]of Object.entries(l.themes)){const[[i,n],[o,s]]=a.dates;if(l.isDateInRange(i,n,o,s))return e}return null}static isCelebrationSeason(){return l.getCurrentTheme()!==null}static getThemes(){return l.themes}constructor(e={}){this.options={theme:e.theme||"auto",intensity:e.intensity||"medium",enabled:e.enabled!==void 0?e.enabled:!0,zIndex:e.zIndex||999,overrides:e.overrides||{}},this.canvasManager=null,this.physicsEngine=null,this.themeModule=null,this.currentTheme=null,this.particles=[],this.specialParticles=[],this.animationFrame=null,this.lastTime=0,this.running=!1,this.initialized=!1}async init(){if(this.initialized){console.warn("[Celebrations] Already initialized");return}console.log("[Celebrations] Initializing...");let e=this.options.theme;if(e==="auto"&&(e=l.getCurrentTheme(),!e)){console.log("[Celebrations] No active celebration detected");return}if(!l.themes[e]){console.error(`[Celebrations] Unknown theme: ${e}`);return}this.currentTheme=e,console.log(`[Celebrations] Loading theme: ${e}`);try{const i=await import(l.themes[e].module);this.themeModule=i.default,console.log(`[Celebrations] Theme loaded: ${this.themeModule.displayName}`)}catch(a){console.error(`[Celebrations] Failed to load theme module: ${a.message}`);return}this.canvasManager=new y({canvasId:"celebrations-canvas",zIndex:this.options.zIndex}),this.canvasManager.create(),this.physicsEngine=new p({gustInterval:[5e3,15e3],gustStrength:[-2,2]}),window.addEventListener("resize",()=>{this.canvasManager.resize(),this.resetParticles()}),this.resetParticles(),this.initialized=!0,console.log("[Celebrations] Initialized successfully"),this.options.enabled&&this.start()}resetParticles(){const e=Object.assign({},this.themeModule.intensityConfig[this.options.intensity]);for(const[s,r]of Object.entries(this.options.overrides))r==="off"&&(e[s]=0);this.config=e;const a=this.canvasManager.canvas.width,i=this.canvasManager.canvas.height;this.particles=[],this.specialParticles=[];const n=e.initialParticleRatio!==void 0?e.initialParticleRatio:1,o=Math.floor(e.count*n);if(this.themeModule.createFallingParticle)for(let s=0;s<o;s++){const r=this.themeModule.createFallingParticle(a,i,e);r.y=Math.random()*i,this.particles.push(r)}else for(let s=0;s<o;s++){const r=M(e,a,i);r.y=Math.random()*i,this.particles.push(r)}if(this.themeModule.createInitialDecorations){const s=this.themeModule.createInitialDecorations(a,i,e);this.specialParticles.push(...s),console.log(`[Celebrations] Created ${s.length} decorations`)}console.log(`[Celebrations] Created ${o}/${e.count} particles (${this.options.intensity}, ${Math.round(n*100)}% initial)`)}start(){if(!this.initialized){console.warn("[Celebrations] Cannot start - not initialized");return}if(this.running){console.warn("[Celebrations] Already running");return}console.log("[Celebrations] Starting animation"),this.running=!0,this.lastTime=performance.now(),this.animate()}pause(){this.running&&(console.log("[Celebrations] Pausing animation"),this.running=!1,this.animationFrame&&(cancelAnimationFrame(this.animationFrame),this.animationFrame=null))}resume(){this.running||this.start()}destroy(){console.log("[Celebrations] Destroying effect"),this.pause(),this.canvasManager&&this.canvasManager.canvas&&this.canvasManager.canvas.remove(),this.particles=[],this.specialParticles=[],this.canvasManager=null,this.physicsEngine=null,this.themeModule=null,this.initialized=!1}setIntensity(e){if(!["light","medium","heavy"].includes(e)){console.error(`[Celebrations] Invalid intensity: ${e}`);return}console.log(`[Celebrations] Changing intensity to: ${e}`),this.options.intensity=e,this.resetParticles()}async setTheme(e){console.log(`[Celebrations] Changing theme to: ${e}`);const a=this.running;this.pause(),await new Promise(i=>setTimeout(i,20)),this.initialized=!1,this.canvasManager&&this.canvasManager.clear(),this.particles=[],this.specialParticles=[],this.canvasManager&&this.canvasManager.clear(),this.options.theme=e,await this.init(),a&&this.start()}animate(){if(!this.running)return;const e=performance.now(),a=e-this.lastTime;this.lastTime=e,this.canvasManager.clear();const i=this.canvasManager.ctx,n=this.canvasManager.canvas.width,o=this.canvasManager.canvas.height,s=this.config;this.physicsEngine.updateWind(e);const r=this.physicsEngine.getWindForce();if(this.particles.length<s.count&&Math.random()<.05&&this.themeModule.createFallingParticle){const t=this.themeModule.createFallingParticle(n,o,s);(t.y===void 0||t.y<0)&&(t.y=-20),this.particles.push(t)}if(this.particles.forEach((t,u)=>{if(w(t,a,r),t.y>o+50&&(t.y=-20,t.x=Math.random()*n),t.x<-50&&(t.x=n+50),t.x>n+50&&(t.x=-50),this.themeModule.drawSnowflake&&t.type==="snowflake")this.themeModule.drawSnowflake(i,t);else if(this.themeModule["draw"+this.capitalizeFirst(t.type)]){const c="draw"+this.capitalizeFirst(t.type);this.themeModule[c](i,t,e)}else i.save(),i.globalAlpha=t.opacity,i.fillStyle=this.themeModule.colors?.primary||"#ffffff",i.beginPath(),i.arc(t.x,t.y,t.size,0,Math.PI*2),i.fill(),i.restore()}),this.themeModule.updateSpecialParticles&&this.themeModule.updateSpecialParticles(this.specialParticles,a,n,o),this.specialParticles=this.specialParticles.filter(t=>{if(!t.active)return!1;if(!t.static){v(t,a,e);const m=t.type==="train"?600:200;if(t.x<-m||t.x>n+m)return t.active=!1,!1}if(!isFinite(t.x)||!isFinite(t.y)||t.size&&!isFinite(t.size))return console.warn("[Celebrations] Invalid particle values, removing:",t.type),t.active=!1,!1;const c="draw"+this.capitalizeFirst(t.type);return this.themeModule[c]&&this.themeModule[c](i,t,e),!0}),this.themeModule.spawnSpecialParticle){const t=this.themeModule.spawnSpecialParticle(this.specialParticles,n,o,s);t&&this.specialParticles.push(t)}this.themeModule.drawGlobalEffects&&this.themeModule.drawGlobalEffects(i,e,n,o),this.animationFrame=requestAnimationFrame(()=>this.animate())}capitalizeFirst(e){if(!e)return"";const a=e.replace(/-([a-z])/g,(i,n)=>n.toUpperCase());return a.charAt(0).toUpperCase()+a.slice(1)}};d(l,"themes",{christmas:{module:"./themes/christmas.js",dates:[[12,1],[1,1]],displayName:"Christmas",emoji:"\u{1F384}"},halloween:{module:"./themes/halloween.js",dates:[[10,26],[10,31]],displayName:"Halloween",emoji:"\u{1F383}"},valentines:{module:"./themes/valentines.js",dates:[[2,9],[2,14]],displayName:"Valentine's Day",emoji:"\u{1F495}"},"guy-fawkes":{module:"./themes/guy-fawkes.js",dates:[[11,1],[11,5]],displayName:"Guy Fawkes Night",emoji:"\u{1F386}"},"st-patricks":{module:"./themes/st-patricks.js",dates:[[3,12],[3,17]],displayName:"St Patrick's Day",emoji:"\u2618\uFE0F"},"st-andrews":{module:"./themes/st-andrews.js",dates:[[11,25],[11,30]],displayName:"St Andrew's Day",emoji:"\u{1F3F4}\u{E0067}\u{E0062}\u{E0073}\u{E0063}\u{E0074}\u{E007F}"},"st-davids":{module:"./themes/st-davids.js",dates:[[2,24],[3,1]],displayName:"St David's Day",emoji:"\u{1F3F4}\u{E0067}\u{E0062}\u{E0077}\u{E006C}\u{E0073}\u{E007F}"},"st-georges":{module:"./themes/st-georges.js",dates:[[4,18],[4,23]],displayName:"St George's Day",emoji:"\u{1F3F4}\u{E0067}\u{E0062}\u{E0065}\u{E006E}\u{E0067}\u{E007F}"}});export let CelebrationsEffect=l;export default CelebrationsEffect;
1
+ var f=Object.defineProperty;var y=(d,e,i)=>e in d?f(d,e,{enumerable:!0,configurable:!0,writable:!0,value:i}):d[e]=i;var u=(d,e,i)=>y(d,typeof e!="symbol"?e+"":e,i);import{CanvasManager as p}from"./core/canvas.js";import{PhysicsEngine as w,updateMovingParticle as v,updateParticlePhysics as M}from"./core/physics.js";import{createParticle as P}from"./core/particles.js";const c=class c{static isDateInRange(e,i,s,o){const l=new Date,n=l.getMonth()+1,h=l.getDate();return s<e?n===e&&h>=i||n>e||n<e&&n<=s||n===s&&h<=o:(n>e||n===e&&h>=i)&&(n<s||n===s&&h<=o)}static getCurrentTheme(){for(const[e,i]of Object.entries(c.themes)){const[[s,o],[l,n]]=i.dates;if(c.isDateInRange(s,o,l,n))return e}return null}static isCelebrationSeason(){return c.getCurrentTheme()!==null}static getThemes(){return c.themes}constructor(e={}){this.options={theme:e.theme||"auto",intensity:e.intensity||"medium",enabled:e.enabled!==void 0?e.enabled:!0,zIndex:e.zIndex||999,overrides:e.overrides||{}},this.canvasManager=null,this.physicsEngine=null,this.themeModule=null,this.currentTheme=null,this.particles=[],this.specialParticles=[],this.animationFrame=null,this.lastTime=0,this.running=!1,this.initialized=!1}async init(){if(this.initialized){console.warn("[Celebrations] Already initialized");return}console.log("[Celebrations] Initializing...");let e=this.options.theme;if(e==="auto"&&(e=c.getCurrentTheme(),!e)){console.log("[Celebrations] No active celebration detected");return}if(!c.themes[e]){console.error(`[Celebrations] Unknown theme: ${e}`);return}this.currentTheme=e,console.log(`[Celebrations] Loading theme: ${e}`);try{const s=await import(c.themes[e].module);this.themeModule=s.default,console.log(`[Celebrations] Theme loaded: ${this.themeModule.displayName}`)}catch(i){console.error(`[Celebrations] Failed to load theme module: ${i.message}`);return}this.canvasManager=new p({canvasId:"celebrations-canvas",zIndex:this.options.zIndex}),this.canvasManager.create(),this.physicsEngine=new w({gustInterval:[5e3,15e3],gustStrength:[-2,2]}),window.addEventListener("resize",()=>{this.canvasManager.resize(),this.resetParticles()}),this.resetParticles(),this.initialized=!0,console.log("[Celebrations] Initialized successfully"),this.options.enabled&&this.start()}resetParticles(){const e=Object.assign({},this.themeModule.intensityConfig[this.options.intensity]),i=a=>String(a).toLowerCase().replace(/[^a-z0-9]/g,""),s=[...Object.keys(e),...this.themeModule.decorationKeys||[]],o=a=>s.find(r=>i(r)===i(a))||a,l=a=>["true","1","yes","on"].includes(String(a).toLowerCase());for(const[a,r]of Object.entries(this.options.overrides)){if((i(a)==="snowonly"||i(a)==="fallingonly")&&l(r)){for(const g of this.themeModule.decorationKeys||[])e[g]=0;continue}r==="off"&&(e[o(a)]=0)}this.config=e;const n=this.canvasManager.canvas.width,h=this.canvasManager.canvas.height;this.particles=[],this.specialParticles=[];const t=e.initialParticleRatio!==void 0?e.initialParticleRatio:1,m=Math.floor(e.count*t);if(this.themeModule.createFallingParticle)for(let a=0;a<m;a++){const r=this.themeModule.createFallingParticle(n,h,e);r.y=Math.random()*h,this.particles.push(r)}else for(let a=0;a<m;a++){const r=P(e,n,h);r.y=Math.random()*h,this.particles.push(r)}if(this.themeModule.createInitialDecorations){const a=this.themeModule.createInitialDecorations(n,h,e);this.specialParticles.push(...a),console.log(`[Celebrations] Created ${a.length} decorations`)}console.log(`[Celebrations] Created ${m}/${e.count} particles (${this.options.intensity}, ${Math.round(t*100)}% initial)`)}start(){if(!this.initialized){console.warn("[Celebrations] Cannot start - not initialized");return}if(this.running){console.warn("[Celebrations] Already running");return}console.log("[Celebrations] Starting animation"),this.running=!0,this.lastTime=performance.now(),this.animate()}pause(){this.running&&(console.log("[Celebrations] Pausing animation"),this.running=!1,this.animationFrame&&(cancelAnimationFrame(this.animationFrame),this.animationFrame=null))}resume(){this.running||this.start()}destroy(){console.log("[Celebrations] Destroying effect"),this.pause(),this.canvasManager&&this.canvasManager.canvas&&this.canvasManager.canvas.remove(),this.particles=[],this.specialParticles=[],this.canvasManager=null,this.physicsEngine=null,this.themeModule=null,this.initialized=!1}setIntensity(e){if(!["light","medium","heavy"].includes(e)){console.error(`[Celebrations] Invalid intensity: ${e}`);return}console.log(`[Celebrations] Changing intensity to: ${e}`),this.options.intensity=e,this.resetParticles()}async setTheme(e){console.log(`[Celebrations] Changing theme to: ${e}`);const i=this.running;this.pause(),await new Promise(s=>setTimeout(s,20)),this.initialized=!1,this.canvasManager&&this.canvasManager.clear(),this.particles=[],this.specialParticles=[],this.canvasManager&&this.canvasManager.clear(),this.options.theme=e,await this.init(),i&&this.start()}animate(){if(!this.running)return;const e=performance.now(),i=e-this.lastTime;this.lastTime=e,this.canvasManager.clear();const s=this.canvasManager.ctx,o=this.canvasManager.canvas.width,l=this.canvasManager.canvas.height,n=this.config;this.physicsEngine.updateWind(e);const h=this.physicsEngine.getWindForce();if(this.particles.length<n.count&&Math.random()<.05&&this.themeModule.createFallingParticle){const t=this.themeModule.createFallingParticle(o,l,n);(t.y===void 0||t.y<0)&&(t.y=-20),this.particles.push(t)}if(this.particles.forEach((t,m)=>{if(M(t,i,h),t.y>l+50&&(t.y=-20,t.x=Math.random()*o),t.x<-50&&(t.x=o+50),t.x>o+50&&(t.x=-50),this.themeModule.drawSnowflake&&t.type==="snowflake")this.themeModule.drawSnowflake(s,t);else if(this.themeModule["draw"+this.capitalizeFirst(t.type)]){const a="draw"+this.capitalizeFirst(t.type);this.themeModule[a](s,t,e)}else s.save(),s.globalAlpha=t.opacity,s.fillStyle=this.themeModule.colors?.primary||"#ffffff",s.beginPath(),s.arc(t.x,t.y,t.size,0,Math.PI*2),s.fill(),s.restore()}),this.themeModule.updateSpecialParticles&&this.themeModule.updateSpecialParticles(this.specialParticles,i,o,l),this.specialParticles=this.specialParticles.filter(t=>{if(!t.active)return!1;if(!t.static){v(t,i,e);const r=t.type==="train"?600:200;if(t.x<-r||t.x>o+r)return t.active=!1,!1}if(!isFinite(t.x)||!isFinite(t.y)||t.size&&!isFinite(t.size))return console.warn("[Celebrations] Invalid particle values, removing:",t.type),t.active=!1,!1;const a="draw"+this.capitalizeFirst(t.type);return this.themeModule[a]&&this.themeModule[a](s,t,e),!0}),this.themeModule.spawnSpecialParticle){const t=this.themeModule.spawnSpecialParticle(this.specialParticles,o,l,n);t&&this.specialParticles.push(t)}this.themeModule.drawGlobalEffects&&this.themeModule.drawGlobalEffects(s,e,o,l),this.animationFrame=requestAnimationFrame(()=>this.animate())}capitalizeFirst(e){if(!e)return"";const i=e.replace(/-([a-z])/g,(s,o)=>o.toUpperCase());return i.charAt(0).toUpperCase()+i.slice(1)}};u(c,"themes",{christmas:{module:"./themes/christmas.js?v=20260930-snow",dates:[[12,1],[1,1]],displayName:"Christmas",emoji:"\u{1F384}"},halloween:{module:"./themes/halloween.js?v=20260930-snow",dates:[[10,26],[10,31]],displayName:"Halloween",emoji:"\u{1F383}"},valentines:{module:"./themes/valentines.js?v=20260930-snow",dates:[[2,9],[2,14]],displayName:"Valentine's Day",emoji:"\u{1F495}"},"guy-fawkes":{module:"./themes/guy-fawkes.js?v=20260930-snow",dates:[[11,1],[11,5]],displayName:"Guy Fawkes Night",emoji:"\u{1F386}"},"st-patricks":{module:"./themes/st-patricks.js?v=20260930-snow",dates:[[3,12],[3,17]],displayName:"St Patrick's Day",emoji:"\u2618\uFE0F"},"st-andrews":{module:"./themes/st-andrews.js?v=20260930-snow",dates:[[11,25],[11,30]],displayName:"St Andrew's Day",emoji:"\u{1F3F4}\u{E0067}\u{E0062}\u{E0073}\u{E0063}\u{E0074}\u{E007F}"},"st-davids":{module:"./themes/st-davids.js?v=20260930-snow",dates:[[2,24],[3,1]],displayName:"St David's Day",emoji:"\u{1F3F4}\u{E0067}\u{E0062}\u{E0077}\u{E006C}\u{E0073}\u{E007F}"},"st-georges":{module:"./themes/st-georges.js?v=20260930-snow",dates:[[4,18],[4,23]],displayName:"St George's Day",emoji:"\u{1F3F4}\u{E0067}\u{E0062}\u{E0065}\u{E006E}\u{E0067}\u{E007F}"}});export let CelebrationsEffect=c;export default CelebrationsEffect;
@@ -1 +1 @@
1
- import{createParticle as ee}from"./../core/particles.js";export default{name:"christmas",displayName:"Christmas",emoji:"\u{1F384}",intensityConfig:{light:{count:50,speedRange:[.5,1.5],sizeRange:[1,3],trees:3,wreaths:2,northStars:1,snowmen:2},medium:{count:150,speedRange:[.8,2.5],sizeRange:[1,4],trees:6,wreaths:3,northStars:1,snowmen:3},heavy:{count:300,speedRange:[1,3.5],sizeRange:[1,5],trees:10,wreaths:4,northStars:1,snowmen:4}},particles:["snowflake"],decorations:["tree","wreath","sleigh","robin","train","elf","firework","north-star","snowman"],colors:{primary:"#ffffff",secondary:"#228B22",accent:"#c00",gold:"#FFD700"},createSnowflakeParticle(e,i,s){const y=ee(s,e,i);return y.type="snowflake",y},createFallingParticle(e,i,s){return this.createSnowflakeParticle(e,i,s)},createTree(e,i,s={}){return{type:"tree",x:s.x!==void 0?s.x:Math.random()*e,y:s.y!==void 0?s.y:Math.random()*i,vx:0,vy:0,size:20+Math.random()*15,opacity:.6+Math.random()*.3,rotation:0,rotationSpeed:0,active:!0,static:!0}},createWreath(e,i,s={}){const y=[];for(let o=0;o<20;o++){const h=o/20*Math.PI*2;y.push({angle:h,radius:.9+Math.random()*.2,thickness:.2+Math.random()*.15,color:o%2===0?"#1a6b1a":"#228B22"})}return{type:"wreath",x:s.x!==void 0?s.x:Math.random()*e,y:s.y!==void 0?s.y:Math.random()*i,vx:0,vy:0,size:15+Math.random()*10,opacity:.7+Math.random()*.2,rotation:0,rotationSpeed:0,active:!0,static:!0,shape:y}},createNorthStar(e,i,s={}){return{type:"north-star",x:s.x!==void 0?s.x:e/2,y:s.y!==void 0?s.y:80,vx:0,vy:0,size:25,opacity:1,twinklePhase:Math.random()*Math.PI*2,twinkleSpeed:.003,active:!0,static:!0}},createSnowman(e,i,s={}){return{type:"snowman",x:s.x!==void 0?s.x:Math.random()*e,y:s.y!==void 0?s.y:i-50,vx:0,vy:0,size:15+Math.random()*10,opacity:1,time:Math.random()*1e3,wavePhase:Math.random()*Math.PI*2,active:!0,static:!0}},createInitialDecorations(e,i,s){const y=[],n=s.trees??6;for(let l=0;l<n;l++)y.push(this.createTree(e,i,{x:e/(n+1)*(l+1),y:i-60-Math.random()*20}));const o=s.wreaths??3;for(let l=0;l<o;l++)y.push(this.createWreath(e,i,{x:e/(o+1)*(l+1),y:50+Math.random()*100}));s.northStars!==0&&s.northStars&&y.push(this.createNorthStar(e,i,{x:e/2,y:60}));const h=s.snowmen??3;for(let l=0;l<h;l++)y.push(this.createSnowman(e,i,{x:e/(h+1)*(l+1),y:i-50-Math.random()*10}));return y},spawnSpecialParticle(e,i,s,y={}){const n=Math.random();if(n<5e-4){if(y.sleigh===0||e.some(a=>a.type==="sleigh"))return null;const o=Math.random()<.5,h=o?-100:i+100,l=100+Math.random()*(s*.3);return{type:"sleigh",x:h,startX:h,y:l,baseY:l,targetX:o?i+100:-100,canvasWidth:i,vx:o?3+Math.random()*2:-(3+Math.random()*2),vy:0,arcHeight:150+Math.random()*100,time:0,size:15+Math.random()*10,opacity:.9,rotation:0,active:!0,static:!1}}else if(n<.0013&&y.elf!==0){const o=Math.random()<.5;return{type:"elf",x:o?-50:i+50,y:s-30,baseY:s-30,vx:o?1.5+Math.random()*1:-(1.5+Math.random()*1),waveAmplitude:3,waveFrequency:.05,waveOffset:Math.random()*Math.PI*2,time:0,size:10+Math.random()*5,opacity:.95,rotation:0,active:!0,static:!1}}else if(n<.005&&y.train!==0){if(e.some(g=>g.type==="train"))return null;const o=Math.random()<.5,h=o?-500:i+500,l=21+Math.random()*9,a=l*.72,r=s-a-10;return{type:"train",x:h,y:r,baseY:r,vx:o?4+Math.random()*2:-(4+Math.random()*2),vy:0,size:l,opacity:1,time:0,smoke:[],active:!0,static:!1,carriages:2+Math.floor(Math.random()*2)}}else{if(n<.008&&y.firework!==0)return{type:"firework",x:Math.random()*i,y:s,vx:(Math.random()-.5)*4,vy:-10-Math.random()*5,size:2+Math.random()*2,opacity:1,active:!0,static:!1,time:0,exploded:!1,explosionTime:30+Math.random()*30};if(n<.012&&y.robin!==0){if(e.some(t=>t.type==="robin"))return null;const o=Math.random()<.5,h=Math.random()*(s*.2),l=o?-50:i+50,a=10+Math.random()*5,r=Math.max(a*3,Math.min(s*.6-a*2,s*.2+Math.random()*(s*.4))),g=Math.random()*(i*.6)+i*.2;return{type:"robin",state:"flying_in",x:l,y:h,startX:l,startY:h,targetX:g,targetY:r,vx:o?.5+Math.random()*.5:-(.5+Math.random()*.5),vy:0,size:a,opacity:.95,active:!0,static:!1,sitTime:3e3+Math.random()*2e3,sitStartTime:0,flightProgress:0,time:0,waveOffset:Math.random()*Math.PI*2}}}return null},updateSpecialParticles(e,i,s=1024,y=768){e.forEach(n=>{switch(n.time!==void 0&&(n.time+=i),n.type){case"sleigh":(n.vx>0&&n.x>n.targetX||n.vx<0&&n.x<n.targetX)&&(n.active=!1);break;case"robin":switch(n.state){case"flying_in":const o=n.targetX-n.x,h=n.targetY-n.y,l=Math.sqrt(o*o+h*h);if(l<20)n.state="sitting",n.vx=0,n.vy=0,n.sitStartTime=n.time;else{const P=o/l,M=h/l,m=3;n.vx=P*m;const k=30,S=.008,u=Math.sin(n.time*S+n.waveOffset)*k,v=Math.cos(n.time*S+n.waveOffset)*k*S;n.vy=M*m*.3+v}break;case"sitting":if(n.vx=0,n.vy=0,n.time-n.sitStartTime>n.sitTime){n.state="flying_away";const P=n.x<s/2?-4:4;n.vx=P,n.flyAwayStartTime=n.time}break;case"flying_away":const a=n.vx>0?1:-1;n.vx=a*4;const r=25,g=.01,t=n.time-n.flyAwayStartTime,f=Math.sin(t*g+n.waveOffset)*r,d=Math.cos(t*g+n.waveOffset)*r*g;n.vy=-1.5+d,(n.x<-50||n.x>s+50||n.y<-50)&&(n.active=!1);break}break;case"train":if(n.smoke||(n.smoke=[]),(n.vx>0&&n.x>s+500||n.vx<0&&n.x<-500)&&(n.active=!1),n.lastSmokeTime||(n.lastSmokeTime=0),n.time-n.lastSmokeTime>150){n.lastSmokeTime=n.time;const h=n.size*1.8/20,l=n.vx>0?1:-1,a=h*8,r=h*7,g=h*10,d=-a-h-r-g*2-h*8,P=h*70,M=h*25,k=(P-M)*.7,S=n.x+k*l,u=n.y+d,v={x:S,y:u,vx:(Math.random()-.5)*.8,vy:-.8-Math.random()*.4,size:8+Math.random()*6,opacity:.7+Math.random()*.2,fadeRate:.012+Math.random()*.008};n.smoke.push(v)}n.smoke=n.smoke.filter(o=>(o.x+=o.vx,o.y+=o.vy,o.vy*=.99,o.size*=1.02,o.opacity-=o.fadeRate,o.opacity>0));break;case"firework":if(!n.exploded){const o=n.y<=n.targetY,h=n.time>=n.explosionTime;(o||h)&&(n.exploded=!0,this.explodeFirework(n,e),n.active=!1)}break}})},drawSnowflake(e,i){e.save(),e.translate(i.x,i.y),e.rotate(i.rotation),e.globalAlpha=i.opacity,e.fillStyle="#ffffff",e.strokeStyle="#ffffff",e.lineWidth=Math.max(i.size*.15,.5);const s=6,y=i.size;for(let n=0;n<s;n++){const o=Math.PI*2*n/s;e.beginPath(),e.moveTo(0,0),e.lineTo(Math.cos(o)*y,Math.sin(o)*y),e.stroke();const h=y*.4,l=Math.PI/6,a=Math.cos(o)*(y*.6),r=Math.sin(o)*(y*.6);e.beginPath(),e.moveTo(a,r),e.lineTo(a+Math.cos(o-l)*h,r+Math.sin(o-l)*h),e.stroke(),e.beginPath(),e.moveTo(a,r),e.lineTo(a+Math.cos(o+l)*h,r+Math.sin(o+l)*h),e.stroke()}e.restore()},drawTree(e,i,s){const y=i.x,n=i.y,o=i.size;e.save(),e.translate(y,n),e.rotate(i.rotation),e.fillStyle="#654321",e.beginPath(),e.moveTo(-o*.2,o*.8),e.lineTo(o*.2,o*.8),e.lineTo(o*.15,o*1.3),e.lineTo(-o*.15,o*1.3),e.closePath(),e.fill(),e.strokeStyle="#4a2f1a",e.lineWidth=1;for(let t=0;t<3;t++)e.beginPath(),e.moveTo(-o*.15,o*.9+t*o*.12),e.lineTo(o*.15,o*.9+t*o*.12),e.stroke();e.fillStyle="#228B22";for(let t=0;t<3;t++){const f=t*o*.4,d=o*(1.2-t*.2);e.beginPath(),e.moveTo(0,-f),e.lineTo(-d,o*.3-f),e.lineTo(d,o*.3-f),e.closePath(),e.fill()}e.strokeStyle="#C0C0C0",e.lineWidth=1.5;for(let t=0;t<3;t++){const f=t*o*.4,d=o*(1.2-t*.2);e.beginPath();for(let P=0;P<=6;P++){const M=-d+P/6*d*2,m=o*.15-f+(P%2===0?-o*.1:0);P===0?e.moveTo(M,m):e.lineTo(M,m)}e.stroke()}const h=["#ff0000","#0000ff","#ffd700","#ff69b4","#00ff00"];for(let t=0;t<8;t++){const f=Math.floor(t/3),d=f*o*.4,P=o*(1.2-f*.2)*.7,M=t%3*(Math.PI*2/3)+f*.5,m=Math.cos(M)*P,k=o*.1-d;e.fillStyle=h[t%h.length],e.beginPath(),e.arc(m,k,o*.12,0,Math.PI*2),e.fill(),e.fillStyle="rgba(255,255,255,0.5)",e.beginPath(),e.arc(m-o*.04,k-o*.04,o*.04,0,Math.PI*2),e.fill()}const l=["#ffff00","#ff0000","#00ff00","#0000ff","#ffffff"];for(let t=0;t<12;t++){const f=Math.floor(t/4),d=f*o*.4,P=o*(1.2-f*.2)*.85,M=t%4*(Math.PI*2/4)+f*.3,m=Math.cos(M)*P,k=o*.2-d,S=(Math.sin(s*.003+t*.5)+1)*.5,u=.3+S*.7,v=e.createRadialGradient(m,k,0,m,k,o*.15),T=l[t%l.length];v.addColorStop(0,T),v.addColorStop(1,"rgba(255,255,255,0)"),e.globalAlpha=u,e.fillStyle=v,e.beginPath(),e.arc(m,k,o*.15,0,Math.PI*2),e.fill(),e.globalAlpha=.5+S*.5,e.fillStyle=T,e.beginPath(),e.arc(m,k,o*.08,0,Math.PI*2),e.fill(),e.globalAlpha=1}const a=o*.35,r=-o*1.4,g=e.createRadialGradient(0,r,0,0,r,a*2);g.addColorStop(0,"rgba(255,223,0,1)"),g.addColorStop(.3,"rgba(255,215,0,0.7)"),g.addColorStop(.6,"rgba(255,215,0,0.3)"),g.addColorStop(1,"rgba(255,215,0,0)"),e.fillStyle=g,e.beginPath(),e.arc(0,r,a*2,0,Math.PI*2),e.fill(),e.fillStyle="#FFD700",e.strokeStyle="#FFA500",e.lineWidth=2,e.save(),e.translate(0,r),e.beginPath();for(let t=0;t<10;t++){const f=Math.PI*2*t/10-Math.PI/2,d=t%2===0?a:a*.4,P=Math.cos(f)*d,M=Math.sin(f)*d;t===0?e.moveTo(P,M):e.lineTo(P,M)}e.closePath(),e.fill(),e.stroke(),e.restore(),e.restore()},drawWreath(e,i,s){const y=i.x,n=i.y,o=i.size;e.save(),e.translate(y,n),e.rotate(i.rotation),i.shape.forEach(a=>{e.lineWidth=o*a.thickness,e.strokeStyle=a.color,e.beginPath();const r=a.angle-Math.PI/i.shape.length,g=a.angle+Math.PI/i.shape.length;e.arc(0,0,o*a.radius,r,g),e.stroke()});const h=["#ff0000","#ffff00","#0000ff"];for(let a=0;a<4;a++){const r=a/4*Math.PI*2+i.rotation,g=Math.cos(r)*o,t=Math.sin(r)*o,f=(Math.sin(s*.002+a*.7)+1)/2;if(f>.5){const d=(f-.5)*2,P=h[a%h.length],M=e.createRadialGradient(g,t,0,g,t,o*.15);M.addColorStop(0,P),M.addColorStop(1,"rgba(255,255,255,0)"),e.globalAlpha=d,e.fillStyle=M,e.beginPath(),e.arc(g,t,o*.15,0,Math.PI*2),e.fill(),e.globalAlpha=1}}e.fillStyle="#c00";const l=-o;e.beginPath(),e.ellipse(-o*.3,l,o*.3,o*.4,-.3,0,Math.PI*2),e.fill(),e.beginPath(),e.ellipse(o*.3,l,o*.3,o*.4,.3,0,Math.PI*2),e.fill(),e.beginPath(),e.arc(0,l,o*.2,0,Math.PI*2),e.fill(),e.restore()},drawNorthStar(e,i,s){const y=i.x,n=i.y,o=i.size,h=.8+Math.sin(s*i.twinkleSpeed+i.twinklePhase)*.2;e.save(),e.translate(y,n);const l=o*4*h,a=e.createRadialGradient(0,0,0,0,0,l);a.addColorStop(0,"rgba(220, 230, 240, 0.4)"),a.addColorStop(.4,"rgba(200, 210, 220, 0.2)"),a.addColorStop(1,"rgba(180, 190, 200, 0)"),e.fillStyle=a,e.beginPath(),e.arc(0,0,l,0,Math.PI*2),e.fill();const r=o*1,g=o*1.5,t=o*.12,f=e.createRadialGradient(0,0,0,0,0,o);f.addColorStop(0,"#ffffff"),f.addColorStop(.3,"#f0f0f0"),f.addColorStop(.6,"#c0c0c0"),f.addColorStop(1,"#a0a0a0"),e.shadowColor="rgba(220, 230, 240, 0.9)",e.shadowBlur=o*.8*h,e.fillStyle=f,e.beginPath(),e.moveTo(0,-g),e.lineTo(t,-t),e.lineTo(r,0),e.lineTo(t,t),e.lineTo(0,g),e.lineTo(-t,t),e.lineTo(-r,0),e.lineTo(-t,-t),e.closePath(),e.fill(),e.shadowBlur=o*1.2*h,e.shadowColor="rgba(255, 255, 255, 1)";const d=e.createRadialGradient(0,0,0,0,0,o*.3);d.addColorStop(0,"#ffffff"),d.addColorStop(.6,"#f5f5f5"),d.addColorStop(1,"rgba(200, 200, 200, 0.8)"),e.fillStyle=d,e.beginPath(),e.arc(0,0,o*.3,0,Math.PI*2),e.fill(),e.shadowBlur=0,e.restore()},drawSleigh(e,i){const s=i.x,y=Math.abs(i.targetX-i.startX),n=Math.abs(i.x-i.startX),o=Math.min(1,n/y),h=Math.sin(o*Math.PI)*i.arcHeight,l=i.baseY-h,a=i.size*1.5,r=i.vx>0?1:-1,g=Math.abs(Math.sin(i.time*.007));if(!isFinite(s)||!isFinite(l)||!isFinite(a)){console.warn("[Christmas] Invalid sleigh values:",{x:s,y:l,size:a});return}const t=[{x:4.8,y:0},{x:4,y:-.3},{x:4,y:.3},{x:3.2,y:-.15},{x:3.2,y:.15}];t.forEach((T,F)=>{const b=s+r*(a*T.x),C=T.y*a;e.fillStyle="#9c6e49",e.strokeStyle="#7b563a",e.lineWidth=1,e.beginPath(),e.moveTo(b-r*a*.4,l+C),e.quadraticCurveTo(b,l+C-a*.4,b+r*a*.4,l+C),e.quadraticCurveTo(b,l+C+a*.4,b-r*a*.4,l+C),e.fill(),e.stroke();const R=b+r*a*.5,G=l+C-a*.3;e.beginPath(),e.ellipse(R,G,a*.25,a*.2,0,0,Math.PI*2),e.fill(),e.stroke(),e.strokeStyle="#6e4a2e",e.lineWidth=1.5;const Y=R-r*a*.1,A=G-a*.15;e.beginPath(),e.moveTo(Y,A),e.lineTo(Y-r*a*.2,A-a*.3),e.lineTo(Y-r*a*.1,A-a*.4),e.moveTo(Y-r*a*.2,A-a*.3),e.lineTo(Y-r*a*.3,A-a*.35),e.stroke();const X=l+C+a*.1;e.lineWidth=2.5,e.strokeStyle="#7b563a";const L=g*a*.45;e.beginPath(),e.moveTo(b+r*a*.3,X),e.lineTo(b+r*(a*.3+L),X+a*.3),e.stroke();const W=(1-g)*a*.45;if(e.beginPath(),e.moveTo(b-r*a*.3,X),e.lineTo(b-r*(a*.3+W),X+a*.3),e.stroke(),F===0){const z=R+r*a*.25,B=G;e.fillStyle="#ff0000",e.beginPath(),e.arc(z,B,a*.03,0,Math.PI*2),e.fill();const q=e.createRadialGradient(z,B,0,z,B,a*.1);q.addColorStop(0,"rgba(255,0,0,0.7)"),q.addColorStop(1,"rgba(255,0,0,0)"),e.fillStyle=q,e.beginPath(),e.arc(z,B,a*.1,0,Math.PI*2),e.fill()}}),e.fillStyle="#c00",e.strokeStyle="#FFD700",e.lineWidth=2;const f=l-a*.7,d=s+r*a*1.5,P=s-r*a*.3,M=l+a*.3;e.beginPath(),e.moveTo(d,f),e.quadraticCurveTo(s+r*a,l-a*.2,d-r*a*.8,l),e.lineTo(P,l),e.lineTo(P,M),e.lineTo(d,M),e.closePath(),e.fill(),e.stroke(),e.beginPath(),e.moveTo(P,l),e.quadraticCurveTo(P-r*a*.4,l-a*.5,P,f),e.quadraticCurveTo(P+r*a*.2,l-a*.4,P+r*a*.2,l),e.closePath(),e.fill(),e.stroke(),e.strokeStyle="#a52a2a",e.lineWidth=4;const m=M+a*.2;function k(T){e.beginPath(),e.moveTo(d+r*a*.1,m+T),e.quadraticCurveTo(s,m+T+a*.2,P-r*a*.2,m+T),e.stroke(),e.beginPath(),e.moveTo(d-r*a*.5,M),e.lineTo(d-r*a*.5,m+T),e.stroke(),e.beginPath(),e.moveTo(P+r*a*.5,M),e.lineTo(P+r*a*.5,m+T),e.stroke()}k(0),k(a*.1);const S=s-r*a*.05;e.fillStyle="#5c4033",e.beginPath(),e.moveTo(S-a*.3,l-a*.4),e.quadraticCurveTo(S-a*.6,l-a*.2,S-a*.4,l+a*.1),e.quadraticCurveTo(S,l+a*.3,S+a*.4,l+a*.1),e.quadraticCurveTo(S+a*.6,l-a*.2,S+a*.3,l-a*.4),e.closePath(),e.fill(),e.fillStyle="rgba(255, 255, 255, 0.2)",e.beginPath(),e.ellipse(S+a*.1,l-a*.3,a*.2,a*.1,-Math.PI/4,0,Math.PI*2),e.fill(),e.strokeStyle="#654321",e.lineWidth=1,e.beginPath(),e.arc(S,l-a*.4,a*.2,0,Math.PI,!0),e.stroke();const u=s+r*a*.6,v=l-a*.55;e.fillStyle="#c00",e.beginPath(),e.moveTo(u-a*.2,v),e.lineTo(u+a*.2,v),e.lineTo(u,v-a*.4),e.closePath(),e.fill(),e.fillStyle="#fff",e.fillRect(u-a*.22,v,a*.44,a*.08),e.beginPath(),e.arc(u,v-a*.4,a*.07,0,Math.PI*2),e.fill(),e.fillStyle="#FFD7BA",e.beginPath(),e.arc(u,v+a*.08,a*.18,0,Math.PI*2),e.fill(),e.fillStyle="#fff",e.beginPath(),e.ellipse(u,v+a*.2,a*.25,a*.2,0,0,Math.PI),e.fill(),e.strokeStyle="#654321",e.lineWidth=1,t.forEach(T=>{const F=s+r*(a*T.x),b=T.y*a,C=F+r*a*.5;e.beginPath(),e.moveTo(u,v+a*.1),e.quadraticCurveTo((u+C)/2,l+b-a*.5,C-r*a*.1,l+b-a*.3),e.stroke()})},drawElf(e,i){const s=i.x,y=i.y,n=i.size,o=i.vx>0?1:-1;e.save(),e.translate(s,y);const h=Math.sin(i.time*.05)*(Math.PI/3);e.fillStyle="#004d00",e.fillRect(o*-n*.1,n*.2,n*.2,n*.5+Math.sin(h+Math.PI)*n*.1),e.fillStyle="#4a2c2a",e.beginPath(),e.ellipse(o*0,n*.7+Math.sin(h+Math.PI)*n*.1,n*.3,n*.15,0,0,Math.PI*2),e.fill(),e.fillStyle="#006400",e.fillRect(o*n*.1,n*.2,n*.2,n*.5+Math.sin(h)*n*.1),e.fillStyle="#5d3836",e.beginPath(),e.ellipse(o*n*.2,n*.7+Math.sin(h)*n*.1,n*.3,n*.15,0,0,Math.PI*2),e.fill(),e.fillStyle="#008000",e.beginPath(),e.moveTo(0,-n*.5),e.lineTo(o*n*.4,n*.3),e.lineTo(o*-n*.4,n*.3),e.closePath(),e.fill(),e.fillStyle="#FFD7BA",e.beginPath(),e.arc(0,-n*.6,n*.3,0,Math.PI*2),e.fill(),e.fillStyle="#c00",e.beginPath(),e.moveTo(0,-n*.7),e.lineTo(o*n*.35,-n*.6),e.lineTo(o*-n*.35,-n*.6),e.closePath(),e.fill(),e.beginPath(),e.moveTo(0,-n*.7),e.quadraticCurveTo(o*n*.2,-n*1.1,o*n*.4,-n*1.3),e.stroke(),e.fillStyle="#ffff00",e.beginPath(),e.arc(o*n*.4,-n*1.3,n*.15,0,Math.PI*2),e.fill(),e.restore()},drawTrain(e,i){const s=i.x,y=i.y,n=i.size*1.8,o=i.vx>0?1:-1,h=i.time;i.smoke&&i.smoke.length>0&&i.smoke.forEach(w=>{e.save(),e.globalAlpha=w.opacity;const I=e.createRadialGradient(w.x,w.y,0,w.x,w.y,w.size);I.addColorStop(0,"#CCCCCC"),I.addColorStop(.5,"#AAAAAA"),I.addColorStop(1,"#888888"),e.fillStyle=I,e.beginPath(),e.arc(w.x,w.y,w.size,0,Math.PI*2),e.fill(),e.restore()}),e.save(),e.translate(s,y),o===-1&&e.scale(-1,1);const l=h*.005,a=n/20,r=0,g=a*8,t=a*5,f=a*7,d=a*35,P=-t-a,M=-g-a,m=P-d,k=a*30,S=a*10,u=M-f-S*2,v=a*70,T=a*25,F=v-T,b=a*60,C=a*15;for(let w=1;w<=i.carriages;w++){const I=-(T+w*(b+C)-C);e.fillStyle="#222",e.strokeStyle="#444",e.lineWidth=1,e.beginPath(),e.arc(I+b*.25,-t,t,0,Math.PI*2),e.fill(),e.stroke(),e.beginPath(),e.arc(I+b*.75,-t,t,0,Math.PI*2),e.fill(),e.stroke(),e.fillStyle="#004d00",e.strokeStyle="#DAA520",e.lineWidth=a*.5,e.fillRect(I,m,b,d),e.strokeRect(I,m,b,d);const U=d*.6,_=m+d*.2;e.shadowColor="#F1C40F",e.shadowBlur=15;const c=.8+Math.sin(h*.001+w)*.2;e.fillStyle=`rgba(255, 235, 150, ${c})`;const E=b*.25,$=I+b*.15,j=I+b*.6;e.fillRect($,_,E,U),e.fillRect(j,_,E,U),e.shadowBlur=0,e.fillStyle="rgba(0, 0, 0, 0.4)";const D=E*.2,V=$+E/2,J=_+D*1.8;e.beginPath(),e.arc(V,J,D,0,Math.PI*2),e.fill(),e.fillRect(V-D,J+D,D*2,D*2);const O=E*.18,K=j+E/2,Q=_+O*1.6;e.beginPath(),e.arc(K,Q,O,0,Math.PI*2),e.fill(),e.fillRect(K-O*1.2,Q,O*2.4,O*2.5);const N=a*4;e.fillStyle="#228B22",e.beginPath(),e.arc(I+b/2,m+d/2,N,0,Math.PI*2),e.arc(I+b/2,m+d/2,N*.6,0,Math.PI*2,!0),e.fill(),e.fillStyle="#c00",e.beginPath(),e.arc(I+b/2,m+d/2+N,N*.3,0,Math.PI*2),e.fill(),e.strokeStyle="#111",e.lineWidth=a,e.beginPath();const Z=P+f/2,x=w===1?-T:I+b+C;e.moveTo(I+b,Z),e.lineTo(x,Z),e.stroke()}e.fillStyle="#2C3E50",e.strokeStyle="#555",e.lineWidth=a;const R=[a*15,a*35,a*55];R.forEach(w=>{e.beginPath(),e.arc(w,-g,g,0,Math.PI*2),e.fill(),e.stroke()});const G=-a*10;e.beginPath(),e.arc(G,-t,t,0,Math.PI*2),e.fill(),e.stroke(),e.fillStyle="#1C2833",e.fillRect(-T-a*5,M-f,v+a*5,f),e.fillStyle="#B03A2E",e.strokeStyle="#DAA520",e.lineWidth=a*.7;const Y=M-f-k;e.beginPath(),e.moveTo(-T,Y),e.lineTo(0,Y),e.lineTo(0,M-f),e.lineTo(-T-a*5,M-f),e.closePath(),e.fill(),e.stroke(),e.fillStyle=`rgba(255, 235, 150, ${.8+Math.sin(h*.001)*.2})`,e.fillRect(-T+a*4,Y+a*4,T-a*12,a*10),e.fillStyle="#2C3E50",e.beginPath(),e.moveTo(0,M-f),e.lineTo(F,M-f),e.arc(F,u+S,S,Math.PI/2,-Math.PI/2),e.lineTo(0,u),e.closePath(),e.fill(),e.stroke();const A=u-a*8;e.fillStyle="#17202A",e.beginPath(),e.moveTo(F*.7,u),e.lineTo(F*.7-a*2,A),e.lineTo(F*.7+a*10,A-a*4),e.lineTo(F*.7+a*8,u),e.closePath(),e.fill();const X=F+S,L=u+S,W=a*4,z=e.createRadialGradient(X,L,W*.2,X,L,W*1.5);z.addColorStop(0,"rgba(255, 255, 200, 1)"),z.addColorStop(.4,"rgba(255, 220, 100, 0.8)"),z.addColorStop(1,"rgba(255, 200, 0, 0)"),e.fillStyle=z,e.fillRect(X-W,L-W,W*2,W*2),e.strokeStyle="#99A3A4",e.lineWidth=a*2;const B=Math.sin(l)*g*.6,q=R[1]+Math.cos(l)*g*.6,H=v-a*10,p=M-f+a*2+Math.sin(h*.01)*a;e.beginPath(),e.moveTo(H,p),e.lineTo(q,-g+B),e.stroke(),e.lineWidth=a*1.5,[R[0],R[2]].forEach(w=>{const I=w+Math.cos(l)*g*.6;e.beginPath(),e.moveTo(q,-g+B),e.lineTo(I,-g+B),e.stroke()}),e.restore()},drawFirework(e,i){e.fillStyle=i.color||"#ffffff",e.beginPath(),e.arc(i.x,i.y,i.size,0,Math.PI*2),e.fill()},explodeFirework(e,i){const s=50+Math.random()*50,y=["#ff0000","#ffff00","#00ff00","#0000ff","#ffffff"];for(let n=0;n<s;n++){const o=Math.random()*Math.PI*2,h=Math.random()*5+2;i.push({type:"spark",x:e.x,y:e.y,vx:Math.cos(o)*h,vy:Math.sin(o)*h,size:2+Math.random()*2,opacity:1,active:!0,static:!1,color:y[Math.floor(Math.random()*y.length)]})}},drawRobin(e,i,s){const y=i.x,n=i.y,o=i.size,h=i.vx>=0?1:-1,l=i.state==="flying_in"||i.state==="flying_away",a=i.state==="sitting";i.wingIntensity===void 0&&(i.wingIntensity=l?1:0);const r=l?1:0,g=.02;i.wingIntensity+=(r-i.wingIntensity)*g;let t=0;if(i.wingIntensity>.01){const M=Math.sin(s*.012+i.waveOffset),m=Math.sin(M*Math.PI/2),k=Math.PI/6;t=m*k*i.wingIntensity}e.save(),e.translate(y,n),h===-1&&e.scale(-1,1),e.fillStyle="#A52A2A",e.beginPath(),e.ellipse(0,0,o*.6,o*.8,0,0,Math.PI*2),e.fill(),e.fillStyle="#DC143C",e.beginPath(),e.ellipse(o*.15,o*.1,o*.45,o*.6,0,0,Math.PI*2),e.fill(),e.fillStyle="#654321",e.strokeStyle="#4a2c2a",e.lineWidth=.5,e.beginPath(),e.moveTo(-o*.5,0),e.quadraticCurveTo(-o*.7,-o*.08,-o*.85,0),e.quadraticCurveTo(-o*.7,o*.08,-o*.5,0),e.fill(),e.stroke(),e.fillStyle="#8B4513",e.strokeStyle="#654321",e.lineWidth=1,e.save(),e.translate(-o*.3,-o*.1),e.rotate(t),e.beginPath(),e.ellipse(0,0,o*.45,o*.25,-Math.PI/6,0,Math.PI*2),e.fill(),e.stroke(),e.restore(),e.fillStyle="#A0692F",e.strokeStyle="#654321",e.lineWidth=1,e.save(),e.translate(-o*.3,o*.1),e.rotate(-t),e.beginPath(),e.ellipse(0,0,o*.45,o*.25,Math.PI/6,0,Math.PI*2),e.fill(),e.stroke(),e.restore(),e.fillStyle="#A52A2A",e.beginPath(),e.arc(o*.5,-o*.2,o*.35,0,Math.PI*2),e.fill(),e.fillStyle="#FFD700",e.strokeStyle="#DAA520",e.lineWidth=.5,e.beginPath(),e.moveTo(o*.75,-o*.2),e.lineTo(o*.95,-o*.15),e.lineTo(o*.75,-o*.1),e.closePath(),e.fill(),e.stroke(),e.fillStyle="#000000",e.beginPath(),e.arc(o*.6,-o*.25,o*.08,0,Math.PI*2),e.fill(),e.fillStyle="#FFFFFF",e.beginPath(),e.arc(o*.62,-o*.27,o*.03,0,Math.PI*2),e.fill(),a&&(e.strokeStyle="#8B4513",e.lineWidth=o*.08,e.lineCap="round",e.beginPath(),e.moveTo(o*.1,o*.7),e.lineTo(o*.1,o*1),e.stroke(),e.beginPath(),e.moveTo(-o*.1,o*.7),e.lineTo(-o*.1,o*1),e.stroke(),e.lineWidth=o*.06,e.beginPath(),e.moveTo(o*.1,o*1),e.lineTo(o*.25,o*1),e.stroke(),e.beginPath(),e.moveTo(-o*.1,o*1),e.lineTo(-o*.25,o*1),e.stroke());const f=o*.5,d=-o*.55;e.fillStyle="#c00",e.beginPath(),e.moveTo(f-o*.3,d),e.lineTo(f+o*.25,d),e.lineTo(f+o*.1,d-o*.5),e.closePath(),e.fill(),e.fillStyle="#FFFFFF",e.fillRect(f-o*.32,d,o*.58,o*.1),e.beginPath(),e.arc(f+o*.1,d-o*.5,o*.1,0,Math.PI*2),e.fill(),e.restore()},drawSnowman(e,i,s){const y=i.x,n=i.y,o=i.size,h=Math.sin(s*.002+i.wavePhase)*.03;e.save(),e.translate(y,n),e.rotate(h),e.fillStyle="rgba(0, 0, 0, 0.2)",e.beginPath(),e.ellipse(0,o*2,o*1.2,o*.3,0,0,Math.PI*2),e.fill(),e.fillStyle="#FFFFFF",e.strokeStyle="#E0E0E0",e.lineWidth=1,e.beginPath(),e.arc(0,o*1.3,o*1,0,Math.PI*2),e.fill(),e.stroke(),e.beginPath(),e.arc(0,o*.3,o*.7,0,Math.PI*2),e.fill(),e.stroke(),e.beginPath(),e.arc(0,-o*.6,o*.5,0,Math.PI*2),e.fill(),e.stroke(),e.fillStyle="#000000",e.beginPath(),e.arc(0,o*.5,o*.08,0,Math.PI*2),e.fill(),e.beginPath(),e.arc(0,o*.1,o*.08,0,Math.PI*2),e.fill(),e.beginPath(),e.arc(0,-o*.2,o*.08,0,Math.PI*2),e.fill(),e.strokeStyle="#654321",e.lineWidth=o*.1,e.beginPath(),e.moveTo(-o*.6,o*.3),e.lineTo(-o*1.2,o*0),e.lineTo(-o*1.5,-o*.1),e.stroke(),e.lineWidth=o*.05,e.beginPath(),e.moveTo(-o*1.5,-o*.1),e.lineTo(-o*1.7,-o*.3),e.stroke(),e.beginPath(),e.moveTo(-o*1.5,-o*.1),e.lineTo(-o*1.8,-o*.05),e.stroke(),e.lineWidth=o*.1,e.beginPath(),e.moveTo(o*.6,o*.3),e.lineTo(o*1.2,o*0),e.lineTo(o*1.5,-o*.1),e.stroke(),e.lineWidth=o*.05,e.beginPath(),e.moveTo(o*1.5,-o*.1),e.lineTo(o*1.7,-o*.3),e.stroke(),e.beginPath(),e.moveTo(o*1.5,-o*.1),e.lineTo(o*1.8,-o*.05),e.stroke(),e.fillStyle="#FF6347",e.beginPath(),e.moveTo(o*.15,-o*.6),e.lineTo(o*.6,-o*.65),e.lineTo(o*.15,-o*.55),e.closePath(),e.fill(),e.fillStyle="#000000",e.beginPath(),e.arc(-o*.15,-o*.7,o*.08,0,Math.PI*2),e.fill(),e.beginPath(),e.arc(o*.15,-o*.7,o*.08,0,Math.PI*2),e.fill(),[{x:-o*.2,y:-o*.4},{x:-o*.1,y:-o*.35},{x:0,y:-o*.33},{x:o*.1,y:-o*.35},{x:o*.2,y:-o*.4}].forEach(a=>{e.beginPath(),e.arc(a.x,a.y,o*.05,0,Math.PI*2),e.fill()}),e.fillStyle="#c00",e.strokeStyle="#8B0000",e.lineWidth=1,e.beginPath(),e.ellipse(0,-o*.15,o*.55,o*.15,0,0,Math.PI*2),e.fill(),e.stroke(),e.fillRect(o*.3,-o*.1,o*.2,o*.8),e.strokeRect(o*.3,-o*.1,o*.2,o*.8),e.strokeStyle="#8B0000",e.lineWidth=o*.03;for(let a=0;a<4;a++)e.beginPath(),e.moveTo(o*.33+a*o*.13,o*.7),e.lineTo(o*.33+a*o*.13,o*.85),e.stroke();e.fillStyle="#000000",e.strokeStyle="#333333",e.lineWidth=2,e.beginPath(),e.ellipse(0,-o*1.1,o*.7,o*.15,0,0,Math.PI*2),e.fill(),e.stroke(),e.fillRect(-o*.45,-o*1.8,o*.9,o*.7),e.strokeRect(-o*.45,-o*1.8,o*.9,o*.7),e.fillStyle="#c00",e.fillRect(-o*.45,-o*1.3,o*.9,o*.15),e.fillStyle="#228B22",e.beginPath(),e.arc(-o*.15,-o*1.22,o*.08,0,Math.PI*2),e.fill(),e.beginPath(),e.arc(o*.15,-o*1.22,o*.08,0,Math.PI*2),e.fill(),e.fillStyle="#FF0000",e.beginPath(),e.arc(0,-o*1.25,o*.06,0,Math.PI*2),e.fill(),e.restore()}};
1
+ import{createParticle as ee}from"./../core/particles.js";export default{name:"christmas",displayName:"Christmas",emoji:"\u{1F384}",intensityConfig:{light:{count:50,speedRange:[.5,1.5],sizeRange:[1,3],trees:3,wreaths:2,northStars:1,snowmen:2},medium:{count:150,speedRange:[.8,2.5],sizeRange:[1,4],trees:6,wreaths:3,northStars:1,snowmen:3},heavy:{count:300,speedRange:[1,3.5],sizeRange:[1,5],trees:10,wreaths:4,northStars:1,snowmen:4}},particles:["snowflake"],decorations:["tree","wreath","sleigh","robin","train","elf","firework","north-star","snowman"],decorationKeys:["trees","wreaths","northStars","snowmen","sleigh","elf","train","firework","robin"],colors:{primary:"#ffffff",secondary:"#228B22",accent:"#c00",gold:"#FFD700"},createSnowflakeParticle(e,i,s){const y=ee(s,e,i);return y.type="snowflake",y},createFallingParticle(e,i,s){return this.createSnowflakeParticle(e,i,s)},createTree(e,i,s={}){return{type:"tree",x:s.x!==void 0?s.x:Math.random()*e,y:s.y!==void 0?s.y:Math.random()*i,vx:0,vy:0,size:20+Math.random()*15,opacity:.6+Math.random()*.3,rotation:0,rotationSpeed:0,active:!0,static:!0}},createWreath(e,i,s={}){const y=[];for(let o=0;o<20;o++){const h=o/20*Math.PI*2;y.push({angle:h,radius:.9+Math.random()*.2,thickness:.2+Math.random()*.15,color:o%2===0?"#1a6b1a":"#228B22"})}return{type:"wreath",x:s.x!==void 0?s.x:Math.random()*e,y:s.y!==void 0?s.y:Math.random()*i,vx:0,vy:0,size:15+Math.random()*10,opacity:.7+Math.random()*.2,rotation:0,rotationSpeed:0,active:!0,static:!0,shape:y}},createNorthStar(e,i,s={}){return{type:"north-star",x:s.x!==void 0?s.x:e/2,y:s.y!==void 0?s.y:80,vx:0,vy:0,size:25,opacity:1,twinklePhase:Math.random()*Math.PI*2,twinkleSpeed:.003,active:!0,static:!0}},createSnowman(e,i,s={}){return{type:"snowman",x:s.x!==void 0?s.x:Math.random()*e,y:s.y!==void 0?s.y:i-50,vx:0,vy:0,size:15+Math.random()*10,opacity:1,time:Math.random()*1e3,wavePhase:Math.random()*Math.PI*2,active:!0,static:!0}},createInitialDecorations(e,i,s){const y=[],n=s.trees??6;for(let l=0;l<n;l++)y.push(this.createTree(e,i,{x:e/(n+1)*(l+1),y:i-60-Math.random()*20}));const o=s.wreaths??3;for(let l=0;l<o;l++)y.push(this.createWreath(e,i,{x:e/(o+1)*(l+1),y:50+Math.random()*100}));s.northStars!==0&&s.northStars&&y.push(this.createNorthStar(e,i,{x:e/2,y:60}));const h=s.snowmen??3;for(let l=0;l<h;l++)y.push(this.createSnowman(e,i,{x:e/(h+1)*(l+1),y:i-50-Math.random()*10}));return y},spawnSpecialParticle(e,i,s,y={}){const n=Math.random();if(n<5e-4){if(y.sleigh===0||e.some(a=>a.type==="sleigh"))return null;const o=Math.random()<.5,h=o?-100:i+100,l=100+Math.random()*(s*.3);return{type:"sleigh",x:h,startX:h,y:l,baseY:l,targetX:o?i+100:-100,canvasWidth:i,vx:o?3+Math.random()*2:-(3+Math.random()*2),vy:0,arcHeight:150+Math.random()*100,time:0,size:15+Math.random()*10,opacity:.9,rotation:0,active:!0,static:!1}}else if(n<.0013&&y.elf!==0){const o=Math.random()<.5;return{type:"elf",x:o?-50:i+50,y:s-30,baseY:s-30,vx:o?1.5+Math.random()*1:-(1.5+Math.random()*1),waveAmplitude:3,waveFrequency:.05,waveOffset:Math.random()*Math.PI*2,time:0,size:10+Math.random()*5,opacity:.95,rotation:0,active:!0,static:!1}}else if(n<.005&&y.train!==0){if(e.some(g=>g.type==="train"))return null;const o=Math.random()<.5,h=o?-500:i+500,l=21+Math.random()*9,a=l*.72,r=s-a-10;return{type:"train",x:h,y:r,baseY:r,vx:o?4+Math.random()*2:-(4+Math.random()*2),vy:0,size:l,opacity:1,time:0,smoke:[],active:!0,static:!1,carriages:2+Math.floor(Math.random()*2)}}else{if(n<.008&&y.firework!==0)return{type:"firework",x:Math.random()*i,y:s,vx:(Math.random()-.5)*4,vy:-10-Math.random()*5,size:2+Math.random()*2,opacity:1,active:!0,static:!1,time:0,exploded:!1,explosionTime:30+Math.random()*30};if(n<.012&&y.robin!==0){if(e.some(t=>t.type==="robin"))return null;const o=Math.random()<.5,h=Math.random()*(s*.2),l=o?-50:i+50,a=10+Math.random()*5,r=Math.max(a*3,Math.min(s*.6-a*2,s*.2+Math.random()*(s*.4))),g=Math.random()*(i*.6)+i*.2;return{type:"robin",state:"flying_in",x:l,y:h,startX:l,startY:h,targetX:g,targetY:r,vx:o?.5+Math.random()*.5:-(.5+Math.random()*.5),vy:0,size:a,opacity:.95,active:!0,static:!1,sitTime:3e3+Math.random()*2e3,sitStartTime:0,flightProgress:0,time:0,waveOffset:Math.random()*Math.PI*2}}}return null},updateSpecialParticles(e,i,s=1024,y=768){e.forEach(n=>{switch(n.time!==void 0&&(n.time+=i),n.type){case"sleigh":(n.vx>0&&n.x>n.targetX||n.vx<0&&n.x<n.targetX)&&(n.active=!1);break;case"robin":switch(n.state){case"flying_in":const o=n.targetX-n.x,h=n.targetY-n.y,l=Math.sqrt(o*o+h*h);if(l<20)n.state="sitting",n.vx=0,n.vy=0,n.sitStartTime=n.time;else{const P=o/l,M=h/l,m=3;n.vx=P*m;const w=30,S=.008,u=Math.sin(n.time*S+n.waveOffset)*w,v=Math.cos(n.time*S+n.waveOffset)*w*S;n.vy=M*m*.3+v}break;case"sitting":if(n.vx=0,n.vy=0,n.time-n.sitStartTime>n.sitTime){n.state="flying_away";const P=n.x<s/2?-4:4;n.vx=P,n.flyAwayStartTime=n.time}break;case"flying_away":const a=n.vx>0?1:-1;n.vx=a*4;const r=25,g=.01,t=n.time-n.flyAwayStartTime,f=Math.sin(t*g+n.waveOffset)*r,d=Math.cos(t*g+n.waveOffset)*r*g;n.vy=-1.5+d,(n.x<-50||n.x>s+50||n.y<-50)&&(n.active=!1);break}break;case"train":if(n.smoke||(n.smoke=[]),(n.vx>0&&n.x>s+500||n.vx<0&&n.x<-500)&&(n.active=!1),n.lastSmokeTime||(n.lastSmokeTime=0),n.time-n.lastSmokeTime>150){n.lastSmokeTime=n.time;const h=n.size*1.8/20,l=n.vx>0?1:-1,a=h*8,r=h*7,g=h*10,d=-a-h-r-g*2-h*8,P=h*70,M=h*25,w=(P-M)*.7,S=n.x+w*l,u=n.y+d,v={x:S,y:u,vx:(Math.random()-.5)*.8,vy:-.8-Math.random()*.4,size:8+Math.random()*6,opacity:.7+Math.random()*.2,fadeRate:.012+Math.random()*.008};n.smoke.push(v)}n.smoke=n.smoke.filter(o=>(o.x+=o.vx,o.y+=o.vy,o.vy*=.99,o.size*=1.02,o.opacity-=o.fadeRate,o.opacity>0));break;case"firework":if(!n.exploded){const o=n.y<=n.targetY,h=n.time>=n.explosionTime;(o||h)&&(n.exploded=!0,this.explodeFirework(n,e),n.active=!1)}break}})},drawSnowflake(e,i){e.save(),e.translate(i.x,i.y),e.rotate(i.rotation),e.globalAlpha=i.opacity,e.fillStyle="#ffffff",e.strokeStyle="#ffffff",e.lineWidth=Math.max(i.size*.15,.5);const s=6,y=i.size;for(let n=0;n<s;n++){const o=Math.PI*2*n/s;e.beginPath(),e.moveTo(0,0),e.lineTo(Math.cos(o)*y,Math.sin(o)*y),e.stroke();const h=y*.4,l=Math.PI/6,a=Math.cos(o)*(y*.6),r=Math.sin(o)*(y*.6);e.beginPath(),e.moveTo(a,r),e.lineTo(a+Math.cos(o-l)*h,r+Math.sin(o-l)*h),e.stroke(),e.beginPath(),e.moveTo(a,r),e.lineTo(a+Math.cos(o+l)*h,r+Math.sin(o+l)*h),e.stroke()}e.restore()},drawTree(e,i,s){const y=i.x,n=i.y,o=i.size;e.save(),e.translate(y,n),e.rotate(i.rotation),e.fillStyle="#654321",e.beginPath(),e.moveTo(-o*.2,o*.8),e.lineTo(o*.2,o*.8),e.lineTo(o*.15,o*1.3),e.lineTo(-o*.15,o*1.3),e.closePath(),e.fill(),e.strokeStyle="#4a2f1a",e.lineWidth=1;for(let t=0;t<3;t++)e.beginPath(),e.moveTo(-o*.15,o*.9+t*o*.12),e.lineTo(o*.15,o*.9+t*o*.12),e.stroke();e.fillStyle="#228B22";for(let t=0;t<3;t++){const f=t*o*.4,d=o*(1.2-t*.2);e.beginPath(),e.moveTo(0,-f),e.lineTo(-d,o*.3-f),e.lineTo(d,o*.3-f),e.closePath(),e.fill()}e.strokeStyle="#C0C0C0",e.lineWidth=1.5;for(let t=0;t<3;t++){const f=t*o*.4,d=o*(1.2-t*.2);e.beginPath();for(let P=0;P<=6;P++){const M=-d+P/6*d*2,m=o*.15-f+(P%2===0?-o*.1:0);P===0?e.moveTo(M,m):e.lineTo(M,m)}e.stroke()}const h=["#ff0000","#0000ff","#ffd700","#ff69b4","#00ff00"];for(let t=0;t<8;t++){const f=Math.floor(t/3),d=f*o*.4,P=o*(1.2-f*.2)*.7,M=t%3*(Math.PI*2/3)+f*.5,m=Math.cos(M)*P,w=o*.1-d;e.fillStyle=h[t%h.length],e.beginPath(),e.arc(m,w,o*.12,0,Math.PI*2),e.fill(),e.fillStyle="rgba(255,255,255,0.5)",e.beginPath(),e.arc(m-o*.04,w-o*.04,o*.04,0,Math.PI*2),e.fill()}const l=["#ffff00","#ff0000","#00ff00","#0000ff","#ffffff"];for(let t=0;t<12;t++){const f=Math.floor(t/4),d=f*o*.4,P=o*(1.2-f*.2)*.85,M=t%4*(Math.PI*2/4)+f*.3,m=Math.cos(M)*P,w=o*.2-d,S=(Math.sin(s*.003+t*.5)+1)*.5,u=.3+S*.7,v=e.createRadialGradient(m,w,0,m,w,o*.15),T=l[t%l.length];v.addColorStop(0,T),v.addColorStop(1,"rgba(255,255,255,0)"),e.globalAlpha=u,e.fillStyle=v,e.beginPath(),e.arc(m,w,o*.15,0,Math.PI*2),e.fill(),e.globalAlpha=.5+S*.5,e.fillStyle=T,e.beginPath(),e.arc(m,w,o*.08,0,Math.PI*2),e.fill(),e.globalAlpha=1}const a=o*.35,r=-o*1.4,g=e.createRadialGradient(0,r,0,0,r,a*2);g.addColorStop(0,"rgba(255,223,0,1)"),g.addColorStop(.3,"rgba(255,215,0,0.7)"),g.addColorStop(.6,"rgba(255,215,0,0.3)"),g.addColorStop(1,"rgba(255,215,0,0)"),e.fillStyle=g,e.beginPath(),e.arc(0,r,a*2,0,Math.PI*2),e.fill(),e.fillStyle="#FFD700",e.strokeStyle="#FFA500",e.lineWidth=2,e.save(),e.translate(0,r),e.beginPath();for(let t=0;t<10;t++){const f=Math.PI*2*t/10-Math.PI/2,d=t%2===0?a:a*.4,P=Math.cos(f)*d,M=Math.sin(f)*d;t===0?e.moveTo(P,M):e.lineTo(P,M)}e.closePath(),e.fill(),e.stroke(),e.restore(),e.restore()},drawWreath(e,i,s){const y=i.x,n=i.y,o=i.size;e.save(),e.translate(y,n),e.rotate(i.rotation),i.shape.forEach(a=>{e.lineWidth=o*a.thickness,e.strokeStyle=a.color,e.beginPath();const r=a.angle-Math.PI/i.shape.length,g=a.angle+Math.PI/i.shape.length;e.arc(0,0,o*a.radius,r,g),e.stroke()});const h=["#ff0000","#ffff00","#0000ff"];for(let a=0;a<4;a++){const r=a/4*Math.PI*2+i.rotation,g=Math.cos(r)*o,t=Math.sin(r)*o,f=(Math.sin(s*.002+a*.7)+1)/2;if(f>.5){const d=(f-.5)*2,P=h[a%h.length],M=e.createRadialGradient(g,t,0,g,t,o*.15);M.addColorStop(0,P),M.addColorStop(1,"rgba(255,255,255,0)"),e.globalAlpha=d,e.fillStyle=M,e.beginPath(),e.arc(g,t,o*.15,0,Math.PI*2),e.fill(),e.globalAlpha=1}}e.fillStyle="#c00";const l=-o;e.beginPath(),e.ellipse(-o*.3,l,o*.3,o*.4,-.3,0,Math.PI*2),e.fill(),e.beginPath(),e.ellipse(o*.3,l,o*.3,o*.4,.3,0,Math.PI*2),e.fill(),e.beginPath(),e.arc(0,l,o*.2,0,Math.PI*2),e.fill(),e.restore()},drawNorthStar(e,i,s){const y=i.x,n=i.y,o=i.size,h=.8+Math.sin(s*i.twinkleSpeed+i.twinklePhase)*.2;e.save(),e.translate(y,n);const l=o*4*h,a=e.createRadialGradient(0,0,0,0,0,l);a.addColorStop(0,"rgba(220, 230, 240, 0.4)"),a.addColorStop(.4,"rgba(200, 210, 220, 0.2)"),a.addColorStop(1,"rgba(180, 190, 200, 0)"),e.fillStyle=a,e.beginPath(),e.arc(0,0,l,0,Math.PI*2),e.fill();const r=o*1,g=o*1.5,t=o*.12,f=e.createRadialGradient(0,0,0,0,0,o);f.addColorStop(0,"#ffffff"),f.addColorStop(.3,"#f0f0f0"),f.addColorStop(.6,"#c0c0c0"),f.addColorStop(1,"#a0a0a0"),e.shadowColor="rgba(220, 230, 240, 0.9)",e.shadowBlur=o*.8*h,e.fillStyle=f,e.beginPath(),e.moveTo(0,-g),e.lineTo(t,-t),e.lineTo(r,0),e.lineTo(t,t),e.lineTo(0,g),e.lineTo(-t,t),e.lineTo(-r,0),e.lineTo(-t,-t),e.closePath(),e.fill(),e.shadowBlur=o*1.2*h,e.shadowColor="rgba(255, 255, 255, 1)";const d=e.createRadialGradient(0,0,0,0,0,o*.3);d.addColorStop(0,"#ffffff"),d.addColorStop(.6,"#f5f5f5"),d.addColorStop(1,"rgba(200, 200, 200, 0.8)"),e.fillStyle=d,e.beginPath(),e.arc(0,0,o*.3,0,Math.PI*2),e.fill(),e.shadowBlur=0,e.restore()},drawSleigh(e,i){const s=i.x,y=Math.abs(i.targetX-i.startX),n=Math.abs(i.x-i.startX),o=Math.min(1,n/y),h=Math.sin(o*Math.PI)*i.arcHeight,l=i.baseY-h,a=i.size*1.5,r=i.vx>0?1:-1,g=Math.abs(Math.sin(i.time*.007));if(!isFinite(s)||!isFinite(l)||!isFinite(a)){console.warn("[Christmas] Invalid sleigh values:",{x:s,y:l,size:a});return}const t=[{x:4.8,y:0},{x:4,y:-.3},{x:4,y:.3},{x:3.2,y:-.15},{x:3.2,y:.15}];t.forEach((T,F)=>{const b=s+r*(a*T.x),C=T.y*a;e.fillStyle="#9c6e49",e.strokeStyle="#7b563a",e.lineWidth=1,e.beginPath(),e.moveTo(b-r*a*.4,l+C),e.quadraticCurveTo(b,l+C-a*.4,b+r*a*.4,l+C),e.quadraticCurveTo(b,l+C+a*.4,b-r*a*.4,l+C),e.fill(),e.stroke();const R=b+r*a*.5,G=l+C-a*.3;e.beginPath(),e.ellipse(R,G,a*.25,a*.2,0,0,Math.PI*2),e.fill(),e.stroke(),e.strokeStyle="#6e4a2e",e.lineWidth=1.5;const Y=R-r*a*.1,A=G-a*.15;e.beginPath(),e.moveTo(Y,A),e.lineTo(Y-r*a*.2,A-a*.3),e.lineTo(Y-r*a*.1,A-a*.4),e.moveTo(Y-r*a*.2,A-a*.3),e.lineTo(Y-r*a*.3,A-a*.35),e.stroke();const X=l+C+a*.1;e.lineWidth=2.5,e.strokeStyle="#7b563a";const L=g*a*.45;e.beginPath(),e.moveTo(b+r*a*.3,X),e.lineTo(b+r*(a*.3+L),X+a*.3),e.stroke();const W=(1-g)*a*.45;if(e.beginPath(),e.moveTo(b-r*a*.3,X),e.lineTo(b-r*(a*.3+W),X+a*.3),e.stroke(),F===0){const z=R+r*a*.25,B=G;e.fillStyle="#ff0000",e.beginPath(),e.arc(z,B,a*.03,0,Math.PI*2),e.fill();const q=e.createRadialGradient(z,B,0,z,B,a*.1);q.addColorStop(0,"rgba(255,0,0,0.7)"),q.addColorStop(1,"rgba(255,0,0,0)"),e.fillStyle=q,e.beginPath(),e.arc(z,B,a*.1,0,Math.PI*2),e.fill()}}),e.fillStyle="#c00",e.strokeStyle="#FFD700",e.lineWidth=2;const f=l-a*.7,d=s+r*a*1.5,P=s-r*a*.3,M=l+a*.3;e.beginPath(),e.moveTo(d,f),e.quadraticCurveTo(s+r*a,l-a*.2,d-r*a*.8,l),e.lineTo(P,l),e.lineTo(P,M),e.lineTo(d,M),e.closePath(),e.fill(),e.stroke(),e.beginPath(),e.moveTo(P,l),e.quadraticCurveTo(P-r*a*.4,l-a*.5,P,f),e.quadraticCurveTo(P+r*a*.2,l-a*.4,P+r*a*.2,l),e.closePath(),e.fill(),e.stroke(),e.strokeStyle="#a52a2a",e.lineWidth=4;const m=M+a*.2;function w(T){e.beginPath(),e.moveTo(d+r*a*.1,m+T),e.quadraticCurveTo(s,m+T+a*.2,P-r*a*.2,m+T),e.stroke(),e.beginPath(),e.moveTo(d-r*a*.5,M),e.lineTo(d-r*a*.5,m+T),e.stroke(),e.beginPath(),e.moveTo(P+r*a*.5,M),e.lineTo(P+r*a*.5,m+T),e.stroke()}w(0),w(a*.1);const S=s-r*a*.05;e.fillStyle="#5c4033",e.beginPath(),e.moveTo(S-a*.3,l-a*.4),e.quadraticCurveTo(S-a*.6,l-a*.2,S-a*.4,l+a*.1),e.quadraticCurveTo(S,l+a*.3,S+a*.4,l+a*.1),e.quadraticCurveTo(S+a*.6,l-a*.2,S+a*.3,l-a*.4),e.closePath(),e.fill(),e.fillStyle="rgba(255, 255, 255, 0.2)",e.beginPath(),e.ellipse(S+a*.1,l-a*.3,a*.2,a*.1,-Math.PI/4,0,Math.PI*2),e.fill(),e.strokeStyle="#654321",e.lineWidth=1,e.beginPath(),e.arc(S,l-a*.4,a*.2,0,Math.PI,!0),e.stroke();const u=s+r*a*.6,v=l-a*.55;e.fillStyle="#c00",e.beginPath(),e.moveTo(u-a*.2,v),e.lineTo(u+a*.2,v),e.lineTo(u,v-a*.4),e.closePath(),e.fill(),e.fillStyle="#fff",e.fillRect(u-a*.22,v,a*.44,a*.08),e.beginPath(),e.arc(u,v-a*.4,a*.07,0,Math.PI*2),e.fill(),e.fillStyle="#FFD7BA",e.beginPath(),e.arc(u,v+a*.08,a*.18,0,Math.PI*2),e.fill(),e.fillStyle="#fff",e.beginPath(),e.ellipse(u,v+a*.2,a*.25,a*.2,0,0,Math.PI),e.fill(),e.strokeStyle="#654321",e.lineWidth=1,t.forEach(T=>{const F=s+r*(a*T.x),b=T.y*a,C=F+r*a*.5;e.beginPath(),e.moveTo(u,v+a*.1),e.quadraticCurveTo((u+C)/2,l+b-a*.5,C-r*a*.1,l+b-a*.3),e.stroke()})},drawElf(e,i){const s=i.x,y=i.y,n=i.size,o=i.vx>0?1:-1;e.save(),e.translate(s,y);const h=Math.sin(i.time*.05)*(Math.PI/3);e.fillStyle="#004d00",e.fillRect(o*-n*.1,n*.2,n*.2,n*.5+Math.sin(h+Math.PI)*n*.1),e.fillStyle="#4a2c2a",e.beginPath(),e.ellipse(o*0,n*.7+Math.sin(h+Math.PI)*n*.1,n*.3,n*.15,0,0,Math.PI*2),e.fill(),e.fillStyle="#006400",e.fillRect(o*n*.1,n*.2,n*.2,n*.5+Math.sin(h)*n*.1),e.fillStyle="#5d3836",e.beginPath(),e.ellipse(o*n*.2,n*.7+Math.sin(h)*n*.1,n*.3,n*.15,0,0,Math.PI*2),e.fill(),e.fillStyle="#008000",e.beginPath(),e.moveTo(0,-n*.5),e.lineTo(o*n*.4,n*.3),e.lineTo(o*-n*.4,n*.3),e.closePath(),e.fill(),e.fillStyle="#FFD7BA",e.beginPath(),e.arc(0,-n*.6,n*.3,0,Math.PI*2),e.fill(),e.fillStyle="#c00",e.beginPath(),e.moveTo(0,-n*.7),e.lineTo(o*n*.35,-n*.6),e.lineTo(o*-n*.35,-n*.6),e.closePath(),e.fill(),e.beginPath(),e.moveTo(0,-n*.7),e.quadraticCurveTo(o*n*.2,-n*1.1,o*n*.4,-n*1.3),e.stroke(),e.fillStyle="#ffff00",e.beginPath(),e.arc(o*n*.4,-n*1.3,n*.15,0,Math.PI*2),e.fill(),e.restore()},drawTrain(e,i){const s=i.x,y=i.y,n=i.size*1.8,o=i.vx>0?1:-1,h=i.time;i.smoke&&i.smoke.length>0&&i.smoke.forEach(k=>{e.save(),e.globalAlpha=k.opacity;const I=e.createRadialGradient(k.x,k.y,0,k.x,k.y,k.size);I.addColorStop(0,"#CCCCCC"),I.addColorStop(.5,"#AAAAAA"),I.addColorStop(1,"#888888"),e.fillStyle=I,e.beginPath(),e.arc(k.x,k.y,k.size,0,Math.PI*2),e.fill(),e.restore()}),e.save(),e.translate(s,y),o===-1&&e.scale(-1,1);const l=h*.005,a=n/20,r=0,g=a*8,t=a*5,f=a*7,d=a*35,P=-t-a,M=-g-a,m=P-d,w=a*30,S=a*10,u=M-f-S*2,v=a*70,T=a*25,F=v-T,b=a*60,C=a*15;for(let k=1;k<=i.carriages;k++){const I=-(T+k*(b+C)-C);e.fillStyle="#222",e.strokeStyle="#444",e.lineWidth=1,e.beginPath(),e.arc(I+b*.25,-t,t,0,Math.PI*2),e.fill(),e.stroke(),e.beginPath(),e.arc(I+b*.75,-t,t,0,Math.PI*2),e.fill(),e.stroke(),e.fillStyle="#004d00",e.strokeStyle="#DAA520",e.lineWidth=a*.5,e.fillRect(I,m,b,d),e.strokeRect(I,m,b,d);const U=d*.6,_=m+d*.2;e.shadowColor="#F1C40F",e.shadowBlur=15;const c=.8+Math.sin(h*.001+k)*.2;e.fillStyle=`rgba(255, 235, 150, ${c})`;const E=b*.25,$=I+b*.15,j=I+b*.6;e.fillRect($,_,E,U),e.fillRect(j,_,E,U),e.shadowBlur=0,e.fillStyle="rgba(0, 0, 0, 0.4)";const D=E*.2,K=$+E/2,V=_+D*1.8;e.beginPath(),e.arc(K,V,D,0,Math.PI*2),e.fill(),e.fillRect(K-D,V+D,D*2,D*2);const O=E*.18,J=j+E/2,Q=_+O*1.6;e.beginPath(),e.arc(J,Q,O,0,Math.PI*2),e.fill(),e.fillRect(J-O*1.2,Q,O*2.4,O*2.5);const N=a*4;e.fillStyle="#228B22",e.beginPath(),e.arc(I+b/2,m+d/2,N,0,Math.PI*2),e.arc(I+b/2,m+d/2,N*.6,0,Math.PI*2,!0),e.fill(),e.fillStyle="#c00",e.beginPath(),e.arc(I+b/2,m+d/2+N,N*.3,0,Math.PI*2),e.fill(),e.strokeStyle="#111",e.lineWidth=a,e.beginPath();const Z=P+f/2,x=k===1?-T:I+b+C;e.moveTo(I+b,Z),e.lineTo(x,Z),e.stroke()}e.fillStyle="#2C3E50",e.strokeStyle="#555",e.lineWidth=a;const R=[a*15,a*35,a*55];R.forEach(k=>{e.beginPath(),e.arc(k,-g,g,0,Math.PI*2),e.fill(),e.stroke()});const G=-a*10;e.beginPath(),e.arc(G,-t,t,0,Math.PI*2),e.fill(),e.stroke(),e.fillStyle="#1C2833",e.fillRect(-T-a*5,M-f,v+a*5,f),e.fillStyle="#B03A2E",e.strokeStyle="#DAA520",e.lineWidth=a*.7;const Y=M-f-w;e.beginPath(),e.moveTo(-T,Y),e.lineTo(0,Y),e.lineTo(0,M-f),e.lineTo(-T-a*5,M-f),e.closePath(),e.fill(),e.stroke(),e.fillStyle=`rgba(255, 235, 150, ${.8+Math.sin(h*.001)*.2})`,e.fillRect(-T+a*4,Y+a*4,T-a*12,a*10),e.fillStyle="#2C3E50",e.beginPath(),e.moveTo(0,M-f),e.lineTo(F,M-f),e.arc(F,u+S,S,Math.PI/2,-Math.PI/2),e.lineTo(0,u),e.closePath(),e.fill(),e.stroke();const A=u-a*8;e.fillStyle="#17202A",e.beginPath(),e.moveTo(F*.7,u),e.lineTo(F*.7-a*2,A),e.lineTo(F*.7+a*10,A-a*4),e.lineTo(F*.7+a*8,u),e.closePath(),e.fill();const X=F+S,L=u+S,W=a*4,z=e.createRadialGradient(X,L,W*.2,X,L,W*1.5);z.addColorStop(0,"rgba(255, 255, 200, 1)"),z.addColorStop(.4,"rgba(255, 220, 100, 0.8)"),z.addColorStop(1,"rgba(255, 200, 0, 0)"),e.fillStyle=z,e.fillRect(X-W,L-W,W*2,W*2),e.strokeStyle="#99A3A4",e.lineWidth=a*2;const B=Math.sin(l)*g*.6,q=R[1]+Math.cos(l)*g*.6,H=v-a*10,p=M-f+a*2+Math.sin(h*.01)*a;e.beginPath(),e.moveTo(H,p),e.lineTo(q,-g+B),e.stroke(),e.lineWidth=a*1.5,[R[0],R[2]].forEach(k=>{const I=k+Math.cos(l)*g*.6;e.beginPath(),e.moveTo(q,-g+B),e.lineTo(I,-g+B),e.stroke()}),e.restore()},drawFirework(e,i){e.fillStyle=i.color||"#ffffff",e.beginPath(),e.arc(i.x,i.y,i.size,0,Math.PI*2),e.fill()},explodeFirework(e,i){const s=50+Math.random()*50,y=["#ff0000","#ffff00","#00ff00","#0000ff","#ffffff"];for(let n=0;n<s;n++){const o=Math.random()*Math.PI*2,h=Math.random()*5+2;i.push({type:"spark",x:e.x,y:e.y,vx:Math.cos(o)*h,vy:Math.sin(o)*h,size:2+Math.random()*2,opacity:1,active:!0,static:!1,color:y[Math.floor(Math.random()*y.length)]})}},drawRobin(e,i,s){const y=i.x,n=i.y,o=i.size,h=i.vx>=0?1:-1,l=i.state==="flying_in"||i.state==="flying_away",a=i.state==="sitting";i.wingIntensity===void 0&&(i.wingIntensity=l?1:0);const r=l?1:0,g=.02;i.wingIntensity+=(r-i.wingIntensity)*g;let t=0;if(i.wingIntensity>.01){const M=Math.sin(s*.012+i.waveOffset),m=Math.sin(M*Math.PI/2),w=Math.PI/6;t=m*w*i.wingIntensity}e.save(),e.translate(y,n),h===-1&&e.scale(-1,1),e.fillStyle="#A52A2A",e.beginPath(),e.ellipse(0,0,o*.6,o*.8,0,0,Math.PI*2),e.fill(),e.fillStyle="#DC143C",e.beginPath(),e.ellipse(o*.15,o*.1,o*.45,o*.6,0,0,Math.PI*2),e.fill(),e.fillStyle="#654321",e.strokeStyle="#4a2c2a",e.lineWidth=.5,e.beginPath(),e.moveTo(-o*.5,0),e.quadraticCurveTo(-o*.7,-o*.08,-o*.85,0),e.quadraticCurveTo(-o*.7,o*.08,-o*.5,0),e.fill(),e.stroke(),e.fillStyle="#8B4513",e.strokeStyle="#654321",e.lineWidth=1,e.save(),e.translate(-o*.3,-o*.1),e.rotate(t),e.beginPath(),e.ellipse(0,0,o*.45,o*.25,-Math.PI/6,0,Math.PI*2),e.fill(),e.stroke(),e.restore(),e.fillStyle="#A0692F",e.strokeStyle="#654321",e.lineWidth=1,e.save(),e.translate(-o*.3,o*.1),e.rotate(-t),e.beginPath(),e.ellipse(0,0,o*.45,o*.25,Math.PI/6,0,Math.PI*2),e.fill(),e.stroke(),e.restore(),e.fillStyle="#A52A2A",e.beginPath(),e.arc(o*.5,-o*.2,o*.35,0,Math.PI*2),e.fill(),e.fillStyle="#FFD700",e.strokeStyle="#DAA520",e.lineWidth=.5,e.beginPath(),e.moveTo(o*.75,-o*.2),e.lineTo(o*.95,-o*.15),e.lineTo(o*.75,-o*.1),e.closePath(),e.fill(),e.stroke(),e.fillStyle="#000000",e.beginPath(),e.arc(o*.6,-o*.25,o*.08,0,Math.PI*2),e.fill(),e.fillStyle="#FFFFFF",e.beginPath(),e.arc(o*.62,-o*.27,o*.03,0,Math.PI*2),e.fill(),a&&(e.strokeStyle="#8B4513",e.lineWidth=o*.08,e.lineCap="round",e.beginPath(),e.moveTo(o*.1,o*.7),e.lineTo(o*.1,o*1),e.stroke(),e.beginPath(),e.moveTo(-o*.1,o*.7),e.lineTo(-o*.1,o*1),e.stroke(),e.lineWidth=o*.06,e.beginPath(),e.moveTo(o*.1,o*1),e.lineTo(o*.25,o*1),e.stroke(),e.beginPath(),e.moveTo(-o*.1,o*1),e.lineTo(-o*.25,o*1),e.stroke());const f=o*.5,d=-o*.55;e.fillStyle="#c00",e.beginPath(),e.moveTo(f-o*.3,d),e.lineTo(f+o*.25,d),e.lineTo(f+o*.1,d-o*.5),e.closePath(),e.fill(),e.fillStyle="#FFFFFF",e.fillRect(f-o*.32,d,o*.58,o*.1),e.beginPath(),e.arc(f+o*.1,d-o*.5,o*.1,0,Math.PI*2),e.fill(),e.restore()},drawSnowman(e,i,s){const y=i.x,n=i.y,o=i.size,h=Math.sin(s*.002+i.wavePhase)*.03;e.save(),e.translate(y,n),e.rotate(h),e.fillStyle="rgba(0, 0, 0, 0.2)",e.beginPath(),e.ellipse(0,o*2,o*1.2,o*.3,0,0,Math.PI*2),e.fill(),e.fillStyle="#FFFFFF",e.strokeStyle="#E0E0E0",e.lineWidth=1,e.beginPath(),e.arc(0,o*1.3,o*1,0,Math.PI*2),e.fill(),e.stroke(),e.beginPath(),e.arc(0,o*.3,o*.7,0,Math.PI*2),e.fill(),e.stroke(),e.beginPath(),e.arc(0,-o*.6,o*.5,0,Math.PI*2),e.fill(),e.stroke(),e.fillStyle="#000000",e.beginPath(),e.arc(0,o*.5,o*.08,0,Math.PI*2),e.fill(),e.beginPath(),e.arc(0,o*.1,o*.08,0,Math.PI*2),e.fill(),e.beginPath(),e.arc(0,-o*.2,o*.08,0,Math.PI*2),e.fill(),e.strokeStyle="#654321",e.lineWidth=o*.1,e.beginPath(),e.moveTo(-o*.6,o*.3),e.lineTo(-o*1.2,o*0),e.lineTo(-o*1.5,-o*.1),e.stroke(),e.lineWidth=o*.05,e.beginPath(),e.moveTo(-o*1.5,-o*.1),e.lineTo(-o*1.7,-o*.3),e.stroke(),e.beginPath(),e.moveTo(-o*1.5,-o*.1),e.lineTo(-o*1.8,-o*.05),e.stroke(),e.lineWidth=o*.1,e.beginPath(),e.moveTo(o*.6,o*.3),e.lineTo(o*1.2,o*0),e.lineTo(o*1.5,-o*.1),e.stroke(),e.lineWidth=o*.05,e.beginPath(),e.moveTo(o*1.5,-o*.1),e.lineTo(o*1.7,-o*.3),e.stroke(),e.beginPath(),e.moveTo(o*1.5,-o*.1),e.lineTo(o*1.8,-o*.05),e.stroke(),e.fillStyle="#FF6347",e.beginPath(),e.moveTo(o*.15,-o*.6),e.lineTo(o*.6,-o*.65),e.lineTo(o*.15,-o*.55),e.closePath(),e.fill(),e.fillStyle="#000000",e.beginPath(),e.arc(-o*.15,-o*.7,o*.08,0,Math.PI*2),e.fill(),e.beginPath(),e.arc(o*.15,-o*.7,o*.08,0,Math.PI*2),e.fill(),[{x:-o*.2,y:-o*.4},{x:-o*.1,y:-o*.35},{x:0,y:-o*.33},{x:o*.1,y:-o*.35},{x:o*.2,y:-o*.4}].forEach(a=>{e.beginPath(),e.arc(a.x,a.y,o*.05,0,Math.PI*2),e.fill()}),e.fillStyle="#c00",e.strokeStyle="#8B0000",e.lineWidth=1,e.beginPath(),e.ellipse(0,-o*.15,o*.55,o*.15,0,0,Math.PI*2),e.fill(),e.stroke(),e.fillRect(o*.3,-o*.1,o*.2,o*.8),e.strokeRect(o*.3,-o*.1,o*.2,o*.8),e.strokeStyle="#8B0000",e.lineWidth=o*.03;for(let a=0;a<4;a++)e.beginPath(),e.moveTo(o*.33+a*o*.13,o*.7),e.lineTo(o*.33+a*o*.13,o*.85),e.stroke();e.fillStyle="#000000",e.strokeStyle="#333333",e.lineWidth=2,e.beginPath(),e.ellipse(0,-o*1.1,o*.7,o*.15,0,0,Math.PI*2),e.fill(),e.stroke(),e.fillRect(-o*.45,-o*1.8,o*.9,o*.7),e.strokeRect(-o*.45,-o*1.8,o*.9,o*.7),e.fillStyle="#c00",e.fillRect(-o*.45,-o*1.3,o*.9,o*.15),e.fillStyle="#228B22",e.beginPath(),e.arc(-o*.15,-o*1.22,o*.08,0,Math.PI*2),e.fill(),e.beginPath(),e.arc(o*.15,-o*1.22,o*.08,0,Math.PI*2),e.fill(),e.fillStyle="#FF0000",e.beginPath(),e.arc(0,-o*1.25,o*.06,0,Math.PI*2),e.fill(),e.restore()}};
@@ -1 +1 @@
1
- (function(){"use strict";fetch("/api/effects/settings").then(function(y){return y.json()}).then(function(y){var z=y.respectMotion!==!1,A=y.defaultDuration||600,S=y.defaultAnimation||"fade",E=y.defaultThreshold!=null?y.defaultThreshold:.1,p=z&&window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches,d=document.querySelector(".page-body");if(!d)return;function r(t,e){return t.getAttribute("data-fx-"+e)}function i(t,e,n){var a=r(t,e);return a!==null?parseFloat(a):n}function v(t,e,n){var a=r(t,e);return a===null?n:a==="true"||a==="1"}function b(t,e,n){var a=new IntersectionObserver(function(o){o[0].isIntersecting&&(a.unobserve(t),n())},{threshold:e});a.observe(t)}d.querySelectorAll(".dm-fx-reveal").forEach(function(t){var e=r(t,"animation")||S,n=i(t,"duration",A),a=i(t,"delay",0),o=i(t,"threshold",E),f=v(t,"once",!0);p||(t.style.opacity="0",t.style.transition="none",e==="slide-up"?t.style.transform="translateY(30px)":e==="slide-down"?t.style.transform="translateY(-30px)":e==="zoom"?t.style.transform="scale(0.85)":e==="flip"&&(t.style.transform="rotateY(90deg)"),requestAnimationFrame(function(){requestAnimationFrame(function(){t.style.transition="opacity "+n+"ms ease "+a+"ms, transform "+n+"ms ease "+a+"ms";var s=new IntersectionObserver(function(u){u.forEach(function(h){h.isIntersecting?(t.style.opacity="1",t.style.transform="",f&&s.unobserve(t)):f||(t.style.opacity="0",e==="slide-up"?t.style.transform="translateY(30px)":e==="slide-down"?t.style.transform="translateY(-30px)":e==="zoom"?t.style.transform="scale(0.85)":e==="flip"?t.style.transform="rotateY(90deg)":t.style.transform="")})},{threshold:o});s.observe(t)})}))}),d.querySelectorAll(".dm-fx-counter").forEach(function(t){var e=i(t,"to",0),n=i(t,"from",0),a=i(t,"duration",2e3),o=r(t,"prefix")||"",f=r(t,"suffix")||"",s=i(t,"decimals",0),u=r(t,"separator")||"";function h(m){var l=m.toFixed(s);if(u){var x=l.split(".");x[0]=x[0].replace(/\B(?=(\d{3})+(?!\d))/g,u),l=x.join(".")}return o+l+f}if(p){t.textContent=h(e);return}b(t,.5,function(){var m=null;requestAnimationFrame(function l(x){m||(m=x);var w=Math.min((x-m)/a,1),q=1-Math.pow(1-w,3);t.textContent=h(n+(e-n)*q),w<1&&requestAnimationFrame(l)})})}),p||d.querySelectorAll(".dm-fx-scramble").forEach(function(t){var e=i(t,"speed",50),n=t.textContent,a="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%&",o=0;b(t,.3,function(){var f=setInterval(function(){for(var s="",u=0;u<n.length;u++)u<o||n[u]===" "?s+=n[u]:s+=a[Math.floor(Math.random()*a.length)];t.textContent=s,o<n.length?o++:clearInterval(f)},e)})}),p||d.querySelectorAll(".dm-fx-shake").forEach(function(t){var e=i(t,"intensity",5),n=i(t,"duration",500),a=r(t,"direction")||"horizontal",o=a==="vertical"?"translateY":"translateX";t.animate([{transform:o+"(0)"},{transform:o+"(-"+e+"px)"},{transform:o+"("+e+"px)"},{transform:o+"(-"+e+"px)"},{transform:o+"("+e+"px)"},{transform:o+"(0)"}],{duration:n,easing:"ease-in-out"})}),d.querySelectorAll(".dm-fx-ripple").forEach(function(t){t.style.position="relative",t.style.overflow="hidden",t.addEventListener("click",function(e){var n=r(t,"colour")||"rgba(255,255,255,0.3)",a=i(t,"duration",600),o=t.getBoundingClientRect(),f=Math.max(o.width,o.height)*2,s=document.createElement("span");s.style.cssText="position:absolute;border-radius:50%;pointer-events:none;width:"+f+"px;height:"+f+"px;left:"+(e.clientX-o.left-f/2)+"px;top:"+(e.clientY-o.top-f/2)+"px;background:"+n+";transform:scale(0);opacity:1;transition:transform "+a+"ms ease,opacity "+a+"ms ease;",t.appendChild(s),requestAnimationFrame(function(){s.style.transform="scale(1)",s.style.opacity="0"}),setTimeout(function(){s.remove()},a)})});var c=window.Domma&&window.Domma.effects;c&&(d.querySelectorAll(".dm-fx-breathe").forEach(function(t){if(!p){var e={};r(t,"amplitude")&&(e.amplitude=i(t,"amplitude",6)),r(t,"duration")&&(e.duration=i(t,"duration",3e3)),r(t,"easing")&&(e.easing=r(t,"easing")),r(t,"stagger")&&(e.stagger=i(t,"stagger",0)),c.breathe(t,e)}}),d.querySelectorAll(".dm-fx-pulse").forEach(function(t){if(!p){var e={};r(t,"scale")&&(e.scale=i(t,"scale",1.05)),r(t,"duration")&&(e.duration=i(t,"duration",2e3)),r(t,"easing")&&(e.easing=r(t,"easing")),c.pulse(t,e)}}),d.querySelectorAll(".dm-fx-scribe").forEach(function(t){var e={};r(t,"speed")&&(e.speed=i(t,"speed",50)),r(t,"delete-speed")&&(e.deleteSpeed=i(t,"delete-speed",30)),r(t,"cursor")&&(e.cursor=v(t,"cursor",!0)),r(t,"cursor-char")&&(e.cursorChar=r(t,"cursor-char")),r(t,"cursor-type")&&(e.cursorType=r(t,"cursor-type")),r(t,"mode")&&(e.mode=r(t,"mode")),r(t,"loop")&&(e.loop=v(t,"loop",!1)),r(t,"loop-delay")&&(e.loopDelay=i(t,"loop-delay",1e3)),r(t,"pause-on-hover")&&(e.pauseOnHover=v(t,"pause-on-hover",!1));var n=r(t,"actions");if(n)try{e.actions=JSON.parse(n)}catch{return}else{var a=t.textContent.trim();if(!a)return;t.textContent="",e.actions=[{render:a}]}c.scribe(t,e)}),d.querySelectorAll(".dm-fx-twinkle").forEach(function(t){var e={};r(t,"count")&&(e.count=i(t,"count",100)),r(t,"shape")&&(e.shape=r(t,"shape")),r(t,"colour")&&(e.colour=r(t,"colour")),r(t,"min-size")&&(e.minSize=i(t,"min-size",1)),r(t,"max-size")&&(e.maxSize=i(t,"max-size",3)),c.twinkle(t,e)}),typeof c.tickerTape=="function"&&d.querySelectorAll(".dm-fx-ticker-tape").forEach(function(t){if(!p){var e={};r(t,"palette")&&(e.palette=r(t,"palette")),r(t,"density")&&(e.density=i(t,"density",50)),r(t,"speed")&&(e.speed=i(t,"speed",1)),r(t,"sway")&&(e.sway=i(t,"sway",60)),r(t,"rotation-speed")&&(e.rotationSpeed=i(t,"rotation-speed",6)),r(t,"min-width")&&(e.minWidth=i(t,"min-width",5)),r(t,"max-width")&&(e.maxWidth=i(t,"max-width",9)),r(t,"min-height")&&(e.minHeight=i(t,"min-height",12)),r(t,"max-height")&&(e.maxHeight=i(t,"max-height",22)),r(t,"fade-start")&&(e.fadeStart=i(t,"fade-start",.55)),r(t,"burst")&&(e.burst=v(t,"burst",!1)),r(t,"burst-count")&&(e.burstCount=i(t,"burst-count",150)),r(t,"z-index")&&(e.zIndex=i(t,"z-index",1));var n=r(t,"mode");n==="page"?c.tickerTape(null,e):c.tickerTape(t,e)}}),typeof c.butterflies=="function"&&d.querySelectorAll(".dm-fx-butterflies").forEach(function(t){if(!p){var e={};r(t,"palette")&&(e.palette=r(t,"palette")),r(t,"density")&&(e.density=i(t,"density",18)),r(t,"speed")&&(e.speed=i(t,"speed",1)),r(t,"wander")&&(e.wander=i(t,"wander",1)),r(t,"rise-speed")&&(e.riseSpeed=i(t,"rise-speed",.4)),r(t,"flap-speed")&&(e.flapSpeed=i(t,"flap-speed",1)),r(t,"min-size")&&(e.minSize=i(t,"min-size",14)),r(t,"max-size")&&(e.maxSize=i(t,"max-size",26)),r(t,"two-tone")&&(e.twoTone=v(t,"two-tone",!0)),r(t,"burst")&&(e.burst=v(t,"burst",!1)),r(t,"burst-count")&&(e.burstCount=i(t,"burst-count",40)),r(t,"z-index")&&(e.zIndex=i(t,"z-index",1));var n=r(t,"mode");n==="page"?c.butterflies(null,e):c.butterflies(t,e)}}),typeof c.strobe=="function"&&d.querySelectorAll(".dm-fx-strobe").forEach(function(t){if(p)return;function e(s){var u=r(t,s);if(!u)return null;var h=u.split(",").map(function(m){return m.trim()}).filter(Boolean);return h.length?h:null}var n={};r(t,"preset")&&(n.preset=r(t,"preset")),r(t,"motion")&&(n.motion=r(t,"motion")),r(t,"speed")&&(n.speed=i(t,"speed",1)),r(t,"sweep-arc")&&(n.sweepArc=i(t,"sweep-arc",100)),r(t,"beam-width")&&(n.beamWidth=i(t,"beam-width",16)),r(t,"intensity")&&(n.intensity=i(t,"intensity",.7)),r(t,"flicker")&&(n.flicker=v(t,"flicker",!0)),r(t,"hz")&&(n.hz=i(t,"hz",10));var a=e("origins");a&&(n.origins=a);var o=e("colours");o&&(n.colours=o),r(t,"duration")&&(n.duration=i(t,"duration",null)),r(t,"z-index")&&(n.zIndex=i(t,"z-index",9999));var f=r(t,"mode");f==="page"?c.strobe(null,n):c.strobe(t,n)}));var g=d.querySelectorAll(".dm-fx-celebrate");g.length&&!p&&import("/public/js/celebrations/index.js").then(function(t){var e=t.CelebrationsEffect;g.forEach(function(n){var a=n.getAttribute("data-fx-theme")||"auto",o=n.getAttribute("data-fx-intensity")||"medium",f=n.getAttribute("data-fx-z-index")?parseInt(n.getAttribute("data-fx-z-index"),10):999;if(!(a==="auto"&&(a=e.getCurrentTheme(),!a))){var s={},u=["data-fx-theme","data-fx-intensity","data-fx-z-index"];Array.from(n.attributes).forEach(function(m){m.name.startsWith("data-fx-")&&!u.includes(m.name)&&(s[m.name.slice(8)]=m.value)});var h=new e({theme:a,intensity:o,enabled:!0,zIndex:f,overrides:s});h.init().then(function(){h.start()})}})}).catch(function(){})}).catch(function(){})})();
1
+ (function(){"use strict";fetch("/api/effects/settings").then(function(y){return y.json()}).then(function(y){var z=y.respectMotion!==!1,A=y.defaultDuration||600,S=y.defaultAnimation||"fade",E=y.defaultThreshold!=null?y.defaultThreshold:.1,p=z&&window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches,d=document.querySelector(".page-body");if(!d)return;function r(t,e){return t.getAttribute("data-fx-"+e)}function i(t,e,n){var a=r(t,e);return a!==null?parseFloat(a):n}function v(t,e,n){var a=r(t,e);return a===null?n:a==="true"||a==="1"}function b(t,e,n){var a=new IntersectionObserver(function(o){o[0].isIntersecting&&(a.unobserve(t),n())},{threshold:e});a.observe(t)}d.querySelectorAll(".dm-fx-reveal").forEach(function(t){var e=r(t,"animation")||S,n=i(t,"duration",A),a=i(t,"delay",0),o=i(t,"threshold",E),f=v(t,"once",!0);p||(t.style.opacity="0",t.style.transition="none",e==="slide-up"?t.style.transform="translateY(30px)":e==="slide-down"?t.style.transform="translateY(-30px)":e==="zoom"?t.style.transform="scale(0.85)":e==="flip"&&(t.style.transform="rotateY(90deg)"),requestAnimationFrame(function(){requestAnimationFrame(function(){t.style.transition="opacity "+n+"ms ease "+a+"ms, transform "+n+"ms ease "+a+"ms";var s=new IntersectionObserver(function(u){u.forEach(function(h){h.isIntersecting?(t.style.opacity="1",t.style.transform="",f&&s.unobserve(t)):f||(t.style.opacity="0",e==="slide-up"?t.style.transform="translateY(30px)":e==="slide-down"?t.style.transform="translateY(-30px)":e==="zoom"?t.style.transform="scale(0.85)":e==="flip"?t.style.transform="rotateY(90deg)":t.style.transform="")})},{threshold:o});s.observe(t)})}))}),d.querySelectorAll(".dm-fx-counter").forEach(function(t){var e=i(t,"to",0),n=i(t,"from",0),a=i(t,"duration",2e3),o=r(t,"prefix")||"",f=r(t,"suffix")||"",s=i(t,"decimals",0),u=r(t,"separator")||"";function h(m){var l=m.toFixed(s);if(u){var x=l.split(".");x[0]=x[0].replace(/\B(?=(\d{3})+(?!\d))/g,u),l=x.join(".")}return o+l+f}if(p){t.textContent=h(e);return}b(t,.5,function(){var m=null;requestAnimationFrame(function l(x){m||(m=x);var w=Math.min((x-m)/a,1),q=1-Math.pow(1-w,3);t.textContent=h(n+(e-n)*q),w<1&&requestAnimationFrame(l)})})}),p||d.querySelectorAll(".dm-fx-scramble").forEach(function(t){var e=i(t,"speed",50),n=t.textContent,a="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%&",o=0;b(t,.3,function(){var f=setInterval(function(){for(var s="",u=0;u<n.length;u++)u<o||n[u]===" "?s+=n[u]:s+=a[Math.floor(Math.random()*a.length)];t.textContent=s,o<n.length?o++:clearInterval(f)},e)})}),p||d.querySelectorAll(".dm-fx-shake").forEach(function(t){var e=i(t,"intensity",5),n=i(t,"duration",500),a=r(t,"direction")||"horizontal",o=a==="vertical"?"translateY":"translateX";t.animate([{transform:o+"(0)"},{transform:o+"(-"+e+"px)"},{transform:o+"("+e+"px)"},{transform:o+"(-"+e+"px)"},{transform:o+"("+e+"px)"},{transform:o+"(0)"}],{duration:n,easing:"ease-in-out"})}),d.querySelectorAll(".dm-fx-ripple").forEach(function(t){t.style.position="relative",t.style.overflow="hidden",t.addEventListener("click",function(e){var n=r(t,"colour")||"rgba(255,255,255,0.3)",a=i(t,"duration",600),o=t.getBoundingClientRect(),f=Math.max(o.width,o.height)*2,s=document.createElement("span");s.style.cssText="position:absolute;border-radius:50%;pointer-events:none;width:"+f+"px;height:"+f+"px;left:"+(e.clientX-o.left-f/2)+"px;top:"+(e.clientY-o.top-f/2)+"px;background:"+n+";transform:scale(0);opacity:1;transition:transform "+a+"ms ease,opacity "+a+"ms ease;",t.appendChild(s),requestAnimationFrame(function(){s.style.transform="scale(1)",s.style.opacity="0"}),setTimeout(function(){s.remove()},a)})});var c=window.Domma&&window.Domma.effects;c&&(d.querySelectorAll(".dm-fx-breathe").forEach(function(t){if(!p){var e={};r(t,"amplitude")&&(e.amplitude=i(t,"amplitude",6)),r(t,"duration")&&(e.duration=i(t,"duration",3e3)),r(t,"easing")&&(e.easing=r(t,"easing")),r(t,"stagger")&&(e.stagger=i(t,"stagger",0)),c.breathe(t,e)}}),d.querySelectorAll(".dm-fx-pulse").forEach(function(t){if(!p){var e={};r(t,"scale")&&(e.scale=i(t,"scale",1.05)),r(t,"duration")&&(e.duration=i(t,"duration",2e3)),r(t,"easing")&&(e.easing=r(t,"easing")),c.pulse(t,e)}}),d.querySelectorAll(".dm-fx-scribe").forEach(function(t){var e={};r(t,"speed")&&(e.speed=i(t,"speed",50)),r(t,"delete-speed")&&(e.deleteSpeed=i(t,"delete-speed",30)),r(t,"cursor")&&(e.cursor=v(t,"cursor",!0)),r(t,"cursor-char")&&(e.cursorChar=r(t,"cursor-char")),r(t,"cursor-type")&&(e.cursorType=r(t,"cursor-type")),r(t,"mode")&&(e.mode=r(t,"mode")),r(t,"loop")&&(e.loop=v(t,"loop",!1)),r(t,"loop-delay")&&(e.loopDelay=i(t,"loop-delay",1e3)),r(t,"pause-on-hover")&&(e.pauseOnHover=v(t,"pause-on-hover",!1));var n=r(t,"actions");if(n)try{e.actions=JSON.parse(n)}catch{return}else{var a=t.textContent.trim();if(!a)return;t.textContent="",e.actions=[{render:a}]}c.scribe(t,e)}),d.querySelectorAll(".dm-fx-twinkle").forEach(function(t){var e={};r(t,"count")&&(e.count=i(t,"count",100)),r(t,"shape")&&(e.shape=r(t,"shape")),r(t,"colour")&&(e.colour=r(t,"colour")),r(t,"min-size")&&(e.minSize=i(t,"min-size",1)),r(t,"max-size")&&(e.maxSize=i(t,"max-size",3)),c.twinkle(t,e)}),typeof c.tickerTape=="function"&&d.querySelectorAll(".dm-fx-ticker-tape").forEach(function(t){if(!p){var e={};r(t,"palette")&&(e.palette=r(t,"palette")),r(t,"density")&&(e.density=i(t,"density",50)),r(t,"speed")&&(e.speed=i(t,"speed",1)),r(t,"sway")&&(e.sway=i(t,"sway",60)),r(t,"rotation-speed")&&(e.rotationSpeed=i(t,"rotation-speed",6)),r(t,"min-width")&&(e.minWidth=i(t,"min-width",5)),r(t,"max-width")&&(e.maxWidth=i(t,"max-width",9)),r(t,"min-height")&&(e.minHeight=i(t,"min-height",12)),r(t,"max-height")&&(e.maxHeight=i(t,"max-height",22)),r(t,"fade-start")&&(e.fadeStart=i(t,"fade-start",.55)),r(t,"burst")&&(e.burst=v(t,"burst",!1)),r(t,"burst-count")&&(e.burstCount=i(t,"burst-count",150)),r(t,"z-index")&&(e.zIndex=i(t,"z-index",1));var n=r(t,"mode");n==="page"?c.tickerTape(null,e):c.tickerTape(t,e)}}),typeof c.butterflies=="function"&&d.querySelectorAll(".dm-fx-butterflies").forEach(function(t){if(!p){var e={};r(t,"palette")&&(e.palette=r(t,"palette")),r(t,"density")&&(e.density=i(t,"density",18)),r(t,"speed")&&(e.speed=i(t,"speed",1)),r(t,"wander")&&(e.wander=i(t,"wander",1)),r(t,"rise-speed")&&(e.riseSpeed=i(t,"rise-speed",.4)),r(t,"flap-speed")&&(e.flapSpeed=i(t,"flap-speed",1)),r(t,"min-size")&&(e.minSize=i(t,"min-size",14)),r(t,"max-size")&&(e.maxSize=i(t,"max-size",26)),r(t,"two-tone")&&(e.twoTone=v(t,"two-tone",!0)),r(t,"burst")&&(e.burst=v(t,"burst",!1)),r(t,"burst-count")&&(e.burstCount=i(t,"burst-count",40)),r(t,"z-index")&&(e.zIndex=i(t,"z-index",1));var n=r(t,"mode");n==="page"?c.butterflies(null,e):c.butterflies(t,e)}}),typeof c.strobe=="function"&&d.querySelectorAll(".dm-fx-strobe").forEach(function(t){if(p)return;function e(s){var u=r(t,s);if(!u)return null;var h=u.split(",").map(function(m){return m.trim()}).filter(Boolean);return h.length?h:null}var n={};r(t,"preset")&&(n.preset=r(t,"preset")),r(t,"motion")&&(n.motion=r(t,"motion")),r(t,"speed")&&(n.speed=i(t,"speed",1)),r(t,"sweep-arc")&&(n.sweepArc=i(t,"sweep-arc",100)),r(t,"beam-width")&&(n.beamWidth=i(t,"beam-width",16)),r(t,"intensity")&&(n.intensity=i(t,"intensity",.7)),r(t,"flicker")&&(n.flicker=v(t,"flicker",!0)),r(t,"hz")&&(n.hz=i(t,"hz",10));var a=e("origins");a&&(n.origins=a);var o=e("colours");o&&(n.colours=o),r(t,"duration")&&(n.duration=i(t,"duration",null)),r(t,"z-index")&&(n.zIndex=i(t,"z-index",9999));var f=r(t,"mode");f==="page"?c.strobe(null,n):c.strobe(t,n)}));var g=d.querySelectorAll(".dm-fx-celebrate");g.length&&!p&&import("/public/js/celebrations/index.js?v=20260930-snow").then(function(t){var e=t.CelebrationsEffect;g.forEach(function(n){var a=n.getAttribute("data-fx-theme")||"auto",o=n.getAttribute("data-fx-intensity")||"medium",f=n.getAttribute("data-fx-z-index")?parseInt(n.getAttribute("data-fx-z-index"),10):999;if(!(a==="auto"&&(a=e.getCurrentTheme(),!a))){var s={},u=["data-fx-theme","data-fx-intensity","data-fx-z-index"];Array.from(n.attributes).forEach(function(m){m.name.startsWith("data-fx-")&&!u.includes(m.name)&&(s[m.name.slice(8)]=m.value)});var h=new e({theme:a,intensity:o,enabled:!0,zIndex:f,overrides:s});h.init().then(function(){h.start()})}})}).catch(function(){})}).catch(function(){})})();
@@ -154,7 +154,7 @@
154
154
  <script src="/public/js/forms.js?v=20260927-submit-btn" type="module"></script>
155
155
 
156
156
  <!-- Core effects runtime -->
157
- <script src="/public/js/effects.js"></script>
157
+ <script src="/public/js/effects.js?v=20260930-snow"></script>
158
158
 
159
159
  <!-- Plugin body-end injection -->
160
160
  {{bodyEndInject}}