trackops 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +341 -232
  2. package/bin/trackops.js +102 -70
  3. package/lib/config.js +260 -35
  4. package/lib/control.js +518 -475
  5. package/lib/env.js +227 -0
  6. package/lib/i18n.js +61 -53
  7. package/lib/init.js +146 -55
  8. package/lib/locale.js +63 -0
  9. package/lib/opera-bootstrap.js +523 -0
  10. package/lib/opera.js +319 -170
  11. package/lib/registry.js +27 -13
  12. package/lib/release.js +56 -0
  13. package/lib/resources.js +42 -0
  14. package/lib/server.js +912 -418
  15. package/lib/skills.js +148 -124
  16. package/lib/workspace.js +260 -0
  17. package/locales/en.json +331 -139
  18. package/locales/es.json +331 -139
  19. package/package.json +14 -3
  20. package/scripts/skills-marketplace-smoke.js +124 -0
  21. package/scripts/smoke-tests.js +445 -0
  22. package/scripts/sync-skill-version.js +21 -0
  23. package/scripts/validate-skill.js +88 -0
  24. package/skills/trackops/SKILL.md +64 -0
  25. package/skills/trackops/agents/openai.yaml +3 -0
  26. package/skills/trackops/references/activation.md +39 -0
  27. package/skills/trackops/references/troubleshooting.md +34 -0
  28. package/skills/trackops/references/workflow.md +20 -0
  29. package/skills/trackops/scripts/bootstrap-trackops.js +201 -0
  30. package/skills/trackops/skill.json +29 -0
  31. package/templates/etapa/agent.md +2 -2
  32. package/templates/etapa/references/etapa-cycle.md +1 -1
  33. package/templates/opera/agent.md +1 -1
  34. package/templates/opera/en/agent.md +26 -0
  35. package/templates/opera/en/genesis.md +79 -0
  36. package/templates/opera/en/references/autonomy-and-recovery.md +23 -0
  37. package/templates/opera/en/references/opera-cycle.md +62 -0
  38. package/templates/opera/en/registry.md +28 -0
  39. package/templates/opera/en/router.md +39 -0
  40. package/templates/opera/genesis.md +79 -94
  41. package/templates/skills/changelog-updater/locales/en/SKILL.md +11 -0
  42. package/templates/skills/commiter/locales/en/SKILL.md +11 -0
  43. package/templates/skills/project-starter-skill/SKILL.md +5 -3
  44. package/templates/skills/project-starter-skill/locales/en/SKILL.md +24 -0
  45. package/ui/css/base.css +266 -0
  46. package/ui/css/charts.css +327 -0
  47. package/ui/css/components.css +570 -0
  48. package/ui/css/panels.css +956 -0
  49. package/ui/css/tokens.css +227 -0
  50. package/ui/favicon.svg +5 -0
  51. package/ui/index.html +91 -351
  52. package/ui/js/api.js +220 -0
  53. package/ui/js/app.js +200 -0
  54. package/ui/js/console-logger.js +172 -0
  55. package/ui/js/i18n.js +14 -0
  56. package/ui/js/icons.js +104 -0
  57. package/ui/js/onboarding.js +439 -0
  58. package/ui/js/router.js +125 -0
  59. package/ui/js/state.js +130 -0
  60. package/ui/js/theme.js +100 -0
  61. package/ui/js/time-tracker.js +248 -0
  62. package/ui/js/utils.js +175 -0
  63. package/ui/js/views/board.js +255 -0
  64. package/ui/js/views/execution.js +256 -0
  65. package/ui/js/views/flash.js +47 -0
  66. package/ui/js/views/insights.js +340 -0
  67. package/ui/js/views/overview.js +365 -0
  68. package/ui/js/views/settings.js +381 -0
  69. package/ui/js/views/sidebar.js +131 -0
  70. package/ui/js/views/skills.js +163 -0
  71. package/ui/js/views/tasks.js +406 -0
  72. package/ui/js/views/topbar.js +239 -0
