trackfw 2.6.0 → 2.7.1

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.
@@ -0,0 +1,687 @@
1
+ /* trackfw dashboard — app.js
2
+ Tecnologias: HTMX + marked.js + Chart.js + D3.js (todos via CDN)
3
+ Sem bundler, sem npm no runtime.
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ // ─── Estado global ────────────────────────────────────────────────────────────
9
+ let _currentView = 'board';
10
+ let _boardData = null; // cache do /api/board
11
+ let _chainData = null; // cache do /api/chain
12
+ let _donutChart = null; // instância Chart.js donut
13
+ let _burnChart = null; // instância Chart.js burndown
14
+ let _d3Sim = null; // simulação D3 force
15
+ let _drawerPath = null; // path atualmente aberto no drawer
16
+
17
+ // Cores canonicas por estado
18
+ const STATE_COLORS = {
19
+ wip: '#3b82f6',
20
+ backlog: '#6b7280',
21
+ blocked: '#ef4444',
22
+ done: '#22c55e',
23
+ abandoned: '#78716c',
24
+ };
25
+
26
+ // Cores canonicas por tipo de nó
27
+ const NODE_COLORS = {
28
+ adr: '#3b82f6',
29
+ req: '#f97316',
30
+ roadmap: null, // usa STATE_COLORS[state]
31
+ };
32
+
33
+ // ─── Utilitários ──────────────────────────────────────────────────────────────
34
+
35
+ function truncate(str, max = 30) {
36
+ if (!str) return '';
37
+ return str.length > max ? str.slice(0, max - 1) + '…' : str;
38
+ }
39
+
40
+ function stateLabel(state) {
41
+ const labels = {
42
+ wip: 'WIP', backlog: 'BACKLOG', blocked: 'BLOCKED', done: 'DONE', abandoned: 'ABANDONED',
43
+ };
44
+ return labels[state] || state.toUpperCase();
45
+ }
46
+
47
+ function el(id) {
48
+ return document.getElementById(id);
49
+ }
50
+
51
+ function show(id) {
52
+ const e = el(id);
53
+ if (e) e.classList.remove('hidden');
54
+ }
55
+
56
+ function hide(id) {
57
+ const e = el(id);
58
+ if (e) e.classList.add('hidden');
59
+ }
60
+
61
+ // ─── Navegação entre views ────────────────────────────────────────────────────
62
+
63
+ function switchView(view) {
64
+ _currentView = view;
65
+
66
+ // Esconder todas as sections
67
+ document.querySelectorAll('.view-section').forEach(s => s.classList.add('hidden'));
68
+
69
+ // Atualizar tabs
70
+ document.querySelectorAll('.tab-btn').forEach(btn => {
71
+ btn.classList.remove('active');
72
+ btn.setAttribute('aria-pressed', 'false');
73
+ });
74
+
75
+ const activeTab = el('tab-' + view);
76
+ if (activeTab) {
77
+ activeTab.classList.add('active');
78
+ activeTab.setAttribute('aria-pressed', 'true');
79
+ }
80
+
81
+ const activeSection = el('view-' + view);
82
+ if (activeSection) activeSection.classList.remove('hidden');
83
+
84
+ // Carregar dados conforme view
85
+ if (view === 'board') {
86
+ loadBoard();
87
+ } else if (view === 'chain') {
88
+ loadChain();
89
+ } else if (view === 'metrics') {
90
+ loadMetrics();
91
+ }
92
+ }
93
+
94
+ // ─── View: Board / Kanban ─────────────────────────────────────────────────────
95
+
96
+ const COLUMNS_ORDER = ['backlog', 'wip', 'blocked', 'done', 'abandoned'];
97
+ const COLUMNS_LABEL = {
98
+ wip: 'WIP', backlog: 'Backlog', blocked: 'Blocked', done: 'Done', abandoned: 'Abandoned',
99
+ };
100
+
101
+ async function loadBoard() {
102
+ const container = el('board-container');
103
+
104
+ // Se já temos dados em cache, apenas re-renderizar
105
+ if (_boardData) {
106
+ renderBoard(_boardData, el('agent-filter') ? el('agent-filter').value : '');
107
+ return;
108
+ }
109
+
110
+ // Loading
111
+ container.innerHTML = `
112
+ <div id="board-loading" class="flex items-center gap-2 text-gray-500 text-sm">
113
+ <svg class="animate-spin h-4 w-4 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
114
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
115
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
116
+ </svg>
117
+ Carregando board...
118
+ </div>`;
119
+ hide('board-error');
120
+
121
+ try {
122
+ const res = await fetch('/api/board');
123
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
124
+ _boardData = await res.json();
125
+
126
+ // Popular dropdown de agentes
127
+ const agents = _boardData.agents || [];
128
+ const filterContainer = el('agent-filter-container');
129
+ const agentSelect = el('agent-filter');
130
+
131
+ if (agents.length > 1 && filterContainer && agentSelect) {
132
+ filterContainer.classList.remove('hidden');
133
+ // Limpar opções extras (manter "Todos")
134
+ while (agentSelect.options.length > 1) agentSelect.remove(1);
135
+ agents.forEach(agent => {
136
+ const opt = document.createElement('option');
137
+ opt.value = agent;
138
+ opt.textContent = agent;
139
+ agentSelect.appendChild(opt);
140
+ });
141
+ }
142
+
143
+ renderBoard(_boardData, '');
144
+ } catch (err) {
145
+ container.innerHTML = '';
146
+ const errEl = el('board-error');
147
+ if (errEl) {
148
+ errEl.textContent = `Erro ao carregar board: ${err.message}`;
149
+ errEl.classList.remove('hidden');
150
+ }
151
+ }
152
+ }
153
+
154
+ function renderBoard(data, agentFilter) {
155
+ const container = el('board-container');
156
+ if (!container) return;
157
+
158
+ container.innerHTML = '';
159
+
160
+ COLUMNS_ORDER.forEach(state => {
161
+ let cards = (data.columns[state] || []);
162
+ if (agentFilter) {
163
+ cards = cards.filter(c => c.agent === agentFilter);
164
+ }
165
+
166
+ const col = document.createElement('div');
167
+ col.className = 'kanban-column bg-white rounded-lg border border-gray-200 shadow-sm flex flex-col';
168
+ col.setAttribute('aria-label', `Coluna ${COLUMNS_LABEL[state]}`);
169
+
170
+ // Header da coluna
171
+ const header = document.createElement('div');
172
+ header.className = 'px-4 py-3 border-b border-gray-200 flex items-center justify-between';
173
+ header.innerHTML = `
174
+ <span class="text-xs font-semibold uppercase tracking-wider text-gray-500">${COLUMNS_LABEL[state]}</span>
175
+ <span class="badge-${state} text-xs font-bold px-2 py-0.5 rounded-full">${cards.length}</span>`;
176
+ col.appendChild(header);
177
+
178
+ // Cards
179
+ const cardsContainer = document.createElement('div');
180
+ cardsContainer.className = 'p-3 flex flex-col gap-2 flex-1 overflow-y-auto';
181
+ cardsContainer.style.maxHeight = 'calc(100vh - 160px)';
182
+
183
+ if (cards.length === 0) {
184
+ const empty = document.createElement('p');
185
+ empty.className = 'text-xs text-gray-400 text-center py-4';
186
+ empty.textContent = 'Nenhum item';
187
+ cardsContainer.appendChild(empty);
188
+ } else {
189
+ cards.forEach(card => {
190
+ cardsContainer.appendChild(createCard(card));
191
+ });
192
+ }
193
+
194
+ col.appendChild(cardsContainer);
195
+ container.appendChild(col);
196
+ });
197
+ }
198
+
199
+ function createCard(card) {
200
+ const div = document.createElement('div');
201
+ div.className = 'kanban-card bg-gray-50 border border-gray-200 rounded-md p-3 hover:bg-white';
202
+ div.setAttribute('tabindex', '0');
203
+ div.setAttribute('role', 'button');
204
+ div.setAttribute('aria-label', `Abrir: ${card.title || card.file}`);
205
+
206
+ div.innerHTML = `
207
+ <p class="text-xs font-semibold text-gray-800 mb-2 leading-snug">${escapeHtml(card.title || card.file)}</p>
208
+ <div class="flex flex-wrap items-center gap-1">
209
+ ${card.agent ? `<span class="inline-block text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded font-medium">${escapeHtml(card.agent)}</span>` : ''}
210
+ <span class="badge-${card.state} inline-block text-xs px-1.5 py-0.5 rounded font-medium">${stateLabel(card.state)}</span>
211
+ </div>`;
212
+
213
+ div.addEventListener('click', () => openDrawer(card.path));
214
+ div.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openDrawer(card.path); } });
215
+
216
+ return div;
217
+ }
218
+
219
+ function filterByAgent(agent) {
220
+ if (_boardData) {
221
+ renderBoard(_boardData, agent);
222
+ }
223
+ }
224
+
225
+ // ─── View: Chain (D3 force-directed graph) ────────────────────────────────────
226
+
227
+ async function loadChain() {
228
+ hide('chain-error');
229
+
230
+ // Se já tem dados, re-renderizar
231
+ if (_chainData) {
232
+ renderChain(_chainData);
233
+ return;
234
+ }
235
+
236
+ show('chain-loading');
237
+
238
+ try {
239
+ const res = await fetch('/api/chain');
240
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
241
+ _chainData = await res.json();
242
+ hide('chain-loading');
243
+ renderChain(_chainData);
244
+ } catch (err) {
245
+ hide('chain-loading');
246
+ const errEl = el('chain-error');
247
+ if (errEl) {
248
+ errEl.textContent = `Erro ao carregar cadeia: ${err.message}`;
249
+ errEl.classList.remove('hidden');
250
+ }
251
+ }
252
+ }
253
+
254
+ function renderChain(data) {
255
+ const svgEl = el('chain-svg');
256
+ if (!svgEl) return;
257
+
258
+ // Limpar SVG anterior
259
+ while (svgEl.firstChild) svgEl.removeChild(svgEl.firstChild);
260
+
261
+ const width = svgEl.clientWidth || window.innerWidth;
262
+ const height = svgEl.clientHeight || window.innerHeight - 60;
263
+
264
+ svgEl.setAttribute('viewBox', `0 0 ${width} ${height}`);
265
+
266
+ const svg = d3.select(svgEl);
267
+
268
+ // Defs para a seta
269
+ svg.append('defs').append('marker')
270
+ .attr('id', 'arrowhead')
271
+ .attr('viewBox', '-0 -5 10 10')
272
+ .attr('refX', 22)
273
+ .attr('refY', 0)
274
+ .attr('orient', 'auto')
275
+ .attr('markerWidth', 6)
276
+ .attr('markerHeight', 6)
277
+ .append('path')
278
+ .attr('d', 'M 0,-5 L 10,0 L 0,5')
279
+ .attr('fill', '#9ca3af');
280
+
281
+ // Grupo raiz (para zoom/pan)
282
+ const g = svg.append('g');
283
+
284
+ // Zoom
285
+ const zoom = d3.zoom()
286
+ .scaleExtent([0.2, 4])
287
+ .on('zoom', (event) => g.attr('transform', event.transform));
288
+ svg.call(zoom);
289
+
290
+ // Preparar nodes e edges (deep copy para D3 mutate)
291
+ const nodes = (data.nodes || []).map(n => ({ ...n }));
292
+ const edges = (data.edges || []).map(e => ({ ...e }));
293
+
294
+ // Links com referência aos objetos de node
295
+ const nodeById = {};
296
+ nodes.forEach(n => { nodeById[n.id] = n; });
297
+
298
+ const links = edges.map(e => ({
299
+ source: nodeById[e.from],
300
+ target: nodeById[e.to],
301
+ })).filter(l => l.source && l.target);
302
+
303
+ // Simulação
304
+ if (_d3Sim) _d3Sim.stop();
305
+
306
+ _d3Sim = d3.forceSimulation(nodes)
307
+ .force('link', d3.forceLink(links).id(d => d.id).distance(120).strength(0.6))
308
+ .force('charge', d3.forceManyBody().strength(-280))
309
+ .force('center', d3.forceCenter(width / 2, height / 2))
310
+ .force('collision', d3.forceCollide(35));
311
+
312
+ // Arestas
313
+ const link = g.append('g')
314
+ .attr('class', 'links')
315
+ .selectAll('line')
316
+ .data(links)
317
+ .enter().append('line')
318
+ .attr('class', 'link-line')
319
+ .attr('marker-end', 'url(#arrowhead)');
320
+
321
+ // Grupos de nós
322
+ const node = g.append('g')
323
+ .attr('class', 'nodes')
324
+ .selectAll('g')
325
+ .data(nodes)
326
+ .enter().append('g')
327
+ .attr('class', 'node-group')
328
+ .attr('cursor', 'pointer')
329
+ .on('click', (_, d) => openDrawer(d.id))
330
+ .call(
331
+ d3.drag()
332
+ .on('start', (event, d) => {
333
+ if (!event.active) _d3Sim.alphaTarget(0.3).restart();
334
+ d.fx = d.x; d.fy = d.y;
335
+ })
336
+ .on('drag', (event, d) => { d.fx = event.x; d.fy = event.y; })
337
+ .on('end', (event, d) => {
338
+ if (!event.active) _d3Sim.alphaTarget(0);
339
+ d.fx = null; d.fy = null;
340
+ })
341
+ );
342
+
343
+ // Círculo de cada nó
344
+ node.append('circle')
345
+ .attr('r', 18)
346
+ .attr('fill', d => {
347
+ if (d.type === 'adr') return NODE_COLORS.adr;
348
+ if (d.type === 'req') return NODE_COLORS.req;
349
+ return STATE_COLORS[d.state] || '#6b7280';
350
+ })
351
+ .attr('stroke', '#fff')
352
+ .attr('stroke-width', 2);
353
+
354
+ // Ícone/sigla dentro do círculo
355
+ node.append('text')
356
+ .attr('text-anchor', 'middle')
357
+ .attr('dy', '0.35em')
358
+ .attr('fill', '#fff')
359
+ .attr('font-size', '9px')
360
+ .attr('font-weight', '700')
361
+ .attr('pointer-events', 'none')
362
+ .text(d => {
363
+ if (d.type === 'adr') return 'ADR';
364
+ if (d.type === 'req') return 'REQ';
365
+ return 'RM';
366
+ });
367
+
368
+ // Label abaixo do círculo
369
+ node.append('text')
370
+ .attr('class', 'node-label')
371
+ .attr('text-anchor', 'middle')
372
+ .attr('dy', '2.4em')
373
+ .text(d => truncate(d.title || d.id, 28));
374
+
375
+ // Tooltip básico via title SVG
376
+ node.append('title').text(d => `${d.type.toUpperCase()}: ${d.title || d.id}\nEstado: ${d.state || '—'}`);
377
+
378
+ // Tick
379
+ _d3Sim.on('tick', () => {
380
+ link
381
+ .attr('x1', d => d.source.x)
382
+ .attr('y1', d => d.source.y)
383
+ .attr('x2', d => d.target.x)
384
+ .attr('y2', d => d.target.y);
385
+
386
+ node.attr('transform', d => `translate(${d.x},${d.y})`);
387
+ });
388
+ }
389
+
390
+ // ─── View: Metrics ────────────────────────────────────────────────────────────
391
+
392
+ async function loadMetrics() {
393
+ hide('metrics-error');
394
+ hide('kpi-cards');
395
+ hide('charts-container');
396
+ show('metrics-loading');
397
+
398
+ try {
399
+ const res = await fetch('/api/metrics');
400
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
401
+ const data = await res.json();
402
+ hide('metrics-loading');
403
+ renderMetrics(data);
404
+ } catch (err) {
405
+ hide('metrics-loading');
406
+ const errEl = el('metrics-error');
407
+ if (errEl) {
408
+ errEl.textContent = `Erro ao carregar métricas: ${err.message}`;
409
+ errEl.classList.remove('hidden');
410
+ }
411
+ }
412
+ }
413
+
414
+ function renderMetrics(data) {
415
+ // KPI cards
416
+ const leadEl = el('kpi-lead-time');
417
+ const cycleEl = el('kpi-cycle-time');
418
+ const abandonEl = el('kpi-abandonment');
419
+
420
+ if (leadEl) leadEl.textContent = `${(data.lead_time_avg_days || 0).toFixed(1)} dias`;
421
+ if (cycleEl) cycleEl.textContent = `${(data.cycle_time_avg_days || 0).toFixed(1)} dias`;
422
+ if (abandonEl) abandonEl.textContent = `${(((data.abandonment_rate || 0) * 100)).toFixed(1)}%`;
423
+
424
+ show('kpi-cards');
425
+
426
+ // Donut chart — distribuição por estado
427
+ const dist = data.state_distribution || {};
428
+ const donutLabels = Object.keys(dist);
429
+ const donutValues = Object.values(dist);
430
+ const donutColors = donutLabels.map(s => STATE_COLORS[s] || '#9ca3af');
431
+
432
+ const donutCanvas = el('donut-chart');
433
+ if (donutCanvas) {
434
+ if (_donutChart) { _donutChart.destroy(); _donutChart = null; }
435
+ _donutChart = new Chart(donutCanvas, {
436
+ type: 'doughnut',
437
+ data: {
438
+ labels: donutLabels.map(stateLabel),
439
+ datasets: [{
440
+ data: donutValues,
441
+ backgroundColor: donutColors,
442
+ borderWidth: 2,
443
+ borderColor: '#fff',
444
+ }],
445
+ },
446
+ options: {
447
+ responsive: true,
448
+ maintainAspectRatio: true,
449
+ plugins: {
450
+ legend: {
451
+ position: 'bottom',
452
+ labels: { font: { size: 11 }, padding: 12 },
453
+ },
454
+ },
455
+ },
456
+ });
457
+ }
458
+
459
+ // Burndown chart
460
+ const burndown = data.burndown || [];
461
+ const burndownCanvas = el('burndown-chart');
462
+ const burndownEmpty = el('burndown-empty');
463
+
464
+ if (burndown.length === 0) {
465
+ if (burndownCanvas) burndownCanvas.style.display = 'none';
466
+ if (burndownEmpty) burndownEmpty.classList.remove('hidden');
467
+ } else {
468
+ if (burndownEmpty) burndownEmpty.classList.add('hidden');
469
+ if (burndownCanvas) {
470
+ burndownCanvas.style.display = '';
471
+ if (_burnChart) { _burnChart.destroy(); _burnChart = null; }
472
+ _burnChart = new Chart(burndownCanvas, {
473
+ type: 'line',
474
+ data: {
475
+ labels: burndown.map(b => b.date),
476
+ datasets: [
477
+ {
478
+ label: 'Abertos',
479
+ data: burndown.map(b => b.open),
480
+ borderColor: '#ef4444',
481
+ backgroundColor: 'rgba(239,68,68,0.08)',
482
+ pointRadius: 4,
483
+ tension: 0.3,
484
+ fill: true,
485
+ },
486
+ {
487
+ label: 'Fechados',
488
+ data: burndown.map(b => b.closed),
489
+ borderColor: '#22c55e',
490
+ backgroundColor: 'rgba(34,197,94,0.08)',
491
+ pointRadius: 4,
492
+ tension: 0.3,
493
+ fill: true,
494
+ },
495
+ ],
496
+ },
497
+ options: {
498
+ responsive: true,
499
+ maintainAspectRatio: false,
500
+ scales: {
501
+ x: { ticks: { font: { size: 10 } } },
502
+ y: { beginAtZero: true, ticks: { font: { size: 10 }, precision: 0 } },
503
+ },
504
+ plugins: {
505
+ legend: { labels: { font: { size: 11 } } },
506
+ },
507
+ },
508
+ });
509
+ }
510
+ }
511
+
512
+ show('charts-container');
513
+ }
514
+
515
+ // ─── Drawer lateral ───────────────────────────────────────────────────────────
516
+
517
+ async function openDrawer(path) {
518
+ if (!path) return;
519
+ _drawerPath = path;
520
+
521
+ const drawer = el('drawer');
522
+ const overlay = el('drawer-overlay');
523
+
524
+ // Exibir drawer e overlay
525
+ drawer.style.display = 'flex';
526
+ overlay.classList.remove('hidden');
527
+ drawer.classList.remove('hidden');
528
+
529
+ // Forçar re-trigger da animação
530
+ drawer.classList.remove('drawer-slide');
531
+ void drawer.offsetWidth;
532
+ drawer.classList.add('drawer-slide');
533
+
534
+ // Filename no header
535
+ const filenameEl = el('drawer-filename');
536
+ if (filenameEl) filenameEl.textContent = path.split('/').pop();
537
+
538
+ // Resetar conteúdo
539
+ hide('drawer-content');
540
+ hide('drawer-error');
541
+ show('drawer-loading');
542
+
543
+ const mdEl = el('drawer-markdown');
544
+ const fmEl = el('drawer-frontmatter');
545
+ if (mdEl) mdEl.innerHTML = '';
546
+ if (fmEl) fmEl.classList.add('hidden');
547
+
548
+ try {
549
+ const res = await fetch(`/api/file?path=${encodeURIComponent(path)}`);
550
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
551
+ const raw = await res.text();
552
+
553
+ hide('drawer-loading');
554
+
555
+ // Extrair e renderizar frontmatter
556
+ const { frontmatter, body } = parseFrontmatter(raw);
557
+ if (frontmatter && Object.keys(frontmatter).length > 0) {
558
+ renderFrontmatterTable(frontmatter);
559
+ }
560
+
561
+ // Renderizar markdown
562
+ if (mdEl) {
563
+ mdEl.innerHTML = marked.parse(body || raw);
564
+
565
+ // Interceptar links internos .md
566
+ mdEl.querySelectorAll('a[href]').forEach(a => {
567
+ const href = a.getAttribute('href');
568
+ if (!href) return;
569
+ const isExternal = href.startsWith('http://') || href.startsWith('https://');
570
+ const isMdLink = href.endsWith('.md');
571
+ if (!isExternal && isMdLink) {
572
+ a.addEventListener('click', e => {
573
+ e.preventDefault();
574
+ openDrawer(href);
575
+ });
576
+ }
577
+ });
578
+ }
579
+
580
+ show('drawer-content');
581
+ } catch (err) {
582
+ hide('drawer-loading');
583
+ const errEl = el('drawer-error');
584
+ if (errEl) {
585
+ errEl.textContent = `Erro ao carregar arquivo: ${err.message}`;
586
+ errEl.classList.remove('hidden');
587
+ }
588
+ }
589
+ }
590
+
591
+ function closeDrawer() {
592
+ const drawer = el('drawer');
593
+ const overlay = el('drawer-overlay');
594
+ if (drawer) drawer.style.display = 'none';
595
+ if (overlay) overlay.classList.add('hidden');
596
+ _drawerPath = null;
597
+ }
598
+
599
+ // ─── Frontmatter YAML ─────────────────────────────────────────────────────────
600
+
601
+ /**
602
+ * Extrai o bloco YAML entre --- e --- do início do markdown.
603
+ * Retorna { frontmatter: Object, body: string }.
604
+ */
605
+ function parseFrontmatter(md) {
606
+ if (!md || !md.trimStart().startsWith('---')) {
607
+ return { frontmatter: null, body: md };
608
+ }
609
+
610
+ const lines = md.split('\n');
611
+ let endIndex = -1;
612
+
613
+ // Pular a primeira linha (---) e procurar o fechamento
614
+ for (let i = 1; i < lines.length; i++) {
615
+ if (lines[i].trim() === '---') {
616
+ endIndex = i;
617
+ break;
618
+ }
619
+ }
620
+
621
+ if (endIndex === -1) {
622
+ return { frontmatter: null, body: md };
623
+ }
624
+
625
+ const yamlLines = lines.slice(1, endIndex);
626
+ const body = lines.slice(endIndex + 1).join('\n');
627
+ const frontmatter = {};
628
+
629
+ yamlLines.forEach(line => {
630
+ const colonIdx = line.indexOf(':');
631
+ if (colonIdx === -1) return;
632
+ const key = line.slice(0, colonIdx).trim();
633
+ const value = line.slice(colonIdx + 1).trim().replace(/^['"]|['"]$/g, '');
634
+ if (key) frontmatter[key] = value;
635
+ });
636
+
637
+ return { frontmatter, body };
638
+ }
639
+
640
+ /**
641
+ * Renderiza o objeto frontmatter como tabela no drawer.
642
+ */
643
+ function renderFrontmatterTable(frontmatter) {
644
+ const container = el('drawer-frontmatter');
645
+ const tbody = document.querySelector('#frontmatter-table tbody');
646
+ if (!container || !tbody) return;
647
+
648
+ tbody.innerHTML = '';
649
+
650
+ Object.entries(frontmatter).forEach(([key, value]) => {
651
+ const tr = document.createElement('tr');
652
+ const tdKey = document.createElement('td');
653
+ const tdVal = document.createElement('td');
654
+ tdKey.textContent = key;
655
+ tdVal.textContent = value;
656
+ tr.appendChild(tdKey);
657
+ tr.appendChild(tdVal);
658
+ tbody.appendChild(tr);
659
+ });
660
+
661
+ container.classList.remove('hidden');
662
+ }
663
+
664
+ // ─── Escape HTML ──────────────────────────────────────────────────────────────
665
+
666
+ function escapeHtml(str) {
667
+ if (!str) return '';
668
+ return str
669
+ .replace(/&/g, '&amp;')
670
+ .replace(/</g, '&lt;')
671
+ .replace(/>/g, '&gt;')
672
+ .replace(/"/g, '&quot;')
673
+ .replace(/'/g, '&#39;');
674
+ }
675
+
676
+ // ─── Atalho de teclado: Escape fecha o drawer ─────────────────────────────────
677
+
678
+ document.addEventListener('keydown', e => {
679
+ if (e.key === 'Escape') closeDrawer();
680
+ });
681
+
682
+ // ─── Init ─────────────────────────────────────────────────────────────────────
683
+
684
+ document.addEventListener('DOMContentLoaded', () => {
685
+ // Ativar tab Board e carregar dados iniciais
686
+ switchView('board');
687
+ });