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/icons.js ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * icons.js — SVG paths de React Icons (Flat / Line) como constantes
3
+ * Stack vanilla: sin React, sin dependencias externas.
4
+ * Se usan los paths SVG de heroicons + lucide-style wrappers.
5
+ * Uso: icons.render('dashboard', 20) → <svg>...</svg>
6
+ */
7
+
8
+ const PATHS = {
9
+ // ── Navegación ──
10
+ dashboard: `<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>`,
11
+ tasks: `<path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/><rect x="9" y="3" width="6" height="4" rx="1"/><path d="m9 12 2 2 4-4"/>`,
12
+ board: `<rect x="3" y="3" width="5" height="18" rx="1"/><rect x="11" y="3" width="5" height="12" rx="1"/><rect x="19" y="3" width="5" height="15" rx="1"/>`, // adjusted
13
+ execution: `<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>`,
14
+ insights: `<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>`,
15
+ settings: `<circle cx="12" cy="12" r="3"/><path d="M19.1 6.9a2.83 2.83 0 0 1 0 4L17 13l-1.4-1.4 2.1-2.1a.83.83 0 0 0 0-1.2L16.4 7l-2.1 2.1L13 7.8l2.1-2.1a.83.83 0 0 0-1.2 0L12.7 6l-1.4 1.4L9.2 5.3a2.83 2.83 0 0 1 4-4L15.3 3.4l1.4 1.4 2.1-2.1L19.1 3a2.83 2.83 0 0 1 4 4l-2.1 2.1 1.4 1.4L24.5 8.8a2.83 2.83 0 0 1-4 4l-2.1-2.1-1.4 1.4 2.1 2.1a.83.83 0 0 1 0 1.2L17.4 17l2.1 2.1a.83.83 0 0 1-1.2 0L16.2 17l-1.4 1.4 2.1 2.1a2.83 2.83 0 0 1-4 4l-2.1-2.1-1.4 1.4 2.1 2.1a.83.83 0 0 1 0 1.2L10.7 26l2.1 2.1a.83.83 0 0 1-1.2 0L9.5 26l-1.4-1.4L5.9 26.7a2.83 2.83 0 0 1-4-4L4 20.6l1.4-1.4-2.1-2.1A2.83 2.83 0 0 1 7.3 13l2.1 2.1 1.4-1.4L8.7 11.6A2.83 2.83 0 0 1 13 7.6l2.1 2.1 1.4-1.4L14.4 6.2A2.83 2.83 0 0 1 19.1 6.9Z"/>`,
16
+
17
+ // ── General ──
18
+ help: `<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>`,
19
+ logout: `<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>`,
20
+ console: `<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>`,
21
+ infinity: `<path d="M12 12c-2-2.5-4-4-6-4a4 4 0 0 0 0 8c2 0 4-1.5 6-4z"/><path d="M12 12c2 2.5 4 4 6 4a4 4 0 0 0 0-8c-2 0-4 1.5-6 4z"/>`,
22
+
23
+ // ── Acciones ──
24
+ plus: `<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>`,
25
+ check: `<polyline points="20 6 9 17 4 12"/>`,
26
+ x: `<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>`,
27
+ edit: `<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>`,
28
+ trash: `<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>`,
29
+ copy: `<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>`,
30
+ refresh: `<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>`,
31
+ sync: `<path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/>`,
32
+ search: `<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>`,
33
+ filter: `<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>`,
34
+ sort: `<line x1="21" y1="10" x2="7" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="7" y2="18"/>`,
35
+ menu: `<line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>`,
36
+ arrowUp: `<line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/>`,
37
+ arrowDown: `<line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/>`,
38
+ arrowRight:`<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>`,
39
+ chevronDown:`<polyline points="6 9 12 15 18 9"/>`,
40
+ externalLink:`<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>`,
41
+
42
+ // ── Tiempo / cronómetro ──
43
+ clock: `<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>`,
44
+ play: `<polygon points="5 3 19 12 5 21 5 3"/>`,
45
+ pause: `<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>`,
46
+ stop: `<rect x="3" y="3" width="18" height="18" rx="2"/>`,
47
+ timer: `<circle cx="12" cy="13" r="8"/><path d="M12 9v4l2 2"/><path d="M5 3 2 6"/><path d="m22 6-3-3"/><path d="M6.38 18.7 4 21"/><path d="M17.64 18.67 20 21"/>`,
48
+
49
+ // ── Git / Repo ──
50
+ gitBranch: `<line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>`,
51
+ gitCommit: `<circle cx="12" cy="12" r="4"/><line x1="1.05" y1="12" x2="7" y2="12"/><line x1="17.01" y1="12" x2="22.96" y2="12"/>`,
52
+ gitMerge: `<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/>`,
53
+
54
+ // ── Información / estado ──
55
+ alertCircle: `<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"/>`,
56
+ alertTriangle:`<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>`,
57
+ checkCircle: `<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>`,
58
+ info: `<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>`,
59
+ shield: `<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>`,
60
+ zap: `<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>`,
61
+ trending: `<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/>`,
62
+ target: `<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>`,
63
+
64
+ // ── Ficheros / docs ──
65
+ fileText: `<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>`,
66
+ folder: `<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>`,
67
+ download: `<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>`,
68
+
69
+ // ── Layout ──
70
+ maximize: `<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>`,
71
+ columns: `<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/>`,
72
+ terminal2: `<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>`,
73
+ package: `<line x1="16.5" y1="9.4" x2="7.5" y2="4.21"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>`,
74
+ };
75
+
76
+ /**
77
+ * Renderiza un SVG icon
78
+ * @param {string} name - nombre del icono
79
+ * @param {number} [size=18] - tamaño en px
80
+ * @param {string} [className=''] - clases adicionales
81
+ * @returns {string} HTML string del SVG
82
+ */
83
+ export function icon(name, size = 18, className = '') {
84
+ const paths = PATHS[name];
85
+ if (!paths) {
86
+ // Icono fallback: círculo vacío
87
+ return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="${className}" aria-hidden="true"><circle cx="12" cy="12" r="10" opacity="0.3"/></svg>`;
88
+ }
89
+ return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="${className}" aria-hidden="true">${paths}</svg>`;
90
+ }
91
+
92
+ /**
93
+ * Crea un elemento SVG icon insertado en el DOM
94
+ * @param {string} name
95
+ * @param {number} [size=18]
96
+ * @returns {SVGElement}
97
+ */
98
+ export function iconEl(name, size = 18) {
99
+ const wrapper = document.createElement('span');
100
+ wrapper.innerHTML = icon(name, size);
101
+ return wrapper.firstElementChild;
102
+ }
103
+
104
+ export default { icon, iconEl };
@@ -0,0 +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
+ 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
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * router.js — Navegación entre vistas
3
+ * Gestiona qué vista está activa en #view-container
4
+ * y sincroniza el estado activo en el sidebar.
5
+ */
6
+
7
+ import * as state from './state.js';
8
+
9
+ /** @type {Map<string, function(): HTMLElement|string>} */
10
+ const _registry = new Map();
11
+ let _currentView = null;
12
+ let _container = null;
13
+
14
+ /**
15
+ * Registra una vista
16
+ * @param {string} id - identificador único de la vista
17
+ * @param {function(): Promise<HTMLElement|string>} factory - función que devuelve el contenido
18
+ */
19
+ export function register(id, factory) {
20
+ _registry.set(id, factory);
21
+ }
22
+
23
+ /**
24
+ * Inicializa el router
25
+ * @param {HTMLElement} container - #view-container
26
+ */
27
+ export function init(container) {
28
+ _container = container;
29
+
30
+ // Navegación por hash
31
+ window.addEventListener('hashchange', _handleHash);
32
+
33
+ // Clic en nav-items del sidebar (delegación)
34
+ document.addEventListener('click', e => {
35
+ const item = e.target.closest('[data-view]');
36
+ if (!item || !item.dataset.view) return;
37
+ e.preventDefault();
38
+ navigate(item.dataset.view);
39
+ });
40
+ }
41
+
42
+ /**
43
+ * Navega a una vista
44
+ * @param {string} viewId
45
+ * @param {boolean} [updateHash=true]
46
+ */
47
+ export async function navigate(viewId, updateHash = true) {
48
+ if (!_registry.has(viewId)) {
49
+ console.warn(`[router] Vista desconocida: "${viewId}"`);
50
+ return;
51
+ }
52
+
53
+ _currentView = viewId;
54
+ state.update('activeView', viewId);
55
+
56
+ // Actualizar hash sin trigger de hashchange
57
+ if (updateHash && window.location.hash !== `#${viewId}`) {
58
+ history.replaceState(null, '', `#${viewId}`);
59
+ }
60
+
61
+ // Actualizar clases activas en sidebar
62
+ document.querySelectorAll('[data-view]').forEach(item => {
63
+ item.classList.toggle('is-active', item.dataset.view === viewId);
64
+ });
65
+
66
+ // Renderizar contenido
67
+ if (_container) {
68
+ _container.innerHTML = '<div style="padding:2rem"><div class="spinner"></div></div>';
69
+ try {
70
+ const factory = _registry.get(viewId);
71
+ const content = await factory();
72
+ if (typeof content === 'string') {
73
+ _container.innerHTML = content;
74
+ } else if (content instanceof HTMLElement) {
75
+ _container.innerHTML = '';
76
+ _container.appendChild(content);
77
+ }
78
+ // Scroll to top
79
+ _container.scrollTop = 0;
80
+ // Focus para a11y (skip-link target)
81
+ _container.focus({ preventScroll: true });
82
+ } catch (err) {
83
+ console.error('[router] Error cargando vista:', err);
84
+ _container.innerHTML = `
85
+ <div class="empty-state" style="margin:2rem">
86
+ <p>Error al cargar la vista <strong>${viewId}</strong></p>
87
+ <p style="font-size:0.8rem;font-family:var(--font-mono)">${err.message}</p>
88
+ </div>`;
89
+ }
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Refresca la vista actual (re-renderizado)
95
+ */
96
+ export async function refresh() {
97
+ if (_currentView) {
98
+ await navigate(_currentView, false);
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Obtiene la vista activa
104
+ */
105
+ export function current() { return _currentView; }
106
+
107
+ /**
108
+ * Handler de hashchange
109
+ */
110
+ function _handleHash() {
111
+ const hash = window.location.hash.slice(1);
112
+ if (hash && _registry.has(hash) && hash !== _currentView) {
113
+ navigate(hash, false);
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Carga la vista inicial desde el hash o usa la vista por defecto
119
+ * @param {string} [defaultView='overview']
120
+ */
121
+ export async function start(defaultView = 'overview') {
122
+ const hash = window.location.hash.slice(1);
123
+ const initial = hash && _registry.has(hash) ? hash : defaultView;
124
+ await navigate(initial, true);
125
+ }