trackops 1.0.1 → 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 (57) hide show
  1. package/README.md +326 -270
  2. package/bin/trackops.js +102 -70
  3. package/lib/config.js +260 -35
  4. package/lib/control.js +517 -475
  5. package/lib/env.js +227 -0
  6. package/lib/i18n.js +61 -53
  7. package/lib/init.js +135 -46
  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 +907 -554
  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 +7 -9
  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/opera/en/agent.md +26 -0
  32. package/templates/opera/en/genesis.md +79 -0
  33. package/templates/opera/en/references/autonomy-and-recovery.md +23 -0
  34. package/templates/opera/en/references/opera-cycle.md +62 -0
  35. package/templates/opera/en/registry.md +28 -0
  36. package/templates/opera/en/router.md +39 -0
  37. package/templates/opera/genesis.md +79 -94
  38. package/templates/skills/changelog-updater/locales/en/SKILL.md +11 -0
  39. package/templates/skills/commiter/locales/en/SKILL.md +11 -0
  40. package/templates/skills/project-starter-skill/locales/en/SKILL.md +24 -0
  41. package/ui/css/panels.css +956 -953
  42. package/ui/index.html +1 -1
  43. package/ui/js/api.js +211 -194
  44. package/ui/js/app.js +200 -199
  45. package/ui/js/i18n.js +14 -0
  46. package/ui/js/onboarding.js +439 -437
  47. package/ui/js/state.js +130 -129
  48. package/ui/js/utils.js +175 -172
  49. package/ui/js/views/board.js +255 -254
  50. package/ui/js/views/execution.js +256 -256
  51. package/ui/js/views/insights.js +340 -339
  52. package/ui/js/views/overview.js +365 -364
  53. package/ui/js/views/settings.js +340 -202
  54. package/ui/js/views/sidebar.js +131 -132
  55. package/ui/js/views/skills.js +163 -162
  56. package/ui/js/views/tasks.js +406 -405
  57. package/ui/js/views/topbar.js +239 -183
