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.
- package/README.md +341 -232
- package/bin/trackops.js +102 -70
- package/lib/config.js +260 -35
- package/lib/control.js +518 -475
- package/lib/env.js +227 -0
- package/lib/i18n.js +61 -53
- package/lib/init.js +146 -55
- package/lib/locale.js +63 -0
- package/lib/opera-bootstrap.js +523 -0
- package/lib/opera.js +319 -170
- package/lib/registry.js +27 -13
- package/lib/release.js +56 -0
- package/lib/resources.js +42 -0
- package/lib/server.js +912 -418
- package/lib/skills.js +148 -124
- package/lib/workspace.js +260 -0
- package/locales/en.json +331 -139
- package/locales/es.json +331 -139
- package/package.json +14 -3
- package/scripts/skills-marketplace-smoke.js +124 -0
- package/scripts/smoke-tests.js +445 -0
- package/scripts/sync-skill-version.js +21 -0
- package/scripts/validate-skill.js +88 -0
- package/skills/trackops/SKILL.md +64 -0
- package/skills/trackops/agents/openai.yaml +3 -0
- package/skills/trackops/references/activation.md +39 -0
- package/skills/trackops/references/troubleshooting.md +34 -0
- package/skills/trackops/references/workflow.md +20 -0
- package/skills/trackops/scripts/bootstrap-trackops.js +201 -0
- package/skills/trackops/skill.json +29 -0
- package/templates/etapa/agent.md +2 -2
- package/templates/etapa/references/etapa-cycle.md +1 -1
- package/templates/opera/agent.md +1 -1
- package/templates/opera/en/agent.md +26 -0
- package/templates/opera/en/genesis.md +79 -0
- package/templates/opera/en/references/autonomy-and-recovery.md +23 -0
- package/templates/opera/en/references/opera-cycle.md +62 -0
- package/templates/opera/en/registry.md +28 -0
- package/templates/opera/en/router.md +39 -0
- package/templates/opera/genesis.md +79 -94
- package/templates/skills/changelog-updater/locales/en/SKILL.md +11 -0
- package/templates/skills/commiter/locales/en/SKILL.md +11 -0
- package/templates/skills/project-starter-skill/SKILL.md +5 -3
- package/templates/skills/project-starter-skill/locales/en/SKILL.md +24 -0
- package/ui/css/base.css +266 -0
- package/ui/css/charts.css +327 -0
- package/ui/css/components.css +570 -0
- package/ui/css/panels.css +956 -0
- package/ui/css/tokens.css +227 -0
- package/ui/favicon.svg +5 -0
- package/ui/index.html +91 -351
- package/ui/js/api.js +220 -0
- package/ui/js/app.js +200 -0
- package/ui/js/console-logger.js +172 -0
- package/ui/js/i18n.js +14 -0
- package/ui/js/icons.js +104 -0
- package/ui/js/onboarding.js +439 -0
- package/ui/js/router.js +125 -0
- package/ui/js/state.js +130 -0
- package/ui/js/theme.js +100 -0
- package/ui/js/time-tracker.js +248 -0
- package/ui/js/utils.js +175 -0
- package/ui/js/views/board.js +255 -0
- package/ui/js/views/execution.js +256 -0
- package/ui/js/views/flash.js +47 -0
- package/ui/js/views/insights.js +340 -0
- package/ui/js/views/overview.js +365 -0
- package/ui/js/views/settings.js +381 -0
- package/ui/js/views/sidebar.js +131 -0
- package/ui/js/views/skills.js +163 -0
- package/ui/js/views/tasks.js +406 -0
- 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
|
+
}
|
package/ui/js/router.js
ADDED
|
@@ -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
|
+
}
|