@benqoder/beam 0.1.0

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.
package/dist/client.js ADDED
@@ -0,0 +1,1847 @@
1
+ import { Idiomorph } from 'idiomorph';
2
+ import { newWebSocketRpcSession } from 'capnweb';
3
+ // ============ BEAM - capnweb RPC Client ============
4
+ //
5
+ // Uses capnweb for:
6
+ // - Promise pipelining (multiple calls in one round-trip)
7
+ // - Bidirectional RPC (server can call client callbacks)
8
+ // - Automatic reconnection
9
+ // - Type-safe method calls
10
+ // Get endpoint from meta tag or default to /beam
11
+ // Usage: <meta name="beam-endpoint" content="/custom-endpoint">
12
+ function getEndpoint() {
13
+ const meta = document.querySelector('meta[name="beam-endpoint"]');
14
+ return meta?.getAttribute('content') ?? '/beam';
15
+ }
16
+ let isOnline = navigator.onLine;
17
+ let rpcSession = null;
18
+ let connectingPromise = null;
19
+ // Client callback handler for server-initiated updates
20
+ function handleServerEvent(event, data) {
21
+ // Dispatch custom event for app to handle
22
+ window.dispatchEvent(new CustomEvent('beam:server-event', { detail: { event, data } }));
23
+ // Built-in handlers
24
+ if (event === 'toast') {
25
+ const { message, type } = data;
26
+ showToast(message, type || 'success');
27
+ }
28
+ else if (event === 'refresh') {
29
+ const { selector } = data;
30
+ // Could trigger a refresh of specific elements
31
+ window.dispatchEvent(new CustomEvent('beam:refresh', { detail: { selector } }));
32
+ }
33
+ }
34
+ function connect() {
35
+ if (connectingPromise) {
36
+ return connectingPromise;
37
+ }
38
+ if (rpcSession) {
39
+ return Promise.resolve(rpcSession);
40
+ }
41
+ connectingPromise = new Promise((resolve, reject) => {
42
+ try {
43
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
44
+ const endpoint = getEndpoint();
45
+ const url = `${protocol}//${location.host}${endpoint}`;
46
+ // Create capnweb RPC session with BeamServer type
47
+ const session = newWebSocketRpcSession(url);
48
+ // Register client callback for bidirectional communication
49
+ // @ts-ignore - capnweb stub methods are dynamically typed
50
+ session.registerCallback?.(handleServerEvent)?.catch?.(() => {
51
+ // Server may not support callbacks, that's ok
52
+ });
53
+ rpcSession = session;
54
+ connectingPromise = null;
55
+ resolve(session);
56
+ }
57
+ catch (err) {
58
+ connectingPromise = null;
59
+ reject(err);
60
+ }
61
+ });
62
+ return connectingPromise;
63
+ }
64
+ async function ensureConnected() {
65
+ if (rpcSession) {
66
+ return rpcSession;
67
+ }
68
+ return connect();
69
+ }
70
+ /**
71
+ * Execute a script string safely
72
+ */
73
+ function executeScript(code) {
74
+ try {
75
+ new Function(code)();
76
+ }
77
+ catch (err) {
78
+ console.error('[beam] Script execution error:', err);
79
+ }
80
+ }
81
+ // API wrapper that ensures connection before calls
82
+ const api = {
83
+ async call(action, data = {}) {
84
+ const session = await ensureConnected();
85
+ // @ts-ignore - capnweb stub methods are dynamically typed
86
+ return session.call(action, data);
87
+ },
88
+ async modal(modalId, params = {}) {
89
+ const session = await ensureConnected();
90
+ // @ts-ignore - capnweb stub methods are dynamically typed
91
+ return session.modal(modalId, params);
92
+ },
93
+ async drawer(drawerId, params = {}) {
94
+ const session = await ensureConnected();
95
+ // @ts-ignore - capnweb stub methods are dynamically typed
96
+ return session.drawer(drawerId, params);
97
+ },
98
+ // Direct access to RPC session for advanced usage (promise pipelining, etc.)
99
+ async getSession() {
100
+ return ensureConnected();
101
+ },
102
+ };
103
+ // ============ DOM HELPERS ============
104
+ let activeModal = null;
105
+ let activeDrawer = null;
106
+ function $(selector) {
107
+ return document.querySelector(selector);
108
+ }
109
+ function $$(selector) {
110
+ return document.querySelectorAll(selector);
111
+ }
112
+ function morph(target, html, options) {
113
+ // Handle beam-keep elements
114
+ const keepSelectors = options?.keepElements || [];
115
+ const keptElements = new Map();
116
+ // Preserve elements marked with beam-keep
117
+ target.querySelectorAll('[beam-keep]').forEach((el) => {
118
+ const id = el.id || `beam-keep-${Math.random().toString(36).slice(2)}`;
119
+ if (!el.id)
120
+ el.id = id;
121
+ const placeholder = document.createComment(`beam-keep:${id}`);
122
+ el.parentNode?.insertBefore(placeholder, el);
123
+ keptElements.set(id, { el, placeholder });
124
+ el.remove();
125
+ });
126
+ // Also handle explicitly specified keep selectors
127
+ keepSelectors.forEach((selector) => {
128
+ target.querySelectorAll(selector).forEach((el) => {
129
+ const id = el.id || `beam-keep-${Math.random().toString(36).slice(2)}`;
130
+ if (!el.id)
131
+ el.id = id;
132
+ if (!keptElements.has(id)) {
133
+ const placeholder = document.createComment(`beam-keep:${id}`);
134
+ el.parentNode?.insertBefore(placeholder, el);
135
+ keptElements.set(id, { el, placeholder });
136
+ el.remove();
137
+ }
138
+ });
139
+ });
140
+ // @ts-ignore - idiomorph types
141
+ Idiomorph.morph(target, html, { morphStyle: 'innerHTML' });
142
+ // Restore kept elements
143
+ keptElements.forEach(({ el, placeholder }, id) => {
144
+ // Find the placeholder or element with same ID in new content
145
+ const walker = document.createTreeWalker(target, NodeFilter.SHOW_COMMENT);
146
+ let node;
147
+ while ((node = walker.nextNode())) {
148
+ if (node.textContent === `beam-keep:${id}`) {
149
+ node.parentNode?.replaceChild(el, node);
150
+ return;
151
+ }
152
+ }
153
+ // If no placeholder, look for element with same ID to replace
154
+ const newEl = target.querySelector(`#${id}`);
155
+ if (newEl) {
156
+ newEl.parentNode?.replaceChild(el, newEl);
157
+ }
158
+ });
159
+ }
160
+ function getParams(el) {
161
+ // Start with beam-params JSON if present
162
+ const params = JSON.parse(el.getAttribute('beam-params') || '{}');
163
+ // Collect beam-data-* attributes
164
+ for (const attr of el.attributes) {
165
+ if (attr.name.startsWith('beam-data-')) {
166
+ const key = attr.name.slice(10); // remove 'beam-data-'
167
+ // Try to parse as JSON for numbers/booleans, fallback to string
168
+ try {
169
+ params[key] = JSON.parse(attr.value);
170
+ }
171
+ catch {
172
+ params[key] = attr.value;
173
+ }
174
+ }
175
+ }
176
+ return params;
177
+ }
178
+ // ============ CONFIRMATION DIALOGS ============
179
+ // Usage: <button beam-action="delete" beam-confirm="Are you sure?">Delete</button>
180
+ // Usage: <button beam-action="delete" beam-confirm.prompt="Type DELETE to confirm|DELETE">Delete</button>
181
+ function checkConfirm(el) {
182
+ const confirmMsg = el.getAttribute('beam-confirm');
183
+ if (!confirmMsg)
184
+ return true;
185
+ // Check for .prompt modifier (e.g., beam-confirm.prompt="message|expected")
186
+ if (el.hasAttribute('beam-confirm-prompt')) {
187
+ const [message, expected] = (el.getAttribute('beam-confirm-prompt') || '').split('|');
188
+ const input = prompt(message);
189
+ return input === expected;
190
+ }
191
+ return confirm(confirmMsg);
192
+ }
193
+ // ============ LOADING INDICATORS ============
194
+ // Store active actions with their params: Map<action, Set<paramsJSON>>
195
+ const activeActions = new Map();
196
+ // Store disabled elements during request
197
+ const disabledElements = new Map();
198
+ function setLoading(el, loading, action, params) {
199
+ // Loading state on trigger element
200
+ el.toggleAttribute('beam-loading', loading);
201
+ // Handle beam-disable
202
+ if (loading && el.hasAttribute('beam-disable')) {
203
+ const disableSelector = el.getAttribute('beam-disable');
204
+ let elementsToDisable;
205
+ if (!disableSelector || disableSelector === '' || disableSelector === 'true') {
206
+ // Disable the element itself and its children
207
+ elementsToDisable = [el, ...Array.from(el.querySelectorAll('button, input, select, textarea'))];
208
+ }
209
+ else {
210
+ // Disable specific elements by selector
211
+ elementsToDisable = Array.from(document.querySelectorAll(disableSelector));
212
+ }
213
+ const originalStates = elementsToDisable.map((e) => e.disabled || false);
214
+ elementsToDisable.forEach((e) => (e.disabled = true));
215
+ disabledElements.set(el, { elements: elementsToDisable, originalStates });
216
+ }
217
+ else if (!loading && disabledElements.has(el)) {
218
+ // Restore disabled state
219
+ const { elements, originalStates } = disabledElements.get(el);
220
+ elements.forEach((e, i) => (e.disabled = originalStates[i]));
221
+ disabledElements.delete(el);
222
+ }
223
+ // Legacy: disable buttons inside if no beam-disable specified
224
+ if (!el.hasAttribute('beam-disable')) {
225
+ el.querySelectorAll('button, input[type="submit"]').forEach((child) => {
226
+ child.disabled = loading;
227
+ });
228
+ }
229
+ // Set .beam-active class on element during loading
230
+ el.classList.toggle('beam-active', loading);
231
+ // Broadcast to loading indicators
232
+ if (action) {
233
+ const paramsKey = JSON.stringify(params || {});
234
+ if (loading) {
235
+ if (!activeActions.has(action)) {
236
+ activeActions.set(action, new Set());
237
+ }
238
+ activeActions.get(action).add(paramsKey);
239
+ }
240
+ else {
241
+ activeActions.get(action)?.delete(paramsKey);
242
+ if (activeActions.get(action)?.size === 0) {
243
+ activeActions.delete(action);
244
+ }
245
+ }
246
+ updateLoadingIndicators();
247
+ }
248
+ }
249
+ function getLoadingParams(el) {
250
+ // Start with beam-loading-params JSON if present
251
+ const params = JSON.parse(el.getAttribute('beam-loading-params') || '{}');
252
+ // Collect beam-loading-data-* attributes (override JSON params)
253
+ for (const attr of el.attributes) {
254
+ if (attr.name.startsWith('beam-loading-data-')) {
255
+ const key = attr.name.slice(18); // remove 'beam-loading-data-'
256
+ try {
257
+ params[key] = JSON.parse(attr.value);
258
+ }
259
+ catch {
260
+ params[key] = attr.value;
261
+ }
262
+ }
263
+ }
264
+ return params;
265
+ }
266
+ function matchesParams(required, activeParamsSet) {
267
+ const requiredKeys = Object.keys(required);
268
+ if (requiredKeys.length === 0)
269
+ return true; // No params required, match any
270
+ for (const paramsJson of activeParamsSet) {
271
+ const params = JSON.parse(paramsJson);
272
+ const matches = requiredKeys.every((key) => String(params[key]) === String(required[key]));
273
+ if (matches)
274
+ return true;
275
+ }
276
+ return false;
277
+ }
278
+ function updateLoadingIndicators() {
279
+ document.querySelectorAll('[beam-loading-for]').forEach((el) => {
280
+ const targets = el
281
+ .getAttribute('beam-loading-for')
282
+ .split(',')
283
+ .map((s) => s.trim());
284
+ const requiredParams = getLoadingParams(el);
285
+ let isActive = false;
286
+ if (targets.includes('*')) {
287
+ // Match any action
288
+ isActive = activeActions.size > 0;
289
+ }
290
+ else {
291
+ // Match specific action(s) with optional params
292
+ isActive = targets.some((action) => {
293
+ const actionParams = activeActions.get(action);
294
+ return actionParams && matchesParams(requiredParams, actionParams);
295
+ });
296
+ }
297
+ // Show/hide
298
+ if (el.hasAttribute('beam-loading-remove')) {
299
+ el.style.display = isActive ? 'none' : '';
300
+ }
301
+ else if (!el.hasAttribute('beam-loading-class')) {
302
+ el.style.display = isActive ? '' : 'none';
303
+ }
304
+ // Add/remove class
305
+ const loadingClass = el.getAttribute('beam-loading-class');
306
+ if (loadingClass) {
307
+ el.classList.toggle(loadingClass, isActive);
308
+ }
309
+ });
310
+ }
311
+ // Hide loading indicators by default on page load
312
+ document.querySelectorAll('[beam-loading-for]:not([beam-loading-remove]):not([beam-loading-class])').forEach((el) => {
313
+ el.style.display = 'none';
314
+ });
315
+ function optimistic(el) {
316
+ const template = el.getAttribute('beam-optimistic');
317
+ const targetSelector = el.getAttribute('beam-target');
318
+ let snapshot = null;
319
+ if (template && targetSelector) {
320
+ const targetEl = $(targetSelector);
321
+ if (targetEl) {
322
+ snapshot = targetEl.innerHTML;
323
+ const params = getParams(el);
324
+ const html = template.replace(/\{\{(\w+)\}\}/g, (_, key) => String(params[key] ?? ''));
325
+ morph(targetEl, html);
326
+ }
327
+ }
328
+ return {
329
+ rollback() {
330
+ if (snapshot && targetSelector) {
331
+ const targetEl = $(targetSelector);
332
+ if (targetEl)
333
+ morph(targetEl, snapshot);
334
+ }
335
+ },
336
+ };
337
+ }
338
+ function showPlaceholder(el) {
339
+ const placeholder = el.getAttribute('beam-placeholder');
340
+ const targetSelector = el.getAttribute('beam-target');
341
+ let snapshot = null;
342
+ if (placeholder && targetSelector) {
343
+ const targetEl = $(targetSelector);
344
+ if (targetEl) {
345
+ snapshot = targetEl.innerHTML;
346
+ // Check if placeholder is a selector (starts with # or .)
347
+ if (placeholder.startsWith('#') || placeholder.startsWith('.')) {
348
+ const tpl = document.querySelector(placeholder);
349
+ if (tpl instanceof HTMLTemplateElement) {
350
+ targetEl.innerHTML = tpl.innerHTML;
351
+ }
352
+ else if (tpl) {
353
+ targetEl.innerHTML = tpl.innerHTML;
354
+ }
355
+ }
356
+ else {
357
+ targetEl.innerHTML = placeholder;
358
+ }
359
+ }
360
+ }
361
+ return {
362
+ restore() {
363
+ if (snapshot && targetSelector) {
364
+ const targetEl = $(targetSelector);
365
+ if (targetEl)
366
+ targetEl.innerHTML = snapshot;
367
+ }
368
+ },
369
+ };
370
+ }
371
+ // ============ SWAP STRATEGIES ============
372
+ /**
373
+ * Deduplicate items by beam-item-id before inserting.
374
+ * - Updates existing items with fresh data (morphs in place)
375
+ * - Removes duplicates from incoming HTML (so they don't double-insert)
376
+ */
377
+ function dedupeItems(target, html) {
378
+ const temp = document.createElement('div');
379
+ temp.innerHTML = html;
380
+ // Collect existing item IDs
381
+ const existingIds = new Set();
382
+ target.querySelectorAll('[beam-item-id]').forEach((el) => {
383
+ const id = el.getAttribute('beam-item-id');
384
+ if (id)
385
+ existingIds.add(id);
386
+ });
387
+ // Process incoming items
388
+ temp.querySelectorAll('[beam-item-id]').forEach((el) => {
389
+ const id = el.getAttribute('beam-item-id');
390
+ if (id && existingIds.has(id)) {
391
+ // Morph existing item with fresh data
392
+ const existing = target.querySelector(`[beam-item-id="${id}"]`);
393
+ if (existing) {
394
+ morph(existing, el.outerHTML);
395
+ }
396
+ // Remove from incoming HTML (already updated in place)
397
+ el.remove();
398
+ }
399
+ });
400
+ return temp.innerHTML;
401
+ }
402
+ function swap(target, html, mode, trigger) {
403
+ const { main, oob } = parseOobSwaps(html);
404
+ switch (mode) {
405
+ case 'append':
406
+ trigger?.remove();
407
+ target.insertAdjacentHTML('beforeend', dedupeItems(target, main));
408
+ break;
409
+ case 'prepend':
410
+ trigger?.remove();
411
+ target.insertAdjacentHTML('afterbegin', dedupeItems(target, main));
412
+ break;
413
+ case 'replace':
414
+ target.innerHTML = main;
415
+ break;
416
+ case 'delete':
417
+ target.remove();
418
+ break;
419
+ case 'morph':
420
+ default:
421
+ morph(target, main);
422
+ break;
423
+ }
424
+ // Out-of-band swaps
425
+ for (const { selector, content, swapMode } of oob) {
426
+ const oobTarget = $(selector);
427
+ if (oobTarget) {
428
+ if (swapMode === 'morph' || !swapMode) {
429
+ morph(oobTarget, content);
430
+ }
431
+ else {
432
+ swap(oobTarget, content, swapMode);
433
+ }
434
+ }
435
+ }
436
+ // Process hungry elements - auto-update elements that match IDs in response
437
+ processHungryElements(html);
438
+ }
439
+ function parseOobSwaps(html) {
440
+ const temp = document.createElement('div');
441
+ temp.innerHTML = html;
442
+ const oob = [];
443
+ temp.querySelectorAll('template[beam-touch]').forEach((tpl) => {
444
+ const selector = tpl.getAttribute('beam-touch');
445
+ const swapMode = tpl.getAttribute('beam-swap') || 'morph';
446
+ if (selector) {
447
+ oob.push({ selector, content: tpl.innerHTML, swapMode });
448
+ }
449
+ tpl.remove();
450
+ });
451
+ return { main: temp.innerHTML, oob };
452
+ }
453
+ // ============ RPC WRAPPER ============
454
+ async function rpc(action, data, el) {
455
+ const targetSelector = el.getAttribute('beam-target');
456
+ const swapMode = el.getAttribute('beam-swap') || 'morph';
457
+ const opt = optimistic(el);
458
+ const placeholder = showPlaceholder(el);
459
+ setLoading(el, true, action, data);
460
+ try {
461
+ const response = await api.call(action, data);
462
+ // Handle redirect (if present) - takes priority
463
+ if (response.redirect) {
464
+ location.href = response.redirect;
465
+ return;
466
+ }
467
+ // Handle HTML (if present)
468
+ if (response.html && targetSelector) {
469
+ const target = $(targetSelector);
470
+ if (target) {
471
+ swap(target, response.html, swapMode, el);
472
+ }
473
+ }
474
+ // Execute script (if present)
475
+ if (response.script) {
476
+ executeScript(response.script);
477
+ }
478
+ // Handle history
479
+ handleHistory(el);
480
+ }
481
+ catch (err) {
482
+ opt.rollback();
483
+ placeholder.restore();
484
+ showToast('Something went wrong. Please try again.', 'error');
485
+ console.error('RPC error:', err);
486
+ }
487
+ finally {
488
+ setLoading(el, false, action, data);
489
+ }
490
+ }
491
+ // ============ HISTORY MANAGEMENT ============
492
+ // Usage: <a beam-action="load" beam-push="/new-url">Link</a>
493
+ // Usage: <button beam-action="filter" beam-replace="?sort=name">Filter</button>
494
+ function handleHistory(el) {
495
+ const pushUrl = el.getAttribute('beam-push');
496
+ const replaceUrl = el.getAttribute('beam-replace');
497
+ if (pushUrl) {
498
+ history.pushState({ beam: true }, '', pushUrl);
499
+ }
500
+ else if (replaceUrl) {
501
+ history.replaceState({ beam: true }, '', replaceUrl);
502
+ }
503
+ }
504
+ // Handle back/forward navigation
505
+ window.addEventListener('popstate', (e) => {
506
+ // Reload page on back/forward for now
507
+ // Could be enhanced to restore content from cache
508
+ if (e.state?.beam) {
509
+ location.reload();
510
+ }
511
+ });
512
+ // ============ BUTTON HANDLING ============
513
+ // Instant click - trigger on mousedown for faster response
514
+ document.addEventListener('mousedown', async (e) => {
515
+ const target = e.target;
516
+ if (!target?.closest)
517
+ return;
518
+ const btn = target.closest('[beam-action][beam-instant]:not(form):not([beam-load-more]):not([beam-infinite])');
519
+ if (!btn || btn.tagName === 'FORM')
520
+ return;
521
+ // Skip if submit button inside a beam form
522
+ if (btn.closest('form[beam-action]') && btn.getAttribute('type') === 'submit')
523
+ return;
524
+ e.preventDefault();
525
+ // Check confirmation
526
+ if (!checkConfirm(btn))
527
+ return;
528
+ const action = btn.getAttribute('beam-action');
529
+ if (!action)
530
+ return;
531
+ const params = getParams(btn);
532
+ await rpc(action, params, btn);
533
+ if (btn.hasAttribute('beam-close')) {
534
+ closeModal();
535
+ closeDrawer();
536
+ }
537
+ });
538
+ // Regular click handling
539
+ document.addEventListener('click', async (e) => {
540
+ const target = e.target;
541
+ if (!target?.closest)
542
+ return;
543
+ const btn = target.closest('[beam-action]:not(form):not([beam-instant]):not([beam-load-more]):not([beam-infinite])');
544
+ if (!btn || btn.tagName === 'FORM')
545
+ return;
546
+ // Skip if submit button inside a beam form
547
+ if (btn.closest('form[beam-action]') && btn.getAttribute('type') === 'submit')
548
+ return;
549
+ e.preventDefault();
550
+ // Check confirmation
551
+ if (!checkConfirm(btn))
552
+ return;
553
+ const action = btn.getAttribute('beam-action');
554
+ if (!action)
555
+ return;
556
+ const params = getParams(btn);
557
+ await rpc(action, params, btn);
558
+ if (btn.hasAttribute('beam-close')) {
559
+ closeModal();
560
+ closeDrawer();
561
+ }
562
+ });
563
+ // ============ MODALS ============
564
+ document.addEventListener('click', (e) => {
565
+ const target = e.target;
566
+ if (!target?.closest)
567
+ return;
568
+ const trigger = target.closest('[beam-modal]');
569
+ if (trigger) {
570
+ e.preventDefault();
571
+ // Check confirmation
572
+ if (!checkConfirm(trigger))
573
+ return;
574
+ const modalId = trigger.getAttribute('beam-modal');
575
+ const params = getParams(trigger);
576
+ if (modalId) {
577
+ openModal(modalId, params);
578
+ }
579
+ }
580
+ // Close on backdrop click
581
+ if (target.matches?.('#modal-backdrop')) {
582
+ closeModal();
583
+ }
584
+ // Close button (handles both modal and drawer)
585
+ const closeBtn = target.closest('[beam-close]');
586
+ if (closeBtn && !closeBtn.hasAttribute('beam-action')) {
587
+ if (activeDrawer) {
588
+ closeDrawer();
589
+ }
590
+ else {
591
+ closeModal();
592
+ }
593
+ }
594
+ });
595
+ // Drawer triggers
596
+ document.addEventListener('click', (e) => {
597
+ const target = e.target;
598
+ if (!target?.closest)
599
+ return;
600
+ const trigger = target.closest('[beam-drawer]');
601
+ if (trigger) {
602
+ e.preventDefault();
603
+ // Check confirmation
604
+ if (!checkConfirm(trigger))
605
+ return;
606
+ const drawerId = trigger.getAttribute('beam-drawer');
607
+ const position = trigger.getAttribute('beam-position') || 'right';
608
+ const size = trigger.getAttribute('beam-size') || 'medium';
609
+ const params = getParams(trigger);
610
+ if (drawerId) {
611
+ openDrawer(drawerId, params, { position, size });
612
+ }
613
+ }
614
+ // Close on backdrop click
615
+ if (target.matches?.('#drawer-backdrop')) {
616
+ closeDrawer();
617
+ }
618
+ });
619
+ document.addEventListener('keydown', (e) => {
620
+ if (e.key === 'Escape') {
621
+ if (activeDrawer) {
622
+ closeDrawer();
623
+ }
624
+ else if (activeModal) {
625
+ closeModal();
626
+ }
627
+ }
628
+ });
629
+ async function openModal(id, params = {}) {
630
+ try {
631
+ const html = await api.modal(id, params);
632
+ let backdrop = $('#modal-backdrop');
633
+ if (!backdrop) {
634
+ backdrop = document.createElement('div');
635
+ backdrop.id = 'modal-backdrop';
636
+ document.body.appendChild(backdrop);
637
+ }
638
+ backdrop.innerHTML = `
639
+ <div id="modal-content" role="dialog" aria-modal="true">
640
+ ${html}
641
+ </div>
642
+ `;
643
+ backdrop.offsetHeight;
644
+ backdrop.classList.add('open');
645
+ document.body.classList.add('modal-open');
646
+ activeModal = $('#modal-content');
647
+ const autoFocus = activeModal?.querySelector('[autofocus]');
648
+ const firstInput = activeModal?.querySelector('input, button, textarea, select');
649
+ (autoFocus || firstInput)?.focus();
650
+ }
651
+ catch (err) {
652
+ showToast('Failed to open modal.', 'error');
653
+ console.error('Modal error:', err);
654
+ }
655
+ }
656
+ function closeModal() {
657
+ const backdrop = $('#modal-backdrop');
658
+ if (backdrop) {
659
+ backdrop.classList.remove('open');
660
+ setTimeout(() => {
661
+ backdrop.innerHTML = '';
662
+ }, 200);
663
+ }
664
+ document.body.classList.remove('modal-open');
665
+ activeModal = null;
666
+ }
667
+ async function openDrawer(id, params = {}, options) {
668
+ try {
669
+ const html = await api.drawer(id, params);
670
+ let backdrop = $('#drawer-backdrop');
671
+ if (!backdrop) {
672
+ backdrop = document.createElement('div');
673
+ backdrop.id = 'drawer-backdrop';
674
+ document.body.appendChild(backdrop);
675
+ }
676
+ // Set position and size as data attributes for CSS styling
677
+ const { position, size } = options;
678
+ backdrop.innerHTML = `
679
+ <div id="drawer-content" role="dialog" aria-modal="true" data-position="${position}" data-size="${size}">
680
+ ${html}
681
+ </div>
682
+ `;
683
+ backdrop.offsetHeight; // Force reflow
684
+ backdrop.classList.add('open');
685
+ document.body.classList.add('drawer-open');
686
+ activeDrawer = $('#drawer-content');
687
+ const autoFocus = activeDrawer?.querySelector('[autofocus]');
688
+ const firstInput = activeDrawer?.querySelector('input, button, textarea, select');
689
+ (autoFocus || firstInput)?.focus();
690
+ }
691
+ catch (err) {
692
+ showToast('Failed to open drawer.', 'error');
693
+ console.error('Drawer error:', err);
694
+ }
695
+ }
696
+ function closeDrawer() {
697
+ const backdrop = $('#drawer-backdrop');
698
+ if (backdrop) {
699
+ backdrop.classList.remove('open');
700
+ setTimeout(() => {
701
+ backdrop.innerHTML = '';
702
+ }, 200);
703
+ }
704
+ document.body.classList.remove('drawer-open');
705
+ activeDrawer = null;
706
+ }
707
+ // ============ TOAST NOTIFICATIONS ============
708
+ function showToast(message, type = 'success') {
709
+ let container = $('#toast-container');
710
+ if (!container) {
711
+ container = document.createElement('div');
712
+ container.id = 'toast-container';
713
+ document.body.appendChild(container);
714
+ }
715
+ const toast = document.createElement('div');
716
+ toast.className = `toast toast-${type}`;
717
+ toast.textContent = message;
718
+ toast.setAttribute('role', 'alert');
719
+ container.appendChild(toast);
720
+ requestAnimationFrame(() => {
721
+ toast.classList.add('show');
722
+ });
723
+ setTimeout(() => {
724
+ toast.classList.remove('show');
725
+ setTimeout(() => toast.remove(), 300);
726
+ }, 3000);
727
+ }
728
+ // ============ OFFLINE DETECTION ============
729
+ // Usage: <div beam-offline>You are offline</div>
730
+ // Usage: <button beam-action="save" beam-offline-disable>Save</button>
731
+ function updateOfflineState() {
732
+ isOnline = navigator.onLine;
733
+ // Show/hide offline indicators
734
+ document.querySelectorAll('[beam-offline]').forEach((el) => {
735
+ const showClass = el.getAttribute('beam-offline-class');
736
+ if (showClass) {
737
+ el.classList.toggle(showClass, !isOnline);
738
+ }
739
+ else {
740
+ el.style.display = isOnline ? 'none' : '';
741
+ }
742
+ });
743
+ // Disable/enable elements when offline
744
+ document.querySelectorAll('[beam-offline-disable]').forEach((el) => {
745
+ ;
746
+ el.disabled = !isOnline;
747
+ });
748
+ // Add/remove body class
749
+ document.body.classList.toggle('beam-offline', !isOnline);
750
+ }
751
+ window.addEventListener('online', updateOfflineState);
752
+ window.addEventListener('offline', updateOfflineState);
753
+ // Initialize offline state
754
+ updateOfflineState();
755
+ // ============ NAVIGATION FEEDBACK ============
756
+ // Usage: <nav beam-nav><a href="/home">Home</a></nav>
757
+ // Links get .beam-current when they match current URL
758
+ function updateNavigation() {
759
+ const currentPath = location.pathname;
760
+ const currentUrl = location.href;
761
+ document.querySelectorAll('[beam-nav] a, a[beam-nav]').forEach((link) => {
762
+ const href = link.getAttribute('href');
763
+ if (!href)
764
+ return;
765
+ // Check if link matches current path
766
+ const linkUrl = new URL(href, location.origin);
767
+ const isExact = linkUrl.pathname === currentPath;
768
+ const isPartial = currentPath.startsWith(linkUrl.pathname) && linkUrl.pathname !== '/';
769
+ // Exact match or partial match (for section highlighting)
770
+ const isCurrent = link.hasAttribute('beam-nav-exact') ? isExact : isExact || isPartial;
771
+ link.classList.toggle('beam-current', isCurrent);
772
+ if (isCurrent) {
773
+ link.setAttribute('aria-current', 'page');
774
+ }
775
+ else {
776
+ link.removeAttribute('aria-current');
777
+ }
778
+ });
779
+ }
780
+ // Update navigation on page load and history changes
781
+ updateNavigation();
782
+ window.addEventListener('popstate', updateNavigation);
783
+ // ============ CONDITIONAL SHOW/HIDE (beam-switch) ============
784
+ // Usage: <select name="type" beam-switch=".type-options">
785
+ // <option value="a">A</option>
786
+ // <option value="b">B</option>
787
+ // </select>
788
+ // <div class="type-options" beam-show-for="a">Options for A</div>
789
+ // <div class="type-options" beam-show-for="b">Options for B</div>
790
+ function setupSwitch(el) {
791
+ const targetSelector = el.getAttribute('beam-switch');
792
+ const event = el.getAttribute('beam-switch-event') || 'input';
793
+ const updateTargets = () => {
794
+ const value = el.value;
795
+ // Find targets within the switch region or document
796
+ const region = el.closest('[beam-switch-region]') || el.closest('form') || document;
797
+ region.querySelectorAll(targetSelector).forEach((target) => {
798
+ const showFor = target.getAttribute('beam-show-for');
799
+ const hideFor = target.getAttribute('beam-hide-for');
800
+ const enableFor = target.getAttribute('beam-enable-for');
801
+ const disableFor = target.getAttribute('beam-disable-for');
802
+ // Handle show/hide
803
+ if (showFor !== null) {
804
+ const values = showFor.split(',').map((v) => v.trim());
805
+ const shouldShow = values.includes(value) || (showFor === '' && value !== '');
806
+ target.style.display = shouldShow ? '' : 'none';
807
+ }
808
+ if (hideFor !== null) {
809
+ const values = hideFor.split(',').map((v) => v.trim());
810
+ const shouldHide = values.includes(value);
811
+ target.style.display = shouldHide ? 'none' : '';
812
+ }
813
+ // Handle enable/disable
814
+ if (enableFor !== null) {
815
+ const values = enableFor.split(',').map((v) => v.trim());
816
+ const shouldEnable = values.includes(value);
817
+ target.disabled = !shouldEnable;
818
+ }
819
+ if (disableFor !== null) {
820
+ const values = disableFor.split(',').map((v) => v.trim());
821
+ const shouldDisable = values.includes(value);
822
+ target.disabled = shouldDisable;
823
+ }
824
+ });
825
+ };
826
+ el.addEventListener(event, updateTargets);
827
+ // Initial state
828
+ updateTargets();
829
+ }
830
+ // Observe switch elements
831
+ const switchObserver = new MutationObserver(() => {
832
+ document.querySelectorAll('[beam-switch]:not([beam-switch-observed])').forEach((el) => {
833
+ el.setAttribute('beam-switch-observed', '');
834
+ setupSwitch(el);
835
+ });
836
+ });
837
+ switchObserver.observe(document.body, { childList: true, subtree: true });
838
+ // Initialize existing switch elements
839
+ document.querySelectorAll('[beam-switch]').forEach((el) => {
840
+ el.setAttribute('beam-switch-observed', '');
841
+ setupSwitch(el);
842
+ });
843
+ // ============ AUTO-SUBMIT FORMS ============
844
+ // Usage: <form beam-action="filter" beam-autosubmit beam-debounce="300">
845
+ function setupAutosubmit(form) {
846
+ const debounce = parseInt(form.getAttribute('beam-debounce') || '300', 10);
847
+ const event = form.getAttribute('beam-autosubmit-event') || 'input';
848
+ let timeout;
849
+ const submitForm = () => {
850
+ clearTimeout(timeout);
851
+ timeout = setTimeout(() => {
852
+ form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
853
+ }, debounce);
854
+ };
855
+ form.querySelectorAll('input, select, textarea').forEach((input) => {
856
+ input.addEventListener(event, submitForm);
857
+ // Also listen to change for selects and checkboxes
858
+ if (input.tagName === 'SELECT' || input.type === 'checkbox' || input.type === 'radio') {
859
+ input.addEventListener('change', submitForm);
860
+ }
861
+ });
862
+ }
863
+ // Observe autosubmit forms
864
+ const autosubmitObserver = new MutationObserver(() => {
865
+ document.querySelectorAll('form[beam-autosubmit]:not([beam-autosubmit-observed])').forEach((form) => {
866
+ form.setAttribute('beam-autosubmit-observed', '');
867
+ setupAutosubmit(form);
868
+ });
869
+ });
870
+ autosubmitObserver.observe(document.body, { childList: true, subtree: true });
871
+ // Initialize existing autosubmit forms
872
+ document.querySelectorAll('form[beam-autosubmit]').forEach((form) => {
873
+ form.setAttribute('beam-autosubmit-observed', '');
874
+ setupAutosubmit(form);
875
+ });
876
+ // ============ BOOST LINKS ============
877
+ // Usage: <main beam-boost>...all links become AJAX...</main>
878
+ // Usage: <a href="/page" beam-boost>Single boosted link</a>
879
+ document.addEventListener('click', async (e) => {
880
+ const target = e.target;
881
+ if (!target?.closest)
882
+ return;
883
+ // Check if click is on a link within a boosted container or a boosted link itself
884
+ const link = target.closest('a[href]');
885
+ if (!link)
886
+ return;
887
+ const isBoosted = link.hasAttribute('beam-boost') || link.closest('[beam-boost]');
888
+ if (!isBoosted)
889
+ return;
890
+ // Skip if explicitly not boosted
891
+ if (link.hasAttribute('beam-boost-off'))
892
+ return;
893
+ // Skip external links
894
+ if (link.host !== location.host)
895
+ return;
896
+ // Skip if modifier keys or non-left click
897
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0)
898
+ return;
899
+ // Skip if target="_blank"
900
+ if (link.target === '_blank')
901
+ return;
902
+ // Skip if download link
903
+ if (link.hasAttribute('download'))
904
+ return;
905
+ e.preventDefault();
906
+ // Check confirmation
907
+ if (!checkConfirm(link))
908
+ return;
909
+ const href = link.href;
910
+ const targetSelector = link.getAttribute('beam-target') || 'body';
911
+ const swapMode = link.getAttribute('beam-swap') || 'morph';
912
+ // Show placeholder if specified
913
+ const placeholder = showPlaceholder(link);
914
+ link.classList.add('beam-active');
915
+ try {
916
+ // Fetch the page
917
+ const response = await fetch(href, {
918
+ headers: { 'X-Beam-Boost': 'true' },
919
+ });
920
+ const html = await response.text();
921
+ // Parse response and extract target content
922
+ const parser = new DOMParser();
923
+ const doc = parser.parseFromString(html, 'text/html');
924
+ // Get content from target selector
925
+ const sourceEl = doc.querySelector(targetSelector);
926
+ if (sourceEl) {
927
+ const target = $(targetSelector);
928
+ if (target) {
929
+ swap(target, sourceEl.innerHTML, swapMode);
930
+ }
931
+ }
932
+ // Update title
933
+ const title = doc.querySelector('title');
934
+ if (title) {
935
+ document.title = title.textContent || '';
936
+ }
937
+ // Push to history
938
+ if (!link.hasAttribute('beam-replace')) {
939
+ history.pushState({ beam: true, url: href }, '', href);
940
+ }
941
+ else {
942
+ history.replaceState({ beam: true, url: href }, '', href);
943
+ }
944
+ // Update navigation state
945
+ updateNavigation();
946
+ }
947
+ catch (err) {
948
+ placeholder.restore();
949
+ // Fallback to normal navigation
950
+ console.error('Boost error, falling back to navigation:', err);
951
+ location.href = href;
952
+ }
953
+ finally {
954
+ link.classList.remove('beam-active');
955
+ }
956
+ });
957
+ const SCROLL_STATE_KEY_PREFIX = 'beam_scroll_';
958
+ const SCROLL_STATE_TTL = 5 * 60 * 1000; // 5 minutes
959
+ function getScrollStateKey(action) {
960
+ return SCROLL_STATE_KEY_PREFIX + location.pathname + location.search + '_' + action;
961
+ }
962
+ function saveScrollState(targetSelector, action) {
963
+ const target = $(targetSelector);
964
+ if (!target)
965
+ return;
966
+ const state = {
967
+ html: target.innerHTML,
968
+ scrollY: window.scrollY,
969
+ timestamp: Date.now(),
970
+ };
971
+ try {
972
+ sessionStorage.setItem(getScrollStateKey(action), JSON.stringify(state));
973
+ }
974
+ catch (e) {
975
+ // sessionStorage might be full or disabled
976
+ console.warn('[beam] Could not save scroll state:', e);
977
+ }
978
+ }
979
+ function restoreScrollState() {
980
+ // Find infinite scroll or load more container
981
+ const sentinel = document.querySelector('[beam-infinite], [beam-load-more]');
982
+ if (!sentinel)
983
+ return false;
984
+ const action = sentinel.getAttribute('beam-action');
985
+ const targetSelector = sentinel.getAttribute('beam-target');
986
+ if (!action || !targetSelector)
987
+ return false;
988
+ const key = getScrollStateKey(action);
989
+ const stored = sessionStorage.getItem(key);
990
+ if (!stored)
991
+ return false;
992
+ try {
993
+ const state = JSON.parse(stored);
994
+ // Check if state is expired
995
+ if (Date.now() - state.timestamp > SCROLL_STATE_TTL) {
996
+ sessionStorage.removeItem(key);
997
+ return false;
998
+ }
999
+ const target = $(targetSelector);
1000
+ if (!target)
1001
+ return false;
1002
+ // Disable browser's automatic scroll restoration
1003
+ if ('scrollRestoration' in history) {
1004
+ history.scrollRestoration = 'manual';
1005
+ }
1006
+ // Capture fresh server-rendered content before replacing
1007
+ const freshHtml = target.innerHTML;
1008
+ const freshContainer = document.createElement('div');
1009
+ freshContainer.innerHTML = freshHtml;
1010
+ // Hide content before restoring to prevent jump
1011
+ target.style.opacity = '0';
1012
+ target.style.transition = 'opacity 0.15s ease-out';
1013
+ // Restore cached content (has all pages)
1014
+ target.innerHTML = state.html;
1015
+ // Morph fresh server data over cached data (server takes precedence)
1016
+ // Match elements by beam-item-id attribute
1017
+ freshContainer.querySelectorAll('[beam-item-id]').forEach((freshEl) => {
1018
+ const itemId = freshEl.getAttribute('beam-item-id');
1019
+ const cachedEl = target.querySelector(`[beam-item-id="${itemId}"]`);
1020
+ if (cachedEl) {
1021
+ morph(cachedEl, freshEl.outerHTML);
1022
+ }
1023
+ });
1024
+ // Also match by id attribute as fallback
1025
+ freshContainer.querySelectorAll('[id]').forEach((freshEl) => {
1026
+ const cachedEl = target.querySelector(`#${freshEl.id}`);
1027
+ if (cachedEl && !freshEl.hasAttribute('beam-item-id')) {
1028
+ morph(cachedEl, freshEl.outerHTML);
1029
+ }
1030
+ });
1031
+ // Restore scroll position and fade in
1032
+ requestAnimationFrame(() => {
1033
+ window.scrollTo(0, state.scrollY);
1034
+ requestAnimationFrame(() => {
1035
+ target.style.opacity = '1';
1036
+ });
1037
+ });
1038
+ // Re-observe any new sentinels in restored content
1039
+ target.querySelectorAll('[beam-infinite]:not([beam-observed])').forEach((el) => {
1040
+ el.setAttribute('beam-observed', '');
1041
+ infiniteObserver.observe(el);
1042
+ });
1043
+ // Don't clear state here - it persists until refresh or new content is loaded
1044
+ // State is cleared in tryRestoreScrollState() when navType is not 'back_forward'
1045
+ return true;
1046
+ }
1047
+ catch (e) {
1048
+ console.warn('[beam] Could not restore scroll state:', e);
1049
+ sessionStorage.removeItem(key);
1050
+ return false;
1051
+ }
1052
+ }
1053
+ // Save scroll position when navigating away (for back button restoration)
1054
+ window.addEventListener('pagehide', () => {
1055
+ // Find any infinite scroll or load more element to get the target and action
1056
+ const sentinel = document.querySelector('[beam-infinite], [beam-load-more]');
1057
+ if (!sentinel)
1058
+ return;
1059
+ const action = sentinel.getAttribute('beam-action');
1060
+ const targetSelector = sentinel.getAttribute('beam-target');
1061
+ if (!action || !targetSelector)
1062
+ return;
1063
+ // Update the saved state with current scroll position
1064
+ const key = getScrollStateKey(action);
1065
+ const stored = sessionStorage.getItem(key);
1066
+ if (!stored)
1067
+ return;
1068
+ try {
1069
+ const state = JSON.parse(stored);
1070
+ state.scrollY = window.scrollY;
1071
+ state.timestamp = Date.now();
1072
+ sessionStorage.setItem(key, JSON.stringify(state));
1073
+ }
1074
+ catch (e) {
1075
+ // Ignore errors
1076
+ }
1077
+ });
1078
+ // Track the target selector for saving state
1079
+ let infiniteScrollTarget = null;
1080
+ const infiniteObserver = new IntersectionObserver(async (entries) => {
1081
+ for (const entry of entries) {
1082
+ if (!entry.isIntersecting)
1083
+ continue;
1084
+ const sentinel = entry.target;
1085
+ if (sentinel.hasAttribute('beam-loading'))
1086
+ continue;
1087
+ const action = sentinel.getAttribute('beam-action');
1088
+ const targetSelector = sentinel.getAttribute('beam-target');
1089
+ const swapMode = sentinel.getAttribute('beam-swap') || 'append';
1090
+ if (!action || !targetSelector)
1091
+ continue;
1092
+ // Track target for state saving
1093
+ infiniteScrollTarget = targetSelector;
1094
+ // Check confirmation
1095
+ if (!checkConfirm(sentinel))
1096
+ continue;
1097
+ const params = getParams(sentinel);
1098
+ sentinel.setAttribute('beam-loading', '');
1099
+ sentinel.classList.add('loading');
1100
+ setLoading(sentinel, true, action, params);
1101
+ try {
1102
+ const response = await api.call(action, params);
1103
+ const target = $(targetSelector);
1104
+ if (target && response.html) {
1105
+ swap(target, response.html, swapMode, sentinel);
1106
+ // Save scroll state after content is loaded
1107
+ requestAnimationFrame(() => {
1108
+ saveScrollState(targetSelector, action);
1109
+ });
1110
+ }
1111
+ // Execute script if present
1112
+ if (response.script) {
1113
+ executeScript(response.script);
1114
+ }
1115
+ }
1116
+ catch (err) {
1117
+ console.error('Infinite scroll error:', err);
1118
+ sentinel.removeAttribute('beam-loading');
1119
+ sentinel.classList.remove('loading');
1120
+ sentinel.classList.add('error');
1121
+ }
1122
+ finally {
1123
+ setLoading(sentinel, false, action, params);
1124
+ }
1125
+ }
1126
+ }, { rootMargin: '200px' });
1127
+ // Observe sentinels (now and future)
1128
+ new MutationObserver(() => {
1129
+ document.querySelectorAll('[beam-infinite]:not([beam-observed])').forEach((el) => {
1130
+ el.setAttribute('beam-observed', '');
1131
+ infiniteObserver.observe(el);
1132
+ });
1133
+ }).observe(document.body, { childList: true, subtree: true });
1134
+ document.querySelectorAll('[beam-infinite]').forEach((el) => {
1135
+ el.setAttribute('beam-observed', '');
1136
+ infiniteObserver.observe(el);
1137
+ });
1138
+ // ============ LOAD MORE (Click-based) ============
1139
+ // Usage: <button beam-load-more beam-action="loadMore" beam-params='{"page":2}' beam-target="#list">Load More</button>
1140
+ document.addEventListener('click', async (e) => {
1141
+ const target = e.target;
1142
+ if (!target?.closest)
1143
+ return;
1144
+ const trigger = target.closest('[beam-load-more]');
1145
+ if (!trigger)
1146
+ return;
1147
+ e.preventDefault();
1148
+ if (trigger.hasAttribute('beam-loading'))
1149
+ return;
1150
+ const action = trigger.getAttribute('beam-action');
1151
+ const targetSelector = trigger.getAttribute('beam-target');
1152
+ const swapMode = trigger.getAttribute('beam-swap') || 'append';
1153
+ if (!action || !targetSelector)
1154
+ return;
1155
+ // Check confirmation
1156
+ if (!checkConfirm(trigger))
1157
+ return;
1158
+ const params = getParams(trigger);
1159
+ trigger.setAttribute('beam-loading', '');
1160
+ trigger.classList.add('loading');
1161
+ setLoading(trigger, true, action, params);
1162
+ try {
1163
+ const response = await api.call(action, params);
1164
+ const targetEl = $(targetSelector);
1165
+ if (targetEl && response.html) {
1166
+ swap(targetEl, response.html, swapMode, trigger);
1167
+ // Save scroll state after content is loaded
1168
+ requestAnimationFrame(() => {
1169
+ saveScrollState(targetSelector, action);
1170
+ });
1171
+ }
1172
+ // Execute script if present
1173
+ if (response.script) {
1174
+ executeScript(response.script);
1175
+ }
1176
+ // Handle history
1177
+ handleHistory(trigger);
1178
+ }
1179
+ catch (err) {
1180
+ console.error('Load more error:', err);
1181
+ trigger.removeAttribute('beam-loading');
1182
+ trigger.classList.remove('loading');
1183
+ trigger.classList.add('error');
1184
+ showToast('Failed to load more. Please try again.', 'error');
1185
+ }
1186
+ finally {
1187
+ setLoading(trigger, false, action, params);
1188
+ }
1189
+ });
1190
+ // Restore scroll state on page load (for back navigation only, not refresh)
1191
+ function tryRestoreScrollState() {
1192
+ // Only restore on back/forward navigation, not on refresh or direct navigation
1193
+ const navEntry = performance.getEntriesByType('navigation')[0];
1194
+ const navType = navEntry?.type;
1195
+ // 'back_forward' = back/forward button, 'reload' = refresh, 'navigate' = direct navigation
1196
+ if (navType !== 'back_forward') {
1197
+ // Clear scroll state on refresh or direct navigation
1198
+ clearScrollState();
1199
+ // Disable browser's automatic scroll restoration and scroll to top
1200
+ if ('scrollRestoration' in history) {
1201
+ history.scrollRestoration = 'manual';
1202
+ }
1203
+ window.scrollTo(0, 0);
1204
+ return;
1205
+ }
1206
+ if (document.querySelector('[beam-infinite], [beam-load-more]')) {
1207
+ restoreScrollState();
1208
+ }
1209
+ }
1210
+ // Restore scroll state when DOM is ready
1211
+ if (document.readyState === 'loading') {
1212
+ document.addEventListener('DOMContentLoaded', tryRestoreScrollState);
1213
+ }
1214
+ else {
1215
+ tryRestoreScrollState();
1216
+ }
1217
+ const cache = new Map();
1218
+ const preloading = new Set();
1219
+ function getCacheKey(action, params) {
1220
+ return `${action}:${JSON.stringify(params)}`;
1221
+ }
1222
+ function parseCacheDuration(duration) {
1223
+ const match = duration.match(/^(\d+)(s|m|h)?$/);
1224
+ if (!match)
1225
+ return 0;
1226
+ const value = parseInt(match[1], 10);
1227
+ const unit = match[2] || 's';
1228
+ switch (unit) {
1229
+ case 'm':
1230
+ return value * 60 * 1000;
1231
+ case 'h':
1232
+ return value * 60 * 60 * 1000;
1233
+ default:
1234
+ return value * 1000;
1235
+ }
1236
+ }
1237
+ async function fetchWithCache(action, params, cacheDuration) {
1238
+ const key = getCacheKey(action, params);
1239
+ // Check cache
1240
+ const cached = cache.get(key);
1241
+ if (cached && cached.expires > Date.now()) {
1242
+ return cached.response;
1243
+ }
1244
+ // Fetch fresh
1245
+ const response = await api.call(action, params);
1246
+ // Store in cache if duration specified
1247
+ if (cacheDuration) {
1248
+ const duration = parseCacheDuration(cacheDuration);
1249
+ if (duration > 0) {
1250
+ cache.set(key, { response, expires: Date.now() + duration });
1251
+ }
1252
+ }
1253
+ return response;
1254
+ }
1255
+ async function preload(el) {
1256
+ const action = el.getAttribute('beam-action');
1257
+ if (!action)
1258
+ return;
1259
+ const params = getParams(el);
1260
+ const key = getCacheKey(action, params);
1261
+ // Skip if already cached or preloading
1262
+ if (cache.has(key) || preloading.has(key))
1263
+ return;
1264
+ preloading.add(key);
1265
+ try {
1266
+ const response = await api.call(action, params);
1267
+ // Cache for 30 seconds by default for preloaded content
1268
+ cache.set(key, { response, expires: Date.now() + 30000 });
1269
+ }
1270
+ catch {
1271
+ // Silently fail preload
1272
+ }
1273
+ finally {
1274
+ preloading.delete(key);
1275
+ }
1276
+ }
1277
+ // Preload on hover
1278
+ document.addEventListener('mouseenter', (e) => {
1279
+ const target = e.target;
1280
+ if (!target?.closest)
1281
+ return;
1282
+ const el = target.closest('[beam-preload][beam-action]');
1283
+ if (el) {
1284
+ preload(el);
1285
+ }
1286
+ }, true);
1287
+ // Preload on touchstart for mobile
1288
+ document.addEventListener('touchstart', (e) => {
1289
+ const target = e.target;
1290
+ if (!target?.closest)
1291
+ return;
1292
+ const el = target.closest('[beam-preload][beam-action]');
1293
+ if (el) {
1294
+ preload(el);
1295
+ }
1296
+ }, { passive: true });
1297
+ // Clear cache utility
1298
+ function clearCache(action) {
1299
+ if (action) {
1300
+ for (const key of cache.keys()) {
1301
+ if (key.startsWith(action + ':')) {
1302
+ cache.delete(key);
1303
+ }
1304
+ }
1305
+ }
1306
+ else {
1307
+ cache.clear();
1308
+ }
1309
+ }
1310
+ // ============ PROGRESSIVE ENHANCEMENT ============
1311
+ // Links with href fallback to full page navigation if JS fails
1312
+ // Usage: <a href="/products/1" beam-action="getProduct" beam-target="#main">View</a>
1313
+ document.addEventListener('click', async (e) => {
1314
+ const target = e.target;
1315
+ if (!target?.closest)
1316
+ return;
1317
+ const link = target.closest('a[beam-action][href]:not([beam-instant])');
1318
+ if (!link)
1319
+ return;
1320
+ // Let normal navigation happen if:
1321
+ // - Meta/Ctrl key held (new tab)
1322
+ // - Middle click
1323
+ // - Link has target="_blank"
1324
+ if (e.metaKey || e.ctrlKey || e.button !== 0 || link.target === '_blank')
1325
+ return;
1326
+ e.preventDefault();
1327
+ // Check confirmation
1328
+ if (!checkConfirm(link))
1329
+ return;
1330
+ const action = link.getAttribute('beam-action');
1331
+ if (!action)
1332
+ return;
1333
+ const params = getParams(link);
1334
+ const cacheDuration = link.getAttribute('beam-cache');
1335
+ // Use cached result if available
1336
+ const key = getCacheKey(action, params);
1337
+ const cached = cache.get(key);
1338
+ const targetSelector = link.getAttribute('beam-target');
1339
+ const swapMode = link.getAttribute('beam-swap') || 'morph';
1340
+ // Show placeholder
1341
+ const placeholder = showPlaceholder(link);
1342
+ setLoading(link, true, action, params);
1343
+ try {
1344
+ const response = cached && cached.expires > Date.now() ? cached.response : await fetchWithCache(action, params, cacheDuration || undefined);
1345
+ // Handle redirect (if present) - takes priority
1346
+ if (response.redirect) {
1347
+ location.href = response.redirect;
1348
+ return;
1349
+ }
1350
+ // Handle HTML (if present)
1351
+ if (response.html && targetSelector) {
1352
+ const target = $(targetSelector);
1353
+ if (target) {
1354
+ swap(target, response.html, swapMode, link);
1355
+ }
1356
+ }
1357
+ // Execute script (if present)
1358
+ if (response.script) {
1359
+ executeScript(response.script);
1360
+ }
1361
+ // Handle history
1362
+ handleHistory(link);
1363
+ // Update navigation
1364
+ updateNavigation();
1365
+ }
1366
+ catch (err) {
1367
+ placeholder.restore();
1368
+ // Fallback to normal navigation on error
1369
+ console.error('Beam link error, falling back to navigation:', err);
1370
+ location.href = link.href;
1371
+ }
1372
+ finally {
1373
+ setLoading(link, false, action, params);
1374
+ }
1375
+ }, true);
1376
+ // ============ FORM HANDLING ============
1377
+ // Pure RPC forms - no traditional POST
1378
+ // Usage: <form beam-action="createProduct" beam-target="#result">
1379
+ document.addEventListener('submit', async (e) => {
1380
+ const target = e.target;
1381
+ if (!target?.closest)
1382
+ return;
1383
+ const form = target.closest('form[beam-action]');
1384
+ if (!form)
1385
+ return;
1386
+ e.preventDefault();
1387
+ // Check confirmation
1388
+ if (!checkConfirm(form))
1389
+ return;
1390
+ const action = form.getAttribute('beam-action');
1391
+ if (!action)
1392
+ return;
1393
+ const data = Object.fromEntries(new FormData(form));
1394
+ const targetSelector = form.getAttribute('beam-target');
1395
+ const swapMode = form.getAttribute('beam-swap') || 'morph';
1396
+ // Show placeholder
1397
+ const placeholder = showPlaceholder(form);
1398
+ setLoading(form, true, action, data);
1399
+ try {
1400
+ const response = await api.call(action, data);
1401
+ // Handle redirect (if present) - takes priority
1402
+ if (response.redirect) {
1403
+ location.href = response.redirect;
1404
+ return;
1405
+ }
1406
+ // Handle HTML (if present)
1407
+ if (response.html && targetSelector) {
1408
+ const target = $(targetSelector);
1409
+ if (target) {
1410
+ swap(target, response.html, swapMode);
1411
+ }
1412
+ }
1413
+ // Execute script (if present)
1414
+ if (response.script) {
1415
+ executeScript(response.script);
1416
+ }
1417
+ if (form.hasAttribute('beam-reset')) {
1418
+ form.reset();
1419
+ }
1420
+ if (form.hasAttribute('beam-close')) {
1421
+ closeModal();
1422
+ }
1423
+ // Handle history
1424
+ handleHistory(form);
1425
+ }
1426
+ catch (err) {
1427
+ placeholder.restore();
1428
+ console.error('Beam form error:', err);
1429
+ showToast('Something went wrong. Please try again.', 'error');
1430
+ }
1431
+ finally {
1432
+ setLoading(form, false, action, data);
1433
+ }
1434
+ });
1435
+ // ============ REAL-TIME VALIDATION ============
1436
+ // Usage: <input name="email" beam-validate="#email-errors" beam-watch="input" beam-debounce="300">
1437
+ function setupValidation(el) {
1438
+ const event = el.getAttribute('beam-watch') || 'change';
1439
+ const debounce = parseInt(el.getAttribute('beam-debounce') || '300', 10);
1440
+ const targetSelector = el.getAttribute('beam-validate');
1441
+ let timeout;
1442
+ el.addEventListener(event, () => {
1443
+ clearTimeout(timeout);
1444
+ timeout = setTimeout(async () => {
1445
+ const form = el.closest('form');
1446
+ if (!form)
1447
+ return;
1448
+ const action = form.getAttribute('beam-action');
1449
+ if (!action)
1450
+ return;
1451
+ const fieldName = el.getAttribute('name');
1452
+ if (!fieldName)
1453
+ return;
1454
+ const formData = Object.fromEntries(new FormData(form));
1455
+ const data = { ...formData, _validate: fieldName };
1456
+ try {
1457
+ const response = await api.call(action, data);
1458
+ if (response.html) {
1459
+ const target = $(targetSelector);
1460
+ if (target) {
1461
+ morph(target, response.html);
1462
+ }
1463
+ }
1464
+ // Execute script (if present)
1465
+ if (response.script) {
1466
+ executeScript(response.script);
1467
+ }
1468
+ }
1469
+ catch (err) {
1470
+ console.error('Validation error:', err);
1471
+ }
1472
+ }, debounce);
1473
+ });
1474
+ }
1475
+ // Observe validation elements (current and future)
1476
+ const validationObserver = new MutationObserver(() => {
1477
+ document.querySelectorAll('[beam-validate]:not([beam-validation-observed])').forEach((el) => {
1478
+ el.setAttribute('beam-validation-observed', '');
1479
+ setupValidation(el);
1480
+ });
1481
+ });
1482
+ validationObserver.observe(document.body, { childList: true, subtree: true });
1483
+ // Initialize existing validation elements
1484
+ document.querySelectorAll('[beam-validate]').forEach((el) => {
1485
+ el.setAttribute('beam-validation-observed', '');
1486
+ setupValidation(el);
1487
+ });
1488
+ // ============ DEFERRED LOADING ============
1489
+ // Usage: <div beam-defer beam-action="loadComments" beam-target="#comments">Loading...</div>
1490
+ const deferObserver = new IntersectionObserver(async (entries) => {
1491
+ for (const entry of entries) {
1492
+ if (!entry.isIntersecting)
1493
+ continue;
1494
+ const el = entry.target;
1495
+ if (el.hasAttribute('beam-defer-loaded'))
1496
+ continue;
1497
+ el.setAttribute('beam-defer-loaded', '');
1498
+ deferObserver.unobserve(el);
1499
+ const action = el.getAttribute('beam-action');
1500
+ if (!action)
1501
+ continue;
1502
+ const params = getParams(el);
1503
+ const targetSelector = el.getAttribute('beam-target');
1504
+ const swapMode = el.getAttribute('beam-swap') || 'morph';
1505
+ setLoading(el, true, action, params);
1506
+ try {
1507
+ const response = await api.call(action, params);
1508
+ if (response.html) {
1509
+ const target = targetSelector ? $(targetSelector) : el;
1510
+ if (target) {
1511
+ swap(target, response.html, swapMode);
1512
+ }
1513
+ }
1514
+ // Execute script (if present)
1515
+ if (response.script) {
1516
+ executeScript(response.script);
1517
+ }
1518
+ }
1519
+ catch (err) {
1520
+ console.error('Defer error:', err);
1521
+ }
1522
+ finally {
1523
+ setLoading(el, false, action, params);
1524
+ }
1525
+ }
1526
+ }, { rootMargin: '100px' });
1527
+ // Observe defer elements (current and future)
1528
+ const deferMutationObserver = new MutationObserver(() => {
1529
+ document.querySelectorAll('[beam-defer]:not([beam-defer-observed])').forEach((el) => {
1530
+ el.setAttribute('beam-defer-observed', '');
1531
+ deferObserver.observe(el);
1532
+ });
1533
+ });
1534
+ deferMutationObserver.observe(document.body, { childList: true, subtree: true });
1535
+ // Initialize existing defer elements
1536
+ document.querySelectorAll('[beam-defer]').forEach((el) => {
1537
+ el.setAttribute('beam-defer-observed', '');
1538
+ deferObserver.observe(el);
1539
+ });
1540
+ // ============ POLLING ============
1541
+ // Usage: <div beam-poll beam-interval="5000" beam-action="getStatus" beam-target="#status">...</div>
1542
+ const pollingElements = new Map();
1543
+ function startPolling(el) {
1544
+ if (pollingElements.has(el))
1545
+ return;
1546
+ const interval = parseInt(el.getAttribute('beam-interval') || '5000', 10);
1547
+ const action = el.getAttribute('beam-action');
1548
+ if (!action)
1549
+ return;
1550
+ const poll = async () => {
1551
+ // Stop if element is no longer in DOM
1552
+ if (!document.body.contains(el)) {
1553
+ stopPolling(el);
1554
+ return;
1555
+ }
1556
+ // Skip if offline
1557
+ if (!isOnline)
1558
+ return;
1559
+ const params = getParams(el);
1560
+ const targetSelector = el.getAttribute('beam-target');
1561
+ const swapMode = el.getAttribute('beam-swap') || 'morph';
1562
+ try {
1563
+ const response = await api.call(action, params);
1564
+ if (response.html) {
1565
+ const target = targetSelector ? $(targetSelector) : el;
1566
+ if (target) {
1567
+ swap(target, response.html, swapMode);
1568
+ }
1569
+ }
1570
+ // Execute script (if present)
1571
+ if (response.script) {
1572
+ executeScript(response.script);
1573
+ }
1574
+ }
1575
+ catch (err) {
1576
+ console.error('Poll error:', err);
1577
+ }
1578
+ };
1579
+ const timerId = setInterval(poll, interval);
1580
+ pollingElements.set(el, timerId);
1581
+ // Initial poll immediately (unless beam-poll-delay is set)
1582
+ if (!el.hasAttribute('beam-poll-delay')) {
1583
+ poll();
1584
+ }
1585
+ }
1586
+ function stopPolling(el) {
1587
+ const timerId = pollingElements.get(el);
1588
+ if (timerId) {
1589
+ clearInterval(timerId);
1590
+ pollingElements.delete(el);
1591
+ }
1592
+ }
1593
+ // Observe polling elements (current and future)
1594
+ const pollMutationObserver = new MutationObserver(() => {
1595
+ document.querySelectorAll('[beam-poll]:not([beam-poll-observed])').forEach((el) => {
1596
+ el.setAttribute('beam-poll-observed', '');
1597
+ startPolling(el);
1598
+ });
1599
+ });
1600
+ pollMutationObserver.observe(document.body, { childList: true, subtree: true });
1601
+ // Initialize existing polling elements
1602
+ document.querySelectorAll('[beam-poll]').forEach((el) => {
1603
+ el.setAttribute('beam-poll-observed', '');
1604
+ startPolling(el);
1605
+ });
1606
+ // ============ HUNGRY AUTO-REFRESH ============
1607
+ // Usage: <span id="cart-count" beam-hungry>0</span>
1608
+ // When any RPC response contains an element with id="cart-count", it auto-updates
1609
+ function processHungryElements(html) {
1610
+ const temp = document.createElement('div');
1611
+ temp.innerHTML = html;
1612
+ // Find hungry elements on the page
1613
+ document.querySelectorAll('[beam-hungry]').forEach((hungry) => {
1614
+ const id = hungry.id;
1615
+ if (!id)
1616
+ return;
1617
+ // Look for matching element in response
1618
+ const fresh = temp.querySelector(`#${id}`);
1619
+ if (fresh) {
1620
+ morph(hungry, fresh.innerHTML);
1621
+ }
1622
+ });
1623
+ }
1624
+ // ============ CLIENT-SIDE UI STATE (Alpine.js Replacement) ============
1625
+ // Toggle, dropdown, collapse utilities that don't require server round-trips
1626
+ // === TOGGLE ===
1627
+ // Usage: <button beam-toggle="#menu">Menu</button>
1628
+ // <div id="menu" beam-hidden>Content</div>
1629
+ document.addEventListener('click', (e) => {
1630
+ const target = e.target;
1631
+ if (!target?.closest)
1632
+ return;
1633
+ const trigger = target.closest('[beam-toggle]');
1634
+ if (trigger) {
1635
+ e.preventDefault();
1636
+ const selector = trigger.getAttribute('beam-toggle');
1637
+ const targetEl = document.querySelector(selector);
1638
+ if (targetEl) {
1639
+ const isHidden = targetEl.hasAttribute('beam-hidden');
1640
+ if (isHidden) {
1641
+ targetEl.removeAttribute('beam-hidden');
1642
+ trigger.setAttribute('aria-expanded', 'true');
1643
+ // Handle transition
1644
+ if (targetEl.hasAttribute('beam-transition')) {
1645
+ targetEl.style.display = '';
1646
+ // Force reflow for transition
1647
+ targetEl.offsetHeight;
1648
+ }
1649
+ }
1650
+ else {
1651
+ targetEl.setAttribute('beam-hidden', '');
1652
+ trigger.setAttribute('aria-expanded', 'false');
1653
+ }
1654
+ }
1655
+ }
1656
+ });
1657
+ // === DROPDOWN (with outside-click auto-close) ===
1658
+ // Usage: <div beam-dropdown>
1659
+ // <button beam-dropdown-trigger>Account ▼</button>
1660
+ // <div beam-dropdown-content beam-hidden>
1661
+ // <a href="/profile">Profile</a>
1662
+ // </div>
1663
+ // </div>
1664
+ document.addEventListener('click', (e) => {
1665
+ const target = e.target;
1666
+ if (!target?.closest)
1667
+ return;
1668
+ const trigger = target.closest('[beam-dropdown-trigger]');
1669
+ if (trigger) {
1670
+ e.preventDefault();
1671
+ e.stopPropagation();
1672
+ const dropdown = trigger.closest('[beam-dropdown]');
1673
+ const content = dropdown?.querySelector('[beam-dropdown-content]');
1674
+ if (content) {
1675
+ const isHidden = content.hasAttribute('beam-hidden');
1676
+ // Close all other dropdowns first
1677
+ document.querySelectorAll('[beam-dropdown-content]:not([beam-hidden])').forEach((el) => {
1678
+ if (el !== content) {
1679
+ el.setAttribute('beam-hidden', '');
1680
+ el.closest('[beam-dropdown]')?.querySelector('[beam-dropdown-trigger]')?.setAttribute('aria-expanded', 'false');
1681
+ }
1682
+ });
1683
+ // Toggle this dropdown
1684
+ if (isHidden) {
1685
+ content.removeAttribute('beam-hidden');
1686
+ trigger.setAttribute('aria-expanded', 'true');
1687
+ }
1688
+ else {
1689
+ content.setAttribute('beam-hidden', '');
1690
+ trigger.setAttribute('aria-expanded', 'false');
1691
+ }
1692
+ }
1693
+ return;
1694
+ }
1695
+ // Close all dropdowns on outside click (if click is not inside a dropdown content)
1696
+ if (!target.closest('[beam-dropdown-content]')) {
1697
+ document.querySelectorAll('[beam-dropdown-content]:not([beam-hidden])').forEach((el) => {
1698
+ el.setAttribute('beam-hidden', '');
1699
+ el.closest('[beam-dropdown]')?.querySelector('[beam-dropdown-trigger]')?.setAttribute('aria-expanded', 'false');
1700
+ });
1701
+ }
1702
+ });
1703
+ // Close dropdowns on Escape key
1704
+ document.addEventListener('keydown', (e) => {
1705
+ if (e.key === 'Escape') {
1706
+ document.querySelectorAll('[beam-dropdown-content]:not([beam-hidden])').forEach((el) => {
1707
+ el.setAttribute('beam-hidden', '');
1708
+ el.closest('[beam-dropdown]')?.querySelector('[beam-dropdown-trigger]')?.setAttribute('aria-expanded', 'false');
1709
+ });
1710
+ }
1711
+ });
1712
+ // === COLLAPSE with text swap ===
1713
+ // Usage: <button beam-collapse="#details" beam-collapse-text="Show less">Show more</button>
1714
+ // <div id="details" beam-collapsed>Expanded content...</div>
1715
+ document.addEventListener('click', (e) => {
1716
+ const target = e.target;
1717
+ if (!target?.closest)
1718
+ return;
1719
+ const trigger = target.closest('[beam-collapse]');
1720
+ if (trigger) {
1721
+ e.preventDefault();
1722
+ const selector = trigger.getAttribute('beam-collapse');
1723
+ const targetEl = document.querySelector(selector);
1724
+ if (targetEl) {
1725
+ const isCollapsed = targetEl.hasAttribute('beam-collapsed');
1726
+ if (isCollapsed) {
1727
+ targetEl.removeAttribute('beam-collapsed');
1728
+ trigger.setAttribute('aria-expanded', 'true');
1729
+ }
1730
+ else {
1731
+ targetEl.setAttribute('beam-collapsed', '');
1732
+ trigger.setAttribute('aria-expanded', 'false');
1733
+ }
1734
+ // Swap button text if beam-collapse-text is specified
1735
+ const altText = trigger.getAttribute('beam-collapse-text');
1736
+ if (altText) {
1737
+ const currentText = trigger.textContent || '';
1738
+ trigger.textContent = altText;
1739
+ trigger.setAttribute('beam-collapse-text', currentText);
1740
+ }
1741
+ }
1742
+ }
1743
+ });
1744
+ // === CLASS TOGGLE ===
1745
+ // Usage: <button beam-class-toggle="active" beam-class-target="#sidebar">Toggle</button>
1746
+ // Or toggle on self: <button beam-class-toggle="active">Toggle</button>
1747
+ document.addEventListener('click', (e) => {
1748
+ const target = e.target;
1749
+ if (!target?.closest)
1750
+ return;
1751
+ const trigger = target.closest('[beam-class-toggle]');
1752
+ if (trigger) {
1753
+ const className = trigger.getAttribute('beam-class-toggle');
1754
+ const targetSelector = trigger.getAttribute('beam-class-target');
1755
+ const targetEl = targetSelector ? document.querySelector(targetSelector) : trigger;
1756
+ if (targetEl && className) {
1757
+ targetEl.classList.toggle(className);
1758
+ }
1759
+ }
1760
+ });
1761
+ // Clear scroll state for current page or all pages
1762
+ // Usage: clearScrollState() - clear all for current URL
1763
+ // clearScrollState('loadMore') - clear specific action
1764
+ // clearScrollState(true) - clear all scroll states
1765
+ function clearScrollState(actionOrAll) {
1766
+ if (actionOrAll === true) {
1767
+ // Clear all scroll states
1768
+ const keysToRemove = [];
1769
+ for (let i = 0; i < sessionStorage.length; i++) {
1770
+ const key = sessionStorage.key(i);
1771
+ if (key?.startsWith(SCROLL_STATE_KEY_PREFIX)) {
1772
+ keysToRemove.push(key);
1773
+ }
1774
+ }
1775
+ keysToRemove.forEach((key) => sessionStorage.removeItem(key));
1776
+ }
1777
+ else if (typeof actionOrAll === 'string') {
1778
+ // Clear specific action's scroll state
1779
+ sessionStorage.removeItem(getScrollStateKey(actionOrAll));
1780
+ }
1781
+ else {
1782
+ // Clear all scroll states for current URL (any action)
1783
+ const prefix = SCROLL_STATE_KEY_PREFIX + location.pathname + location.search;
1784
+ const keysToRemove = [];
1785
+ for (let i = 0; i < sessionStorage.length; i++) {
1786
+ const key = sessionStorage.key(i);
1787
+ if (key?.startsWith(prefix)) {
1788
+ keysToRemove.push(key);
1789
+ }
1790
+ }
1791
+ keysToRemove.forEach((key) => sessionStorage.removeItem(key));
1792
+ }
1793
+ }
1794
+ // Base utilities that are always available on window.beam
1795
+ const beamUtils = {
1796
+ showToast,
1797
+ closeModal,
1798
+ closeDrawer,
1799
+ clearCache,
1800
+ clearScrollState,
1801
+ isOnline: () => isOnline,
1802
+ getSession: api.getSession,
1803
+ };
1804
+ // Create a Proxy that handles both utility methods and dynamic action calls
1805
+ window.beam = new Proxy(beamUtils, {
1806
+ get(target, prop) {
1807
+ // Return existing utility methods
1808
+ if (prop in target) {
1809
+ return target[prop];
1810
+ }
1811
+ // Return a dynamic action caller for any other property
1812
+ return async (data = {}, options) => {
1813
+ const rawResponse = await api.call(prop, data);
1814
+ // Normalize response: string -> {html: string}, object -> as-is
1815
+ const response = typeof rawResponse === 'string'
1816
+ ? { html: rawResponse }
1817
+ : rawResponse;
1818
+ // Handle redirect (takes priority)
1819
+ if (response.redirect) {
1820
+ location.href = response.redirect;
1821
+ return response;
1822
+ }
1823
+ // Normalize options: string is shorthand for { target: string }
1824
+ const opts = typeof options === 'string'
1825
+ ? { target: options }
1826
+ : (options || {});
1827
+ // Handle HTML swap if target provided
1828
+ if (response.html && opts.target) {
1829
+ const targetEl = document.querySelector(opts.target);
1830
+ if (targetEl) {
1831
+ swap(targetEl, response.html, opts.swap || 'morph');
1832
+ }
1833
+ }
1834
+ // Execute script if present
1835
+ if (response.script) {
1836
+ executeScript(response.script);
1837
+ }
1838
+ return response;
1839
+ };
1840
+ }
1841
+ });
1842
+ window.showToast = showToast;
1843
+ window.closeModal = closeModal;
1844
+ window.closeDrawer = closeDrawer;
1845
+ window.clearCache = clearCache;
1846
+ // Initialize capnweb RPC connection
1847
+ connect().catch(console.error);