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,437 +1,439 @@
1
- /**
2
- * onboarding.js — Tour interactivo tipo spotlight
3
- *
4
- * Técnica: un div con box-shadow gigante rodea el target,
5
- * dejando solo ese elemento visible. El tooltip-bocadillo
6
- * tiene una flecha CSS que apunta directamente al target.
7
- */
8
-
9
- import * as state from './state.js';
10
-
11
- const STORAGE_KEY = 'trackops-onboarding-v2';
12
-
13
- /* ── PASOS ──────────────────────────────────────────────────────────────── */
14
-
15
- const STEPS = [
16
- // 0 Bienvenida (sin target)
17
- {
18
- title: '¡Bienvenido a TrackOps!',
19
- desc: 'Tu motor operativo local para desarrolladores. Vamos a hacer un tour rápido — solo tarda 2 minutos.',
20
- target: null,
21
- view: null,
22
- pos: 'center',
23
- },
24
-
25
- // 1 — Sidebar / navegación
26
- {
27
- title: 'Navegación principal',
28
- desc: 'La barra lateral te lleva entre las vistas principales: resumen, tareas, tablero, ejecución y analíticas. El indicador naranja marca tareas pendientes.',
29
- target: '#sidebar',
30
- view: 'overview',
31
- pos: 'right',
32
- },
33
-
34
- // 2 — KPI cards
35
- {
36
- title: 'Métricas de un vistazo',
37
- desc: 'Cuatro KPI cards muestran en tiempo real: trabajo abierto, completado, bloqueado y en revisión. Los colores del borde superior indican el estado.',
38
- target: '.kpi-grid',
39
- view: 'overview',
40
- pos: 'bottom',
41
- },
42
-
43
- // 3 — Gráfico de actividad
44
- {
45
- title: 'Actividad semanal',
46
- desc: 'El gráfico de barras muestra los cambios de estado de los últimos 10 días. Identifica picos de trabajo y periodos de inactividad.',
47
- target: '.chart-card',
48
- view: 'overview',
49
- pos: 'bottom',
50
- },
51
-
52
- // 4 — Donut de progreso
53
- {
54
- title: 'Progreso Global',
55
- desc: 'El donut SVG desglosa el estado de todas las tareas: completadas (verde), en progreso (azul), bloqueadas (rojo) y pendientes (naranja).',
56
- target: '.donut-wrapper',
57
- view: 'overview',
58
- pos: 'left',
59
- },
60
-
61
- // 5 — Time Tracker
62
- {
63
- title: 'Seguimiento de tiempo',
64
- desc: 'Registra el tiempo dedicado a cada tarea. Pulsa iniciar, trabaja y luego detén el contador. La duración queda guardada para análisis posterior.',
65
- target: '.time-tracker-card',
66
- view: 'overview',
67
- pos: 'top',
68
- },
69
-
70
- // 6 — Topbar: búsqueda
71
- {
72
- title: 'Búsqueda global',
73
- desc: 'Filtra tareas en tiempo real desde cualquier vista con ⌘/Ctrl+F. El selector de proyecto te permite cambiar entre proyectos registrados.',
74
- target: '.topbar-search',
75
- view: 'overview',
76
- pos: 'bottom',
77
- },
78
-
79
- // 7 — Board (Kanban)
80
- {
81
- title: 'Tablero Kanban',
82
- desc: 'Arrastra las tarjetas entre columnas para cambiar el estado de una tarea. Cada movimiento actualiza el historial y regenera los docs Markdown.',
83
- target: '.board-grid',
84
- view: 'board',
85
- pos: 'top',
86
- },
87
-
88
- // 8 — Editor de tareas
89
- {
90
- title: 'Editor de Tareas',
91
- desc: 'Selecciona cualquier tarea para editarla en el panel derecho. Puedes cambiar prioridad, fase, criterios de aceptación y usar el action strip para transicionar estados.',
92
- target: '.task-list',
93
- view: 'tasks',
94
- pos: 'right',
95
- },
96
-
97
- // 9 — Ejecución / Consola
98
- {
99
- title: 'Consola Integrada',
100
- desc: 'Ejecuta comandos de terminal directamente desde el dashboard. Los Quick Commands incluyen los flujos más comunes: status, sync, git log…',
101
- target: '.terminal-surface',
102
- view: 'execution',
103
- pos: 'top',
104
- },
105
-
106
- // 10 — Analytics
107
- {
108
- title: 'Analíticas del proyecto',
109
- desc: 'Esta vista reúne salud operativa, distribución por estado, tiempo por tarea, progreso por fase y la actividad reciente.',
110
- target: '.health-grid',
111
- view: 'insights',
112
- pos: 'bottom',
113
- },
114
-
115
- // 11 — AI Skill Hub
116
- {
117
- title: 'Centro de habilidades',
118
- desc: 'Tu copiloto evoluciona. Descubre e instala nuevas habilidades específicas para tu contexto a través del repositorio integrado de la comunidad.',
119
- target: '#view-skills',
120
- view: 'skills',
121
- pos: 'right',
122
- },
123
-
124
- // 12 — Theme toggle
125
- {
126
- title: 'Tema claro / oscuro',
127
- desc: 'Alterna entre tema claro y oscuro con un clic. Tu preferencia se guarda automáticamente y respeta la configuración del sistema operativo.',
128
- target: '#theme-toggle-btn',
129
- view: 'overview',
130
- pos: 'bottom',
131
- },
132
-
133
- // 13 — Fin
134
- {
135
- title: '¡Todo listo!',
136
- desc: 'Puedes relanzar este tour en cualquier momento desde "Ayuda & Tour" en la parte inferior del sidebar.',
137
- target: null,
138
- view: null,
139
- pos: 'center',
140
- },
141
- ];
142
-
143
- /* ── ESTADO ──────────────────────────────────────────────────────────────── */
144
-
145
- let _step = 0;
146
- let _spotlight = null;
147
- let _tooltip = null;
148
- let _active = false;
149
- let _ring = null;
150
- let _escHandler = null;
151
-
152
- /* ── API PÚBLICA ─────────────────────────────────────────────────────────── */
153
-
154
- export function init() {
155
- _spotlight = document.getElementById('onboarding-spotlight');
156
- _tooltip = document.getElementById('onboarding-tooltip');
157
- if (!_spotlight || !_tooltip) return;
158
-
159
- _bindStaticEvents();
160
-
161
- if (!localStorage.getItem(STORAGE_KEY)) {
162
- setTimeout(show, 900);
163
- }
164
- }
165
-
166
- export function show() {
167
- _step = 0;
168
- _active = true;
169
- _spotlight.classList.remove('is-hidden');
170
- _tooltip.classList.remove('is-hidden');
171
- _renderStep();
172
- }
173
-
174
- export function finish() {
175
- _active = false;
176
- localStorage.setItem(STORAGE_KEY, '1');
177
- _spotlight.classList.add('is-hidden');
178
- _tooltip.classList.add('is-hidden');
179
- _clearRing();
180
- _clearSpotlight();
181
- if (_escHandler) {
182
- document.removeEventListener('keydown', _escHandler);
183
- _escHandler = null;
184
- }
185
- state.update('onboardingDone', true);
186
- }
187
-
188
- export function reset() {
189
- localStorage.removeItem(STORAGE_KEY);
190
- }
191
-
192
- /* ── RENDERIZADO ─────────────────────────────────────────────────────────── */
193
-
194
- async function _renderStep() {
195
- if (!_active) return;
196
- const step = STEPS[_step];
197
- const total = STEPS.length;
198
- const isLast = _step === total - 1;
199
- const isFirst = _step === 0;
200
-
201
- // Navegar a la vista correcta antes de medir el target
202
- if (step.view) {
203
- const router = await import('./router.js');
204
- if (router.current() !== step.view) {
205
- await router.navigate(step.view);
206
- // Esperar al siguiente frame para que el DOM esté pintado
207
- await _nextFrame(120);
208
- }
209
- }
210
-
211
- // Actualizar contenido del tooltip
212
- document.getElementById('ob-step-label').textContent = `Paso ${_step + 1} de ${total}`;
213
- document.getElementById('ob-title').textContent = step.title;
214
- document.getElementById('ob-desc').textContent = step.desc;
215
- document.getElementById('ob-next').textContent = isLast ? '¡Empezar!' : 'Siguiente →';
216
- document.getElementById('ob-prev').style.visibility = isFirst ? 'hidden' : 'visible';
217
-
218
- // Dots
219
- const dotsEl = document.getElementById('ob-dots');
220
- if (dotsEl) {
221
- dotsEl.innerHTML = STEPS.map((_, i) =>
222
- `<span class="ob-dot ${i === _step ? 'is-active' : ''}" aria-hidden="true"></span>`
223
- ).join('');
224
- }
225
-
226
- // Mostrar tooltip con animación de entrada oculta temporalmente (para no saltar)
227
- _tooltip.classList.remove('ob-enter');
228
- _clearRing();
229
-
230
- // Posicionar
231
- if (step.target) {
232
- const targetEl = _findTarget(step.target, step.view);
233
- if (targetEl) {
234
- // Hacer scroll suave para centrar el elemento en el viewport
235
- targetEl.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
236
- await _nextFrame(400); // Esperar que termine el scroll
237
- _applySpotlight(targetEl);
238
- _applyRing(targetEl);
239
- _positionTooltip(targetEl, step.pos);
240
- _mostrarTooltip();
241
- return;
242
- }
243
- }
244
-
245
- // Sin target → centrado
246
- _clearSpotlight();
247
- _positionCenter();
248
- _mostrarTooltip();
249
- }
250
-
251
- function _mostrarTooltip() {
252
- void _tooltip.offsetWidth; // reflow
253
- _tooltip.classList.add('ob-enter');
254
- }
255
-
256
- /* ── SPOTLIGHT ───────────────────────────────────────────────────────────── */
257
-
258
- function _applySpotlight(el) {
259
- const PADDING = 10;
260
- const rect = el.getBoundingClientRect();
261
-
262
- // El spotlight es el propio elemento; usamos box-shadow inmenso para oscurecer el resto
263
- _spotlight.style.cssText = `
264
- position: fixed;
265
- left: ${rect.left - PADDING}px;
266
- top: ${rect.top - PADDING}px;
267
- width: ${rect.width + PADDING * 2}px;
268
- height: ${rect.height + PADDING * 2}px;
269
- border-radius: 12px;
270
- box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.72);
271
- z-index: var(--z-onboard);
272
- pointer-events: none;
273
- transition: all 280ms cubic-bezier(0.16, 1, 0.3, 1);
274
- `;
275
- }
276
-
277
- function _clearSpotlight() {
278
- _spotlight.style.cssText = `
279
- position: fixed;
280
- inset: 0;
281
- box-shadow: 0 0 0 9999px rgba(0,0,0,0.72);
282
- z-index: var(--z-onboard);
283
- pointer-events: none;
284
- background: transparent;
285
- border-radius: 0;
286
- `;
287
- }
288
-
289
- /* ── RING ANIMADO alrededor del target ───────────────────────────────────── */
290
-
291
- function _applyRing(el) {
292
- _clearRing();
293
- const PADDING = 10;
294
- const rect = el.getBoundingClientRect();
295
-
296
- _ring = document.createElement('div');
297
- _ring.className = 'onboarding-ring';
298
- _ring.setAttribute('aria-hidden', 'true');
299
- _ring.style.cssText = `
300
- position: fixed;
301
- left: ${rect.left - PADDING}px;
302
- top: ${rect.top - PADDING}px;
303
- width: ${rect.width + PADDING * 2}px;
304
- height: ${rect.height + PADDING * 2}px;
305
- border-radius: 14px;
306
- border: 2px solid var(--accent);
307
- z-index: calc(var(--z-onboard) + 1);
308
- pointer-events: none;
309
- animation: ob-ring-pulse 1.8s ease-in-out infinite;
310
- `;
311
- document.body.appendChild(_ring);
312
- }
313
-
314
- function _clearRing() {
315
- if (_ring) {
316
- _ring.remove();
317
- _ring = null;
318
- }
319
- }
320
-
321
- /* ── POSICIONAMIENTO DEL TOOLTIP ─────────────────────────────────────────── */
322
-
323
- function _positionCenter() {
324
- _tooltip.removeAttribute('data-pos');
325
- _tooltip.style.cssText = `
326
- position: fixed;
327
- left: 50%;
328
- top: 50%;
329
- transform: translate(-50%, -50%);
330
- z-index: calc(var(--z-onboard) + 2);
331
- `;
332
- }
333
-
334
- function _positionTooltip(el, preferredPos) {
335
- const MARGIN = 18;
336
- const TW = 420; // Más ancho para acomodar navegación + botones
337
- const TH = 220;
338
- const PADDING = 10;
339
- const rect = el.getBoundingClientRect();
340
- const vw = window.innerWidth;
341
- const vh = window.innerHeight;
342
-
343
- // Determinar la mejor posición disponible
344
- const available = {
345
- right: vw - rect.right - PADDING >= TW + MARGIN,
346
- left: rect.left - PADDING >= TW + MARGIN,
347
- bottom: vh - rect.bottom - PADDING >= TH + MARGIN,
348
- top: rect.top - PADDING >= TH + MARGIN,
349
- };
350
-
351
- let pos = preferredPos;
352
- if (!available[pos]) {
353
- // Fallback en orden de preferencia
354
- pos = ['right', 'left', 'bottom', 'top'].find(p => available[p]) || 'bottom';
355
- }
356
-
357
- let left, top;
358
-
359
- switch (pos) {
360
- case 'right':
361
- left = rect.right + PADDING + MARGIN;
362
- top = rect.top + rect.height / 2 - TH / 2;
363
- break;
364
- case 'left':
365
- left = rect.left - PADDING - MARGIN - TW;
366
- top = rect.top + rect.height / 2 - TH / 2;
367
- break;
368
- case 'bottom':
369
- left = rect.left + rect.width / 2 - TW / 2;
370
- top = rect.bottom + PADDING + MARGIN;
371
- break;
372
- case 'top':
373
- left = rect.left + rect.width / 2 - TW / 2;
374
- top = rect.top - PADDING - MARGIN - TH;
375
- break;
376
- }
377
-
378
- // Clamp dentro del viewport
379
- left = Math.max(MARGIN, Math.min(left, vw - TW - MARGIN));
380
- top = Math.max(MARGIN, Math.min(top, vh - TH - MARGIN));
381
-
382
- _tooltip.setAttribute('data-pos', pos);
383
- _tooltip.style.cssText = `
384
- position: fixed;
385
- left: ${left}px;
386
- top: ${top}px;
387
- width: ${TW}px;
388
- z-index: calc(var(--z-onboard) + 2);
389
- transform: none;
390
- `;
391
- }
392
-
393
- /* ── EVENTOS ─────────────────────────────────────────────────────────────── */
394
-
395
- function _bindStaticEvents() {
396
- const tooltip = _tooltip;
397
- if (!tooltip) return;
398
-
399
- tooltip.addEventListener('click', e => {
400
- const btn = e.target.closest('button[id]');
401
- if (!btn) return;
402
- if (btn.id === 'ob-next') _advance(1);
403
- if (btn.id === 'ob-prev') _advance(-1);
404
- if (btn.id === 'ob-skip') finish();
405
- });
406
-
407
- // Escape para cerrar
408
- _escHandler = e => {
409
- if (e.key === 'Escape' && _active) finish();
410
- };
411
- document.addEventListener('keydown', _escHandler);
412
-
413
- // Clic en el backdrop (fuera del tooltip) → cerrar
414
- _spotlight.addEventListener('click', finish);
415
- }
416
-
417
- function _advance(dir) {
418
- if (dir > 0 && _step === STEPS.length - 1) {
419
- finish();
420
- return;
421
- }
422
- _step = Math.max(0, Math.min(_step + dir, STEPS.length - 1));
423
- _renderStep();
424
- }
425
-
426
- /* ── UTILS ───────────────────────────────────────────────────────────────── */
427
-
428
- function _findTarget(selector, _view) {
429
- // Intentar el selector tal cual
430
- let el = document.querySelector(selector);
431
- if (el) return el;
432
- return null;
433
- }
434
-
435
- function _nextFrame(ms = 60) {
436
- return new Promise(res => setTimeout(res, ms));
437
- }
1
+ /**
2
+ * onboarding.js — Tour interactivo tipo spotlight
3
+ *
4
+ * Técnica: un div con box-shadow gigante rodea el target,
5
+ * dejando solo ese elemento visible. El tooltip-bocadillo
6
+ * tiene una flecha CSS que apunta directamente al target.
7
+ */
8
+
9
+ import * as state from './state.js';
10
+ import { t } from './i18n.js';
11
+
12
+ const STORAGE_KEY = 'trackops-onboarding-v2';
13
+
14
+ /* ── PASOS ──────────────────────────────────────────────────────────────── */
15
+
16
+ const STEPS = [
17
+ // 0 — Bienvenida (sin target)
18
+ {
19
+ titleKey: 'ui.onboarding.welcome.title',
20
+ descKey: 'ui.onboarding.welcome.desc',
21
+ target: null,
22
+ view: null,
23
+ pos: 'center',
24
+ },
25
+
26
+ // 1 — Sidebar / navegación
27
+ {
28
+ titleKey: 'ui.onboarding.nav.title',
29
+ descKey: 'ui.onboarding.nav.desc',
30
+ target: '#sidebar',
31
+ view: 'overview',
32
+ pos: 'right',
33
+ },
34
+
35
+ // 2 — KPI cards
36
+ {
37
+ titleKey: 'ui.onboarding.kpi.title',
38
+ descKey: 'ui.onboarding.kpi.desc',
39
+ target: '.kpi-grid',
40
+ view: 'overview',
41
+ pos: 'bottom',
42
+ },
43
+
44
+ // 3 — Gráfico de actividad
45
+ {
46
+ titleKey: 'ui.onboarding.activity.title',
47
+ descKey: 'ui.onboarding.activity.desc',
48
+ target: '.chart-card',
49
+ view: 'overview',
50
+ pos: 'bottom',
51
+ },
52
+
53
+ // 4 — Donut de progreso
54
+ {
55
+ titleKey: 'ui.onboarding.progress.title',
56
+ descKey: 'ui.onboarding.progress.desc',
57
+ target: '.donut-wrapper',
58
+ view: 'overview',
59
+ pos: 'left',
60
+ },
61
+
62
+ // 5 — Time Tracker
63
+ {
64
+ titleKey: 'ui.onboarding.time.title',
65
+ descKey: 'ui.onboarding.time.desc',
66
+ target: '.time-tracker-card',
67
+ view: 'overview',
68
+ pos: 'top',
69
+ },
70
+
71
+ // 6 — Topbar: búsqueda
72
+ {
73
+ titleKey: 'ui.onboarding.search.title',
74
+ descKey: 'ui.onboarding.search.desc',
75
+ target: '.topbar-search',
76
+ view: 'overview',
77
+ pos: 'bottom',
78
+ },
79
+
80
+ // 7 — Board (Kanban)
81
+ {
82
+ titleKey: 'ui.onboarding.board.title',
83
+ descKey: 'ui.onboarding.board.desc',
84
+ target: '.board-grid',
85
+ view: 'board',
86
+ pos: 'top',
87
+ },
88
+
89
+ // 8 — Editor de tareas
90
+ {
91
+ titleKey: 'ui.onboarding.tasks.title',
92
+ descKey: 'ui.onboarding.tasks.desc',
93
+ target: '.task-list',
94
+ view: 'tasks',
95
+ pos: 'right',
96
+ },
97
+
98
+ // 9 — Ejecución / Consola
99
+ {
100
+ titleKey: 'ui.onboarding.execution.title',
101
+ descKey: 'ui.onboarding.execution.desc',
102
+ target: '.terminal-surface',
103
+ view: 'execution',
104
+ pos: 'top',
105
+ },
106
+
107
+ // 10 — Analytics
108
+ {
109
+ titleKey: 'ui.onboarding.insights.title',
110
+ descKey: 'ui.onboarding.insights.desc',
111
+ target: '.health-grid',
112
+ view: 'insights',
113
+ pos: 'bottom',
114
+ },
115
+
116
+ // 11 — AI Skill Hub
117
+ {
118
+ titleKey: 'ui.onboarding.skills.title',
119
+ descKey: 'ui.onboarding.skills.desc',
120
+ target: '#view-skills',
121
+ view: 'skills',
122
+ pos: 'right',
123
+ },
124
+
125
+ // 12 — Theme toggle
126
+ {
127
+ titleKey: 'ui.onboarding.theme.title',
128
+ descKey: 'ui.onboarding.theme.desc',
129
+ target: '#theme-toggle-btn',
130
+ view: 'overview',
131
+ pos: 'bottom',
132
+ },
133
+
134
+ // 13 — Fin
135
+ {
136
+ titleKey: 'ui.onboarding.done.title',
137
+ descKey: 'ui.onboarding.done.desc',
138
+ target: null,
139
+ view: null,
140
+ pos: 'center',
141
+ },
142
+ ];
143
+
144
+ /* ── ESTADO ──────────────────────────────────────────────────────────────── */
145
+
146
+ let _step = 0;
147
+ let _spotlight = null;
148
+ let _tooltip = null;
149
+ let _active = false;
150
+ let _ring = null;
151
+ let _escHandler = null;
152
+
153
+ /* ── API PÚBLICA ─────────────────────────────────────────────────────────── */
154
+
155
+ export function init() {
156
+ _spotlight = document.getElementById('onboarding-spotlight');
157
+ _tooltip = document.getElementById('onboarding-tooltip');
158
+ if (!_spotlight || !_tooltip) return;
159
+
160
+ _bindStaticEvents();
161
+
162
+ if (!localStorage.getItem(STORAGE_KEY)) {
163
+ setTimeout(show, 900);
164
+ }
165
+ }
166
+
167
+ export function show() {
168
+ _step = 0;
169
+ _active = true;
170
+ _spotlight.classList.remove('is-hidden');
171
+ _tooltip.classList.remove('is-hidden');
172
+ _renderStep();
173
+ }
174
+
175
+ export function finish() {
176
+ _active = false;
177
+ localStorage.setItem(STORAGE_KEY, '1');
178
+ _spotlight.classList.add('is-hidden');
179
+ _tooltip.classList.add('is-hidden');
180
+ _clearRing();
181
+ _clearSpotlight();
182
+ if (_escHandler) {
183
+ document.removeEventListener('keydown', _escHandler);
184
+ _escHandler = null;
185
+ }
186
+ state.update('onboardingDone', true);
187
+ }
188
+
189
+ export function reset() {
190
+ localStorage.removeItem(STORAGE_KEY);
191
+ }
192
+
193
+ /* ── RENDERIZADO ─────────────────────────────────────────────────────────── */
194
+
195
+ async function _renderStep() {
196
+ if (!_active) return;
197
+ const step = STEPS[_step];
198
+ const total = STEPS.length;
199
+ const isLast = _step === total - 1;
200
+ const isFirst = _step === 0;
201
+
202
+ // Navegar a la vista correcta antes de medir el target
203
+ if (step.view) {
204
+ const router = await import('./router.js');
205
+ if (router.current() !== step.view) {
206
+ await router.navigate(step.view);
207
+ // Esperar al siguiente frame para que el DOM esté pintado
208
+ await _nextFrame(120);
209
+ }
210
+ }
211
+
212
+ // Actualizar contenido del tooltip
213
+ document.getElementById('ob-step-label').textContent = t('ui.onboarding.step', { current: _step + 1, total }, `Step ${_step + 1} of ${total}`);
214
+ document.getElementById('ob-title').textContent = t(step.titleKey, {}, step.titleKey);
215
+ document.getElementById('ob-desc').textContent = t(step.descKey, {}, step.descKey);
216
+ document.getElementById('ob-prev').textContent = t('ui.onboarding.prev', {}, 'Previous');
217
+ document.getElementById('ob-next').textContent = isLast ? t('ui.onboarding.start', {}, 'Start') : t('ui.onboarding.next', {}, 'Next →');
218
+ document.getElementById('ob-prev').style.visibility = isFirst ? 'hidden' : 'visible';
219
+
220
+ // Dots
221
+ const dotsEl = document.getElementById('ob-dots');
222
+ if (dotsEl) {
223
+ dotsEl.innerHTML = STEPS.map((_, i) =>
224
+ `<span class="ob-dot ${i === _step ? 'is-active' : ''}" aria-hidden="true"></span>`
225
+ ).join('');
226
+ }
227
+
228
+ // Mostrar tooltip con animación de entrada oculta temporalmente (para no saltar)
229
+ _tooltip.classList.remove('ob-enter');
230
+ _clearRing();
231
+
232
+ // Posicionar
233
+ if (step.target) {
234
+ const targetEl = _findTarget(step.target, step.view);
235
+ if (targetEl) {
236
+ // Hacer scroll suave para centrar el elemento en el viewport
237
+ targetEl.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
238
+ await _nextFrame(400); // Esperar que termine el scroll
239
+ _applySpotlight(targetEl);
240
+ _applyRing(targetEl);
241
+ _positionTooltip(targetEl, step.pos);
242
+ _mostrarTooltip();
243
+ return;
244
+ }
245
+ }
246
+
247
+ // Sin target → centrado
248
+ _clearSpotlight();
249
+ _positionCenter();
250
+ _mostrarTooltip();
251
+ }
252
+
253
+ function _mostrarTooltip() {
254
+ void _tooltip.offsetWidth; // reflow
255
+ _tooltip.classList.add('ob-enter');
256
+ }
257
+
258
+ /* ── SPOTLIGHT ───────────────────────────────────────────────────────────── */
259
+
260
+ function _applySpotlight(el) {
261
+ const PADDING = 10;
262
+ const rect = el.getBoundingClientRect();
263
+
264
+ // El spotlight es el propio elemento; usamos box-shadow inmenso para oscurecer el resto
265
+ _spotlight.style.cssText = `
266
+ position: fixed;
267
+ left: ${rect.left - PADDING}px;
268
+ top: ${rect.top - PADDING}px;
269
+ width: ${rect.width + PADDING * 2}px;
270
+ height: ${rect.height + PADDING * 2}px;
271
+ border-radius: 12px;
272
+ box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.72);
273
+ z-index: var(--z-onboard);
274
+ pointer-events: none;
275
+ transition: all 280ms cubic-bezier(0.16, 1, 0.3, 1);
276
+ `;
277
+ }
278
+
279
+ function _clearSpotlight() {
280
+ _spotlight.style.cssText = `
281
+ position: fixed;
282
+ inset: 0;
283
+ box-shadow: 0 0 0 9999px rgba(0,0,0,0.72);
284
+ z-index: var(--z-onboard);
285
+ pointer-events: none;
286
+ background: transparent;
287
+ border-radius: 0;
288
+ `;
289
+ }
290
+
291
+ /* ── RING ANIMADO alrededor del target ───────────────────────────────────── */
292
+
293
+ function _applyRing(el) {
294
+ _clearRing();
295
+ const PADDING = 10;
296
+ const rect = el.getBoundingClientRect();
297
+
298
+ _ring = document.createElement('div');
299
+ _ring.className = 'onboarding-ring';
300
+ _ring.setAttribute('aria-hidden', 'true');
301
+ _ring.style.cssText = `
302
+ position: fixed;
303
+ left: ${rect.left - PADDING}px;
304
+ top: ${rect.top - PADDING}px;
305
+ width: ${rect.width + PADDING * 2}px;
306
+ height: ${rect.height + PADDING * 2}px;
307
+ border-radius: 14px;
308
+ border: 2px solid var(--accent);
309
+ z-index: calc(var(--z-onboard) + 1);
310
+ pointer-events: none;
311
+ animation: ob-ring-pulse 1.8s ease-in-out infinite;
312
+ `;
313
+ document.body.appendChild(_ring);
314
+ }
315
+
316
+ function _clearRing() {
317
+ if (_ring) {
318
+ _ring.remove();
319
+ _ring = null;
320
+ }
321
+ }
322
+
323
+ /* ── POSICIONAMIENTO DEL TOOLTIP ─────────────────────────────────────────── */
324
+
325
+ function _positionCenter() {
326
+ _tooltip.removeAttribute('data-pos');
327
+ _tooltip.style.cssText = `
328
+ position: fixed;
329
+ left: 50%;
330
+ top: 50%;
331
+ transform: translate(-50%, -50%);
332
+ z-index: calc(var(--z-onboard) + 2);
333
+ `;
334
+ }
335
+
336
+ function _positionTooltip(el, preferredPos) {
337
+ const MARGIN = 18;
338
+ const TW = 420; // Más ancho para acomodar navegación + botones
339
+ const TH = 220;
340
+ const PADDING = 10;
341
+ const rect = el.getBoundingClientRect();
342
+ const vw = window.innerWidth;
343
+ const vh = window.innerHeight;
344
+
345
+ // Determinar la mejor posición disponible
346
+ const available = {
347
+ right: vw - rect.right - PADDING >= TW + MARGIN,
348
+ left: rect.left - PADDING >= TW + MARGIN,
349
+ bottom: vh - rect.bottom - PADDING >= TH + MARGIN,
350
+ top: rect.top - PADDING >= TH + MARGIN,
351
+ };
352
+
353
+ let pos = preferredPos;
354
+ if (!available[pos]) {
355
+ // Fallback en orden de preferencia
356
+ pos = ['right', 'left', 'bottom', 'top'].find(p => available[p]) || 'bottom';
357
+ }
358
+
359
+ let left, top;
360
+
361
+ switch (pos) {
362
+ case 'right':
363
+ left = rect.right + PADDING + MARGIN;
364
+ top = rect.top + rect.height / 2 - TH / 2;
365
+ break;
366
+ case 'left':
367
+ left = rect.left - PADDING - MARGIN - TW;
368
+ top = rect.top + rect.height / 2 - TH / 2;
369
+ break;
370
+ case 'bottom':
371
+ left = rect.left + rect.width / 2 - TW / 2;
372
+ top = rect.bottom + PADDING + MARGIN;
373
+ break;
374
+ case 'top':
375
+ left = rect.left + rect.width / 2 - TW / 2;
376
+ top = rect.top - PADDING - MARGIN - TH;
377
+ break;
378
+ }
379
+
380
+ // Clamp dentro del viewport
381
+ left = Math.max(MARGIN, Math.min(left, vw - TW - MARGIN));
382
+ top = Math.max(MARGIN, Math.min(top, vh - TH - MARGIN));
383
+
384
+ _tooltip.setAttribute('data-pos', pos);
385
+ _tooltip.style.cssText = `
386
+ position: fixed;
387
+ left: ${left}px;
388
+ top: ${top}px;
389
+ width: ${TW}px;
390
+ z-index: calc(var(--z-onboard) + 2);
391
+ transform: none;
392
+ `;
393
+ }
394
+
395
+ /* ── EVENTOS ─────────────────────────────────────────────────────────────── */
396
+
397
+ function _bindStaticEvents() {
398
+ const tooltip = _tooltip;
399
+ if (!tooltip) return;
400
+
401
+ tooltip.addEventListener('click', e => {
402
+ const btn = e.target.closest('button[id]');
403
+ if (!btn) return;
404
+ if (btn.id === 'ob-next') _advance(1);
405
+ if (btn.id === 'ob-prev') _advance(-1);
406
+ if (btn.id === 'ob-skip') finish();
407
+ });
408
+
409
+ // Escape para cerrar
410
+ _escHandler = e => {
411
+ if (e.key === 'Escape' && _active) finish();
412
+ };
413
+ document.addEventListener('keydown', _escHandler);
414
+
415
+ // Clic en el backdrop (fuera del tooltip) → cerrar
416
+ _spotlight.addEventListener('click', finish);
417
+ }
418
+
419
+ function _advance(dir) {
420
+ if (dir > 0 && _step === STEPS.length - 1) {
421
+ finish();
422
+ return;
423
+ }
424
+ _step = Math.max(0, Math.min(_step + dir, STEPS.length - 1));
425
+ _renderStep();
426
+ }
427
+
428
+ /* ── UTILS ───────────────────────────────────────────────────────────────── */
429
+
430
+ function _findTarget(selector, _view) {
431
+ // Intentar el selector tal cual
432
+ let el = document.querySelector(selector);
433
+ if (el) return el;
434
+ return null;
435
+ }
436
+
437
+ function _nextFrame(ms = 60) {
438
+ return new Promise(res => setTimeout(res, ms));
439
+ }