@@ -1,12 +1,109 @@
1
- /**
2
- * settings.js — Vista de configuración del proyecto
3
- */
1
+ /**
2
+ * settings.js — Vista de configuración del proyecto
3
+ */
4
+
5
+ import { icon } from '../icons.js';
6
+ import * as state from '../state.js';
7
+ import * as api from '../api.js';
8
+ import { flash } from './flash.js';
9
+ import { esc, formatDate } from '../utils.js';
10
+ import { t } from '../i18n.js';
11
+
12
+ function applyLocaleState(payload) {
13
+ if (!payload) return;
14
+ state.update('payload', payload);
15
+ if (payload.i18n) {
16
+ state.update({
17
+ phases: payload.i18n.phases || [],
18
+ statusLabels: payload.i18n.statusLabels || {},
19
+ locale: payload.i18n.locale || 'es',
20
+ messages: payload.i18n.messages || {},
21
+ });
22
+ }
23
+ }
24
+
25
+ function renderWorkspacePanel(payload) {
26
+ const project = payload?.project;
27
+ if (!project) return '';
4
28
 
5
- import { icon } from '../icons.js';
6
- import * as state from '../state.js';
7
- import * as api from '../api.js';
8
- import { flash } from './flash.js';
9
- import { esc, formatDate } from '../utils.js';
29
+ return `
30
+ <div class="panel">
31
+ <div class="panel-header">
32
+ <p class="panel-title">${t('ui.settings.workspaceTitle', {}, 'Workspace')}</p>
33
+ <span class="badge badge-muted">${esc(project.layout || 'legacy')}</span>
34
+ </div>
35
+ <div class="panel-body" style="display:flex;flex-direction:column;gap:var(--space-3)">
36
+ <div class="info-row">
37
+ <p class="label-sm">${t('ui.settings.workspaceRoot', {}, 'Workspace root')}</p>
38
+ <p class="value">${esc(project.workspaceRoot || project.root || '—')}</p>
39
+ </div>
40
+ <div class="info-row">
41
+ <p class="label-sm">${t('ui.settings.appRoot', {}, 'App root')}</p>
42
+ <p class="value">${esc(project.appRoot || project.root || '—')}</p>
43
+ </div>
44
+ <div class="info-row">
45
+ <p class="label-sm">${t('ui.settings.opsRoot', {}, 'Ops root')}</p>
46
+ <p class="value">${esc(project.opsRoot || project.root || '—')}</p>
47
+ </div>
48
+ </div>
49
+ </div>
50
+ `;
51
+ }
52
+
53
+ function renderEnvPanel(envState) {
54
+ if (!envState) return '';
55
+
56
+ const required = Array.isArray(envState.requiredKeys) ? envState.requiredKeys : [];
57
+ const present = Array.isArray(envState.presentKeys) ? envState.presentKeys : [];
58
+ const missing = Array.isArray(envState.missingKeys) ? envState.missingKeys : [];
59
+ const files = envState.files || {};
60
+
61
+ return `
62
+ <div class="panel">
63
+ <div class="panel-header">
64
+ <p class="panel-title">${t('ui.settings.envTitle', {}, 'Environment')}</p>
65
+ <span class="badge badge-${missing.length ? 'warning' : 'success'}">${missing.length ? t('ui.settings.envMissingBadge', { count: missing.length }, `${missing.length} missing`) : t('ui.settings.envHealthyBadge', {}, 'Ready')}</span>
66
+ </div>
67
+ <div class="panel-body" style="display:flex;flex-direction:column;gap:var(--space-3)">
68
+ <div class="info-row">
69
+ <p class="label-sm">${t('ui.settings.envBridgeMode', {}, 'Bridge mode')}</p>
70
+ <p class="value">${esc(envState.bridgeMode || 'none')}</p>
71
+ </div>
72
+ <div class="info-row">
73
+ <p class="label-sm">${t('ui.settings.envRootFile', {}, 'Root .env')}</p>
74
+ <p class="value">${esc(files.rootEnv || '—')}</p>
75
+ </div>
76
+ <div class="info-row">
77
+ <p class="label-sm">${t('ui.settings.envExampleFile', {}, '.env.example')}</p>
78
+ <p class="value">${esc(files.rootExample || '—')}</p>
79
+ </div>
80
+ <div class="info-row">
81
+ <p class="label-sm">${t('ui.settings.envAppBridge', {}, 'App bridge')}</p>
82
+ <p class="value">${esc(files.appBridge || '—')}</p>
83
+ </div>
84
+ <div class="info-row">
85
+ <p class="label-sm">${t('ui.settings.envRequiredKeys', {}, 'Required keys')}</p>
86
+ <p class="value">${required.length ? esc(required.join(', ')) : '—'}</p>
87
+ </div>
88
+ <div class="info-row">
89
+ <p class="label-sm">${t('ui.settings.envPresentKeys', {}, 'Present keys')}</p>
90
+ <p class="value">${present.length ? esc(present.join(', ')) : '—'}</p>
91
+ </div>
92
+ <div class="info-row">
93
+ <p class="label-sm">${t('ui.settings.envMissingKeys', {}, 'Missing keys')}</p>
94
+ <p class="value">${missing.length ? esc(missing.join(', ')) : t('ui.settings.envNoMissing', {}, 'None')}</p>
95
+ </div>
96
+ <div class="info-row">
97
+ <p class="label-sm">${t('ui.settings.envLastAudit', {}, 'Last audit')}</p>
98
+ <p class="value">${envState.lastAuditAt ? formatDate(envState.lastAuditAt, 'date') : '—'}</p>
99
+ </div>
100
+ <button class="btn btn-ghost btn-sm" type="button" id="sync-env-btn">
101
+ ${icon('sync', 14)} ${t('ui.settings.envSync', {}, 'Sync env')}
102
+ </button>
103
+ </div>
104
+ </div>
105
+ `;
106
+ }
10
107
 
