@cluesmith/codev 1.4.1 → 1.4.3

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 (49) hide show
  1. package/dist/agent-farm/servers/dashboard-server.js +487 -9
  2. package/dist/agent-farm/servers/dashboard-server.js.map +1 -1
  3. package/dist/agent-farm/servers/tower-server.js +141 -40
  4. package/dist/agent-farm/servers/tower-server.js.map +1 -1
  5. package/dist/agent-farm/utils/port-registry.d.ts.map +1 -1
  6. package/dist/agent-farm/utils/port-registry.js +19 -5
  7. package/dist/agent-farm/utils/port-registry.js.map +1 -1
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +2 -0
  10. package/dist/cli.js.map +1 -1
  11. package/dist/commands/adopt.d.ts.map +1 -1
  12. package/dist/commands/adopt.js +10 -0
  13. package/dist/commands/adopt.js.map +1 -1
  14. package/dist/commands/consult/index.d.ts +1 -0
  15. package/dist/commands/consult/index.d.ts.map +1 -1
  16. package/dist/commands/consult/index.js +56 -8
  17. package/dist/commands/consult/index.js.map +1 -1
  18. package/dist/commands/init.d.ts.map +1 -1
  19. package/dist/commands/init.js +8 -0
  20. package/dist/commands/init.js.map +1 -1
  21. package/package.json +1 -1
  22. package/skeleton/resources/commands/consult.md +50 -0
  23. package/skeleton/templates/projectlist-archive.md +21 -0
  24. package/skeleton/templates/projectlist.md +17 -0
  25. package/templates/dashboard/css/activity.css +151 -0
  26. package/templates/dashboard/css/dialogs.css +149 -0
  27. package/templates/dashboard/css/files.css +530 -0
  28. package/templates/dashboard/css/layout.css +124 -0
  29. package/templates/dashboard/css/projects.css +501 -0
  30. package/templates/dashboard/css/statusbar.css +23 -0
  31. package/templates/dashboard/css/tabs.css +314 -0
  32. package/templates/dashboard/css/utilities.css +50 -0
  33. package/templates/dashboard/css/variables.css +45 -0
  34. package/templates/dashboard/index.html +158 -0
  35. package/templates/dashboard/js/activity.js +238 -0
  36. package/templates/dashboard/js/dialogs.js +328 -0
  37. package/templates/dashboard/js/files.js +436 -0
  38. package/templates/dashboard/js/main.js +487 -0
  39. package/templates/dashboard/js/projects.js +544 -0
  40. package/templates/dashboard/js/state.js +91 -0
  41. package/templates/dashboard/js/tabs.js +500 -0
  42. package/templates/dashboard/js/utils.js +57 -0
  43. package/templates/tower.html +172 -4
  44. package/dist/commands/eject.d.ts +0 -18
  45. package/dist/commands/eject.d.ts.map +0 -1
  46. package/dist/commands/eject.js +0 -149
  47. package/dist/commands/eject.js.map +0 -1
  48. package/templates/dashboard-split.html +0 -3721
  49. package/templates/dashboard.html +0 -149
