@zolomedia/bifrost-client 1.7.74

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.
Files changed (140) hide show
  1. package/L1_Foundation/L1_Foundation.js +13 -0
  2. package/L1_Foundation/bootstrap/bootstrap.js +11 -0
  3. package/L1_Foundation/bootstrap/bootstrap_hooks.js +123 -0
  4. package/L1_Foundation/bootstrap/bootstrap_index.js +15 -0
  5. package/L1_Foundation/bootstrap/bootstrap_logger.js +135 -0
  6. package/L1_Foundation/bootstrap/cdn_loader.js +217 -0
  7. package/L1_Foundation/bootstrap/module_registry.js +102 -0
  8. package/L1_Foundation/bootstrap/prism_loader.js +164 -0
  9. package/L1_Foundation/config/client_config.js +110 -0
  10. package/L1_Foundation/config/config.js +7 -0
  11. package/L1_Foundation/connection/connection.js +8 -0
  12. package/L1_Foundation/connection/websocket_connection.js +122 -0
  13. package/L1_Foundation/constants/bifrost_constants.js +284 -0
  14. package/L1_Foundation/constants/constants.js +7 -0
  15. package/L1_Foundation/logger/logger.js +10 -0
  16. package/L2_Handling/L2_Handling.js +15 -0
  17. package/L2_Handling/cache/cache.js +22 -0
  18. package/L2_Handling/cache/cache_constants.js +69 -0
  19. package/L2_Handling/cache/orchestration/cache_manager.js +299 -0
  20. package/L2_Handling/cache/orchestration/cache_orchestrator.js +260 -0
  21. package/L2_Handling/cache/orchestration/orchestration.js +12 -0
  22. package/L2_Handling/cache/storage/session_manager.js +289 -0
  23. package/L2_Handling/cache/storage/storage.js +10 -0
  24. package/L2_Handling/cache/storage/storage_manager.js +590 -0
  25. package/L2_Handling/display/composite/composite.js +13 -0
  26. package/L2_Handling/display/composite/dashboard_renderer.js +221 -0
  27. package/L2_Handling/display/composite/swiper_renderer.js +564 -0
  28. package/L2_Handling/display/composite/terminal_renderer.js +922 -0
  29. package/L2_Handling/display/composite/wizard_conditional_renderer.js +274 -0
  30. package/L2_Handling/display/display.js +30 -0
  31. package/L2_Handling/display/feedback/feedback.js +11 -0
  32. package/L2_Handling/display/feedback/progressbar_renderer.js +418 -0
  33. package/L2_Handling/display/feedback/spinner_renderer.js +246 -0
  34. package/L2_Handling/display/inputs/button_renderer.js +634 -0
  35. package/L2_Handling/display/inputs/form_renderer.js +583 -0
  36. package/L2_Handling/display/inputs/input_renderer.js +658 -0
  37. package/L2_Handling/display/inputs/inputs.js +12 -0
  38. package/L2_Handling/display/navigation/menu_renderer.js +206 -0
  39. package/L2_Handling/display/navigation/navigation.js +11 -0
  40. package/L2_Handling/display/navigation/navigation_renderer.js +703 -0
  41. package/L2_Handling/display/orchestration/orchestration.js +11 -0
  42. package/L2_Handling/display/orchestration/renderer.js +430 -0
  43. package/L2_Handling/display/orchestration/zdisplay_orchestrator.js +1759 -0
  44. package/L2_Handling/display/outputs/alert_renderer.js +161 -0
  45. package/L2_Handling/display/outputs/audio_renderer.js +94 -0
  46. package/L2_Handling/display/outputs/card_renderer.js +229 -0
  47. package/L2_Handling/display/outputs/code_renderer.js +66 -0
  48. package/L2_Handling/display/outputs/dl_renderer.js +131 -0
  49. package/L2_Handling/display/outputs/header_renderer.js +162 -0
  50. package/L2_Handling/display/outputs/icon_renderer.js +107 -0
  51. package/L2_Handling/display/outputs/image_renderer.js +145 -0
  52. package/L2_Handling/display/outputs/list_renderer.js +190 -0
  53. package/L2_Handling/display/outputs/outputs.js +19 -0
  54. package/L2_Handling/display/outputs/table_renderer.js +765 -0
  55. package/L2_Handling/display/outputs/text_renderer.js +818 -0
  56. package/L2_Handling/display/outputs/typography_renderer.js +293 -0
  57. package/L2_Handling/display/outputs/video_renderer.js +116 -0
  58. package/L2_Handling/display/primitives/document_structure_primitives.js +319 -0
  59. package/L2_Handling/display/primitives/form_primitives.js +526 -0
  60. package/L2_Handling/display/primitives/generic_containers.js +109 -0
  61. package/L2_Handling/display/primitives/interactive_primitives.js +305 -0
  62. package/L2_Handling/display/primitives/link_primitives.js +552 -0
  63. package/L2_Handling/display/primitives/lists_primitives.js +262 -0
  64. package/L2_Handling/display/primitives/media_primitives.js +383 -0
  65. package/L2_Handling/display/primitives/primitives.js +19 -0
  66. package/L2_Handling/display/primitives/semantic_element_primitive.js +226 -0
  67. package/L2_Handling/display/primitives/table_primitives.js +528 -0
  68. package/L2_Handling/display/primitives/typography_primitives.js +175 -0
  69. package/L2_Handling/display/specialized/input_request_renderer.js +467 -0
  70. package/L2_Handling/display/specialized/specialized.js +10 -0
  71. package/L2_Handling/hooks/hooks.js +9 -0
  72. package/L2_Handling/hooks/menu_integration.js +57 -0
  73. package/L2_Handling/hooks/widget_hook_manager.js +292 -0
  74. package/L2_Handling/message/message.js +8 -0
  75. package/L2_Handling/message/message_handler.js +701 -0
  76. package/L2_Handling/navigation/navigation.js +8 -0
  77. package/L2_Handling/navigation/navigation_manager.js +403 -0
  78. package/L2_Handling/zhooks/features/cache_live.js +287 -0
  79. package/L2_Handling/zhooks/features/crumbs_live.js +292 -0
  80. package/L2_Handling/zhooks/zhooks_manager.js +65 -0
  81. package/L2_Handling/zvaf/zvaf.js +8 -0
  82. package/L2_Handling/zvaf/zvaf_manager.js +334 -0
  83. package/L3_Abstraction/L3_Abstraction.js +12 -0
  84. package/L3_Abstraction/orchestrator/container_unwrapper.js +101 -0
  85. package/L3_Abstraction/orchestrator/group_renderer.js +698 -0
  86. package/L3_Abstraction/orchestrator/input_event_handler.js +797 -0
  87. package/L3_Abstraction/orchestrator/metadata_processor.js +249 -0
  88. package/L3_Abstraction/orchestrator/navbar_builder.js +201 -0
  89. package/L3_Abstraction/orchestrator/orchestrator.js +13 -0
  90. package/L3_Abstraction/orchestrator/wizard_gate_handler.js +360 -0
  91. package/L3_Abstraction/renderer/renderer.js +1 -0
  92. package/L3_Abstraction/session/session.js +1 -0
  93. package/L4_Orchestration/L4_Orchestration.js +11 -0
  94. package/L4_Orchestration/client/client.js +1 -0
  95. package/L4_Orchestration/facade/facade.js +9 -0
  96. package/L4_Orchestration/facade/manager_registry.js +118 -0
  97. package/L4_Orchestration/facade/renderer_registry.js +274 -0
  98. package/L4_Orchestration/lifecycle/asset_loader.js +255 -0
  99. package/L4_Orchestration/lifecycle/initializer.js +135 -0
  100. package/L4_Orchestration/lifecycle/lifecycle.js +8 -0
  101. package/L4_Orchestration/rendering/facade.js +94 -0
  102. package/L4_Orchestration/rendering/rendering.js +7 -0
  103. package/LICENSE +21 -0
  104. package/README.md +82 -0
  105. package/bifrost_client.js +204 -0
  106. package/bifrost_core.js +1686 -0
  107. package/docs/ARCHITECTURE.md +111 -0
  108. package/docs/PROTOCOL.md +106 -0
  109. package/docs/RENDERERS.md +101 -0
  110. package/docs/SECURITY.md +92 -0
  111. package/package.json +24 -0
  112. package/syntax/prism-zconfig.js +41 -0
  113. package/syntax/prism-zenv.js +69 -0
  114. package/syntax/prism-zolo-theme.css +288 -0
  115. package/syntax/prism-zolo.js +380 -0
  116. package/syntax/prism-zschema.js +38 -0
  117. package/syntax/prism-zspark.js +25 -0
  118. package/syntax/prism-zui.js +68 -0
  119. package/zSys/accessibility/accessibility.js +10 -0
  120. package/zSys/accessibility/emoji_accessibility.js +173 -0
  121. package/zSys/dom/block_utils.js +122 -0
  122. package/zSys/dom/container_utils.js +370 -0
  123. package/zSys/dom/dom.js +13 -0
  124. package/zSys/dom/dom_utils.js +328 -0
  125. package/zSys/dom/encoding_utils.js +117 -0
  126. package/zSys/dom/style_utils.js +71 -0
  127. package/zSys/errors/error_display.js +299 -0
  128. package/zSys/errors/errors.js +10 -0
  129. package/zSys/theme/color_utils.js +274 -0
  130. package/zSys/theme/dark_mode_utils.js +272 -0
  131. package/zSys/theme/size_utils.js +256 -0
  132. package/zSys/theme/spacing_utils.js +405 -0
  133. package/zSys/theme/theme.js +14 -0
  134. package/zSys/theme/zbase.css +1735 -0
  135. package/zSys/theme/zbase_inject.js +161 -0
  136. package/zSys/theme/ztheme_utils.js +305 -0
  137. package/zSys/validation/error_boundary.js +201 -0
  138. package/zSys/validation/validation.js +11 -0
  139. package/zSys/validation/validation_utils.js +238 -0
  140. package/zSys/zSys.js +14 -0