11
108
  export async function render() {
12
109
  const payload = state.getPayload();
@@ -15,229 +112,270 @@ export async function render() {
15
112
  const control = payload?.control;
16
113
  const runtime = payload?.runtime;
17
114
  const docsDirty = payload?.docsDirty || [];
18
-
19
- return `
20
- <div class="view-enter">
21
- <div class="section-header">
22
- <div class="section-header-left">
23
- <p class="eyebrow">Configuración</p>
24
- <h2>Configuración del Proyecto</h2>
25
- </div>
26
- </div>
27
-
28
- <div class="grid-2" style="align-items:start">
29
-
30
- <!-- Col izquierda -->
31
- <div style="display:flex;flex-direction:column;gap:var(--space-4)">
32
-
33
- <!-- Info del proyecto activo -->
115
+ const envState = payload?.env;
116
+
117
+ return `
118
+ <div class="view-enter">
119
+ <div class="section-header">
120
+ <div class="section-header-left">
121
+ <p class="eyebrow">${t('ui.settings.eyebrow', {}, 'Settings')}</p>
122
+ <h2>${t('ui.settings.title', {}, 'Project settings')}</h2>
123
+ </div>
124
+ </div>
125
+
126
+ <div class="grid-2" style="align-items:start">
127
+
128
+ <!-- Col izquierda -->
129
+ <div style="display:flex;flex-direction:column;gap:var(--space-4)">
130
+
131
+ <!-- Info del proyecto activo -->
34
132
  ${control ? `
35
133
  <div class="panel">
36
134
  <div class="panel-header">
37
135
  <p class="panel-title">Proyecto Activo</p>
38
- <span class="badge badge-success">Activo</span>
39
- </div>
40
- <div class="panel-body" style="display:flex;flex-direction:column;gap:var(--space-3)">
41
- <div class="info-row">
42
- <p class="label-sm">Nombre</p>
43
- <p class="value">${esc(control.meta?.projectName || '—')}</p>
44
- </div>
45
- <div class="info-row">
46
- <p class="label-sm">Ruta</p>
47
- <p class="value">${esc(payload?.project?.root || '—')}</p>
48
- </div>
49
- <div class="info-row">
50
- <p class="label-sm">Fase activa</p>
51
- <p class="value">${esc(payload?.derived?.activePhase?.id || '—')} — ${esc(payload?.derived?.activePhase?.label || '')}</p>
52
- </div>
53
- <div class="info-row">
54
- <p class="label-sm">Foco actual</p>
55
- <p class="value">${esc(control.meta?.currentFocus || '—')}</p>
56
- </div>
57
- <div class="info-row">
58
- <p class="label-sm">Entrega objetivo</p>
59
- <p class="value">${esc(control.meta?.deliveryTarget || '—')}</p>
60
- </div>
61
- <div class="info-row">
62
- <p class="label-sm">Última actualización</p>
63
- <p class="value">${formatDate(control.meta?.updatedAt || '', 'date')}</p>
64
- </div>
65
- <div class="info-row">
66
- <p class="label-sm">Idioma</p>
67
- <p class="value">${esc(payload?.i18n?.locale || 'es')}</p>
68
- </div>
136
+ <span class="badge badge-success">Activo</span>
137
+ </div>
138
+ <div class="panel-body" style="display:flex;flex-direction:column;gap:var(--space-3)">
139
+ <div class="info-row">
140
+ <p class="label-sm">Nombre</p>
141
+ <p class="value">${esc(control.meta?.projectName || '—')}</p>
142
+ </div>
143
+ <div class="info-row">
144
+ <p class="label-sm">Ruta</p>
145
+ <p class="value">${esc(payload?.project?.root || '—')}</p>
146
+ </div>
147
+ <div class="info-row">
148
+ <p class="label-sm">Fase activa</p>
149
+ <p class="value">${esc(payload?.derived?.activePhase?.id || '—')} — ${esc(payload?.derived?.activePhase?.label || '')}</p>
150
+ </div>
151
+ <div class="info-row">
152
+ <p class="label-sm">Foco actual</p>
153
+ <p class="value">${esc(control.meta?.currentFocus || '—')}</p>
154
+ </div>
155
+ <div class="info-row">
156
+ <p class="label-sm">Entrega objetivo</p>
157
+ <p class="value">${esc(control.meta?.deliveryTarget || '—')}</p>
158
+ </div>
159
+ <div class="info-row">
160
+ <p class="label-sm">Última actualización</p>
161
+ <p class="value">${formatDate(control.meta?.updatedAt || '', 'date')}</p>
162
+ </div>
163
+ <div class="info-row">
164
+ <p class="label-sm">${t('ui.topbar.language', {}, 'Language')}</p>
165
+ <div class="project-select-wrapper locale-select-wrapper">
166
+ <select id="settings-locale-select" aria-label="${t('ui.topbar.languageAria', {}, 'Select dashboard language')}">
167
+ <option value="es" ${(payload?.i18n?.locale || 'es') === 'es' ? 'selected' : ''}>${t('ui.topbar.languageEs', {}, 'ES')}</option>
168
+ <option value="en" ${(payload?.i18n?.locale || 'es') === 'en' ? 'selected' : ''}>${t('ui.topbar.languageEn', {}, 'EN')}</option>
169
+ </select>
170
+ </div>
171
+ </div>
69
172
  </div>
70
173
  </div>
71
- ` : '<p class="text-muted">Sin proyecto cargado.</p>'}
174
+ ` : `<p class="text-muted">${t('ui.settings.noProject', {}, 'No project loaded.')}</p>`}
175
+
176
+ ${renderWorkspacePanel(payload)}
72
177
 
73
178
  <!-- Estado del Repo -->
74
179
  ${runtime ? `
75
180
  <div class="panel">
76
- <div class="panel-header">
77
- <p class="panel-title">Repositorio</p>
78
- <span class="badge badge-${runtime.clean ? 'success' : 'warning'}">${runtime.clean ? 'Limpio' : 'Con cambios'}</span>
79
- </div>
80
- <div class="panel-body" style="display:flex;flex-direction:column;gap:var(--space-3)">
81
- <div class="info-row">
82
- <p class="label-sm">Rama</p>
83
- <p class="value">${esc(runtime.branch || '—')}</p>
84
- </div>
85
- <div class="info-row">
86
- <p class="label-sm">Estado</p>
87
- <p class="value">${runtime.staged}s ${runtime.unstaged}u ${runtime.untracked}?</p>
88
- </div>
89
- ${runtime.lastCommit ? `
90
- <div class="info-row">
91
- <p class="label-sm">Último commit</p>
92
- <p class="value">${esc(runtime.lastCommit.shortHash)} · ${esc(runtime.lastCommit.subject)}</p>
93
- </div>
94
- <div class="info-row">
95
- <p class="label-sm">Fecha commit</p>
96
- <p class="value">${formatDate(runtime.lastCommit.date, 'date')}</p>
97
- </div>
98
- ` : ''}
99
- <div class="info-row">
100
- <p class="label-sm">Divergencia</p>
101
- <p class="value">↑ ${runtime.ahead || 0} por delante · ↓ ${runtime.behind || 0} por detrás</p>
102
- </div>
103
- </div>
104
- </div>
105
- ` : ''}
106
-
107
- </div>
108
-
109
- <!-- Col derecha -->
110
- <div style="display:flex;flex-direction:column;gap:var(--space-4)">
111
-
112
- <!-- Portfolio -->
113
- <div class="panel">
114
- <div class="panel-header">
115
- <p class="panel-title">Portfolio de Proyectos</p>
116
- <span class="badge badge-muted">${projects.length}</span>
117
- </div>
118
- <div class="panel-body" style="display:flex;flex-direction:column;gap:var(--space-3)">
119
- ${projects.length === 0
120
- ? '<div class="empty-state">Sin proyectos registrados.</div>'
121
- : projects.map(p => `
122
- <div class="project-row ${p.id === currentId ? 'is-active' : ''}">
123
- <div class="project-row-info">
124
- <p class="project-name">${esc(p.name)}</p>
125
- <p class="project-path">${esc(p.root)}</p>
126
- </div>
127
- <div class="project-row-actions">
128
- ${p.available
129
- ? `<span class="badge badge-success">Disponible</span>`
130
- : `<span class="badge badge-warning" title="No se puede cargar el control">No disponible</span>`}
131
- ${p.id === currentId
132
- ? `<span class="badge badge-accent">Activo</span>`
133
- : p.available ? `<button class="btn btn-ghost btn-sm" type="button" data-switch="${esc(p.id)}">Abrir</button>` : ''
134
- }
135
- </div>
136
- </div>
137
- `).join('')
138
- }
139
-
140
- <!-- Registrar nuevo -->
141
- <div style="margin-top:var(--space-2)">
142
- <div class="field">
143
- <label for="new-project-path">Registrar proyecto existente</label>
144
- <input id="new-project-path" type="text" placeholder="/ruta/al/proyecto" />
145
- </div>
146
- <div class="form-actions" style="margin-top:var(--space-2)">
147
- <button class="btn btn-ghost btn-sm" type="button" id="register-project-btn">
148
- ${icon('plus', 14)} Registrar
149
- </button>
150
- </div>
151
- </div>
152
- </div>
153
- </div>
154
-
155
- <!-- Docs Drift -->
156
- <div class="panel">
157
- <div class="panel-header">
158
- <p class="panel-title">Documentación</p>
159
- <span class="badge badge-${docsDirty.length ? 'warning' : 'success'}">${docsDirty.length ? `${docsDirty.length} desfasados` : 'Sincronizados'}</span>
160
- </div>
161
- <div class="panel-body" style="display:flex;flex-direction:column;gap:var(--space-3)">
162
- ${docsDirty.length > 0 ? `
163
- <p style="font-size:var(--text-sm);color:var(--text-secondary)">Los siguientes archivos de documentación están desactualizados:</p>
164
- <div class="stack stack-sm">
165
- ${docsDirty.map(d => `
166
- <div class="info-row"><p class="value">${esc(d)}</p></div>
167
- `).join('')}
168
- </div>
169
- ` : `
170
- <p style="font-size:var(--text-sm);color:var(--success)">✓ Todos los archivos de documentación están sincronizados.</p>
171
- `}
181
+ <div class="panel-header">
182
+ <p class="panel-title">Repositorio</p>
183
+ <span class="badge badge-${runtime.clean ? 'success' : 'warning'}">${runtime.clean ? 'Limpio' : 'Con cambios'}</span>
184
+ </div>
185
+ <div class="panel-body" style="display:flex;flex-direction:column;gap:var(--space-3)">
186
+ <div class="info-row">
187
+ <p class="label-sm">Rama</p>
188
+ <p class="value">${esc(runtime.branch || '—')}</p>
189
+ </div>
190
+ <div class="info-row">
191
+ <p class="label-sm">Estado</p>
192
+ <p class="value">${runtime.staged}s ${runtime.unstaged}u ${runtime.untracked}?</p>
193
+ </div>
194
+ ${runtime.lastCommit ? `
195
+ <div class="info-row">
196
+ <p class="label-sm">Último commit</p>
197
+ <p class="value">${esc(runtime.lastCommit.shortHash)} · ${esc(runtime.lastCommit.subject)}</p>
198
+ </div>
199
+ <div class="info-row">
200
+ <p class="label-sm">Fecha commit</p>
201
+ <p class="value">${formatDate(runtime.lastCommit.date, 'date')}</p>
202
+ </div>
203
+ ` : ''}
204
+ <div class="info-row">
205
+ <p class="label-sm">Divergencia</p>
206
+ <p class="value">↑ ${runtime.ahead || 0} por delante · ↓ ${runtime.behind || 0} por detrás</p>
207
+ </div>
208
+ </div>
209
+ </div>
210
+ ` : ''}
211
+
212
+ </div>
213
+
214
+ <!-- Col derecha -->
215
+ <div style="display:flex;flex-direction:column;gap:var(--space-4)">
216
+
217
+ <!-- Portfolio -->
218
+ <div class="panel">
219
+ <div class="panel-header">
220
+ <p class="panel-title">Portfolio de Proyectos</p>
221
+ <span class="badge badge-muted">${projects.length}</span>
222
+ </div>
223
+ <div class="panel-body" style="display:flex;flex-direction:column;gap:var(--space-3)">
224
+ ${projects.length === 0
225
+ ? '<div class="empty-state">Sin proyectos registrados.</div>'
226
+ : projects.map(p => `
227
+ <div class="project-row ${p.id === currentId ? 'is-active' : ''}">
228
+ <div class="project-row-info">
229
+ <p class="project-name">${esc(p.name)}</p>
230
+ <p class="project-path">${esc(p.root)}</p>
231
+ </div>
232
+ <div class="project-row-actions">
233
+ ${p.available
234
+ ? `<span class="badge badge-success">Disponible</span>`
235
+ : `<span class="badge badge-warning" title="No se puede cargar el control">No disponible</span>`}
236
+ ${p.id === currentId
237
+ ? `<span class="badge badge-accent">Activo</span>`
238
+ : p.available ? `<button class="btn btn-ghost btn-sm" type="button" data-switch="${esc(p.id)}">Abrir</button>` : ''
239
+ }
240
+ </div>
241
+ </div>
242
+ `).join('')
243
+ }
244
+
245
+ <!-- Registrar nuevo -->
246
+ <div style="margin-top:var(--space-2)">
247
+ <div class="field">
248
+ <label for="new-project-path">Registrar proyecto existente</label>
249
+ <input id="new-project-path" type="text" placeholder="/ruta/al/proyecto" />
250
+ </div>
251
+ <div class="form-actions" style="margin-top:var(--space-2)">
252
+ <button class="btn btn-ghost btn-sm" type="button" id="register-project-btn">
253
+ ${icon('plus', 14)} Registrar
254
+ </button>
255
+ </div>
256
+ </div>
257
+ </div>
258
+ </div>
259
+
260
+ <!-- Docs Drift -->
261
+ <div class="panel">
262
+ <div class="panel-header">
263
+ <p class="panel-title">Documentación</p>
264
+ <span class="badge badge-${docsDirty.length ? 'warning' : 'success'}">${docsDirty.length ? `${docsDirty.length} desfasados` : 'Sincronizados'}</span>
265
+ </div>
266
+ <div class="panel-body" style="display:flex;flex-direction:column;gap:var(--space-3)">
267
+ ${docsDirty.length > 0 ? `
268
+ <p style="font-size:var(--text-sm);color:var(--text-secondary)">Los siguientes archivos de documentación están desactualizados:</p>
269
+ <div class="stack stack-sm">
270
+ ${docsDirty.map(d => `
271
+ <div class="info-row"><p class="value">${esc(d)}</p></div>
272
+ `).join('')}
273
+ </div>
274
+ ` : `
275
+ <p style="font-size:var(--text-sm);color:var(--success)">✓ Todos los archivos de documentación están sincronizados.</p>
276
+ `}
172
277
  <button class="btn btn-ghost btn-sm" type="button" id="sync-docs-btn">
173
278
  ${icon('sync', 14)} Sincronizar ahora
174
279
  </button>
175
280
  </div>
176
281
  </div>
177
282
 
283
+ ${renderEnvPanel(envState)}
284
+
178
285
  <!-- Milestones -->
179
286
  ${control?.milestones?.length ? `
180
287
  <div class="panel">
181
- <div class="panel-header">
182
- <p class="panel-title">Milestones</p>
183
- <span class="badge badge-accent">${control.milestones.length}</span>
184
- </div>
185
- <div class="panel-body" style="display:flex;flex-direction:column;gap:var(--space-3)">
186
- ${control.milestones.map(m => `
187
- <div class="finding-item" style="border-left-color:var(--accent)">
188
- <p style="font-weight:700;font-size:var(--text-sm)">${esc(m.title)}</p>
189
- <p style="font-size:var(--text-xs);color:var(--text-muted)">${esc(m.date)}</p>
190
- <ul style="margin-top:var(--space-2);padding-left:1rem">
191
- ${(m.items || []).map(item => `<li style="font-size:var(--text-xs);color:var(--text-secondary)">${esc(item)}</li>`).join('')}
192
- </ul>
193
- </div>
194
- `).join('')}
195
- </div>
196
- </div>
197
- ` : ''}
198
-
199
- </div>
200
- </div>
201
- </div>
202
- `;
203
- }
204
-
205
- export function bindEvents() {
206
- // Switch project
207
- document.querySelectorAll('[data-switch]').forEach(btn => {
208
- btn.addEventListener('click', () => {
209
- const id = btn.dataset.switch;
210
- state.update('currentProjectId', id);
211
- localStorage.setItem('ops-dashboard-project', id);
212
- window.dispatchEvent(new CustomEvent('ops:refresh'));
213
- });
214
- });
215
-
216
- // Registrar proyecto
217
- document.getElementById('register-project-btn')?.addEventListener('click', async () => {
218
- const input = document.getElementById('new-project-path');
219
- const root = input?.value.trim();
220
- if (!root) { flash('Introduce la ruta del proyecto.', 'warning'); return; }
288
+ <div class="panel-header">
289
+ <p class="panel-title">Milestones</p>
290
+ <span class="badge badge-accent">${control.milestones.length}</span>
291
+ </div>
292
+ <div class="panel-body" style="display:flex;flex-direction:column;gap:var(--space-3)">
293
+ ${control.milestones.map(m => `
294
+ <div class="finding-item" style="border-left-color:var(--accent)">
295
+ <p style="font-weight:700;font-size:var(--text-sm)">${esc(m.title)}</p>
296
+ <p style="font-size:var(--text-xs);color:var(--text-muted)">${esc(m.date)}</p>
297
+ <ul style="margin-top:var(--space-2);padding-left:1rem">
298
+ ${(m.items || []).map(item => `<li style="font-size:var(--text-xs);color:var(--text-secondary)">${esc(item)}</li>`).join('')}
299
+ </ul>
300
+ </div>
301
+ `).join('')}
302
+ </div>
303
+ </div>
304
+ ` : ''}
305
+
306
+ </div>
307
+ </div>
308
+ </div>
309
+ `;
310
+ }
311
+
312
+ export function bindEvents() {
313
+ // Switch project
314
+ document.querySelectorAll('[data-switch]').forEach(btn => {
315
+ btn.addEventListener('click', () => {
316
+ const id = btn.dataset.switch;
317
+ state.update('currentProjectId', id);
318
+ localStorage.setItem('ops-dashboard-project', id);
319
+ window.dispatchEvent(new CustomEvent('ops:refresh'));
320
+ });
321
+ });
322
+
323
+ // Registrar proyecto
324
+ document.getElementById('register-project-btn')?.addEventListener('click', async () => {
325
+ const input = document.getElementById('new-project-path');
326
+ const root = input?.value.trim();
327
+ if (!root) { flash(t('ui.settings.enterPath', {}, 'Enter the project path.'), 'warning'); return; }
328
+ try {
329
+ await api.registerProject(root);
330
+ flash('Proyecto registrado.', 'success');
331
+ if (input) input.value = '';
332
+ window.dispatchEvent(new CustomEvent('ops:refresh'));
333
+ } catch (err) {
334
+ flash(err.message, 'error');
335
+ }
336
+ });
337
+
338
+ // Sync docs
339
+ document.getElementById('sync-docs-btn')?.addEventListener('click', async () => {
221
340
  try {
222
- await api.registerProject(root);
223
- flash('Proyecto registrado.', 'success');
224
- if (input) input.value = '';
341
+ await api.syncDocs();
342
+ flash('Documentación sincronizada.', 'success');
225
343
  window.dispatchEvent(new CustomEvent('ops:refresh'));
226
- } catch (err) {
344
+ } catch (err) {
227
345
  flash(err.message, 'error');
228
346
  }
229
347
  });
230
348
 
231
- // Sync docs
232
- document.getElementById('sync-docs-btn')?.addEventListener('click', async () => {
349
+ document.getElementById('sync-env-btn')?.addEventListener('click', async () => {
233
350
  try {
234
- await api.syncDocs();
235
- flash('Documentación sincronizada.', 'success');
351
+ await api.syncEnv();
352
+ flash(t('ui.settings.envSynced', {}, 'Environment synced.'), 'success');
236
353
  window.dispatchEvent(new CustomEvent('ops:refresh'));
237
354
  } catch (err) {
238
355
  flash(err.message, 'error');
239
356
  }
240
357
  });
241
- }
242
358
 
243
- export { bindEvents as bind };
359
+ document.getElementById('settings-locale-select')?.addEventListener('change', async e => {
360
+ const select = e.target;
361
+ const previousLocale = state.get('locale') || 'es';
362
+ const nextLocale = select.value;
363
+
364
+ if (nextLocale === previousLocale) return;
365
+
366
+ select.disabled = true;
367
+ try {
368
+ const result = await api.updateProjectLocale(nextLocale);
369
+ applyLocaleState(result.state);
370
+ flash(t('ui.topbar.localeUpdated', {}, 'Language updated.'), 'success');
371
+ window.dispatchEvent(new CustomEvent('ops:refresh'));
372
+ } catch (err) {
373
+ select.value = previousLocale;
374
+ flash(err.message, 'error');
375
+ } finally {
376
+ select.disabled = false;
377
+ }
378
+ });
379
+ }
380
+
381
+ export { bindEvents as bind };