package/ui/js/api.js ADDED
@@ -0,0 +1,220 @@
1
+ /**
2
+ * api.js — Capa de comunicación con el backend TrackOps
3
+ * Wrapper sobre fetch con gestión de errores, project-awareness
4
+ * y tipado de los endpoints disponibles en lib/server.js
5
+ */
6
+
7
+ import * as state from './state.js';
8
+
9
+ /**
10
+ * Llamada base a la API
11
+ * @param {string} url
12
+ * @param {RequestInit & { projectAware?: boolean }} options
13
+ * @returns {Promise<Object>}
14
+ */
15
+ async function call(url, options = {}) {
16
+ const target = new URL(url, window.location.origin);
17
+ const currentId = state.get('currentProjectId');
18
+ if (options.projectAware !== false && currentId && !target.searchParams.has('project')) {
19
+ target.searchParams.set('project', currentId);
20
+ }
21
+
22
+ const response = await fetch(target, {
23
+ ...options,
24
+ headers: {
25
+ 'Content-Type': 'application/json',
26
+ ...(options.headers || {}),
27
+ },
28
+ });
29
+
30
+ const text = await response.text();
31
+ const json = text ? JSON.parse(text) : {};
32
+
33
+ if (!response.ok || json.ok === false) {
34
+ const err = new Error(json.error || `HTTP ${response.status}: ${response.statusText}`);
35
+ err.status = response.status;
36
+ throw err;
37
+ }
38
+
39
+ return json;
40
+ }
41
+
42
+ // ─────────────────────────────── PROYECTOS ──────────────────────────────────
43
+
44
+ /**
45
+ * Lista todos los proyectos del portfolio
46
+ */
47
+ export async function getProjects() {
48
+ return call('/api/projects', { projectAware: false });
49
+ }
50
+
51
+ /**
52
+ * Registra un proyecto existente en el portfolio
53
+ * @param {string} root - ruta del directorio del proyecto
54
+ */
55
+ export async function registerProject(root) {
56
+ return call('/api/projects/register', {
57
+ method: 'POST',
58
+ projectAware: false,
59
+ body: JSON.stringify({ root }),
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Instala trackops en un nuevo proyecto
65
+ * @param {string} root
66
+ */
67
+ export async function installProject(root, options = {}) {
68
+ return call('/api/projects/install', {
69
+ method: 'POST',
70
+ projectAware: false,
71
+ body: JSON.stringify({ root, ...options }),
72
+ });
73
+ }
74
+
75
+ export async function updateProjectLocale(locale) {
76
+ return call('/api/projects/locale', {
77
+ method: 'POST',
78
+ body: JSON.stringify({ projectId: state.get('currentProjectId'), locale }),
79
+ });
80
+ }
81
+
82
+ // ─────────────────────────────── ESTADO ─────────────────────────────────────
83
+
84
+ /**
85
+ * Obtiene el estado completo del proyecto activo
86
+ */
87
+ export async function getState() {
88
+ return call('/api/state');
89
+ }
90
+
91
+ export async function getEnvStatus() {
92
+ return call('/api/env');
93
+ }
94
+
95
+ export async function syncEnv() {
96
+ return call('/api/env/sync', {
97
+ method: 'POST',
98
+ });
99
+ }
100
+
101
+ // ─────────────────────────────── TAREAS ─────────────────────────────────────
102
+
103
+ /**
104
+ * Crea una nueva tarea
105
+ * @param {Object} payload
106
+ */
107
+ export async function createTask(payload) {
108
+ return call('/api/tasks', {
109
+ method: 'POST',
110
+ body: JSON.stringify({ projectId: state.get('currentProjectId'), ...payload }),
111
+ });
112
+ }
113
+
114
+ /**
115
+ * Actualiza una tarea existente (edición completa)
116
+ * @param {string} taskId
117
+ * @param {Object} payload
118
+ */
119
+ export async function updateTask(taskId, payload) {
120
+ return call(`/api/tasks/${encodeURIComponent(taskId)}`, {
121
+ method: 'PUT',
122
+ body: JSON.stringify({ projectId: state.get('currentProjectId'), ...payload }),
123
+ });
124
+ }
125
+
126
+ /**
127
+ * Ejecuta una acción sobre una tarea (start, review, complete, block, pending, cancel)
128
+ * @param {string} taskId
129
+ * @param {string} action
130
+ * @param {string} [note]
131
+ */
132
+ export async function taskAction(taskId, action, note = '') {
133
+ return call(`/api/tasks/${encodeURIComponent(taskId)}/action`, {
134
+ method: 'POST',
135
+ body: JSON.stringify({ projectId: state.get('currentProjectId'), action, note }),
136
+ });
137
+ }
138
+
139
+ // ─────────────────────────────── SYNC ───────────────────────────────────────
140
+
141
+ /**
142
+ * Sincroniza los docs del proyecto (task_plan.md, progress.md, findings.md)
143
+ */
144
+ export async function syncDocs() {
145
+ return call('/api/sync', {
146
+ method: 'POST',
147
+ body: JSON.stringify({ projectId: state.get('currentProjectId') }),
148
+ });
149
+ }
150
+
151
+ // ─────────────────────────────── COMANDOS ───────────────────────────────────
152
+
153
+ /**
154
+ * Ejecuta un comando en el shell del proyecto
155
+ * @param {string} command
156
+ */
157
+ export async function runCommand(command) {
158
+ return call('/api/commands', {
159
+ method: 'POST',
160
+ body: JSON.stringify({ projectId: state.get('currentProjectId'), command }),
161
+ });
162
+ }
163
+
164
+ /**
165
+ * Crea un EventSource para hacer streaming de salida de una sesión
166
+ * @param {string} sessionId
167
+ * @returns {EventSource}
168
+ */
169
+ export function streamSession(sessionId) {
170
+ return new EventSource(`/api/commands/${encodeURIComponent(sessionId)}/stream`);
171
+ }
172
+
173
+ // ─────────────────────────────── TIME TRACKING ──────────────────────────────
174
+
175
+ /**
176
+ * Inicia un time entry para una tarea
177
+ * @param {string} taskId
178
+ * @param {string} taskTitle
179
+ */
180
+ export async function startTimeEntry(taskId, taskTitle) {
181
+ return call('/api/time/start', {
182
+ method: 'POST',
183
+ body: JSON.stringify({ projectId: state.get('currentProjectId'), taskId, taskTitle }),
184
+ });
185
+ }
186
+
187
+ /**
188
+ * Detiene el time entry activo
189
+ * @param {string} entryId
190
+ */
191
+ export async function stopTimeEntry(entryId) {
192
+ return call('/api/time/stop', {
193
+ method: 'POST',
194
+ body: JSON.stringify({ projectId: state.get('currentProjectId'), entryId }),
195
+ });
196
+ }
197
+
198
+ /**
199
+ * Obtiene los time entries del proyecto activo
200
+ */
201
+ export async function getTimeEntries() {
202
+ return call('/api/time');
203
+ }
204
+
205
+ // ─────────────────────────────── SKILLS HUB ────────────────────────────────
206
+
207
+ export async function fetchSkillsLocal() {
208
+ return call('/api/skills/local');
209
+ }
210
+
211
+ export async function fetchSkillsDiscover() {
212
+ return call('/api/skills/discover');
213
+ }
214
+
215
+ export async function installSkill(skillId) {
216
+ return call('/api/skills/install', {
217
+ method: 'POST',
218
+ body: JSON.stringify({ skillId })
219
+ });
220
+ }
package/ui/js/app.js ADDED
@@ -0,0 +1,200 @@
1
+ /**
2
+ * app.js — Orquestador principal del dashboard TrackOps
3
+ * Punto de entrada del módulo ES.
4
+ */
5
+
6
+ import * as state from './state.js';
7
+ import * as api from './api.js';
8
+ import * as router from './router.js';
9
+ import * as consoleLogger from './console-logger.js';
10
+ import * as onboarding from './onboarding.js';
11
+ import * as timeTracker from './time-tracker.js';
12
+ import * as theme from './theme.js';
13
+
14
+ // Vistas
15
+ import { render as renderSidebar } from './views/sidebar.js';
16
+ import { render as renderTopbar } from './views/topbar.js';
17
+ import { render as renderOverview } from './views/overview.js';
18
+ import { render as renderBoard } from './views/board.js';
19
+ import { render as renderTasks } from './views/tasks.js';
20
+ import * as executionView from './views/execution.js';
21
+ import { render as renderInsights } from './views/insights.js';
22
+ import * as settingsView from './views/settings.js';
23
+ import * as skillsView from './views/skills.js';
24
+
25
+ // ─────────────────────────────── INIT ───────────────────────────────────────
26
+
27
+ async function init() {
28
+ // 0. Tema (PRIMERO: evitar flash de tema incorrecto)
29
+ theme.init();
30
+
31
+ // 1. Console logger (capturar errores desde el arranque)
32
+ consoleLogger.init();
33
+
34
+ // 2. Registrar rutas en el router
35
+ router.register('overview', renderOverview);
36
+ router.register('board', renderBoard);
37
+ router.register('tasks', renderTasks);
38
+ router.register('execution', async () => {
39
+ const html = await executionView.render();
40
+ setTimeout(() => executionView.bindEvents(), 50);
41
+ return html;
42
+ });
43
+ router.register('insights', renderInsights);
44
+ router.register('settings', async () => {
45
+ const html = await settingsView.render();
46
+ setTimeout(() => settingsView.bindEvents(), 50);
47
+ return html;
48
+ });
49
+ router.register('skills', async () => {
50
+ const html = await skillsView.render();
51
+ setTimeout(() => {
52
+ skillsView.bindEvents();
53
+ skillsView.loadData();
54
+ }, 50);
55
+ return html;
56
+ });
57
+
58
+ // 3. Inicializar el router
59
+ router.init(document.getElementById('view-container'));
60
+
61
+ // 4. Cargar estado inicial
62
+ await _loadInitialState();
63
+
64
+ // 5. Renderizar chrome (sidebar + topbar)
65
+ renderSidebar();
66
+ renderTopbar();
67
+
68
+ // 6. Navegar a la vista inicial
69
+ await router.start('overview');
70
+
71
+ // 7. Cargar time entries en background
72
+ timeTracker.loadEntries().catch(err => {
73
+ console.warn('[app] No se pudieron cargar time entries:', err.message);
74
+ });
75
+
76
+ // 8. Inicializar onboarding
77
+ onboarding.init();
78
+
79
+ // 9. Suscribir refreshes globales
80
+ _bindGlobalEvents();
81
+
82
+ // 10. Auto-refresh cada 60s
83
+ setInterval(_refreshState, 60_000);
84
+ }
85
+
86
+ // ─────────────────────────────── CARGA DE ESTADO ────────────────────────────
87
+
88
+ async function _loadInitialState() {
89
+ try {
90
+ // Cargar lista de proyectos
91
+ const projectsResult = await api.getProjects();
92
+ const projects = projectsResult.projects || [];
93
+ state.update('projects', projects);
94
+
95
+ // Determinar proyecto activo (persistido o primero disponible)
96
+ const saved = localStorage.getItem('ops-dashboard-project');
97
+ const first = projects.find(p => p.available);
98
+ const currentId = saved && projects.some(p => p.id === saved)
99
+ ? saved
100
+ : first?.id || null;
101
+
102
+ state.update('currentProjectId', currentId);
103
+
104
+ // Cargar el estado del proyecto activo
105
+ if (currentId) {
106
+ await _refreshState();
107
+ }
108
+ } catch (err) {
109
+ console.error('[app] Error cargando estado inicial:', err);
110
+ // Si el endpoint de proyectos falla, intentar cargar el estado directamente
111
+ try {
112
+ await _refreshState();
113
+ } catch (fallbackErr) {
114
+ console.error('[app] Error en fallback de estado:', fallbackErr);
115
+ }
116
+ }
117
+ }
118
+
119
+ async function _refreshState() {
120
+ try {
121
+ const payload = await api.getState();
122
+ state.update('payload', payload);
123
+
124
+ // Actualizar datos de i18n desde el backend
125
+ if (payload.i18n) {
126
+ state.update('phases', payload.i18n.phases || []);
127
+ state.update('statusLabels', payload.i18n.statusLabels || {});
128
+ state.update('locale', payload.i18n.locale || 'es');
129
+ state.update('messages', payload.i18n.messages || {});
130
+ }
131
+
132
+ // Actualizar proyectos si el payload incluye info de proyectos
133
+ if (payload.project && state.get('projects').length === 0) {
134
+ state.update('projects', [{ ...payload.project, available: true }]);
135
+ state.update('currentProjectId', payload.project.id);
136
+ }
137
+
138
+ // Actualizar helper findTask en el state (se accede via get('payload'))
139
+ // NO se puede asignar a state.findTask porque los namespaces de módulos ES son readonly.
140
+
141
+ // Re-renderizar chrome (puede haber cambiado el repo status)
142
+ renderTopbar();
143
+ renderSidebar();
144
+
145
+ // Refrescar la vista actual
146
+ await router.refresh();
147
+
148
+ } catch (err) {
149
+ console.error('[app] Error actualizando estado:', err);
150
+ }
151
+ }
152
+
153
+ // ─────────────────────────────── EVENTOS GLOBALES ───────────────────────────
154
+
155
+ function _bindGlobalEvents() {
156
+ // Refresh global (disparado por sync, cambio de proyecto, etc.)
157
+ window.addEventListener('ops:refresh', async () => {
158
+ await _refreshState();
159
+ });
160
+
161
+ // Búsqueda global → refrescar la vista actual
162
+ window.addEventListener('ops:search', () => {
163
+ const active = router.current();
164
+ if (active === 'board' || active === 'tasks') {
165
+ router.refresh();
166
+ }
167
+ });
168
+
169
+ // Navegación por teclado: Escape cierra modales / deselecciona
170
+ document.addEventListener('keydown', e => {
171
+ if (e.key === 'Escape') {
172
+ // Deseleccionar tarea si no hay modal abierto
173
+ const modalEl = document.querySelector('.modal-overlay:not(.is-hidden)');
174
+ if (!modalEl) {
175
+ // No deseleccionar: permite a las vistas manejar escape internamente
176
+ }
177
+ }
178
+ });
179
+
180
+ // Actualizar sidebar badges cuando cambia el payload
181
+ state.subscribe('payload', () => {
182
+ import('./views/sidebar.js').then(m => m.updateBadges?.());
183
+ });
184
+ }
185
+
186
+ // ─────────────────────────────── ARRANQUE ───────────────────────────────────
187
+
188
+ document.addEventListener('DOMContentLoaded', () => {
189
+ init().catch(err => {
190
+ console.error('[app] Error fatal en la inicialización:', err);
191
+ document.getElementById('view-container').innerHTML = `
192
+ <div class="empty-state" style="margin:4rem auto;max-width:440px">
193
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--danger)"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
194
+ <h3 style="margin-top:var(--space-2)">Error al iniciar el dashboard</h3>
195
+ <p style="font-size:var(--text-sm);color:var(--text-secondary)">${err.message}</p>
196
+ <button class="btn btn-primary" onclick="location.reload()">Reintentar</button>
197
+ </div>
198
+ `;
199
+ });
200
+ });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * console-logger.js — Panel de logs y captura de errores del frontend
3
+ * Intercepta console.error, console.warn y window.onerror.
4
+ * Muestra un panel colapsable en la parte inferior de la pantalla.
5
+ */
6
+
7
+ import * as state from './state.js';
8
+
9
+ const MAX_LOGS = 100;
10
+ let _panel = null;
11
+ let _logsList = null;
12
+ let _errorBadge = null;
13
+ let _titleEl = null;
14
+ let _errorCount = 0;
15
+
16
+ /** Inicializa el módulo y vincula el panel del DOM */
17
+ export function init() {
18
+ _panel = document.getElementById('console-panel');
19
+ _logsList = document.getElementById('console-logs');
20
+ _errorBadge = document.getElementById('console-error-count');
21
+ _titleEl = document.getElementById('console-panel-title');
22
+
23
+ const closeBtn = document.getElementById('console-close-btn');
24
+ const clearBtn = document.getElementById('console-clear-btn');
25
+
26
+ closeBtn?.addEventListener('click', close);
27
+ clearBtn?.addEventListener('click', clear);
28
+
29
+ _patchConsole();
30
+ _attachWindowErrors();
31
+ }
32
+
33
+ /** Abre el panel de console */
34
+ export function open() {
35
+ _panel?.classList.add('is-open');
36
+ state.update('consolePanelOpen', true);
37
+ // Scroll to bottom
38
+ setTimeout(() => { if (_logsList) _logsList.scrollTop = _logsList.scrollHeight; }, 50);
39
+ }
40
+
41
+ /** Cierra el panel */
42
+ export function close() {
43
+ _panel?.classList.remove('is-open');
44
+ state.update('consolePanelOpen', false);
45
+ }
46
+
47
+ /** Toggle */
48
+ export function toggle() {
49
+ if (state.get('consolePanelOpen')) close();
50
+ else open();
51
+ }
52
+
53
+ /** Limpia los logs */
54
+ export function clear() {
55
+ state.update('consoleLogs', []);
56
+ _errorCount = 0;
57
+ if (_logsList) _logsList.innerHTML = '';
58
+ _updateErrorBadge();
59
+ }
60
+
61
+ /**
62
+ * Añade una entrada al log
63
+ * @param {'error'|'warn'|'info'|'debug'} level
64
+ * @param {string} message
65
+ */
66
+ export function log(level, message) {
67
+ const entry = {
68
+ level,
69
+ message: String(message),
70
+ time: new Date().toISOString(),
71
+ };
72
+
73
+ const logs = state.get('consoleLogs');
74
+ logs.push(entry);
75
+ if (logs.length > MAX_LOGS) logs.shift();
76
+ state.update('consoleLogs', logs);
77
+
78
+ _appendEntry(entry);
79
+
80
+ if (level === 'error') {
81
+ _errorCount++;
82
+ _updateErrorBadge();
83
+ // Auto-abrir en error
84
+ if (!state.get('consolePanelOpen')) open();
85
+ }
86
+ }
87
+
88
+ function _appendEntry(entry) {
89
+ if (!_logsList) return;
90
+ const time = entry.time.slice(11, 23); // HH:mm:ss.mmm
91
+ const item = document.createElement('div');
92
+ item.className = 'console-log-entry';
93
+ item.setAttribute('role', 'listitem');
94
+ item.innerHTML = `
95
+ <span class="console-log-time">${time}</span>
96
+ <span class="log-level log-${entry.level}">[${entry.level.toUpperCase()}]</span>
97
+ <span class="console-log-msg">${_escapeHtml(entry.message)}</span>
98
+ `;
99
+ _logsList.appendChild(item);
100
+ _logsList.scrollTop = _logsList.scrollHeight;
101
+
102
+ // Limitar nodos en el DOM
103
+ while (_logsList.childElementCount > MAX_LOGS) {
104
+ _logsList.firstElementChild?.remove();
105
+ }
106
+ }
107
+
108
+ function _updateErrorBadge() {
109
+ if (!_errorBadge) return;
110
+ if (_errorCount > 0) {
111
+ _errorBadge.textContent = _errorCount;
112
+ _errorBadge.style.display = '';
113
+ } else {
114
+ _errorBadge.style.display = 'none';
115
+ }
116
+ // Actualizar badge del sidebar
117
+ const sidebarBadge = document.getElementById('sidebar-console-badge');
118
+ if (sidebarBadge) {
119
+ sidebarBadge.textContent = _errorCount;
120
+ sidebarBadge.style.display = _errorCount > 0 ? '' : 'none';
121
+ }
122
+ }
123
+
124
+ function _patchConsole() {
125
+ const _origError = console.error.bind(console);
126
+ const _origWarn = console.warn.bind(console);
127
+ const _origLog = console.log.bind(console);
128
+
129
+ console.error = (...args) => {
130
+ _origError(...args);
131
+ log('error', args.map(_serialize).join(' '));
132
+ };
133
+ console.warn = (...args) => {
134
+ _origWarn(...args);
135
+ log('warn', args.map(_serialize).join(' '));
136
+ };
137
+ // Solo capturar console.log marcados como [ops]
138
+ console.log = (...args) => {
139
+ _origLog(...args);
140
+ const msg = args.map(_serialize).join(' ');
141
+ if (msg.startsWith('[ops]') || msg.startsWith('[router]') || msg.startsWith('[api]')) {
142
+ log('info', msg);
143
+ }
144
+ };
145
+ }
146
+
147
+ function _attachWindowErrors() {
148
+ window.onerror = (message, source, lineno, colno, error) => {
149
+ const msg = error?.stack || `${message} (${source}:${lineno}:${colno})`;
150
+ log('error', msg);
151
+ return false; // Dejar que sigan propagándose
152
+ };
153
+
154
+ window.addEventListener('unhandledrejection', event => {
155
+ const msg = event.reason?.stack || String(event.reason);
156
+ log('error', `Unhandled Promise rejection: ${msg}`);
157
+ });
158
+ }
159
+
160
+ function _serialize(value) {
161
+ if (typeof value === 'string') return value;
162
+ if (value instanceof Error) return value.stack || value.message;
163
+ try { return JSON.stringify(value, null, 0); } catch { return String(value); }
164
+ }
165
+
166
+ function _escapeHtml(str) {
167
+ return String(str)
168
+ .replace(/&/g, '&amp;')
169
+ .replace(/</g, '&lt;')
170
+ .replace(/>/g, '&gt;')
171
+ .replace(/"/g, '&quot;');
172
+ }
package/ui/js/i18n.js ADDED
@@ -0,0 +1,14 @@
1
+ import * as state from './state.js';
2
+
3
+ export function t(key, params = {}, fallback = null) {
4
+ const messages = state.get('messages') || {};
5
+ let value = messages[key] || fallback || key;
6
+ for (const [param, replacement] of Object.entries(params)) {
7
+ value = value.replace(new RegExp(`\\{${param}\\}`, 'g'), String(replacement));
8
+ }
9
+ return value;
10
+ }
11
+
12
+ export function listFormat(values, separator = ', ') {
13
+ return (values || []).filter(Boolean).join(separator);
14
+ }