@@ -0,0 +1,418 @@
1
+ /**
2
+ * ProgressBarRenderer - Render progress bars with percentage and ETA
3
+ *
4
+ * Terminal-first implementation matching backend zDisplay.progress_bar()
5
+ *
6
+ * Backend Events (from display_event_timebased.py):
7
+ * - progress_bar: Update progress
8
+ * - progress_complete: Finish progress
9
+ *
10
+ * Terminal Paradigm:
11
+ * - Carriage return (\r) updates: Overwrites same line for smooth updates
12
+ * - ETA calculation: Based on elapsed time and remaining work
13
+ * - Visual bars: 80% (2m 30s remaining)
14
+ *
15
+ * Bifrost Paradigm:
16
+ * - WebSocket events trigger updates
17
+ * - CSS transitions for smooth width changes
18
+ * - Multiple progress bars can be active simultaneously
19
+ * - Striped/animated variants for indeterminate or processing states
20
+ *
21
+ * Features:
22
+ * - Determinate progress (0-100%)
23
+ * - Percentage display (show_percentage)
24
+ * - ETA display (show_eta)
25
+ * - Color variants (primary, success, danger, warning, info)
26
+ * - Striped/animated variants
27
+ * - Height variants (using size_utils)
28
+ * - Auto-removal on completion
29
+ *
30
+ * @see https://github.com/ZoloAi/zTheme/blob/main/Manual/ztheme-progress.html
31
+ */
32
+
33
+ // Layer 2: Utilities
34
+ import { getBackgroundClass } from '../../../zSys/theme/color_utils.js';
35
+ import { applyHeight } from '../../../zSys/theme/size_utils.js';
36
+ import { withErrorBoundary } from '../../../zSys/validation/error_boundary.js';
37
+
38
+ // Layer 3: Primitives
39
+ import { createDiv, createSpan } from '../primitives/generic_containers.js';
40
+
41
+ // Constants
42
+ import { TIMEOUTS, COLORS } from '../../../L1_Foundation/constants/bifrost_constants.js';
43
+
44
+ export default class ProgressBarRenderer {
45
+ /**
46
+ * @param {Object} logger - Logger instance
47
+ */
48
+ constructor(logger) {
49
+ this.logger = logger;
50
+ this._activeProgressBars = new Map(); // Track active progress bars by progressId
51
+
52
+ // Wrap render method with error boundary
53
+ if (typeof this.render === 'function') {
54
+ const originalRender = this.render.bind(this);
55
+ this.render = withErrorBoundary(originalRender, {
56
+ component: 'ProgressBarRenderer',
57
+ logger: this.logger
58
+ });
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Start or update a progress bar (progress_bar event)
64
+ * @param {Object} event - Progress bar event
65
+ * @param {string} event.progressId - Unique progress bar ID
66
+ * @param {string} event.label - Progress bar label text
67
+ * @param {number} event.current - Current progress value
68
+ * @param {number} event.total - Total value (max progress)
69
+ * @param {boolean} [event.showPercentage=true] - Show percentage text
70
+ * @param {boolean} [event.showETA=true] - Show ETA text
71
+ * @param {string} [event.eta] - ETA string from backend
72
+ * @param {string} [event.color='primary'] - Progress bar color
73
+ * @param {string} [event.container='#app'] - Target container selector
74
+ * @param {boolean} [event.striped=false] - Use striped variant
75
+ * @param {boolean} [event.animated=false] - Animate stripes
76
+ * @param {string} [event.height='md'] - Height (sm, md, lg)
77
+ * @returns {HTMLElement} Progress bar container element
78
+ */
79
+ render(event) {
80
+ const {
81
+ progressId,
82
+ label = 'Processing...',
83
+ current = 0,
84
+ total = 100,
85
+ eta = null,
86
+ color = 'primary',
87
+ container = '#app',
88
+ striped = false,
89
+ animated = false,
90
+ height = 'md'
91
+ } = event;
92
+
93
+ // Accept backend snake_case (show_percentage / show_eta) as well as camelCase.
94
+ const showPercentage = event.showPercentage ?? event.show_percentage ?? true;
95
+ const showETA = event.showETA ?? event.show_eta ?? false;
96
+
97
+ this.logger.log('[ProgressBarRenderer] Rendering progress:', {
98
+ progressId,
99
+ label,
100
+ progress: `${current}/${total}`
101
+ });
102
+
103
+ // Check if progress bar already exists
104
+ let progressContainer;
105
+ if (this._activeProgressBars.has(progressId)) {
106
+ // Update existing progress bar
107
+ progressContainer = this._activeProgressBars.get(progressId).element;
108
+ this._updateProgressBar(progressContainer, current, total, showPercentage, eta, showETA);
109
+ } else {
110
+ // Create new progress bar
111
+ progressContainer = this._createProgressBarContainer(
112
+ progressId,
113
+ label,
114
+ current,
115
+ total,
116
+ showPercentage,
117
+ showETA,
118
+ eta,
119
+ color,
120
+ striped,
121
+ animated,
122
+ height
123
+ );
124
+
125
+ // Find target container. The backend default is the literal "default"
126
+ // (not a selector) — treat that (and any unresolved selector) as the app
127
+ // root so a streamed bar lands in the page flow, never orphaned on <body>.
128
+ const selector = (container && container !== 'default') ? container : '#app';
129
+ const targetElement = document.querySelector(selector)
130
+ || document.querySelector('#app')
131
+ || document.body;
132
+ targetElement.appendChild(progressContainer);
133
+
134
+ // Track active progress bar
135
+ this._activeProgressBars.set(progressId, {
136
+ element: progressContainer,
137
+ label,
138
+ startTime: Date.now()
139
+ });
140
+ }
141
+
142
+ // Check if complete
143
+ if (current >= total) {
144
+ this.complete({ progressId });
145
+ }
146
+
147
+ this.logger.log('[ProgressBarRenderer] Progress updated successfully');
148
+ return progressContainer;
149
+ }
150
+
151
+ /**
152
+ * Build a progress bar element WITHOUT appending it (inline/declarative use).
153
+ *
154
+ * The orchestrator's renderZDisplayEvent() switch returns a node that the
155
+ * caller places in the page flow — same contract as image/table renderers.
156
+ * We still register the bar by progressId so a later streamed update (a wizard
157
+ * advancing, same id) finds and animates this exact element in place.
158
+ *
159
+ * Accepts both camelCase (showPercentage) and the backend's snake_case
160
+ * (show_percentage); leaving `total` off yields an indeterminate striped bar.
161
+ * @param {Object} event - Progress bar event (declarative or streamed)
162
+ * @returns {HTMLElement} Progress bar container element (not yet attached)
163
+ */
164
+ renderInline(event) {
165
+ const {
166
+ progressId = `progress-${Date.now()}`,
167
+ label = 'Processing...',
168
+ current = 0,
169
+ color = 'primary',
170
+ height = 'md',
171
+ eta = null
172
+ } = event;
173
+
174
+ const showPercentage = event.showPercentage ?? event.show_percentage ?? true;
175
+ const showETA = event.showETA ?? event.show_eta ?? false;
176
+
177
+ // Indeterminate (no total) → a full striped/animated bar that reads as "working".
178
+ let { total, striped = false, animated = false } = event;
179
+ if (!total) {
180
+ total = 100;
181
+ striped = true;
182
+ animated = true;
183
+ }
184
+
185
+ // Re-render of a bar we already drew (a wizard advancing past its gate
186
+ // re-emits the SAME progressId at 100%). Update it in place and return null
187
+ // so the caller never appends a duplicate — the existing node animates to its
188
+ // new width where it sits (the bar stays at the top of the wizard).
189
+ if (event.progressId && this._activeProgressBars.has(event.progressId)) {
190
+ const existing = this._activeProgressBars.get(event.progressId).element;
191
+ if (existing && document.contains(existing)) {
192
+ this._updateProgressBar(existing, current, total, showPercentage, eta, showETA);
193
+ return null;
194
+ }
195
+ }
196
+
197
+ const element = this._createProgressBarContainer(
198
+ progressId, label, current, total,
199
+ showPercentage, showETA, eta, color, striped, animated, height
200
+ );
201
+
202
+ // Register so streamed updates (same progressId) update this node in place.
203
+ this._activeProgressBars.set(progressId, {
204
+ element,
205
+ label,
206
+ startTime: Date.now()
207
+ });
208
+
209
+ return element;
210
+ }
211
+
212
+ /**
213
+ * Complete a progress bar (progress_complete event)
214
+ * @param {Object} event - Progress complete event
215
+ * @param {string} event.progressId - Unique progress bar ID
216
+ */
217
+ complete(event) {
218
+ const { progressId } = event;
219
+
220
+ this.logger.log('[ProgressBarRenderer] Completing progress:', progressId);
221
+
222
+ const progressData = this._activeProgressBars.get(progressId);
223
+ if (!progressData) {
224
+ this.logger.warn('[ProgressBarRenderer] Progress bar not found:', progressId);
225
+ return;
226
+ }
227
+
228
+ const { element } = progressData;
229
+
230
+ // Auto-remove after configured timeout
231
+ setTimeout(() => {
232
+ if (element.parentNode) {
233
+ element.style.transition = 'opacity 0.3s';
234
+ element.style.opacity = '0';
235
+ setTimeout(() => {
236
+ if (element.parentNode) {
237
+ element.parentNode.removeChild(element);
238
+ }
239
+ }, TIMEOUTS.FADE_TRANSITION);
240
+ }
241
+ }, TIMEOUTS.AUTO_REMOVE_PROGRESS);
242
+
243
+ // Remove from active progress bars
244
+ this._activeProgressBars.delete(progressId);
245
+
246
+ this.logger.log('[ProgressBarRenderer] Progress completed successfully');
247
+ }
248
+
249
+ /**
250
+ * Create progress bar container using primitives
251
+ * @private
252
+ */
253
+ _createProgressBarContainer(
254
+ progressId,
255
+ label,
256
+ current,
257
+ total,
258
+ showPercentage,
259
+ showETA,
260
+ eta,
261
+ color,
262
+ striped,
263
+ animated,
264
+ height
265
+ ) {
266
+ // Main container (using primitive)
267
+ const container = createDiv({
268
+ id: progressId,
269
+ class: 'zProgress-container zMy-3'
270
+ });
271
+
272
+ // Label row (using primitives)
273
+ const labelRow = createDiv({
274
+ class: 'zD-flex zFlex-justify-between zMb-1'
275
+ });
276
+
277
+ const labelSpan = createSpan({
278
+ class: 'zText-dark zFw-bold'
279
+ });
280
+ labelSpan.textContent = label;
281
+
282
+ const infoSpan = createSpan({
283
+ class: 'zText-muted',
284
+ 'data-info': 'progress-info'
285
+ });
286
+ infoSpan.textContent = this._formatProgressInfo(current, total, showPercentage, eta, showETA);
287
+
288
+ labelRow.appendChild(labelSpan);
289
+ labelRow.appendChild(infoSpan);
290
+
291
+ // Progress bar wrapper (using primitive + zTheme classes)
292
+ const progressWrapper = createDiv({
293
+ class: 'zProgress'
294
+ });
295
+
296
+ // Apply height using size_utils (consolidated to avoid duplication)
297
+ // TODO: Add .zProgress-sm, .zProgress-md, .zProgress-lg to zTheme
298
+ const PROGRESS_HEIGHT_MAP = {
299
+ sm: '0.5rem', // SIZE_SCALE[2]
300
+ md: '1rem', // SIZE_SCALE[4]
301
+ lg: '1.5rem' // SIZE_SCALE[6]
302
+ };
303
+ if (PROGRESS_HEIGHT_MAP[height]) {
304
+ applyHeight(progressWrapper, PROGRESS_HEIGHT_MAP[height]);
305
+ }
306
+
307
+ // Progress bar (using primitive + composition pattern)
308
+ const progressBar = createDiv({
309
+ class: 'zProgress-bar',
310
+ role: 'progressbar',
311
+ 'aria-valuenow': current,
312
+ 'aria-valuemin': '0',
313
+ 'aria-valuemax': total,
314
+ 'data-bar': 'progress-bar'
315
+ });
316
+
317
+ // Apply color using color_utils (Layer 2 composition)
318
+ const bgClass = getBackgroundClass(color);
319
+ progressBar.classList.add(bgClass);
320
+
321
+ // Apply variants
322
+ if (striped) {
323
+ progressBar.classList.add('zProgress-bar-striped');
324
+ }
325
+ if (animated) {
326
+ progressBar.classList.add('zProgress-bar-animated');
327
+ }
328
+
329
+ // Set initial width
330
+ const percentage = Math.min(100, Math.max(0, (current / total) * 100));
331
+ progressBar.style.width = `${percentage}%`;
332
+ progressBar.style.transition = 'width 0.3s ease-in-out';
333
+
334
+ // Assemble
335
+ progressWrapper.appendChild(progressBar);
336
+ container.appendChild(labelRow);
337
+ container.appendChild(progressWrapper);
338
+
339
+ return container;
340
+ }
341
+
342
+ /**
343
+ * Update existing progress bar
344
+ * @private
345
+ */
346
+ _updateProgressBar(container, current, total, showPercentage, eta, showETA) {
347
+ // Update progress bar width
348
+ const progressBar = container.querySelector('[data-bar="progress-bar"]');
349
+ if (progressBar) {
350
+ const percentage = Math.min(100, Math.max(0, (current / total) * 100));
351
+
352
+ // Step-oriented vs time-oriented. A real step landing (finite total,
353
+ // still in progress) means this is determinate progress — it should
354
+ // "just slowly fill", not borrow the indeterminate/time treatment. Drop
355
+ // the marching marquee + flowing stripes so only the smooth width
356
+ // transition (zbase: width 0.45s) shows. Time/indeterminate bars never
357
+ // reach here with current < total, so they keep their motion.
358
+ const determinate = Number.isFinite(total) && total > 0 && current < total;
359
+ if (determinate) {
360
+ const track = container.querySelector('.zProgress');
361
+ if (track) track.classList.remove('zProgress--indeterminate');
362
+ progressBar.classList.remove('zProgress-bar-striped', 'zProgress-bar-animated');
363
+ }
364
+
365
+ progressBar.style.width = `${percentage}%`;
366
+ progressBar.setAttribute('aria-valuenow', current);
367
+ }
368
+
369
+ // Update info text
370
+ const infoSpan = container.querySelector('[data-info="progress-info"]');
371
+ if (infoSpan) {
372
+ infoSpan.textContent = this._formatProgressInfo(current, total, showPercentage, eta, showETA);
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Format progress info text (percentage + ETA)
378
+ * @private
379
+ */
380
+ _formatProgressInfo(current, total, showPercentage, eta, showETA) {
381
+ const parts = [];
382
+
383
+ if (showPercentage) {
384
+ const percentage = Math.min(100, Math.max(0, (current / total) * 100));
385
+ parts.push(`${Math.round(percentage)}%`);
386
+ }
387
+
388
+ if (showETA && eta) {
389
+ parts.push(`ETA: ${eta}`);
390
+ }
391
+
392
+ return parts.join(' • ');
393
+ }
394
+
395
+ /**
396
+ * Get active progress bar count
397
+ * @returns {number} Number of active progress bars
398
+ */
399
+ getActiveCount() {
400
+ return this._activeProgressBars.size;
401
+ }
402
+
403
+ /**
404
+ * Clear all progress bars (cleanup utility)
405
+ */
406
+ clearAll() {
407
+ this.logger.log('[ProgressBarRenderer] Clearing all progress bars');
408
+
409
+ for (const [_progressId, data] of this._activeProgressBars) {
410
+ if (data.element.parentNode) {
411
+ data.element.parentNode.removeChild(data.element);
412
+ }
413
+ }
414
+
415
+ this._activeProgressBars.clear();
416
+ }
417
+ }
418
+
@@ -0,0 +1,246 @@
1
+ /**
2
+ * SpinnerRenderer - Render loading spinners
3
+ *
4
+ * Terminal-first implementation matching backend zDisplay.spinner()
5
+ *
6
+ * Backend Events (from display_event_timebased.py):
7
+ * - spinner_start: { event, spinnerId, label, style, container }
8
+ * - spinner_stop: { event, spinnerId }
9
+ *
10
+ * Spinner Styles (matching terminal):
11
+ * - dots:
12
+ * - line: - \ | /
13
+ * - arc:
14
+ * - bouncingBall: ( ) ( ) ( ) ( )
15
+ * - simple: . .. ...
16
+ *
17
+ * Terminal Paradigm:
18
+ * - Context manager: with display.spinner("Loading"): do_work()
19
+ * - Background thread animates frames
20
+ * - Auto-cleanup with checkmark on completion
21
+ *
22
+ * Bifrost Paradigm:
23
+ * - WebSocket events trigger start/stop
24
+ * - CSS animations handle visual feedback
25
+ * - Multiple spinners can be active simultaneously
26
+ *
27
+ * Features:
28
+ * - Size variants (sm, md, lg)
29
+ * - Color variants (primary, secondary, success, danger, warning, info)
30
+ * - CSS animations (no JavaScript required after rendering)
31
+ * - Auto-cleanup on stop
32
+ *
33
+ * @see https://github.com/ZoloAi/zTheme/blob/main/Manual/ztheme-spinners.html
34
+ */
35
+
36
+ // Layer 2: Utilities
37
+ import { applySpinnerSize } from '../../../zSys/theme/size_utils.js';
38
+
39
+ // Layer 3: Primitives
40
+ import { createDiv, createSpan } from '../primitives/generic_containers.js';
41
+
42
+ // Constants
43
+ import { TIMEOUTS, COLORS } from '../../../L1_Foundation/constants/bifrost_constants.js';
44
+
45
+ export default class SpinnerRenderer {
46
+ /**
47
+ * @param {Object} logger - Logger instance
48
+ */
49
+ constructor(logger) {
50
+ this.logger = logger;
51
+ this._activeSpinners = new Map(); // Track active spinners by spinnerId
52
+ }
53
+
54
+ /**
55
+ * Start a spinner (spinner_start event)
56
+ * @param {Object} event - Spinner start event
57
+ * @param {string} event.spinnerId - Unique spinner ID
58
+ * @param {string} event.label - Spinner label text
59
+ * @param {string} event.style - Spinner style (dots, line, arc, etc.)
60
+ * @param {string} event.container - Target container selector
61
+ * @param {string} [event.size='md'] - Spinner size (sm, md, lg)
62
+ * @param {string} [event.color='primary'] - Spinner color
63
+ * @returns {HTMLElement} Spinner container element
64
+ */
65
+ start(event) {
66
+ const {
67
+ spinnerId,
68
+ label = 'Loading...',
69
+ style = 'dots',
70
+ container = '#app',
71
+ size = 'md',
72
+ color = 'primary'
73
+ } = event;
74
+
75
+ this.logger.log('[SpinnerRenderer] Starting spinner:', { spinnerId, label, style });
76
+
77
+ // Create spinner structure using primitives
78
+ const spinnerContainer = this._createSpinnerContainer(spinnerId, label, size, color);
79
+
80
+ // Find target container
81
+ const targetElement = document.querySelector(container) || document.body;
82
+ targetElement.appendChild(spinnerContainer);
83
+
84
+ // Track active spinner
85
+ this._activeSpinners.set(spinnerId, {
86
+ element: spinnerContainer,
87
+ label,
88
+ startTime: Date.now()
89
+ });
90
+
91
+ this.logger.log('[SpinnerRenderer] Spinner started successfully');
92
+ return spinnerContainer;
93
+ }
94
+
95
+ /**
96
+ * Stop a spinner (spinner_stop event)
97
+ * @param {Object} event - Spinner stop event
98
+ * @param {string} event.spinnerId - Unique spinner ID
99
+ * @param {boolean} [event.success=true] - Whether operation succeeded
100
+ * @param {string} [event.message] - Optional completion message
101
+ */
102
+ stop(event) {
103
+ const { spinnerId, success = true, message } = event;
104
+
105
+ this.logger.log('[SpinnerRenderer] Stopping spinner:', { spinnerId, success });
106
+
107
+ const spinnerData = this._activeSpinners.get(spinnerId);
108
+ if (!spinnerData) {
109
+ this.logger.warn('[SpinnerRenderer] Spinner not found:', spinnerId);
110
+ return;
111
+ }
112
+
113
+ const { element, label, startTime } = spinnerData;
114
+ const duration = ((Date.now() - startTime) / 1000).toFixed(1);
115
+
116
+ // Replace spinner with completion message
117
+ this._replaceWithCompletion(element, label, success, message, duration);
118
+
119
+ // Remove from active spinners
120
+ this._activeSpinners.delete(spinnerId);
121
+
122
+ this.logger.log('[SpinnerRenderer] Spinner stopped successfully');
123
+ }
124
+
125
+ /**
126
+ * Create spinner container using primitives
127
+ * @private
128
+ */
129
+ _createSpinnerContainer(spinnerId, label, size, color) {
130
+ // Main container (using primitive)
131
+ const container = createDiv({
132
+ id: spinnerId,
133
+ class: 'zSpinner-container zD-flex zFlex-items-center zGap-2 zMy-2'
134
+ });
135
+
136
+ // Determine spinner classes
137
+ // zTheme has .zSpinner-border-sm for small, but no -md or -lg
138
+ // TODO: Add .zSpinner-border-md and .zSpinner-border-lg to zTheme
139
+ // See: https://github.com/ZoloAi/zTheme/blob/main/Manual/ztheme-spinners.html
140
+ let spinnerClass = 'zSpinner-border';
141
+ if (size === 'sm') {
142
+ spinnerClass = 'zSpinner-border-sm'; // Use zTheme's built-in small size
143
+ }
144
+
145
+ // Spinner element (using primitive + zTheme classes)
146
+ const spinner = createDiv({
147
+ class: `${spinnerClass} zText-${color}`,
148
+ role: 'status'
149
+ });
150
+
151
+ // For md/lg sizes, apply inline styles using size_utils
152
+ // TODO: Remove this once zTheme has full size scale (.zSpinner-border-md, .zSpinner-border-lg)
153
+ if (size === 'md' || size === 'lg') {
154
+ applySpinnerSize(spinner, size);
155
+ }
156
+
157
+ // Accessibility label (using primitive)
158
+ const srOnly = createSpan({
159
+ class: 'zVisually-hidden'
160
+ });
161
+ srOnly.textContent = 'Loading...';
162
+ spinner.appendChild(srOnly);
163
+
164
+ // Label text (using primitive)
165
+ const labelSpan = createSpan({
166
+ class: 'zSpinner-label zText-muted'
167
+ });
168
+ labelSpan.textContent = label;
169
+
170
+ // Assemble spinner
171
+ container.appendChild(spinner);
172
+ container.appendChild(labelSpan);
173
+
174
+ return container;
175
+ }
176
+
177
+ /**
178
+ * Replace spinner with completion message
179
+ * @private
180
+ */
181
+ _replaceWithCompletion(element, label, success, message, duration) {
182
+ // Clear spinner content
183
+ element.innerHTML = '';
184
+
185
+ // Create completion icon (checkmark or X)
186
+ const icon = document.createElement('i');
187
+ icon.className = success
188
+ ? 'bi bi-check-circle-fill zText-success'
189
+ : 'bi bi-x-circle-fill zText-danger';
190
+ icon.style.fontSize = '1.2rem';
191
+
192
+ // Create completion message (using primitive)
193
+ const messageSpan = createSpan({
194
+ class: success ? 'zText-success' : 'zText-danger'
195
+ });
196
+ messageSpan.textContent = message || `${label} ${success ? '[ok]' : ''}`;
197
+
198
+ // Create duration badge (using primitive)
199
+ const durationBadge = createSpan({
200
+ class: 'zBadge zBadge-secondary zMs-2'
201
+ });
202
+ durationBadge.textContent = `${duration}s`;
203
+
204
+ // Assemble completion message
205
+ element.appendChild(icon);
206
+ element.appendChild(document.createTextNode(' '));
207
+ element.appendChild(messageSpan);
208
+ element.appendChild(durationBadge);
209
+
210
+ // Remove classes
211
+ element.className = 'zSpinner-complete zD-flex zFlex-items-center zGap-2 zMy-2';
212
+
213
+ // Auto-remove after configured timeout (optional)
214
+ setTimeout(() => {
215
+ if (element.parentNode) {
216
+ element.style.transition = 'opacity 0.3s';
217
+ element.style.opacity = '0';
218
+ setTimeout(() => {
219
+ if (element.parentNode) {
220
+ element.parentNode.removeChild(element);
221
+ }
222
+ }, TIMEOUTS.FADE_TRANSITION);
223
+ }
224
+ }, TIMEOUTS.AUTO_REMOVE_SPINNER);
225
+ }
226
+
227
+ /**
228
+ * Stop all active spinners (cleanup utility)
229
+ * @param {boolean} [success=true] - Whether operations succeeded
230
+ */
231
+ stopAll(success = true) {
232
+ this.logger.log('[SpinnerRenderer] Stopping all spinners');
233
+
234
+ for (const [spinnerId, _data] of this._activeSpinners) {
235
+ this.stop({ spinnerId, success });
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Get active spinner count
241
+ * @returns {number} Number of active spinners
242
+ */
243
+ getActiveCount() {
244
+ return this._activeSpinners.size;
245
+ }
246
+ }