@@ -0,0 +1,500 @@
1
+ // Tab Management - Rendering, Selection, Overflow
2
+
3
+ // Build tabs from initial state
4
+ function buildTabsFromState() {
5
+ const previousTabIds = new Set(tabs.map(t => t.id));
6
+ // Preserve client-side-only tabs (like activity)
7
+ const clientSideTabs = tabs.filter(t => t.type === 'activity');
8
+ tabs = [];
9
+
10
+ // Dashboard tab is ALWAYS first and uncloseable (Spec 0045, 0057)
11
+ tabs.push({
12
+ id: 'dashboard',
13
+ type: 'dashboard',
14
+ name: 'Dashboard',
15
+ closeable: false
16
+ });
17
+
18
+ // Add file tabs from annotations
19
+ for (const annotation of state.annotations || []) {
20
+ tabs.push({
21
+ id: `file-${annotation.id}`,
22
+ type: 'file',
23
+ name: getFileName(annotation.file),
24
+ path: annotation.file,
25
+ port: annotation.port,
26
+ annotationId: annotation.id
27
+ });
28
+ }
29
+
30
+ // Add builder tabs
31
+ for (const builder of state.builders || []) {
32
+ tabs.push({
33
+ id: `builder-${builder.id}`,
34
+ type: 'builder',
35
+ name: builder.name || `Builder ${builder.id}`,
36
+ projectId: builder.id,
37
+ port: builder.port,
38
+ status: builder.status
39
+ });
40
+ }
41
+
42
+ // Add shell tabs
43
+ for (const util of state.utils || []) {
44
+ tabs.push({
45
+ id: `shell-${util.id}`,
46
+ type: 'shell',
47
+ name: util.name,
48
+ port: util.port,
49
+ utilId: util.id
50
+ });
51
+ }
52
+
53
+ // Re-add preserved client-side tabs
54
+ for (const tab of clientSideTabs) {
55
+ tabs.push(tab);
56
+ }
57
+
58
+ // Detect new tabs and auto-switch to them (skip projects tab)
59
+ for (const tab of tabs) {
60
+ if (tab.id !== 'dashboard' && tab.id !== 'files' && !knownTabIds.has(tab.id) && previousTabIds.size > 0) {
61
+ // This is a new tab - switch to it
62
+ activeTabId = tab.id;
63
+ break;
64
+ }
65
+ }
66
+
67
+ // Update known tab IDs
68
+ knownTabIds = new Set(tabs.map(t => t.id));
69
+
70
+ // Set active tab to Dashboard on first load if none selected
71
+ if (!activeTabId) {
72
+ activeTabId = 'dashboard';
73
+ }
74
+ }
75
+
76
+ // Render architect pane
77
+ function renderArchitect() {
78
+ const content = document.getElementById('architect-content');
79
+ const statusDot = document.getElementById('architect-status');
80
+
81
+ if (state.architect && state.architect.port) {
82
+ statusDot.classList.remove('inactive');
83
+ // Only update iframe if port changed (avoid flashing on poll)
84
+ if (currentArchitectPort !== state.architect.port) {
85
+ currentArchitectPort = state.architect.port;
86
+ content.innerHTML = `<iframe src="http://localhost:${state.architect.port}" title="Architect Terminal" allow="clipboard-read; clipboard-write"></iframe>`;
87
+ }
88
+ } else {
89
+ if (currentArchitectPort !== null) {
90
+ currentArchitectPort = null;
91
+ content.innerHTML = `
92
+ <div class="architect-placeholder">
93
+ <p>Architect not running</p>
94
+ <p>Run <code>agent-farm start</code> to begin</p>
95
+ </div>
96
+ `;
97
+ }
98
+ statusDot.classList.add('inactive');
99
+ }
100
+ }
101
+
102
+ // Render tabs
103
+ function renderTabs() {
104
+ const container = document.getElementById('tabs-container');
105
+
106
+ if (tabs.length === 0) {
107
+ container.innerHTML = '';
108
+ checkTabOverflow(); // Update overflow state when tabs cleared
109
+ return;
110
+ }
111
+
112
+ container.innerHTML = tabs.map(tab => {
113
+ const isActive = tab.id === activeTabId;
114
+ const icon = getTabIcon(tab.type);
115
+ const statusDot = tab.type === 'builder' ? getStatusDot(tab.status) : '';
116
+ const tooltip = getTabTooltip(tab);
117
+ const isUncloseable = tab.closeable === false;
118
+
119
+ return `
120
+ <div class="tab ${isActive ? 'active' : ''} ${isUncloseable ? 'tab-uncloseable' : ''}"
121
+ onclick="selectTab('${tab.id}')"
122
+ oncontextmenu="showContextMenu(event, '${tab.id}')"
123
+ data-tab-id="${tab.id}"
124
+ title="${tooltip}">
125
+ <span class="icon">${icon}</span>
126
+ <span class="name">${tab.name}</span>
127
+ ${statusDot}
128
+ ${!isUncloseable ? `<span class="close"
129
+ onclick="event.stopPropagation(); closeTab('${tab.id}', event)"
130
+ role="button"
131
+ tabindex="0"
132
+ aria-label="Close ${tab.name}"
133
+ onkeydown="if(event.key==='Enter'||event.key===' '){event.stopPropagation();closeTab('${tab.id}',event)}">&times;</span>` : ''}
134
+ </div>
135
+ `;
136
+ }).join('');
137
+
138
+ // Check overflow after tabs are rendered
139
+ checkTabOverflow();
140
+ }
141
+
142
+ // Get tab icon
143
+ function getTabIcon(type) {
144
+ switch (type) {
145
+ case 'dashboard': return '🏠';
146
+ case 'files': return '📁';
147
+ case 'file': return '📄';
148
+ case 'builder': return '🔨';
149
+ case 'shell': return '>_';
150
+ default: return '?';
151
+ }
152
+ }
153
+
154
+ // Status configuration - hoisted for performance (per Codex review)
155
+ const STATUS_CONFIG = {
156
+ 'spawning': { color: 'var(--status-active)', label: 'Spawning', shape: 'circle', animation: 'pulse' },
157
+ 'implementing': { color: 'var(--status-active)', label: 'Implementing', shape: 'circle', animation: 'pulse' },
158
+ 'blocked': { color: 'var(--status-error)', label: 'Blocked', shape: 'diamond', animation: 'blink-fast' },
159
+ 'pr-ready': { color: 'var(--status-waiting)', label: 'PR Ready', shape: 'ring', animation: 'blink-slow' },
160
+ 'complete': { color: 'var(--status-complete)', label: 'Complete', shape: 'circle', animation: null }
161
+ };
162
+ const DEFAULT_STATUS_CONFIG = { color: 'var(--text-muted)', label: 'Unknown', shape: 'circle', animation: null };
163
+
164
+ // Get status dot HTML with accessibility support
165
+ function getStatusDot(status) {
166
+ const config = STATUS_CONFIG[status] || { ...DEFAULT_STATUS_CONFIG, label: status || 'Unknown' };
167
+ const classes = ['status-dot'];
168
+ if (config.shape === 'diamond') classes.push('status-dot--diamond');
169
+ if (config.shape === 'ring') classes.push('status-dot--ring');
170
+ if (config.animation === 'pulse') classes.push('status-dot--pulse');
171
+ if (config.animation === 'blink-slow') classes.push('status-dot--blink-slow');
172
+ if (config.animation === 'blink-fast') classes.push('status-dot--blink-fast');
173
+ return `<span class="${classes.join(' ')}" style="background: ${config.color}" title="${config.label}" role="img" aria-label="${config.label}"></span>`;
174
+ }
175
+
176
+ // Generate tooltip text for tab hover
177
+ function getTabTooltip(tab) {
178
+ const lines = [tab.name];
179
+
180
+ if (tab.type === 'builder') {
181
+ if (tab.port) lines.push(`Port: ${tab.port}`);
182
+ lines.push(`Status: ${tab.status || 'unknown'}`);
183
+ const projectId = tab.id.replace('builder-', '');
184
+ lines.push(`Worktree: .builders/${projectId}`);
185
+ } else if (tab.type === 'file') {
186
+ lines.push(`Path: ${tab.path}`);
187
+ if (tab.port) lines.push(`Port: ${tab.port}`);
188
+ } else if (tab.type === 'shell') {
189
+ if (tab.port) lines.push(`Port: ${tab.port}`);
190
+ }
191
+
192
+ return escapeHtml(lines.join('\n'));
193
+ }
194
+
195
+ // Render tab content
196
+ function renderTabContent() {
197
+ const content = document.getElementById('tab-content');
198
+
199
+ if (!activeTabId || tabs.length === 0) {
200
+ if (currentTabPort !== null || currentTabType !== null) {
201
+ currentTabPort = null;
202
+ currentTabType = null;
203
+ content.innerHTML = `
204
+ <div class="empty-state">
205
+ <p>No tabs open</p>
206
+ <p class="hint">Click the + buttons above or ask the architect to open files/builders</p>
207
+ </div>
208
+ `;
209
+ }
210
+ return;
211
+ }
212
+
213
+ const tab = tabs.find(t => t.id === activeTabId);
214
+ if (!tab) {
215
+ if (currentTabPort !== null || currentTabType !== null) {
216
+ currentTabPort = null;
217
+ currentTabType = null;
218
+ content.innerHTML = '<div class="empty-state"><p>Tab not found</p></div>';
219
+ }
220
+ return;
221
+ }
222
+
223
+ // Handle dashboard tab specially (no iframe, inline content)
224
+ if (tab.type === 'dashboard') {
225
+ if (currentTabType !== 'dashboard') {
226
+ currentTabType = 'dashboard';
227
+ currentTabPort = null;
228
+ renderDashboardTab();
229
+ }
230
+ return;
231
+ }
232
+
233
+ // Handle activity tab specially (no iframe, inline content)
234
+ if (tab.type === 'activity') {
235
+ if (currentTabType !== 'activity') {
236
+ currentTabType = 'activity';
237
+ currentTabPort = null;
238
+ renderActivityTab();
239
+ }
240
+ return;
241
+ }
242
+
243
+ // For other tabs, only update iframe if port changed (avoid flashing on poll)
244
+ if (currentTabPort !== tab.port || currentTabType !== tab.type) {
245
+ currentTabPort = tab.port;
246
+ currentTabType = tab.type;
247
+ content.innerHTML = `<iframe src="http://localhost:${tab.port}" title="${tab.name}" allow="clipboard-read; clipboard-write"></iframe>`;
248
+ }
249
+ }
250
+
251
+ // Force refresh the iframe for a file tab (reloads content from server)
252
+ function refreshFileTab(tabId) {
253
+ const tab = tabs.find(t => t.id === tabId);
254
+ if (!tab || tab.type !== 'file' || !tab.port) return;
255
+
256
+ if (activeTabId === tabId) {
257
+ const content = document.getElementById('tab-content');
258
+ const iframe = content.querySelector('iframe');
259
+ if (iframe) {
260
+ iframe.src = `http://localhost:${tab.port}?t=${Date.now()}`;
261
+ }
262
+ }
263
+ }
264
+
265
+ // Update status bar
266
+ function updateStatusBar() {
267
+ const archStatus = document.getElementById('status-architect');
268
+ if (state.architect) {
269
+ archStatus.innerHTML = `
270
+ <span class="dot" style="background: var(--status-active)"></span>
271
+ <span>Architect: running</span>
272
+ `;
273
+ } else {
274
+ archStatus.innerHTML = `
275
+ <span class="dot" style="background: var(--text-muted)"></span>
276
+ <span>Architect: stopped</span>
277
+ `;
278
+ }
279
+
280
+ const builderCount = (state.builders || []).length;
281
+ const shellCount = (state.utils || []).length;
282
+ const fileCount = (state.annotations || []).length;
283
+
284
+ document.getElementById('status-builders').innerHTML = `<span>${builderCount} builder${builderCount !== 1 ? 's' : ''}</span>`;
285
+ document.getElementById('status-shells').innerHTML = `<span>${shellCount} shell${shellCount !== 1 ? 's' : ''}</span>`;
286
+ document.getElementById('status-files').innerHTML = `<span>${fileCount} file${fileCount !== 1 ? 's' : ''}</span>`;
287
+ }
288
+
289
+ // Select tab
290
+ function selectTab(tabId) {
291
+ activeTabId = tabId;
292
+ renderTabs();
293
+ renderTabContent();
294
+ scrollActiveTabIntoView();
295
+ }
296
+
297
+ // Scroll the active tab into view
298
+ function scrollActiveTabIntoView() {
299
+ const container = document.getElementById('tabs-container');
300
+ const activeTab = container.querySelector('.tab.active');
301
+ if (activeTab) {
302
+ activeTab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
303
+ }
304
+ }
305
+
306
+ // Set up overflow detection for the tab bar
307
+ function setupOverflowDetection() {
308
+ const container = document.getElementById('tabs-container');
309
+
310
+ checkTabOverflow();
311
+
312
+ let resizeTimeout;
313
+ window.addEventListener('resize', () => {
314
+ clearTimeout(resizeTimeout);
315
+ resizeTimeout = setTimeout(checkTabOverflow, 100);
316
+ });
317
+
318
+ if (container) {
319
+ let scrollTimeout;
320
+ container.addEventListener('scroll', () => {
321
+ clearTimeout(scrollTimeout);
322
+ scrollTimeout = setTimeout(checkTabOverflow, 50);
323
+ });
324
+ }
325
+
326
+ if (typeof ResizeObserver !== 'undefined') {
327
+ if (container) {
328
+ const observer = new ResizeObserver(() => {
329
+ checkTabOverflow();
330
+ });
331
+ observer.observe(container);
332
+ }
333
+ }
334
+ }
335
+
336
+ // Check if tabs are overflowing and update the overflow button
337
+ function checkTabOverflow() {
338
+ const container = document.getElementById('tabs-container');
339
+ const overflowBtn = document.getElementById('overflow-btn');
340
+ const overflowCount = document.getElementById('overflow-count');
341
+
342
+ if (!container || !overflowBtn) return;
343
+
344
+ const isOverflowing = container.scrollWidth > container.clientWidth;
345
+ overflowBtn.style.display = isOverflowing ? 'flex' : 'none';
346
+
347
+ if (isOverflowing) {
348
+ const tabElements = container.querySelectorAll('.tab');
349
+ const containerRect = container.getBoundingClientRect();
350
+ let hiddenCount = 0;
351
+
352
+ tabElements.forEach(tab => {
353
+ const rect = tab.getBoundingClientRect();
354
+ if (rect.right > containerRect.right + 1) {
355
+ hiddenCount++;
356
+ } else if (rect.left < containerRect.left - 1) {
357
+ hiddenCount++;
358
+ }
359
+ });
360
+
361
+ overflowCount.textContent = `+${hiddenCount}`;
362
+ }
363
+ }
364
+
365
+ // Toggle the overflow menu
366
+ function toggleOverflowMenu() {
367
+ const menu = document.getElementById('overflow-menu');
368
+ const isHidden = menu.classList.contains('hidden');
369
+
370
+ if (isHidden) {
371
+ showOverflowMenu();
372
+ } else {
373
+ hideOverflowMenu();
374
+ }
375
+ }
376
+
377
+ // Show the overflow menu
378
+ function showOverflowMenu() {
379
+ const menu = document.getElementById('overflow-menu');
380
+ const btn = document.getElementById('overflow-btn');
381
+
382
+ menu.innerHTML = tabs.map((tab, index) => {
383
+ const icon = getTabIcon(tab.type);
384
+ const isActive = tab.id === activeTabId;
385
+ return `
386
+ <div class="overflow-menu-item ${isActive ? 'active' : ''}"
387
+ role="menuitem"
388
+ tabindex="${index === 0 ? 0 : -1}"
389
+ data-tab-id="${tab.id}"
390
+ onclick="selectTabFromMenu('${tab.id}')"
391
+ onkeydown="handleOverflowMenuKeydown(event, '${tab.id}')">
392
+ <span class="icon">${icon}</span>
393
+ <span class="name">${tab.name}</span>
394
+ <span class="open-external"
395
+ onclick="event.stopPropagation(); openInNewTabFromMenu('${tab.id}')"
396
+ onkeydown="if(event.key==='Enter'||event.key===' '){event.stopPropagation();openInNewTabFromMenu('${tab.id}')}"
397
+ title="Open in new tab"
398
+ role="button"
399
+ tabindex="0"
400
+ aria-label="Open ${tab.name} in new tab">↗</span>
401
+ </div>
402
+ `;
403
+ }).join('');
404
+
405
+ menu.classList.remove('hidden');
406
+ btn.setAttribute('aria-expanded', 'true');
407
+
408
+ const firstItem = menu.querySelector('.overflow-menu-item');
409
+ if (firstItem) firstItem.focus();
410
+
411
+ setTimeout(() => {
412
+ document.addEventListener('click', handleOverflowClickOutside);
413
+ }, 0);
414
+ }
415
+
416
+ // Hide the overflow menu
417
+ function hideOverflowMenu() {
418
+ const menu = document.getElementById('overflow-menu');
419
+ const btn = document.getElementById('overflow-btn');
420
+ menu.classList.add('hidden');
421
+ btn.setAttribute('aria-expanded', 'false');
422
+ document.removeEventListener('click', handleOverflowClickOutside);
423
+ }
424
+
425
+ // Handle click outside overflow menu
426
+ function handleOverflowClickOutside(event) {
427
+ const menu = document.getElementById('overflow-menu');
428
+ const btn = document.getElementById('overflow-btn');
429
+ if (!menu.contains(event.target) && !btn.contains(event.target)) {
430
+ hideOverflowMenu();
431
+ }
432
+ }
433
+
434
+ // Select tab from overflow menu
435
+ function selectTabFromMenu(tabId) {
436
+ hideOverflowMenu();
437
+ selectTab(tabId);
438
+ }
439
+
440
+ // Open tab in new window from overflow menu
441
+ function openInNewTabFromMenu(tabId) {
442
+ hideOverflowMenu();
443
+ openInNewTab(tabId);
444
+ }
445
+
446
+ // Handle keyboard navigation in overflow menu
447
+ function handleOverflowMenuKeydown(event, tabId) {
448
+ const menu = document.getElementById('overflow-menu');
449
+ const items = Array.from(menu.querySelectorAll('.overflow-menu-item'));
450
+ const currentIndex = items.findIndex(item => item === document.activeElement);
451
+
452
+ switch (event.key) {
453
+ case 'ArrowDown':
454
+ event.preventDefault();
455
+ const nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
456
+ items[nextIndex].focus();
457
+ break;
458
+ case 'ArrowUp':
459
+ event.preventDefault();
460
+ const prevIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
461
+ items[prevIndex].focus();
462
+ break;
463
+ case 'Enter':
464
+ case ' ':
465
+ event.preventDefault();
466
+ selectTabFromMenu(tabId);
467
+ break;
468
+ case 'Escape':
469
+ event.preventDefault();
470
+ hideOverflowMenu();
471
+ document.getElementById('overflow-btn').focus();
472
+ break;
473
+ case 'Tab':
474
+ hideOverflowMenu();
475
+ break;
476
+ }
477
+ }
478
+
479
+ // Open tab content in a new browser tab
480
+ function openInNewTab(tabId) {
481
+ const tab = tabs.find(t => t.id === tabId);
482
+ if (!tab) return;
483
+
484
+ let url;
485
+ if (tab.type === 'file') {
486
+ if (!tab.port) {
487
+ showToast('Tab not ready', 'error');
488
+ return;
489
+ }
490
+ url = `http://localhost:${tab.port}`;
491
+ } else {
492
+ if (!tab.port) {
493
+ showToast('Tab not ready', 'error');
494
+ return;
495
+ }
496
+ url = `http://localhost:${tab.port}`;
497
+ }
498
+
499
+ window.open(url, '_blank', 'noopener,noreferrer');
500
+ }
@@ -0,0 +1,57 @@
1
+ // Dashboard Utility Functions
2
+
3
+ // Escape HTML special characters to prevent XSS
4
+ function escapeHtml(text) {
5
+ return String(text)
6
+ .replace(/&/g, '&amp;')
7
+ .replace(/</g, '&lt;')
8
+ .replace(/>/g, '&gt;')
9
+ .replace(/"/g, '&quot;')
10
+ .replace(/'/g, '&#39;');
11
+ }
12
+
13
+ // XSS-safe HTML escaping (used by projects module)
14
+ function escapeProjectHtml(text) {
15
+ if (!text) return '';
16
+ const div = document.createElement('div');
17
+ div.textContent = String(text);
18
+ return div.innerHTML;
19
+ }
20
+
21
+ // Escape a string for use inside a JavaScript string literal in onclick handlers
22
+ function escapeJsString(str) {
23
+ return str
24
+ .replace(/\\/g, '\\\\')
25
+ .replace(/'/g, "\\'")
26
+ .replace(/"/g, '\\"')
27
+ .replace(/\n/g, '\\n')
28
+ .replace(/\r/g, '\\r');
29
+ }
30
+
31
+ // Get filename from path
32
+ function getFileName(path) {
33
+ const parts = path.split('/');
34
+ return parts[parts.length - 1];
35
+ }
36
+
37
+ // Simple DJB2 hash for change detection
38
+ function hashString(str) {
39
+ let hash = 5381;
40
+ for (let i = 0; i < str.length; i++) {
41
+ hash = ((hash << 5) + hash) + str.charCodeAt(i);
42
+ }
43
+ return hash >>> 0;
44
+ }
45
+
46
+ // Toast notifications
47
+ function showToast(message, type = 'info') {
48
+ const container = document.getElementById('toast-container');
49
+ const toast = document.createElement('div');
50
+ toast.className = `toast ${type}`;
51
+ toast.textContent = message;
52
+ container.appendChild(toast);
53
+
54
+ setTimeout(() => {
55
+ toast.remove();
56
+ }, 3000);
57
+ }