slicejs-web-framework 3.3.8 → 3.4.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.
@@ -72,6 +72,8 @@ export default class ContextManager {
72
72
  },
73
73
  });
74
74
 
75
+ slice.events.emit('context:__created', { name, state });
76
+
75
77
  slice.logger.logInfo('ContextManager', `Contexto "${name}" creado`);
76
78
 
77
79
  return true;
@@ -6,6 +6,8 @@ export default class ContextManagerDebugger extends HTMLElement {
6
6
  super();
7
7
  this.isOpen = false;
8
8
  this.filterText = '';
9
+ this._contextSubscriptions = new Map();
10
+ this._debounceTimer = null;
9
11
  }
10
12
 
11
13
  /**
@@ -18,6 +20,7 @@ export default class ContextManagerDebugger extends HTMLElement {
18
20
  this.cacheElements();
19
21
  this.bindEvents();
20
22
  this.makeDraggable();
23
+ this._subscribeToAllContexts();
21
24
  this.renderList();
22
25
  }
23
26
 
@@ -29,6 +32,7 @@ export default class ContextManagerDebugger extends HTMLElement {
29
32
  this.isOpen = !this.isOpen;
30
33
  this.container.classList.toggle('active', this.isOpen);
31
34
  if (this.isOpen) {
35
+ this._subscribeToAllContexts();
32
36
  this.renderList();
33
37
  }
34
38
  }
@@ -40,6 +44,7 @@ export default class ContextManagerDebugger extends HTMLElement {
40
44
  open() {
41
45
  this.isOpen = true;
42
46
  this.container.classList.add('active');
47
+ this._subscribeToAllContexts();
43
48
  this.renderList();
44
49
  }
45
50
 
@@ -52,6 +57,46 @@ export default class ContextManagerDebugger extends HTMLElement {
52
57
  this.container.classList.remove('active');
53
58
  }
54
59
 
60
+ disconnectedCallback() {
61
+ this._unsubscribeAll();
62
+ }
63
+
64
+ _unsubscribeAll() {
65
+ for (const [name, id] of this._contextSubscriptions) {
66
+ slice.events.unsubscribe(`context:${name}`, id);
67
+ }
68
+ this._contextSubscriptions.clear();
69
+ if (this._createdSub) {
70
+ slice.events.unsubscribe('context:__created', this._createdSub);
71
+ this._createdSub = null;
72
+ }
73
+ }
74
+
75
+ _subscribeToAllContexts() {
76
+ if (!slice?.context?.contexts) return;
77
+ slice.context.contexts.forEach((value, name) => {
78
+ if (this._contextSubscriptions.has(name)) return;
79
+ const id = slice.events.subscribe(`context:${name}`, () => this._scheduleRender());
80
+ this._contextSubscriptions.set(name, id);
81
+ });
82
+ if (!this._createdSub) {
83
+ this._createdSub = slice.events.subscribe('context:__created', ({ name }) => {
84
+ if (!this._contextSubscriptions.has(name)) {
85
+ const id = slice.events.subscribe(`context:${name}`, () => this._scheduleRender());
86
+ this._contextSubscriptions.set(name, id);
87
+ }
88
+ this._scheduleRender();
89
+ });
90
+ }
91
+ }
92
+
93
+ _scheduleRender() {
94
+ if (this._debounceTimer) clearTimeout(this._debounceTimer);
95
+ this._debounceTimer = setTimeout(() => {
96
+ if (this.isOpen) this.renderList();
97
+ }, 50);
98
+ }
99
+
55
100
  cacheElements() {
56
101
  this.container = this.querySelector('#context-debugger');
57
102
  this.header = this.querySelector('.context-header');
@@ -178,18 +223,22 @@ export default class ContextManagerDebugger extends HTMLElement {
178
223
  renderStyles() {
179
224
  return `
180
225
  /* Slice Instruments — context store. All selectors scoped to the
181
- <slice-contextmanager-debugger> tag so nothing clashes with app styles. */
226
+ <slice-contextmanager-debugger> tag so nothing clashes with app styles.
227
+ Every --si-* token reads the matching framework theme variable from
228
+ :root, falling back to the original hardcoded value if absent. */
182
229
  slice-contextmanager-debugger {
183
- --si-accent: var(--primary-color, #6ee7ff);
184
- --si-accent-rgb: var(--primary-color-rgb, 110, 231, 255);
185
- --si-surface: rgba(17, 19, 28, 0.86);
186
- --si-raised: rgba(255, 255, 255, 0.035);
187
- --si-raised-2: rgba(255, 255, 255, 0.06);
188
- --si-border: rgba(255, 255, 255, 0.09);
189
- --si-text: #e8eaf2;
190
- --si-dim: #888fa6;
191
- --si-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', 'Cascadia Code', Menlo, Consolas, monospace;
192
- }
230
+ --si-accent: var(--primary-color, #6ee7ff);
231
+ --si-accent-rgb: var(--primary-color-rgb, 110, 231, 255);
232
+ --si-surface: var(--primary-background-color, rgba(17, 19, 28, 0.86));
233
+ --si-raised: var(--secondary-background-color, rgba(255, 255, 255, 0.035));
234
+ --si-raised-2: var(--tertiary-background-color, rgba(255, 255, 255, 0.06));
235
+ --si-border: var(--medium-color, rgba(255, 255, 255, 0.09));
236
+ --si-text: var(--font-primary-color, #e8eaf2);
237
+ --si-dim: var(--font-secondary-color, #888fa6);
238
+ --si-danger: var(--danger-color, #ff6b6b);
239
+ --si-success: var(--success-color, #46d39a);
240
+ --si-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', 'Cascadia Code', Menlo, Consolas, monospace;
241
+ }
193
242
 
194
243
  slice-contextmanager-debugger #context-debugger {
195
244
  position: fixed;
@@ -811,24 +811,25 @@ return `
811
811
  All selectors are scoped under the <slice-debugger> tag so the
812
812
  panel never clashes with (or leaks into) app styles. Tokens live
813
813
  on the tag so both #debugger-container and the sibling
814
- #editor-modal inherit them. Chrome colors are static; only the
815
- accent reads the theme (--primary-color) with a static fallback.
814
+ #editor-modal inherit them. Every --si-* token reads the matching
815
+ framework theme variable from :root, falling back to the original
816
+ hardcoded value if absent — so debuggers always match the app theme.
816
817
  ============================================================ */
817
818
  slice-debugger {
818
- --si-accent: var(--primary-color, #6ee7ff);
819
- --si-accent-rgb: var(--primary-color-rgb, 110, 231, 255);
820
- --si-surface: rgba(17, 19, 28, 0.88);
821
- --si-raised: rgba(255, 255, 255, 0.035);
822
- --si-raised-2: rgba(255, 255, 255, 0.06);
823
- --si-inset: rgba(0, 0, 0, 0.28);
824
- --si-border: rgba(255, 255, 255, 0.09);
825
- --si-text: #e8eaf2;
826
- --si-dim: #888fa6;
827
- --si-danger: #ff6b6b;
828
- --si-success: #46d39a;
829
- --si-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', 'Cascadia Code', Menlo, Consolas, monospace;
830
- --si-sans: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
831
- }
819
+ --si-accent: var(--primary-color, #6ee7ff);
820
+ --si-accent-rgb: var(--primary-color-rgb, 110, 231, 255);
821
+ --si-surface: var(--primary-background-color, rgba(17, 19, 28, 0.88));
822
+ --si-raised: var(--secondary-background-color, rgba(255, 255, 255, 0.035));
823
+ --si-raised-2: var(--tertiary-background-color, rgba(255, 255, 255, 0.06));
824
+ --si-inset: var(--primary-color-shade, rgba(0, 0, 0, 0.28));
825
+ --si-border: var(--medium-color, rgba(255, 255, 255, 0.09));
826
+ --si-text: var(--font-primary-color, #e8eaf2);
827
+ --si-dim: var(--font-secondary-color, #888fa6);
828
+ --si-danger: var(--danger-color, #ff6b6b);
829
+ --si-success: var(--success-color, #46d39a);
830
+ --si-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', 'Cascadia Code', Menlo, Consolas, monospace;
831
+ --si-sans: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
832
+ }
832
833
 
833
834
  slice-debugger *,
834
835
  slice-debugger *::before,
@@ -23,6 +23,15 @@ export default class EventManager {
23
23
  // Map<sliceId, Set<{ eventName, subscriptionId }>> - Para auto-cleanup
24
24
  this.componentSubscriptions = new Map();
25
25
 
26
+ // Ring buffer de últimos emits (max 500) — solo se llena cuando el debugger está abierto
27
+ this.emitHistory = [];
28
+
29
+ // Contador de emits por evento en la sesión actual de grabación
30
+ this.emitCounts = new Map();
31
+
32
+ // Flag: solo grabamos cuando algún debugger UI está visible
33
+ this._recording = false;
34
+
26
35
  // Contador para IDs únicos
27
36
  this.idCounter = 0;
28
37
  }
@@ -95,6 +104,22 @@ export default class EventManager {
95
104
  * console.log('App lista!');
96
105
  * });
97
106
  */
107
+ /**
108
+ * Activar registro de emits (lo llama el debugger al abrirse).
109
+ */
110
+ startRecording() {
111
+ this._recording = true;
112
+ this.emitHistory = [];
113
+ this.emitCounts = new Map();
114
+ }
115
+
116
+ /**
117
+ * Desactivar registro de emits (lo llama el debugger al cerrarse).
118
+ */
119
+ stopRecording() {
120
+ this._recording = false;
121
+ }
122
+
98
123
  subscribeOnce(eventName, callback, options = {}) {
99
124
  if (typeof callback !== 'function') {
100
125
  slice.logger.logError('EventManager', 'El callback debe ser una función');
@@ -165,6 +190,13 @@ export default class EventManager {
165
190
  emit(eventName, ...args) {
166
191
  slice.logger.info('EventManager', `Emitting "${eventName}"`, args[0] ?? null);
167
192
 
193
+ // Solo grabamos el histórico si algún debugger está abierto
194
+ if (this._recording) {
195
+ this.emitHistory.push({ eventName, timestamp: Date.now() });
196
+ if (this.emitHistory.length > 500) this.emitHistory.shift();
197
+ this.emitCounts.set(eventName, (this.emitCounts.get(eventName) || 0) + 1);
198
+ }
199
+
168
200
  if (!this.subscriptions.has(eventName)) {
169
201
  return;
170
202
  }
@@ -6,7 +6,10 @@ export default class EventManagerDebugger extends HTMLElement {
6
6
  super();
7
7
  this.isOpen = false;
8
8
  this.filterText = '';
9
+ this.activeTab = 'subscribers';
9
10
  this.refreshInterval = null;
11
+ this._autoRefreshTimer = null;
12
+ this._lastHistoryLength = 0;
10
13
  }
11
14
 
12
15
  /**
@@ -41,6 +44,11 @@ export default class EventManagerDebugger extends HTMLElement {
41
44
  open() {
42
45
  this.isOpen = true;
43
46
  this.container.classList.add('active');
47
+ this.activeTab = 'subscribers';
48
+ if (slice.events) {
49
+ slice.events.startRecording();
50
+ }
51
+ this._startAutoRefresh();
44
52
  this.renderList();
45
53
  }
46
54
 
@@ -51,6 +59,29 @@ export default class EventManagerDebugger extends HTMLElement {
51
59
  close() {
52
60
  this.isOpen = false;
53
61
  this.container.classList.remove('active');
62
+ this._stopAutoRefresh();
63
+ if (slice.events) {
64
+ slice.events.stopRecording();
65
+ }
66
+ }
67
+
68
+ _startAutoRefresh() {
69
+ this._stopAutoRefresh();
70
+ this._autoRefreshTimer = setInterval(() => {
71
+ if (!this.isOpen) return;
72
+ if (this.activeTab === 'history') {
73
+ this.renderHistory();
74
+ } else {
75
+ this.renderList();
76
+ }
77
+ }, 1500);
78
+ }
79
+
80
+ _stopAutoRefresh() {
81
+ if (this._autoRefreshTimer) {
82
+ clearInterval(this._autoRefreshTimer);
83
+ this._autoRefreshTimer = null;
84
+ }
54
85
  }
55
86
 
56
87
  cacheElements() {
@@ -61,15 +92,35 @@ export default class EventManagerDebugger extends HTMLElement {
61
92
  this.countLabel = this.querySelector('#events-count');
62
93
  this.refreshButton = this.querySelector('#events-refresh');
63
94
  this.closeButton = this.querySelector('#events-close');
95
+ this.tabSubs = this.querySelector('#tab-subscribers');
96
+ this.tabHistory = this.querySelector('#tab-history');
64
97
  }
65
98
 
66
99
  bindEvents() {
67
- this.refreshButton.addEventListener('click', () => this.renderList());
100
+ this.refreshButton.addEventListener('click', () => {
101
+ if (this.activeTab === 'history') this.renderHistory();
102
+ else this.renderList();
103
+ });
68
104
  this.closeButton.addEventListener('click', () => this.close());
69
105
  this.filterInput.addEventListener('input', (event) => {
70
106
  this.filterText = event.target.value.trim().toLowerCase();
107
+ if (this.activeTab === 'history') this.renderHistory();
108
+ else this.renderList();
109
+ });
110
+ this.tabSubs.addEventListener('click', () => {
111
+ this.activeTab = 'subscribers';
112
+ this.tabSubs.classList.add('active');
113
+ this.tabHistory.classList.remove('active');
114
+ this.filterInput.placeholder = 'filter events…';
71
115
  this.renderList();
72
116
  });
117
+ this.tabHistory.addEventListener('click', () => {
118
+ this.activeTab = 'history';
119
+ this.tabHistory.classList.add('active');
120
+ this.tabSubs.classList.remove('active');
121
+ this.filterInput.placeholder = 'filter history…';
122
+ this.renderHistory();
123
+ });
73
124
  }
74
125
 
75
126
  makeDraggable() {
@@ -117,6 +168,7 @@ export default class EventManagerDebugger extends HTMLElement {
117
168
 
118
169
  const items = [];
119
170
  slice.events.subscriptions.forEach((subs, eventName) => {
171
+ const emitCount = slice.events.emitCounts?.get(eventName) || 0;
120
172
  const entries = Array.from(subs.entries()).map(([id, sub]) => {
121
173
  const componentSliceId = sub.componentSliceId || null;
122
174
  const component = componentSliceId ? slice.controller.getComponent(componentSliceId) : null;
@@ -133,7 +185,7 @@ export default class EventManagerDebugger extends HTMLElement {
133
185
  return;
134
186
  }
135
187
 
136
- items.push({ eventName, count: subs.size, entries });
188
+ items.push({ eventName, count: subs.size, emitCount, entries });
137
189
  });
138
190
 
139
191
  items.sort((a, b) => a.eventName.localeCompare(b.eventName));
@@ -141,32 +193,76 @@ export default class EventManagerDebugger extends HTMLElement {
141
193
  this.countLabel.textContent = String(items.length);
142
194
  this.list.innerHTML = items.length
143
195
  ? items.map((item) => {
144
- const details = item.entries.map((entry) => {
145
- const label = entry.componentName
146
- ? `${entry.componentName} (${entry.componentSliceId})`
147
- : entry.componentSliceId || 'Global';
148
- const onceBadge = entry.once ? '<span class="badge">once</span>' : '';
149
- return `
150
- <div class="subscriber-row">
151
- <div class="subscriber-name">${label}</div>
152
- <div class="subscriber-meta">${entry.id}${onceBadge}</div>
153
- </div>
154
- `;
155
- }).join('');
156
-
157
- return `
158
- <details class="event-row">
159
- <summary>
160
- <div class="event-name">${item.eventName}</div>
161
- <div class="event-count">${item.count}</div>
162
- </summary>
163
- <div class="subscriber-list">
164
- ${details || '<div class="empty">No subscribers</div>'}
165
- </div>
166
- </details>
167
- `;
168
- }).join('')
169
- : '<div class="empty">No events</div>';
196
+ const details = item.entries.map((entry) => {
197
+ const label = entry.componentName
198
+ ? `${entry.componentName} (${entry.componentSliceId})`
199
+ : entry.componentSliceId || 'Global';
200
+ const onceBadge = entry.once ? '<span class="badge">once</span>' : '';
201
+ return `
202
+ <div class="subscriber-row">
203
+ <div class="subscriber-name">${label}</div>
204
+ <div class="subscriber-meta">${entry.id}${onceBadge}</div>
205
+ </div>
206
+ `;
207
+ }).join('');
208
+
209
+ return `
210
+ <details class="event-row">
211
+ <summary>
212
+ <div class="event-name">${item.eventName}</div>
213
+ <div class="event-metrics">
214
+ <span class="emit-count" title="Emits this session">⚡${item.emitCount}</span>
215
+ <span class="event-count">${item.count}</span>
216
+ </div>
217
+ </summary>
218
+ <div class="subscriber-list">
219
+ ${details || '<div class="empty">No subscribers</div>'}
220
+ </div>
221
+ </details>
222
+ `;
223
+ }).join('')
224
+ : '<div class="empty">No events</div>';
225
+ }
226
+
227
+ renderHistory() {
228
+ if (!slice?.events?.emitHistory) {
229
+ this.list.textContent = 'EventManager not available.';
230
+ this.countLabel.textContent = '0';
231
+ return;
232
+ }
233
+
234
+ const history = slice.events.emitHistory;
235
+ const filtered = this.filterText
236
+ ? history.filter(e => e.eventName.toLowerCase().includes(this.filterText))
237
+ : history;
238
+
239
+ this.countLabel.textContent = String(filtered.length);
240
+ if (!filtered.length) {
241
+ this.list.innerHTML = '<div class="empty">No emits recorded yet</div>';
242
+ return;
243
+ }
244
+
245
+ const now = Date.now();
246
+ this.list.innerHTML = [...filtered].reverse().map((entry) => {
247
+ const diff = now - entry.timestamp;
248
+ const timeStr = diff < 1000 ? 'now'
249
+ : diff < 60000 ? `${Math.floor(diff / 1000)}s ago`
250
+ : `${Math.floor(diff / 60000)}m ago`;
251
+ return `
252
+ <div class="history-row">
253
+ <div class="history-event">${this.escapeHtml(entry.eventName)}</div>
254
+ <div class="history-time">${timeStr}</div>
255
+ </div>
256
+ `;
257
+ }).join('');
258
+ }
259
+
260
+ escapeHtml(value) {
261
+ return String(value)
262
+ .replace(/&/g, '&amp;')
263
+ .replace(/</g, '&lt;')
264
+ .replace(/>/g, '&gt;')
265
+ .replace(/"/g, '&quot;');
170
266
  }
171
267
 
172
268
  renderTemplate() {
@@ -183,6 +279,10 @@ export default class EventManagerDebugger extends HTMLElement {
183
279
  <button id="events-close" class="btn" title="Close" aria-label="Close">✕</button>
184
280
  </div>
185
281
  </div>
282
+ <div class="events-tabs">
283
+ <button id="tab-subscribers" class="tab-btn active">Subscribers</button>
284
+ <button id="tab-history" class="tab-btn">History</button>
285
+ </div>
186
286
  <div class="events-toolbar">
187
287
  <input id="events-filter" type="text" placeholder="filter events…" autocomplete="off" spellcheck="false" />
188
288
  <div class="count"><span id="events-count">0</span></div>
@@ -195,18 +295,22 @@ export default class EventManagerDebugger extends HTMLElement {
195
295
  renderStyles() {
196
296
  return `
197
297
  /* Slice Instruments — events console. All selectors scoped to the
198
- <slice-eventmanager-debugger> tag so nothing clashes with app styles. */
298
+ <slice-eventmanager-debugger> tag so nothing clashes with app styles.
299
+ Every --si-* token reads the matching framework theme variable from
300
+ :root, falling back to the original hardcoded value if absent. */
199
301
  slice-eventmanager-debugger {
200
- --si-accent: var(--primary-color, #6ee7ff);
201
- --si-accent-rgb: var(--primary-color-rgb, 110, 231, 255);
202
- --si-surface: rgba(17, 19, 28, 0.86);
203
- --si-raised: rgba(255, 255, 255, 0.035);
204
- --si-raised-2: rgba(255, 255, 255, 0.06);
205
- --si-border: rgba(255, 255, 255, 0.09);
206
- --si-text: #e8eaf2;
207
- --si-dim: #888fa6;
208
- --si-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', 'Cascadia Code', Menlo, Consolas, monospace;
209
- }
302
+ --si-accent: var(--primary-color, #6ee7ff);
303
+ --si-accent-rgb: var(--primary-color-rgb, 110, 231, 255);
304
+ --si-surface: var(--primary-background-color, rgba(17, 19, 28, 0.86));
305
+ --si-raised: var(--secondary-background-color, rgba(255, 255, 255, 0.035));
306
+ --si-raised-2: var(--tertiary-background-color, rgba(255, 255, 255, 0.06));
307
+ --si-border: var(--medium-color, rgba(255, 255, 255, 0.09));
308
+ --si-text: var(--font-primary-color, #e8eaf2);
309
+ --si-dim: var(--font-secondary-color, #888fa6);
310
+ --si-danger: var(--danger-color, #ff6b6b);
311
+ --si-success: var(--success-color, #46d39a);
312
+ --si-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', 'Cascadia Code', Menlo, Consolas, monospace;
313
+ }
210
314
 
211
315
  slice-eventmanager-debugger #events-debugger {
212
316
  position: fixed;
@@ -311,6 +415,31 @@ slice-eventmanager-debugger .btn:hover {
311
415
  slice-eventmanager-debugger .btn:active { transform: scale(0.92); }
312
416
  slice-eventmanager-debugger #events-refresh:hover { color: var(--si-accent); }
313
417
 
418
+ slice-eventmanager-debugger .events-tabs {
419
+ display: flex;
420
+ gap: 0;
421
+ padding: 0 12px;
422
+ border-bottom: 1px solid var(--si-border);
423
+ background: var(--si-raised);
424
+ }
425
+ slice-eventmanager-debugger .tab-btn {
426
+ all: unset;
427
+ cursor: pointer;
428
+ font-size: 10px;
429
+ font-weight: 600;
430
+ letter-spacing: 0.06em;
431
+ text-transform: uppercase;
432
+ padding: 7px 12px;
433
+ color: var(--si-dim);
434
+ border-bottom: 2px solid transparent;
435
+ transition: color 0.15s ease, border-color 0.15s ease;
436
+ }
437
+ slice-eventmanager-debugger .tab-btn:hover { color: var(--si-text); }
438
+ slice-eventmanager-debugger .tab-btn.active {
439
+ color: var(--si-accent);
440
+ border-bottom-color: var(--si-accent);
441
+ }
442
+
314
443
  slice-eventmanager-debugger .events-toolbar {
315
444
  display: flex;
316
445
  gap: 10px;
@@ -397,6 +526,21 @@ slice-eventmanager-debugger .event-name {
397
526
  white-space: nowrap;
398
527
  }
399
528
 
529
+ slice-eventmanager-debugger .event-metrics {
530
+ display: flex;
531
+ align-items: center;
532
+ gap: 6px;
533
+ }
534
+ slice-eventmanager-debugger .emit-count {
535
+ font-weight: 600;
536
+ font-size: 10px;
537
+ color: var(--si-success);
538
+ background: rgba(70, 211, 154, 0.12);
539
+ border: 1px solid rgba(70, 211, 154, 0.25);
540
+ padding: 1px 6px;
541
+ border-radius: 999px;
542
+ white-space: nowrap;
543
+ }
400
544
  slice-eventmanager-debugger .event-count {
401
545
  font-weight: 600;
402
546
  font-size: 11px;
@@ -408,6 +552,33 @@ slice-eventmanager-debugger .event-count {
408
552
  min-width: 22px;
409
553
  text-align: center;
410
554
  }
555
+ slice-eventmanager-debugger .history-row {
556
+ display: flex;
557
+ justify-content: space-between;
558
+ align-items: center;
559
+ gap: 10px;
560
+ padding: 6px 11px;
561
+ background: var(--si-raised);
562
+ border-radius: 7px;
563
+ border: 1px solid var(--si-border);
564
+ border-left: 2px solid transparent;
565
+ transition: border-color 0.15s ease;
566
+ }
567
+ slice-eventmanager-debugger .history-row:hover { border-left-color: var(--si-accent); }
568
+ slice-eventmanager-debugger .history-event {
569
+ font-family: var(--si-mono);
570
+ font-size: 11.5px;
571
+ color: var(--si-text);
572
+ overflow: hidden;
573
+ text-overflow: ellipsis;
574
+ white-space: nowrap;
575
+ }
576
+ slice-eventmanager-debugger .history-time {
577
+ font-size: 10px;
578
+ color: var(--si-dim);
579
+ white-space: nowrap;
580
+ flex-shrink: 0;
581
+ }
411
582
 
412
583
  slice-eventmanager-debugger .subscriber-list {
413
584
  margin-top: 9px;
@@ -0,0 +1,541 @@
1
+ const LV_LOG_TYPES = ['error', 'warn', 'info', 'debug'];
2
+
3
+ const LV_BADGE_LABEL = { error: 'ERR', warn: 'WRN', info: 'INF', debug: 'DBG' };
4
+
5
+ function pad2(n) {
6
+ return String(n).padStart(2, '0');
7
+ }
8
+
9
+ function formatTime(d) {
10
+ return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
11
+ }
12
+
13
+ function escapeHtml(str) {
14
+ if (!str) return '';
15
+ return String(str).replace(/[&<>"']/g, function (m) {
16
+ if (m === '&') return '&amp;';
17
+ if (m === '<') return '&lt;';
18
+ if (m === '>') return '&gt;';
19
+ if (m === '"') return '&quot;';
20
+ return '&#39;';
21
+ });
22
+ }
23
+
24
+ export default class LogViewer extends HTMLElement {
25
+ constructor() {
26
+ super();
27
+
28
+ slice.stylesManager.registerComponentStyles('LogViewer', productionOnlyCSS());
29
+ this.innerHTML = productionOnlyHtml();
30
+
31
+ this.$header = this.querySelector('.lv__header');
32
+ this.$body = this.querySelector('[data-body]');
33
+ this.$count = this.querySelector('[data-count]');
34
+
35
+ this._isOpen = false;
36
+ this._logCount = 0;
37
+ this._filters = new Set();
38
+ this._dragState = null;
39
+ this._logHandler = null;
40
+ }
41
+
42
+ init() {
43
+ this._attachDrag();
44
+ this._attachFilterClicks();
45
+ this._attachClear();
46
+ this._attachMinimize();
47
+ this._attachBodyClick();
48
+ this._render();
49
+ this._subscribeLogger();
50
+ }
51
+
52
+ toggle() {
53
+ this._isOpen = !this._isOpen;
54
+ this.style.display = this._isOpen ? '' : 'none';
55
+ if (this._isOpen) {
56
+ this._subscribeLogger();
57
+ this._render();
58
+ } else {
59
+ this._unsubscribeLogger();
60
+ }
61
+ return this._isOpen;
62
+ }
63
+
64
+ _detach() {
65
+ this._unsubscribeLogger();
66
+ if (this._dragCleanup) this._dragCleanup();
67
+ }
68
+
69
+ /* -- drag -- */
70
+ _attachDrag() {
71
+ const header = this.$header;
72
+ if (!header) return;
73
+
74
+ const onDown = (e) => {
75
+ if (e.target.closest('button')) return;
76
+ this._dragState = {
77
+ startX: e.clientX,
78
+ startY: e.clientY,
79
+ origLeft: this.offsetLeft,
80
+ origTop: this.offsetTop,
81
+ origRight: this.style.right,
82
+ origBottom: this.style.bottom
83
+ };
84
+ this.style.right = 'auto';
85
+ this.style.bottom = 'auto';
86
+ document.addEventListener('mousemove', onMove);
87
+ document.addEventListener('mouseup', onUp);
88
+ };
89
+
90
+ const onMove = (e) => {
91
+ if (!this._dragState) return;
92
+ const dx = e.clientX - this._dragState.startX;
93
+ const dy = e.clientY - this._dragState.startY;
94
+ this.style.left = (this._dragState.origLeft + dx) + 'px';
95
+ this.style.top = (this._dragState.origTop + dy) + 'px';
96
+ };
97
+
98
+ const onUp = () => {
99
+ this._dragState = null;
100
+ document.removeEventListener('mousemove', onMove);
101
+ document.removeEventListener('mouseup', onUp);
102
+ };
103
+
104
+ header.addEventListener('mousedown', onDown);
105
+ this._dragCleanup = () => {
106
+ header.removeEventListener('mousedown', onDown);
107
+ document.removeEventListener('mousemove', onMove);
108
+ document.removeEventListener('mouseup', onUp);
109
+ };
110
+ }
111
+
112
+ /* -- filter buttons -- */
113
+ _attachFilterClicks() {
114
+ const btns = this.querySelectorAll('.lv__filter');
115
+ for (const btn of btns) {
116
+ btn.addEventListener('click', () => {
117
+ const type = btn.dataset.filter;
118
+ if (btn.hasAttribute('data-active')) {
119
+ btn.removeAttribute('data-active');
120
+ this._filters.delete(type);
121
+ } else {
122
+ btn.setAttribute('data-active', '');
123
+ this._filters.add(type);
124
+ }
125
+ this._render();
126
+ });
127
+ }
128
+ }
129
+
130
+ /* -- clear -- */
131
+ _attachClear() {
132
+ const btn = this.querySelector('.lv__clear');
133
+ if (!btn) return;
134
+ btn.addEventListener('click', () => {
135
+ if (slice.logger) slice.logger.logs = [];
136
+ this._logCount = 0;
137
+ this._render();
138
+ });
139
+ }
140
+
141
+ /* -- minimize -- */
142
+ _attachMinimize() {
143
+ const btn = this.querySelector('.lv__close');
144
+ if (!btn) return;
145
+ btn.addEventListener('click', () => {
146
+ if (this.hasAttribute('data-minimized')) {
147
+ this.removeAttribute('data-minimized');
148
+ btn.textContent = '\u00d7';
149
+ } else {
150
+ this.setAttribute('data-minimized', '');
151
+ btn.textContent = '\u25a1';
152
+ }
153
+ });
154
+ }
155
+
156
+ /* -- expand error detail on click -- */
157
+ _attachBodyClick() {
158
+ if (!this.$body) return;
159
+ this.$body.addEventListener('click', (e) => {
160
+ const entry = e.target.closest('.lv__entry');
161
+ if (!entry) return;
162
+ const hasErr = entry.querySelector('.lv__entry-error');
163
+ if (!hasErr) return;
164
+ entry.toggleAttribute('data-expanded');
165
+ });
166
+ }
167
+
168
+ /* -- real-time subscription via logger.onLog -- */
169
+ _subscribeLogger() {
170
+ const logger = slice.logger;
171
+ if (!logger || typeof logger.onLog !== 'function') return;
172
+ this._logHandler = logger.onLog(() => {
173
+ this._render();
174
+ });
175
+ }
176
+
177
+ _unsubscribeLogger() {
178
+ const logger = slice.logger;
179
+ if (!logger || typeof logger.offLog !== 'function') return;
180
+ if (this._logHandler) {
181
+ logger.offLog(this._logHandler);
182
+ this._logHandler = null;
183
+ }
184
+ }
185
+
186
+ /* -- render -- */
187
+ _render() {
188
+ const logger = slice.logger;
189
+ if (!logger || !logger.logs) return;
190
+ const logs = logger.logs;
191
+ this._logCount = logs.length;
192
+
193
+ const filters = this._filters;
194
+ const hasFilters = filters.size > 0;
195
+
196
+ if (this.$count) {
197
+ this.$count.textContent = logs.length;
198
+ }
199
+
200
+ if (!this.$body) return;
201
+
202
+ if (logs.length === 0) {
203
+ this.$body.innerHTML = '<div class="lv__empty"><span class="lv__empty-icon">&#128225;</span><span>No logs yet</span></div>';
204
+ return;
205
+ }
206
+
207
+ const filtered = hasFilters ? logs.filter((log) => filters.has(log.logType)) : logs;
208
+ const reversed = [...filtered].reverse();
209
+
210
+ let html = '';
211
+ for (const log of reversed) {
212
+ const type = log.logType || 'info';
213
+ const ts = formatTime(log.timestamp instanceof Date ? log.timestamp : new Date(log.timestamp));
214
+ const badge = LV_BADGE_LABEL[type] || 'INF';
215
+ const component = escapeHtml(log.componentSliceId || '');
216
+ const msg = escapeHtml(log.message || '');
217
+
218
+ let errHtml = '';
219
+ if (log.error) {
220
+ const stack = log.error.stack || String(log.error);
221
+ errHtml = `<div class="lv__entry-error">${escapeHtml(stack)}</div>`;
222
+ }
223
+
224
+ html += `<div class="lv__entry lv__entry--${type}">
225
+ <span class="lv__entry-time">${ts}</span>
226
+ <span class="lv__entry-badge">${badge}</span>
227
+ <div class="lv__entry-body">
228
+ <div class="lv__entry-component">${component}</div>
229
+ <div class="lv__entry-message">${msg}</div>
230
+ ${errHtml}
231
+ </div>
232
+ </div>`;
233
+ }
234
+
235
+ this.$body.innerHTML = html;
236
+ if (this.$body.scrollTop < 20) {
237
+ this.$body.scrollTop = 0;
238
+ }
239
+ }
240
+ }
241
+
242
+ customElements.define('slice-log-viewer', LogViewer);
243
+
244
+ function productionOnlyCSS() {
245
+ return `/* ── LogViewer (Structural) ── */
246
+ slice-log-viewer {
247
+ --si-accent: var(--primary-color, #6ee7ff);
248
+ --si-accent-rgb: var(--primary-color-rgb, 110, 231, 255);
249
+ --si-surface: var(--primary-background-color, #1e1e2e);
250
+ --si-raised: var(--secondary-background-color, rgba(255, 255, 255, 0.035));
251
+ --si-raised-2: var(--tertiary-background-color, #2a2a3e);
252
+ --si-border: var(--medium-color, #555);
253
+ --si-text: var(--font-primary-color, #cdd6f4);
254
+ --si-dim: var(--font-secondary-color, #a6adc8);
255
+ --si-danger: var(--danger-color, #f38ba8);
256
+ --si-warning: var(--warning-color, #f9e2af);
257
+ --si-info: var(--secondary-color, #89dceb);
258
+ --si-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
259
+ position: fixed;
260
+ z-index: 2147483647;
261
+ bottom: 24px;
262
+ right: 24px;
263
+ width: 520px;
264
+ max-width: calc(100vw - 48px);
265
+ height: 380px;
266
+ max-height: calc(100vh - 48px);
267
+ display: block;
268
+ font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
269
+ font-size: 12px;
270
+ line-height: 1.5;
271
+ border: 1px solid color-mix(in srgb, var(--si-border, #555) 60%, transparent);
272
+ border-radius: 12px;
273
+ background: color-mix(in srgb, var(--si-surface, #1e1e2e) 97%, #000);
274
+ box-shadow: 0 12px 48px rgba(0,0,0,.45), 0 0 0 1px rgba(0,0,0,.08);
275
+ overflow: hidden;
276
+ user-select: none;
277
+ resize: both;
278
+ min-width: 280px;
279
+ min-height: 200px;
280
+ transition: opacity .18s ease, transform .18s ease;
281
+ }
282
+
283
+ slice-log-viewer[data-minimized] {
284
+ height: auto !important;
285
+ resize: none;
286
+ min-height: 0;
287
+ }
288
+
289
+ slice-log-viewer[data-minimized] .lv__body {
290
+ display: none;
291
+ }
292
+
293
+ .lv__header {
294
+ display: flex;
295
+ align-items: center;
296
+ justify-content: space-between;
297
+ gap: 8px;
298
+ padding: 8px 10px 8px 14px;
299
+ background: color-mix(in srgb, var(--si-raised-2, #2a2a3e) 80%, #000);
300
+ border-bottom: 1px solid color-mix(in srgb, var(--si-border, #555) 40%, transparent);
301
+ cursor: grab;
302
+ position: relative;
303
+ z-index: 1;
304
+ }
305
+
306
+ .lv__header:active { cursor: grabbing; }
307
+
308
+ .lv__header-left {
309
+ display: flex;
310
+ align-items: center;
311
+ gap: 8px;
312
+ min-width: 0;
313
+ }
314
+
315
+ .lv__title {
316
+ font-size: 13px;
317
+ font-weight: 700;
318
+ letter-spacing: .02em;
319
+ color: var(--si-text, #cdd6f4);
320
+ white-space: nowrap;
321
+ }
322
+
323
+ .lv__count {
324
+ font-size: 10px;
325
+ font-weight: 600;
326
+ padding: 1px 6px;
327
+ border-radius: 6px;
328
+ background: color-mix(in srgb, var(--si-border, #555) 40%, transparent);
329
+ color: var(--si-dim, #a6adc8);
330
+ font-variant-numeric: tabular-nums;
331
+ }
332
+
333
+ .lv__header-actions {
334
+ display: flex;
335
+ align-items: center;
336
+ gap: 4px;
337
+ flex-shrink: 0;
338
+ }
339
+
340
+ .lv__filter,
341
+ .lv__clear,
342
+ .lv__close {
343
+ all: unset;
344
+ cursor: pointer;
345
+ font-size: 10px;
346
+ font-weight: 600;
347
+ padding: 3px 7px;
348
+ border-radius: 6px;
349
+ color: var(--si-dim, #a6adc8);
350
+ background: color-mix(in srgb, var(--si-border, #555) 25%, transparent);
351
+ transition: background .12s ease, color .12s ease;
352
+ font-family: inherit;
353
+ line-height: 1.4;
354
+ }
355
+
356
+ .lv__filter:hover,
357
+ .lv__clear:hover,
358
+ .lv__close:hover {
359
+ background: color-mix(in srgb, var(--si-border, #555) 45%, transparent);
360
+ color: var(--si-text, #cdd6f4);
361
+ }
362
+
363
+ .lv__filter[data-active] { color: var(--si-text, #fff); }
364
+
365
+ .lv__filter[data-active][data-filter="error"] {
366
+ background: var(--si-danger, #f38ba8);
367
+ color: var(--danger-contrast, #1e1e2e);
368
+ }
369
+
370
+ .lv__filter[data-active][data-filter="warn"] {
371
+ background: var(--si-warning, #f9e2af);
372
+ color: var(--warning-contrast, #1e1e2e);
373
+ }
374
+
375
+ .lv__filter[data-active][data-filter="info"] {
376
+ background: var(--si-info, #89dceb);
377
+ color: var(--secondary-color-contrast, #1e1e2e);
378
+ }
379
+
380
+ .lv__filter[data-active][data-filter="debug"] {
381
+ background: var(--si-dim, #a6adc8);
382
+ color: var(--si-text, #1e1e2e);
383
+ }
384
+
385
+ .lv__clear { margin-left: 2px; }
386
+
387
+ .lv__close {
388
+ font-size: 16px;
389
+ line-height: 1;
390
+ padding: 3px 8px 4px;
391
+ }
392
+
393
+ .lv__body {
394
+ overflow-y: auto;
395
+ overflow-x: hidden;
396
+ height: calc(100% - 38px);
397
+ padding: 4px 0;
398
+ cursor: auto;
399
+ }
400
+
401
+ .lv__body::-webkit-scrollbar { width: 5px; }
402
+ .lv__body::-webkit-scrollbar-track { background: transparent; }
403
+ .lv__body::-webkit-scrollbar-thumb {
404
+ background: color-mix(in srgb, var(--si-border, #555) 40%, transparent);
405
+ border-radius: 3px;
406
+ }
407
+
408
+ .lv__entry {
409
+ display: flex;
410
+ align-items: flex-start;
411
+ gap: 8px;
412
+ padding: 5px 14px;
413
+ border-bottom: 1px solid color-mix(in srgb, var(--si-border, #555) 12%, transparent);
414
+ transition: background .1s ease;
415
+ cursor: pointer;
416
+ }
417
+
418
+ .lv__entry:hover {
419
+ background: color-mix(in srgb, var(--si-border, #555) 8%, transparent);
420
+ }
421
+
422
+ .lv__entry:last-child { border-bottom: none; }
423
+
424
+ .lv__entry-time {
425
+ font-size: 10px;
426
+ color: color-mix(in srgb, var(--si-dim, #a6adc8) 55%, transparent);
427
+ white-space: nowrap;
428
+ flex-shrink: 0;
429
+ font-variant-numeric: tabular-nums;
430
+ margin-top: 1px;
431
+ }
432
+
433
+ .lv__entry-badge {
434
+ font-size: 9px;
435
+ font-weight: 700;
436
+ padding: 1px 5px;
437
+ border-radius: 4px;
438
+ text-transform: uppercase;
439
+ letter-spacing: .04em;
440
+ flex-shrink: 0;
441
+ margin-top: 1px;
442
+ }
443
+
444
+ .lv__entry--error .lv__entry-badge {
445
+ background: var(--si-danger, #f38ba8);
446
+ color: var(--danger-contrast, #1e1e2e);
447
+ }
448
+
449
+ .lv__entry--warn .lv__entry-badge {
450
+ background: var(--si-warning, #f9e2af);
451
+ color: var(--warning-contrast, #1e1e2e);
452
+ }
453
+
454
+ .lv__entry--info .lv__entry-badge {
455
+ background: var(--si-info, #89dceb);
456
+ color: var(--secondary-color-contrast, #1e1e2e);
457
+ }
458
+
459
+ .lv__entry--debug .lv__entry-badge {
460
+ background: color-mix(in srgb, var(--si-border, #555) 40%, transparent);
461
+ color: var(--si-dim, #a6adc8);
462
+ }
463
+
464
+ .lv__entry-body {
465
+ flex: 1;
466
+ min-width: 0;
467
+ }
468
+
469
+ .lv__entry-component {
470
+ font-size: 10px;
471
+ font-weight: 600;
472
+ color: var(--si-text, #cdd6f4);
473
+ margin-bottom: 1px;
474
+ }
475
+
476
+ .lv__entry-message {
477
+ font-size: 11px;
478
+ color: var(--si-dim, #a6adc8);
479
+ word-break: break-word;
480
+ line-height: 1.45;
481
+ }
482
+
483
+ .lv__entry--error .lv__entry-message { color: var(--si-danger, #f38ba8); }
484
+ .lv__entry--warn .lv__entry-message { color: var(--si-warning, #f9e2af); }
485
+
486
+ .lv__entry-error {
487
+ display: none;
488
+ margin-top: 5px;
489
+ padding: 6px 8px;
490
+ border-radius: 6px;
491
+ background: color-mix(in srgb, var(--si-surface, #1e1e2e) 60%, #000);
492
+ font-size: 10px;
493
+ color: color-mix(in srgb, var(--si-dim, #a6adc8) 80%, transparent);
494
+ white-space: pre-wrap;
495
+ word-break: break-all;
496
+ font-family: inherit;
497
+ line-height: 1.5;
498
+ max-height: 180px;
499
+ overflow-y: auto;
500
+ }
501
+
502
+ .lv__entry[data-expanded] .lv__entry-error { display: block; }
503
+
504
+ .lv__empty {
505
+ display: flex;
506
+ flex-direction: column;
507
+ align-items: center;
508
+ justify-content: center;
509
+ height: 100%;
510
+ gap: 6px;
511
+ color: color-mix(in srgb, var(--si-dim, #a6adc8) 40%, transparent);
512
+ font-size: 12px;
513
+ }
514
+
515
+ .lv__empty-icon {
516
+ font-size: 24px;
517
+ opacity: .35;
518
+ line-height: 1;
519
+ }
520
+ `;
521
+ }
522
+
523
+ function productionOnlyHtml() {
524
+ return `<div class="lv">
525
+ <div class="lv__header">
526
+ <div class="lv__header-left">
527
+ <span class="lv__title">Log Console</span>
528
+ <span class="lv__count" data-count></span>
529
+ </div>
530
+ <div class="lv__header-actions">
531
+ <button class="lv__filter" data-filter="error" title="Show errors only">Error</button>
532
+ <button class="lv__filter" data-filter="warn" title="Show warnings only">Warn</button>
533
+ <button class="lv__filter" data-filter="info" title="Show info only">Info</button>
534
+ <button class="lv__filter" data-filter="debug" title="Show debug only">Debug</button>
535
+ <button class="lv__clear" title="Clear all logs">Clear</button>
536
+ <button class="lv__close" title="Close">&times;</button>
537
+ </div>
538
+ </div>
539
+ <div class="lv__body" data-body></div>
540
+ </div>`;
541
+ }
@@ -10,6 +10,7 @@ const logTypes = {
10
10
  export default class Logger {
11
11
  constructor() {
12
12
  this.logs = [];
13
+ this._logListeners = new Set();
13
14
  this.logEnabled = slice.loggerConfig.enabled;
14
15
  this.showLogsConfig = slice.loggerConfig.showLogs;
15
16
 
@@ -92,6 +93,9 @@ export default class Logger {
92
93
  const log = new Log(logType, componentCategory, componentSliceId, message, error);
93
94
  this.logs.push(log);
94
95
  this.showLog(log);
96
+ for (const listener of this._logListeners) {
97
+ try { listener(log); } catch (_) { }
98
+ }
95
99
  }
96
100
 
97
101
  /**
@@ -155,6 +159,27 @@ export default class Logger {
155
159
  this.info(componentSliceId, message, error);
156
160
  }
157
161
 
162
+ /**
163
+ * Subscribe to new log entries in real time.
164
+ * @param {Function} callback — receives the Log instance
165
+ * @returns {Function} the same callback (for passing to offLog)
166
+ */
167
+ onLog(callback) {
168
+ if (typeof callback === 'function') {
169
+ this._logListeners.add(callback);
170
+ }
171
+ return callback;
172
+ }
173
+
174
+ /**
175
+ * Unsubscribe a previously registered listener.
176
+ * @param {Function} callback
177
+ * @returns {void}
178
+ */
179
+ offLog(callback) {
180
+ this._logListeners.delete(callback);
181
+ }
182
+
158
183
  /**
159
184
  * Get all logs.
160
185
  * @returns {Array}
package/Slice/Slice.js CHANGED
@@ -564,9 +564,23 @@ async function init() {
564
564
  window.slice.logger.logError('Slice', 'ContextManager disabled');
565
565
  }
566
566
 
567
- if (sliceConfig.loading.enabled) {
568
- const loading = await window.slice.build('Loading', {});
569
- window.slice.loading = loading;
567
+ if (sliceConfig.logger?.ui?.enabled) {
568
+ try {
569
+ const LogViewerModule = window.slice.frameworkClasses?.LogViewer
570
+ || await window.slice.getClass(`${slice.paths.structuralComponentFolderPath}/Logger/LogViewer/LogViewer.js`);
571
+ const logViewer = new LogViewerModule();
572
+ window.slice.logViewer = logViewer;
573
+ logViewer.style.display = 'none';
574
+ document.body.appendChild(logViewer);
575
+ if (typeof logViewer.init === 'function') logViewer.init();
576
+ } catch (e) {
577
+ window.slice.logger?.warn?.('Slice', 'Could not load LogViewer component', e);
578
+ }
579
+ }
580
+
581
+ if (sliceConfig.loading.enabled) {
582
+ const loading = await window.slice.build('Loading', {});
583
+ window.slice.loading = loading;
570
584
  if (typeof loading?.start === 'function') {
571
585
  loading.start();
572
586
  }
@@ -575,7 +589,7 @@ async function init() {
575
589
  const stylesInitPromise = window.slice.stylesManager.init();
576
590
  const routesModulePromise = import(slice.paths.routesFile);
577
591
 
578
- if (sliceConfig.events?.ui?.shortcut || sliceConfig.context?.ui?.shortcut) {
592
+ if (sliceConfig.events?.ui?.shortcut || sliceConfig.context?.ui?.shortcut || sliceConfig.logger?.ui?.shortcut) {
579
593
  const normalize = (value) => (typeof value === 'string' ? value.toLowerCase() : '');
580
594
  const toKey = (event) => {
581
595
  const parts = [];
@@ -593,6 +607,7 @@ async function init() {
593
607
  const handlers = {
594
608
  [normalize(sliceConfig.events?.ui?.shortcut)]: () => window.slice.eventsDebugger?.toggle?.(),
595
609
  [normalize(sliceConfig.context?.ui?.shortcut)]: () => window.slice.contextDebugger?.toggle?.(),
610
+ [normalize(sliceConfig.logger?.ui?.shortcut)]: () => window.slice.logViewer?.toggle?.(),
596
611
  };
597
612
 
598
613
  document.addEventListener('keydown', (event) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slicejs-web-framework",
3
- "version": "3.3.8",
3
+ "version": "3.4.0",
4
4
  "description": "",
5
5
  "engines": {
6
6
  "node": ">=20"