trackfw 6.0.0 → 6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trackfw",
3
- "version": "6.0.0",
3
+ "version": "6.1.0",
4
4
  "description": "Governance CLI for AI-native software delivery and AI coding agents. Enforces traceability: ADR → REQ → ROADMAP → kanban. Native support for Codex, Claude Code, Gemini CLI, Cursor, Copilot, Windsurf, and Amazon Q.",
5
5
  "keywords": [
6
6
  "cli",
@@ -419,10 +419,12 @@ function newRoadmap(title, reqPath) {
419
419
  const filename = `${backlogDir}/ROADMAP-${date}-${slug}.md`
420
420
  fs.mkdirSync(backlogDir, { recursive: true })
421
421
 
422
+ const reqField = reqPath ? `"${reqPath}"` : '""'
423
+
422
424
  const body = `---
423
425
  status: backlog
424
426
  date: ${date}
425
- req: ""
427
+ req: ${reqField}
426
428
  squad: ""
427
429
  ---
428
430
 
@@ -434,6 +436,11 @@ squad: ""
434
436
  <!-- What problem does this roadmap solve? Link the REQ. -->
435
437
  REQ: ${reqPath || ''}
436
438
 
439
+ ## Acceptance Criteria
440
+ <!-- Consolidated criteria for this roadmap. Detail per ML in the waves below. -->
441
+ - [ ]
442
+ - [ ]
443
+
437
444
  ## Wave 1 — <name> (parallel MLs)
438
445
  > Dependencies: none
439
446
 
@@ -509,7 +516,7 @@ function newRoadmapFromReq(reqPath) {
509
516
  const body = `---
510
517
  status: backlog
511
518
  date: ${date}
512
- req: "${basename}"
519
+ req: "${reqPath}"
513
520
  squad: ""
514
521
  ---
515
522
 
@@ -521,6 +528,11 @@ squad: ""
521
528
  <!-- Derived from REQ: ${basename} -->
522
529
  REQ: ${reqPath}${adrRef}
523
530
 
531
+ ## Acceptance Criteria
532
+ <!-- Consolidated criteria for this roadmap. Detail per ML in the waves below. -->
533
+ - [ ]
534
+ - [ ]
535
+
524
536
  ${mlSection}
525
537
  `
526
538
 
@@ -1,5 +1,5 @@
1
1
  /* trackfw dashboard — app.js
2
- Tecnologias: HTMX + marked.js + Chart.js + D3.js (todos via CDN)
2
+ Tecnologias: Tailwind CSS + marked.js + DOMPurify + Chart.js + D3.js (todos via CDN)
3
3
  Sem bundler, sem npm no runtime.
4
4
  */
5
5
 
@@ -14,6 +14,12 @@ let _burnChart = null; // instância Chart.js burndown
14
14
  let _d3Sim = null; // simulação D3 force
15
15
  let _drawerPath = null; // path atualmente aberto no drawer
16
16
 
17
+ // ─── Views de lista (ADRs / REQs) ──────────────────────────────────────────────
18
+ let _listState = {
19
+ adr: { search: '', status: '' },
20
+ req: { search: '', status: '' },
21
+ };
22
+
17
23
  // ─── Auto-refresh ─────────────────────────────────────────────────────────────
18
24
  let _refreshInterval = parseInt(localStorage.getItem('trackfw_refresh_interval') || '60');
19
25
  let _refreshPaused = localStorage.getItem('trackfw_refresh_paused') === 'true';
@@ -98,6 +104,8 @@ function switchView(view) {
98
104
  loadChain();
99
105
  } else if (view === 'metrics') {
100
106
  loadMetrics();
107
+ } else if (view === 'adr' || view === 'req') {
108
+ loadListView(view);
101
109
  }
102
110
  }
103
111
 
@@ -572,6 +580,169 @@ function renderChain(data) {
572
580
  });
573
581
  }
574
582
 
583
+ // ─── View: ADRs / REQs (listas navegáveis) ────────────────────────────────────
584
+
585
+ function normalizeText(str) {
586
+ return (str || '')
587
+ .toString()
588
+ .normalize('NFD')
589
+ .replace(/\p{Diacritic}/gu, '')
590
+ .toLowerCase();
591
+ }
592
+
593
+ async function loadListView(type) {
594
+ hide(`${type}-error`);
595
+
596
+ // Reusa o cache de /api/chain já usado pela aba Chain. Se o cache já foi
597
+ // aquecido (ex.: usuário visitou a aba Chain antes), o spinner nunca chega
598
+ // a ser exibido por show('${type}-loading') — mas ele parte visível no
599
+ // HTML (sem classe "hidden"), então precisa ser escondido explicitamente
600
+ // aqui também, não só no ramo de erro/loading do fetch abaixo.
601
+ hide(`${type}-loading`);
602
+ if (_chainData) {
603
+ renderListView(type);
604
+ return;
605
+ }
606
+
607
+ show(`${type}-loading`);
608
+
609
+ try {
610
+ const res = await fetch('/api/chain');
611
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
612
+ _chainData = await res.json();
613
+ hide(`${type}-loading`);
614
+ renderListView(type);
615
+ } catch (err) {
616
+ hide(`${type}-loading`);
617
+ const errEl = el(`${type}-error`);
618
+ if (errEl) {
619
+ errEl.textContent = `Erro ao carregar ${type === 'adr' ? 'ADRs' : 'REQs'}: ${err.message}`;
620
+ errEl.classList.remove('hidden');
621
+ }
622
+ }
623
+ }
624
+
625
+ function getListNodes(type) {
626
+ if (!_chainData) return [];
627
+ return (_chainData.nodes || []).filter(n => n.type === type);
628
+ }
629
+
630
+ // Popula o <select> de status a partir dos valores distintos presentes na
631
+ // resposta do /api/chain. NUNCA hardcodar uma lista fixa de estados aqui:
632
+ // state é texto livre e uma lista fixa esconderia silenciosamente artefatos
633
+ // com status inesperado (ex.: "unknown").
634
+ function populateStatusFilter(type, nodes) {
635
+ const select = el(`${type}-status-filter`);
636
+ if (!select) return;
637
+
638
+ const current = select.value;
639
+ const states = Array.from(new Set(nodes.map(n => n.state || 'unknown'))).sort((a, b) => a.localeCompare(b));
640
+
641
+ select.innerHTML = '<option value="">Todos</option>';
642
+ states.forEach(state => {
643
+ const opt = document.createElement('option');
644
+ opt.value = state;
645
+ opt.textContent = state;
646
+ select.appendChild(opt);
647
+ });
648
+
649
+ if (states.includes(current)) {
650
+ select.value = current;
651
+ } else {
652
+ select.value = '';
653
+ _listState[type].status = '';
654
+ }
655
+ }
656
+
657
+ function renderListView(type) {
658
+ const nodes = getListNodes(type);
659
+ populateStatusFilter(type, nodes);
660
+ applyListFilters(type);
661
+ }
662
+
663
+ function onListSearch(type, value) {
664
+ _listState[type].search = value;
665
+ applyListFilters(type);
666
+ }
667
+
668
+ function onListStatusChange(type, value) {
669
+ _listState[type].status = value;
670
+ applyListFilters(type);
671
+ }
672
+
673
+ function applyListFilters(type) {
674
+ const nodes = getListNodes(type);
675
+ const { search, status } = _listState[type];
676
+ const normSearch = normalizeText(search);
677
+
678
+ const filtered = nodes.filter(n => {
679
+ if (status && (n.state || 'unknown') !== status) return false;
680
+ if (!normSearch) return true;
681
+ const haystack = normalizeText(`${n.title || ''} ${n.id || ''}`);
682
+ return haystack.includes(normSearch);
683
+ });
684
+
685
+ renderListRows(type, filtered, nodes.length);
686
+ }
687
+
688
+ function renderListRows(type, filtered, total) {
689
+ const listEl = el(`${type}-list`);
690
+ const emptyEl = el(`${type}-empty`);
691
+ const countEl = el(`${type}-count`);
692
+ if (!listEl) return;
693
+
694
+ const typeLabel = type === 'adr' ? 'ADRs' : 'REQs';
695
+ listEl.innerHTML = '';
696
+
697
+ if (countEl) {
698
+ countEl.textContent = `${filtered.length} de ${total} ${typeLabel}`;
699
+ }
700
+
701
+ if (filtered.length === 0) {
702
+ if (emptyEl) {
703
+ emptyEl.textContent = total === 0
704
+ ? `Nenhum ${type === 'adr' ? 'ADR' : 'REQ'} encontrado.`
705
+ : 'Nenhum item corresponde à busca/filtro atual.';
706
+ emptyEl.classList.remove('hidden');
707
+ }
708
+ return;
709
+ }
710
+
711
+ if (emptyEl) emptyEl.classList.add('hidden');
712
+
713
+ filtered
714
+ .slice()
715
+ .sort((a, b) => (a.id || '').localeCompare(b.id || ''))
716
+ .forEach(node => listEl.appendChild(createListRow(node)));
717
+ }
718
+
719
+ function createListRow(node) {
720
+ const identifier = (node.id || '').split('/').pop();
721
+ const title = node.title || identifier;
722
+ const state = node.state || 'unknown';
723
+
724
+ const row = document.createElement('div');
725
+ row.className = 'list-row';
726
+ row.setAttribute('tabindex', '0');
727
+ row.setAttribute('role', 'button');
728
+ row.setAttribute('aria-label', `Abrir: ${title}`);
729
+
730
+ row.innerHTML = `
731
+ <span class="list-row-id">${escapeHtml(identifier)}</span>
732
+ <span class="list-row-title">${escapeHtml(title)}</span>
733
+ <span class="status-chip" data-state="${escapeHtml(state.toLowerCase())}">${escapeHtml(state)}</span>`;
734
+
735
+ row.addEventListener('click', () => openDrawer(node.id));
736
+ row.addEventListener('keydown', e => {
737
+ if (e.key === 'Enter' || e.key === ' ') {
738
+ e.preventDefault();
739
+ openDrawer(node.id);
740
+ }
741
+ });
742
+
743
+ return row;
744
+ }
745
+
575
746
  // ─── View: Metrics ────────────────────────────────────────────────────────────
576
747
 
577
748
  async function loadMetrics() {
@@ -699,6 +870,85 @@ function renderMetrics(data) {
699
870
 
700
871
  // ─── Drawer lateral ───────────────────────────────────────────────────────────
701
872
 
873
+ // Allowlist do DOMPurify: cobre o markdown legítimo de ADRs/REQs/roadmaps
874
+ // (headings, listas ordenadas/não ordenadas, blockquote, code inline e em bloco,
875
+ // tabelas, links e checklists GFM `- [ ]`). Não inclui `img`, `iframe`, `style`,
876
+ // `on*` (event handlers) nem `script` — nenhum é usado pelo markdown do projeto
877
+ // e cada um ampliaria a superfície de ataque sem necessidade.
878
+ const MARKDOWN_SANITIZE_CONFIG = {
879
+ ALLOWED_TAGS: [
880
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
881
+ 'p', 'br', 'hr',
882
+ 'ul', 'ol', 'li',
883
+ 'blockquote', 'pre', 'code',
884
+ 'strong', 'em', 'b', 'i', 'del', 's', 'u',
885
+ 'a',
886
+ 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
887
+ 'input',
888
+ ],
889
+ ALLOWED_ATTR: ['href', 'title', 'class', 'id', 'align', 'type', 'checked', 'disabled'],
890
+ ALLOW_DATA_ATTR: false,
891
+ };
892
+
893
+ /**
894
+ * Converte markdown em HTML e sanitiza o resultado antes de expor ao DOM.
895
+ * Fail-safe: se DOMPurify não estiver carregado (CDN fora do ar, SRI falhou,
896
+ * uso offline), NUNCA devolve HTML — devolve null para o chamador decidir
897
+ * como degradar (texto puro / erro). Não há caminho silencioso e inseguro.
898
+ */
899
+ function renderMarkdownSafe(md) {
900
+ if (typeof DOMPurify === 'undefined') {
901
+ return null;
902
+ }
903
+ const html = marked.parse(md || '');
904
+ return DOMPurify.sanitize(html, MARKDOWN_SANITIZE_CONFIG);
905
+ }
906
+
907
+ /**
908
+ * Resolve um href de link markdown relativo ao documento atualmente aberto no
909
+ * drawer, devolvendo um caminho normalizado relativo à raiz do repositório.
910
+ *
911
+ * Todos os links `.md` encontrados em docs/ são relativos ao documento que os
912
+ * contém (nunca à raiz) — ver ADR-2026-08-01. Formas tratadas:
913
+ * - "./ARQUIVO.md" → mesma pasta do documento atual
914
+ * - "ARQUIVO.md" (sem prefixo) → mesma pasta do documento atual
915
+ * - "../dir/ARQUIVO.md" → sobe um nível a partir da pasta atual
916
+ * - "../../dir/ARQUIVO.md" → ".." encadeados, sobe N níveis
917
+ *
918
+ * Algoritmo: concatena o href com o dirname do documento atual e resolve os
919
+ * segmentos com uma pilha — "." é descartado, ".." remove o topo da pilha (ou
920
+ * é descartado silenciosamente se a pilha já estiver vazia, para nunca deixar
921
+ * o resultado escapar acima da raiz do repositório).
922
+ *
923
+ * Este helper NÃO é idempotente para um caminho já completo (ex.:
924
+ * "docs/adr/X.md") — se recebesse um como `href`, o resultado ficaria
925
+ * prefixado incorretamente pelo dirname do documento atual. A segurança aqui
926
+ * não vem do algoritmo, vem do isolamento do ponto de chamada: os três
927
+ * pontos de entrada que já passam caminho completo (card do Board, nó do
928
+ * grafo Chain, linha das listas ADR/REQ) chamam `openDrawer` diretamente e
929
+ * nunca passam pelo interceptador de links do markdown renderizado — o único
930
+ * lugar onde este helper é invocado. O levantamento do corpus em docs/ (ver
931
+ * ADR-2026-08-01) confirma zero links `.md` raiz-relativos dentro de
932
+ * documentos, então este helper nunca recebe, na prática, algo que já não
933
+ * seja relativo ao documento que o contém.
934
+ */
935
+ function resolveRelativeMdHref(href, currentDocPath) {
936
+ const lastSlash = (currentDocPath || '').lastIndexOf('/');
937
+ const baseDir = lastSlash === -1 ? '' : currentDocPath.slice(0, lastSlash);
938
+ const combined = baseDir ? `${baseDir}/${href}` : href;
939
+
940
+ const stack = [];
941
+ combined.split('/').forEach(part => {
942
+ if (part === '' || part === '.') return;
943
+ if (part === '..') {
944
+ if (stack.length > 0) stack.pop();
945
+ return;
946
+ }
947
+ stack.push(part);
948
+ });
949
+ return stack.join('/');
950
+ }
951
+
702
952
  async function openDrawer(path) {
703
953
  if (!path) return;
704
954
  _drawerPath = path;
@@ -732,7 +982,11 @@ async function openDrawer(path) {
732
982
 
733
983
  try {
734
984
  const res = await fetch(`/api/file?path=${encodeURIComponent(path)}`);
735
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
985
+ if (!res.ok) {
986
+ const httpErr = new Error(`HTTP ${res.status}`);
987
+ httpErr.status = res.status;
988
+ throw httpErr;
989
+ }
736
990
  const raw = await res.text();
737
991
 
738
992
  hide('drawer-loading');
@@ -743,23 +997,42 @@ async function openDrawer(path) {
743
997
  renderFrontmatterTable(frontmatter);
744
998
  }
745
999
 
746
- // Renderizar markdown
1000
+ // Renderizar markdown (sanitizado — ver renderMarkdownSafe)
747
1001
  if (mdEl) {
748
- mdEl.innerHTML = marked.parse(body || raw);
749
-
750
- // Interceptar links internos .md
751
- mdEl.querySelectorAll('a[href]').forEach(a => {
752
- const href = a.getAttribute('href');
753
- if (!href) return;
754
- const isExternal = href.startsWith('http://') || href.startsWith('https://');
755
- const isMdLink = href.endsWith('.md');
756
- if (!isExternal && isMdLink) {
757
- a.addEventListener('click', e => {
758
- e.preventDefault();
759
- openDrawer(href);
760
- });
1002
+ const safeHtml = renderMarkdownSafe(body || raw);
1003
+ if (safeHtml === null) {
1004
+ // Fail-safe: sem DOMPurify carregado, nunca renderizar HTML bruto.
1005
+ mdEl.textContent = body || raw;
1006
+ const errEl = el('drawer-error');
1007
+ if (errEl) {
1008
+ errEl.textContent = 'Aviso: sanitizador de HTML indisponível — conteúdo exibido como texto puro.';
1009
+ errEl.classList.remove('hidden');
761
1010
  }
762
- });
1011
+ } else {
1012
+ mdEl.innerHTML = safeHtml;
1013
+
1014
+ // Interceptar links internos .md
1015
+ mdEl.querySelectorAll('a[href]').forEach(a => {
1016
+ const href = a.getAttribute('href');
1017
+ if (!href) return;
1018
+ const isExternal = href.startsWith('http://') || href.startsWith('https://');
1019
+ // Um link pode carregar uma âncora de seção (ex.: "outro.md#secao").
1020
+ // O drawer não tem conceito de rolagem para âncora dentro do markdown
1021
+ // renderizado, então a âncora é descartada na resolução — apenas o
1022
+ // caminho do arquivo é usado para decidir se é um link .md e para
1023
+ // montar o caminho a buscar.
1024
+ const hashIndex = href.indexOf('#');
1025
+ const hrefPath = hashIndex === -1 ? href : href.slice(0, hashIndex);
1026
+ const isMdLink = hrefPath.endsWith('.md');
1027
+ if (!isExternal && isMdLink) {
1028
+ const resolved = resolveRelativeMdHref(hrefPath, _drawerPath);
1029
+ a.addEventListener('click', e => {
1030
+ e.preventDefault();
1031
+ openDrawer(resolved);
1032
+ });
1033
+ }
1034
+ });
1035
+ }
763
1036
  }
764
1037
 
765
1038
  show('drawer-content');
@@ -767,7 +1040,11 @@ async function openDrawer(path) {
767
1040
  hide('drawer-loading');
768
1041
  const errEl = el('drawer-error');
769
1042
  if (errEl) {
770
- errEl.textContent = `Erro ao carregar arquivo: ${err.message}`;
1043
+ if (err.status === 403) {
1044
+ errEl.textContent = `Arquivo fora dos diretórios permitidos: ${path}`;
1045
+ } else {
1046
+ errEl.textContent = `Erro ao carregar arquivo: ${err.message}`;
1047
+ }
771
1048
  errEl.classList.remove('hidden');
772
1049
  }
773
1050
  }
@@ -4,16 +4,28 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>trackfw — dashboard</title>
7
- <!-- Tailwind CSS CDN -->
7
+ <!-- Tailwind CSS CDN: URL não-versionada (JIT em runtime), HTTP/2 302 — sem integrity de propósito.
8
+ Um hash fixo aqui quebraria o dashboard inteiro no próximo release deles. Migrar para uma
9
+ build versionada exige auditoria visual completa e é escopo de REQ própria. -->
8
10
  <script src="https://cdn.tailwindcss.com"></script>
9
- <!-- HTMX -->
10
- <script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
11
11
  <!-- marked.js -->
12
- <script src="https://cdn.jsdelivr.net/npm/marked@12.0.0/marked.min.js"></script>
12
+ <script
13
+ src="https://cdn.jsdelivr.net/npm/marked@12.0.0/marked.min.js"
14
+ integrity="sha384-NNQgBjjuhtXzPmmy4gurS5X7P4uTt1DThyevz4Ua0IVK5+kazYQI1W27JHjbbxQz"
15
+ crossorigin="anonymous"
16
+ referrerpolicy="no-referrer"></script>
13
17
  <!-- Chart.js -->
14
- <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
18
+ <script
19
+ src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"
20
+ integrity="sha384-NrKB+u6Ts6AtkIhwPixiKTzgSKNblyhlk0Sohlgar9UHUBzai/sgnNNWWd291xqt"
21
+ crossorigin="anonymous"
22
+ referrerpolicy="no-referrer"></script>
15
23
  <!-- D3.js -->
16
- <script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"></script>
24
+ <script
25
+ src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"
26
+ integrity="sha384-CjloA8y00+1SDAUkjs099PVfnY2KmDC2BZnws9kh8D/lX1s46w6EPhpXdqMfjK6i"
27
+ crossorigin="anonymous"
28
+ referrerpolicy="no-referrer"></script>
17
29
  <link rel="stylesheet" href="/static/style.css" />
18
30
  </head>
19
31
  <body class="bg-gray-50 text-gray-900 min-h-screen flex flex-col">
@@ -40,6 +52,18 @@
40
52
  class="tab-btn px-4 py-1.5 rounded text-sm font-medium transition-colors"
41
53
  aria-pressed="false"
42
54
  >Metrics</button>
55
+ <button
56
+ id="tab-adr"
57
+ onclick="switchView('adr')"
58
+ class="tab-btn px-4 py-1.5 rounded text-sm font-medium transition-colors"
59
+ aria-pressed="false"
60
+ >ADRs</button>
61
+ <button
62
+ id="tab-req"
63
+ onclick="switchView('req')"
64
+ class="tab-btn px-4 py-1.5 rounded text-sm font-medium transition-colors"
65
+ aria-pressed="false"
66
+ >REQs</button>
43
67
  </nav>
44
68
  </header>
45
69
 
@@ -175,6 +199,76 @@
175
199
  </div>
176
200
  </section>
177
201
 
202
+ <!-- View: ADRs -->
203
+ <section id="view-adr" class="view-section w-full hidden p-6 overflow-auto" aria-label="Lista de ADRs">
204
+ <div class="flex items-center gap-3 flex-wrap mb-4">
205
+ <input
206
+ type="search"
207
+ id="adr-search"
208
+ oninput="onListSearch('adr', this.value)"
209
+ placeholder="Buscar por título ou identificador..."
210
+ class="text-sm border border-gray-300 rounded px-3 py-1.5 bg-white flex-1 min-w-[220px]"
211
+ aria-label="Buscar ADRs por título ou identificador"
212
+ />
213
+ <label for="adr-status-filter" class="text-sm font-medium text-gray-600 sr-only">Filtrar por status</label>
214
+ <select
215
+ id="adr-status-filter"
216
+ onchange="onListStatusChange('adr', this.value)"
217
+ class="text-sm border border-gray-300 rounded px-2 py-1.5 bg-white"
218
+ aria-label="Filtrar ADRs por status"
219
+ >
220
+ <option value="">Todos</option>
221
+ </select>
222
+ <span id="adr-count" class="text-xs text-gray-500 ml-auto" aria-live="polite"></span>
223
+ </div>
224
+
225
+ <div id="adr-loading" class="flex items-center gap-2 text-gray-500 text-sm">
226
+ <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">
227
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
228
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
229
+ </svg>
230
+ Carregando ADRs...
231
+ </div>
232
+ <div id="adr-error" class="hidden text-red-600 text-sm mt-4" role="alert"></div>
233
+ <div id="adr-empty" class="hidden text-sm text-gray-400 text-center py-8"></div>
234
+ <div id="adr-list" class="list-view-container"></div>
235
+ </section>
236
+
237
+ <!-- View: REQs -->
238
+ <section id="view-req" class="view-section w-full hidden p-6 overflow-auto" aria-label="Lista de REQs">
239
+ <div class="flex items-center gap-3 flex-wrap mb-4">
240
+ <input
241
+ type="search"
242
+ id="req-search"
243
+ oninput="onListSearch('req', this.value)"
244
+ placeholder="Buscar por título ou identificador..."
245
+ class="text-sm border border-gray-300 rounded px-3 py-1.5 bg-white flex-1 min-w-[220px]"
246
+ aria-label="Buscar REQs por título ou identificador"
247
+ />
248
+ <label for="req-status-filter" class="text-sm font-medium text-gray-600 sr-only">Filtrar por status</label>
249
+ <select
250
+ id="req-status-filter"
251
+ onchange="onListStatusChange('req', this.value)"
252
+ class="text-sm border border-gray-300 rounded px-2 py-1.5 bg-white"
253
+ aria-label="Filtrar REQs por status"
254
+ >
255
+ <option value="">Todos</option>
256
+ </select>
257
+ <span id="req-count" class="text-xs text-gray-500 ml-auto" aria-live="polite"></span>
258
+ </div>
259
+
260
+ <div id="req-loading" class="flex items-center gap-2 text-gray-500 text-sm">
261
+ <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">
262
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
263
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
264
+ </svg>
265
+ Carregando REQs...
266
+ </div>
267
+ <div id="req-error" class="hidden text-red-600 text-sm mt-4" role="alert"></div>
268
+ <div id="req-empty" class="hidden text-sm text-gray-400 text-center py-8"></div>
269
+ <div id="req-list" class="list-view-container"></div>
270
+ </section>
271
+
178
272
  </main>
179
273
 
180
274
  <!-- Overlay do drawer -->
@@ -233,6 +327,12 @@
233
327
  </div>
234
328
  </aside>
235
329
 
330
+ <!-- DOMPurify (sanitização de HTML do drawer) -->
331
+ <script
332
+ src="https://cdn.jsdelivr.net/npm/dompurify@3.4.12/dist/purify.min.js"
333
+ integrity="sha384-piCcpDdJ7qVeK4Tv8Z6Hpcr3ZBIgP16TxQTPVfsLFdZ5uDgwc3Y8Ho7oUnqf12qu"
334
+ crossorigin="anonymous"
335
+ referrerpolicy="no-referrer"></script>
236
336
  <script src="/static/app.js"></script>
237
337
  </body>
238
338
  </html>
@@ -205,6 +205,85 @@
205
205
  animation: attentionRing 2s ease-in-out infinite;
206
206
  }
207
207
 
208
+ /* ─── Views de lista (ADRs / REQs) ─────────────────────────────────────────── */
209
+ .list-view-container {
210
+ display: flex;
211
+ flex-direction: column;
212
+ gap: 0.4rem;
213
+ }
214
+
215
+ .list-row {
216
+ display: grid;
217
+ grid-template-columns: 220px 1fr auto;
218
+ align-items: center;
219
+ gap: 0.75rem;
220
+ background-color: #f9fafb;
221
+ border: 1px solid #e5e7eb;
222
+ border-radius: 6px;
223
+ padding: 0.6rem 0.9rem;
224
+ cursor: pointer;
225
+ transition: box-shadow 0.15s ease, transform 0.1s ease, background-color 0.15s ease;
226
+ }
227
+
228
+ .list-row:hover {
229
+ background-color: #ffffff;
230
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
231
+ transform: translateY(-1px);
232
+ }
233
+
234
+ .list-row:focus-visible {
235
+ outline: 2px solid #3b82f6;
236
+ outline-offset: 2px;
237
+ }
238
+
239
+ .list-row-id {
240
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
241
+ font-size: 0.78rem;
242
+ color: #6b7280;
243
+ white-space: nowrap;
244
+ overflow: hidden;
245
+ text-overflow: ellipsis;
246
+ }
247
+
248
+ .list-row-title {
249
+ font-size: 0.85rem;
250
+ font-weight: 500;
251
+ color: #1f2937;
252
+ overflow: hidden;
253
+ text-overflow: ellipsis;
254
+ white-space: nowrap;
255
+ }
256
+
257
+ .status-chip {
258
+ display: inline-block;
259
+ font-size: 0.72rem;
260
+ font-weight: 600;
261
+ padding: 0.15rem 0.55rem;
262
+ border-radius: 999px;
263
+ background-color: #e5e7eb;
264
+ color: #374151;
265
+ white-space: nowrap;
266
+ }
267
+
268
+ /* Cores conhecidas de status (fallback cinza acima cobre qualquer texto livre) */
269
+ .status-chip[data-state="accepted"] { background-color: #dcfce7; color: #15803d; }
270
+ .status-chip[data-state="proposed"] { background-color: #fef3c7; color: #92400e; }
271
+ .status-chip[data-state="done"] { background-color: #dbeafe; color: #1d4ed8; }
272
+ .status-chip[data-state="closed"] { background-color: #f3f4f6; color: #374151; }
273
+ .status-chip[data-state="unknown"] { background-color: #fee2e2; color: #991b1b; }
274
+
275
+ @media (max-width: 640px) {
276
+ .list-row {
277
+ grid-template-columns: 1fr auto;
278
+ grid-template-areas:
279
+ "id status"
280
+ "title title";
281
+ }
282
+ .list-row-id { grid-area: id; }
283
+ .list-row-title { grid-area: title; }
284
+ .status-chip { grid-area: status; justify-self: end; }
285
+ }
286
+
208
287
  /* Banner de atenção (entre header e main) */
209
288
  .attention-banner {
210
289
  display: flex;
@@ -754,7 +754,7 @@ function validateRefTargetsExist() {
754
754
  const content = readFileForRule('ref_targets_exist', path.join(dir, name), warnings)
755
755
  if (content === null) continue
756
756
  const ref = extractRefPath(content, 'REQ')
757
- if (ref && !referenceExists(ref, [cfg.reqDir])) {
757
+ if (ref && !referenceExists(ref)) {
758
758
  warnings.push(`roadmap "${name}" links to REQ "${ref}" which does not exist`)
759
759
  }
760
760
  }
@@ -766,11 +766,11 @@ function validateRefTargetsExist() {
766
766
  if (content === null) continue
767
767
  const name = path.basename(filePath)
768
768
  const adrRef = extractRefPath(content, 'ADR')
769
- if (adrRef && !referenceExists(adrRef, cfg.adrDirs)) {
769
+ if (adrRef && !referenceExists(adrRef)) {
770
770
  warnings.push(`req "${name}" links to ADR "${adrRef}" which does not exist`)
771
771
  }
772
772
  const roadmapRef = extractRefPath(content, 'Roadmap')
773
- if (roadmapRef && !referenceExists(roadmapRef, [cfg.roadmapDir])) {
773
+ if (roadmapRef && !referenceExists(roadmapRef)) {
774
774
  warnings.push(`req "${name}" links to Roadmap "${roadmapRef}" which does not exist`)
775
775
  }
776
776
  }
@@ -778,7 +778,7 @@ function validateRefTargetsExist() {
778
778
  return warnings
779
779
  }
780
780
 
781
- function referenceExists(ref, roots) {
781
+ function referenceExists(ref) {
782
782
  const expandedRef = config.expandPath ? config.expandPath(ref) : ref
783
783
  if (fs.existsSync(expandedRef)) return true
784
784
  return false