issue-flow 0.7.7 → 0.8.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 (68) hide show
  1. package/dist/{analyze-2RVGY5OK.js → analyze-RBNKXWL5.js} +6 -6
  2. package/dist/{chunk-6L2BPOE3.js → chunk-36ECROTC.js} +7 -7
  3. package/dist/{chunk-EOTULFWV.js → chunk-A4M6AKIK.js} +6 -6
  4. package/dist/{chunk-RUTDK6ZF.js → chunk-BASRRXAM.js} +2 -2
  5. package/dist/{chunk-WO6ERABE.js → chunk-BH7DYADV.js} +10 -1
  6. package/dist/chunk-BH7DYADV.js.map +1 -0
  7. package/dist/{chunk-JOK7VXRV.js → chunk-F52S6ZGF.js} +3 -3
  8. package/dist/{chunk-EYVXLRRD.js → chunk-HSGTZ3CT.js} +3 -3
  9. package/dist/{chunk-XPW46TI5.js → chunk-J3PJBZXJ.js} +3 -3
  10. package/dist/{chunk-6UTVPKNF.js → chunk-J4SICTAN.js} +7 -7
  11. package/dist/{chunk-KHTLN54U.js → chunk-JWZKV3CL.js} +2 -2
  12. package/dist/{chunk-3E6KASSE.js → chunk-MK43N4X3.js} +7 -7
  13. package/dist/{chunk-ZYBYMEWG.js → chunk-QO2MWMAZ.js} +2 -2
  14. package/dist/{chunk-OPIDR2DC.js → chunk-RQW5W4XC.js} +2 -2
  15. package/dist/{chunk-WGDLUPSF.js → chunk-TGP6WK65.js} +5 -5
  16. package/dist/{chunk-G37JNQKB.js → chunk-VPDWTZ3F.js} +2 -2
  17. package/dist/{chunk-6IOPYFUM.js → chunk-VSF2CR3G.js} +6 -6
  18. package/dist/cli.js +15 -15
  19. package/dist/{config-OAZG7KIK.js → config-R7O5YC65.js} +3 -3
  20. package/dist/execute-LU3HWA54.js +14 -0
  21. package/dist/{generate-BEJF3SVJ.js → generate-TGJQLGMV.js} +6 -6
  22. package/dist/init-WP7Z2LFQ.js +9 -0
  23. package/dist/plan-2MWMCKRY.js +16 -0
  24. package/dist/pr-XVQDEQ7B.js +16 -0
  25. package/dist/pr-review-VO6ZXAFM.js +16 -0
  26. package/dist/prd-FPZAHQEO.js +16 -0
  27. package/dist/review-AHALLMXO.js +14 -0
  28. package/dist/{run-SEB6F3W4.js → run-2CXVR3VZ.js} +16 -16
  29. package/dist/{web-3SBYL7QJ.js → web-JVPK2PIF.js} +4 -4
  30. package/package.json +1 -1
  31. package/web/CLAUDE.md +48 -0
  32. package/web/public/app.css +266 -0
  33. package/web/public/app.js +237 -0
  34. package/web/public/index.html +26 -0
  35. package/dist/chunk-WO6ERABE.js.map +0 -1
  36. package/dist/execute-AOEM52CD.js +0 -14
  37. package/dist/init-TBC3WI74.js +0 -9
  38. package/dist/plan-YDZYB2BO.js +0 -16
  39. package/dist/pr-SIXMJIZF.js +0 -16
  40. package/dist/pr-review-AY56ZPJR.js +0 -16
  41. package/dist/prd-MBSSBLGO.js +0 -16
  42. package/dist/review-2MDDHZ4X.js +0 -14
  43. /package/dist/{analyze-2RVGY5OK.js.map → analyze-RBNKXWL5.js.map} +0 -0
  44. /package/dist/{chunk-6L2BPOE3.js.map → chunk-36ECROTC.js.map} +0 -0
  45. /package/dist/{chunk-EOTULFWV.js.map → chunk-A4M6AKIK.js.map} +0 -0
  46. /package/dist/{chunk-RUTDK6ZF.js.map → chunk-BASRRXAM.js.map} +0 -0
  47. /package/dist/{chunk-JOK7VXRV.js.map → chunk-F52S6ZGF.js.map} +0 -0
  48. /package/dist/{chunk-EYVXLRRD.js.map → chunk-HSGTZ3CT.js.map} +0 -0
  49. /package/dist/{chunk-XPW46TI5.js.map → chunk-J3PJBZXJ.js.map} +0 -0
  50. /package/dist/{chunk-6UTVPKNF.js.map → chunk-J4SICTAN.js.map} +0 -0
  51. /package/dist/{chunk-KHTLN54U.js.map → chunk-JWZKV3CL.js.map} +0 -0
  52. /package/dist/{chunk-3E6KASSE.js.map → chunk-MK43N4X3.js.map} +0 -0
  53. /package/dist/{chunk-ZYBYMEWG.js.map → chunk-QO2MWMAZ.js.map} +0 -0
  54. /package/dist/{chunk-OPIDR2DC.js.map → chunk-RQW5W4XC.js.map} +0 -0
  55. /package/dist/{chunk-WGDLUPSF.js.map → chunk-TGP6WK65.js.map} +0 -0
  56. /package/dist/{chunk-G37JNQKB.js.map → chunk-VPDWTZ3F.js.map} +0 -0
  57. /package/dist/{chunk-6IOPYFUM.js.map → chunk-VSF2CR3G.js.map} +0 -0
  58. /package/dist/{config-OAZG7KIK.js.map → config-R7O5YC65.js.map} +0 -0
  59. /package/dist/{execute-AOEM52CD.js.map → execute-LU3HWA54.js.map} +0 -0
  60. /package/dist/{generate-BEJF3SVJ.js.map → generate-TGJQLGMV.js.map} +0 -0
  61. /package/dist/{init-TBC3WI74.js.map → init-WP7Z2LFQ.js.map} +0 -0
  62. /package/dist/{plan-YDZYB2BO.js.map → plan-2MWMCKRY.js.map} +0 -0
  63. /package/dist/{pr-SIXMJIZF.js.map → pr-XVQDEQ7B.js.map} +0 -0
  64. /package/dist/{pr-review-AY56ZPJR.js.map → pr-review-VO6ZXAFM.js.map} +0 -0
  65. /package/dist/{prd-MBSSBLGO.js.map → prd-FPZAHQEO.js.map} +0 -0
  66. /package/dist/{review-2MDDHZ4X.js.map → review-AHALLMXO.js.map} +0 -0
  67. /package/dist/{run-SEB6F3W4.js.map → run-2CXVR3VZ.js.map} +0 -0
  68. /package/dist/{web-3SBYL7QJ.js.map → web-JVPK2PIF.js.map} +0 -0
@@ -18,6 +18,7 @@
18
18
  --ok-bg: #dcfce7;
19
19
  --idle-bg: #e5e7eb;
20
20
  --shadow: 0 1px 2px rgb(0 0 0 / 0.06);
21
+ --overlay: rgb(0 0 0 / 0.45);
21
22
  }
22
23
 
23
24
  @media (prefers-color-scheme: dark) {
@@ -38,6 +39,7 @@
38
39
  --ok-bg: #14301f;
39
40
  --idle-bg: #262d38;
40
41
  --shadow: none;
42
+ --overlay: rgb(0 0 0 / 0.65);
41
43
  }
42
44
  }
43
45
 
@@ -110,6 +112,53 @@ a:hover {
110
112
  text-decoration: underline;
111
113
  }
112
114
 
115
+ /* ---- Abas ---- */
116
+
117
+ .tabs {
118
+ display: flex;
119
+ gap: 4px;
120
+ border-bottom: 1px solid var(--border);
121
+ }
122
+
123
+ .tab {
124
+ appearance: none;
125
+ background: none;
126
+ border: none;
127
+ border-bottom: 2px solid transparent;
128
+ margin-bottom: -1px;
129
+ padding: 8px 14px;
130
+ font: inherit;
131
+ font-weight: 600;
132
+ color: var(--muted);
133
+ cursor: pointer;
134
+ border-radius: 6px 6px 0 0;
135
+ }
136
+
137
+ .tab:hover {
138
+ color: var(--fg);
139
+ background: var(--card);
140
+ }
141
+
142
+ .tab.is-active {
143
+ color: var(--accent);
144
+ border-bottom-color: var(--accent);
145
+ }
146
+
147
+ /* O painel repete o grid de main: as seções dentro dele são os cards que antes
148
+ eram filhos diretos de main e dependem desse espaçamento. */
149
+ .tab-panel {
150
+ display: grid;
151
+ gap: 16px;
152
+ }
153
+
154
+ .tab-panel[hidden] {
155
+ display: none;
156
+ }
157
+
158
+ .tab-panel > * {
159
+ min-width: 0;
160
+ }
161
+
113
162
  /* ---- Cabeçalho ---- */
114
163
 
115
164
  .header {
@@ -534,6 +583,223 @@ progress {
534
583
  word-break: break-word;
535
584
  }
536
585
 
586
+ /* ---- Kanban ---- */
587
+
588
+ .kanban {
589
+ display: grid;
590
+ grid-template-columns: repeat(4, minmax(0, 1fr));
591
+ gap: 12px;
592
+ align-items: start;
593
+ }
594
+
595
+ .kanban-column {
596
+ background: var(--bg);
597
+ border: 1px solid var(--border);
598
+ border-radius: 8px;
599
+ padding: 10px;
600
+ display: grid;
601
+ gap: 8px;
602
+ min-width: 0;
603
+ }
604
+
605
+ .kanban-column-head {
606
+ display: flex;
607
+ align-items: center;
608
+ justify-content: space-between;
609
+ gap: 8px;
610
+ }
611
+
612
+ .kanban-column-title {
613
+ margin: 0;
614
+ font-size: 0.72rem;
615
+ font-weight: 600;
616
+ text-transform: uppercase;
617
+ letter-spacing: 0.06em;
618
+ color: var(--muted);
619
+ }
620
+
621
+ .kanban-column-count {
622
+ flex: none;
623
+ background: var(--idle-bg);
624
+ color: var(--muted);
625
+ border-radius: 999px;
626
+ padding: 0 7px;
627
+ font-size: 0.74rem;
628
+ font-variant-numeric: tabular-nums;
629
+ }
630
+
631
+ .kanban-card {
632
+ appearance: none;
633
+ display: grid;
634
+ gap: 6px;
635
+ justify-items: start;
636
+ width: 100%;
637
+ text-align: left;
638
+ background: var(--card);
639
+ border: 1px solid var(--border);
640
+ border-radius: 8px;
641
+ padding: 10px;
642
+ font: inherit;
643
+ color: inherit;
644
+ cursor: pointer;
645
+ box-shadow: var(--shadow);
646
+ }
647
+
648
+ .kanban-card:hover {
649
+ border-color: var(--accent);
650
+ }
651
+
652
+ .kanban-card-head {
653
+ display: flex;
654
+ align-items: center;
655
+ gap: 2px;
656
+ color: var(--muted);
657
+ font-size: 0.8rem;
658
+ }
659
+
660
+ .kanban-card-title {
661
+ display: block;
662
+ font-weight: 600;
663
+ line-height: 1.35;
664
+ overflow-wrap: anywhere;
665
+ }
666
+
667
+ /* Trecho curto: limita a 3 linhas sem truncar a string, então o texto completo
668
+ continua no DOM para leitores de tela e para o drawer. */
669
+ .kanban-card-desc {
670
+ margin: 0;
671
+ color: var(--muted);
672
+ font-size: 0.84rem;
673
+ line-height: 1.4;
674
+ overflow: hidden;
675
+ display: -webkit-box;
676
+ -webkit-box-orient: vertical;
677
+ -webkit-line-clamp: 3;
678
+ line-clamp: 3;
679
+ overflow-wrap: anywhere;
680
+ }
681
+
682
+ .kanban-empty {
683
+ font-size: 0.84rem;
684
+ }
685
+
686
+ @media (max-width: 719px) {
687
+ .kanban {
688
+ grid-template-columns: minmax(0, 1fr);
689
+ }
690
+ }
691
+
692
+ /* ---- Drawer de detalhes ---- */
693
+
694
+ /* Acima do .banner sticky (z-index 10), que senão atravessa o overlay. */
695
+ .drawer-overlay {
696
+ position: fixed;
697
+ inset: 0;
698
+ z-index: 20;
699
+ background: var(--overlay);
700
+ }
701
+
702
+ .drawer {
703
+ position: fixed;
704
+ top: 0;
705
+ right: 0;
706
+ bottom: 0;
707
+ width: min(420px, 100vw);
708
+ z-index: 21;
709
+ display: flex;
710
+ flex-direction: column;
711
+ background: var(--card);
712
+ border-left: 1px solid var(--border);
713
+ padding: 16px;
714
+ gap: 12px;
715
+ }
716
+
717
+ .drawer[hidden],
718
+ .drawer-overlay[hidden] {
719
+ display: none;
720
+ }
721
+
722
+ .drawer-head {
723
+ display: flex;
724
+ align-items: flex-start;
725
+ justify-content: space-between;
726
+ gap: 12px;
727
+ }
728
+
729
+ .drawer-head h2 {
730
+ margin: 0;
731
+ /* O título carrega o id + o título da story: fora do padrão caixa-alta das
732
+ demais seções, que são rótulos fixos e curtos. */
733
+ font-size: 0.95rem;
734
+ text-transform: none;
735
+ letter-spacing: normal;
736
+ color: var(--fg);
737
+ overflow-wrap: anywhere;
738
+ }
739
+
740
+ .drawer-close {
741
+ appearance: none;
742
+ flex: none;
743
+ background: none;
744
+ border: none;
745
+ padding: 2px 6px;
746
+ font: inherit;
747
+ color: var(--muted);
748
+ cursor: pointer;
749
+ border-radius: 6px;
750
+ }
751
+
752
+ .drawer-close:hover {
753
+ color: var(--fg);
754
+ background: var(--idle-bg);
755
+ }
756
+
757
+ /* A rolagem vive aqui, não na página: o drawer é fixo e ocupa a altura toda. */
758
+ .drawer-body {
759
+ overflow-y: auto;
760
+ display: grid;
761
+ gap: 14px;
762
+ align-content: start;
763
+ justify-items: start;
764
+ }
765
+
766
+ .drawer-timing {
767
+ margin: 0;
768
+ font-size: 0.84rem;
769
+ }
770
+
771
+ .drawer-section {
772
+ width: 100%;
773
+ display: grid;
774
+ gap: 6px;
775
+ }
776
+
777
+ .drawer-section-title {
778
+ margin: 0;
779
+ font-size: 0.72rem;
780
+ font-weight: 600;
781
+ text-transform: uppercase;
782
+ letter-spacing: 0.06em;
783
+ color: var(--muted);
784
+ }
785
+
786
+ .drawer-text {
787
+ margin: 0;
788
+ white-space: pre-wrap;
789
+ overflow-wrap: anywhere;
790
+ }
791
+
792
+ .drawer-list {
793
+ margin: 0;
794
+ padding-left: 18px;
795
+ display: grid;
796
+ gap: 4px;
797
+ }
798
+
799
+ .drawer-list li {
800
+ overflow-wrap: anywhere;
801
+ }
802
+
537
803
  /* ---- Diversos ---- */
538
804
 
539
805
  .empty {
package/web/public/app.js CHANGED
@@ -32,6 +32,15 @@
32
32
  done: 'concluída',
33
33
  };
34
34
 
35
+ // Colunas do Kanban, na ordem em que a execução avança. Os títulos são os das
36
+ // colunas, não os rótulos dos badges (STORY_STATUS_LABELS), que seguem em minúsculas.
37
+ const KANBAN_COLUMNS = [
38
+ { status: 'backlog', title: 'Backlog' },
39
+ { status: 'in_progress', title: 'Em andamento' },
40
+ { status: 'in_review', title: 'Em revisão' },
41
+ { status: 'done', title: 'Concluído' },
42
+ ];
43
+
35
44
  const els = {
36
45
  banner: document.getElementById('banner-disconnected'),
37
46
  issueLink: document.getElementById('issue-link'),
@@ -56,6 +65,13 @@
56
65
  logFilter: document.getElementById('log-filter'),
57
66
  logs: document.getElementById('logs'),
58
67
  sessionMeta: document.getElementById('session-meta'),
68
+ tabs: Array.prototype.slice.call(document.querySelectorAll('[role="tab"]')),
69
+ kanban: document.getElementById('kanban'),
70
+ drawer: document.getElementById('drawer'),
71
+ drawerOverlay: document.getElementById('drawer-overlay'),
72
+ drawerClose: document.getElementById('drawer-close'),
73
+ drawerTitle: document.getElementById('drawer-title'),
74
+ drawerBody: document.getElementById('drawer-body'),
59
75
  };
60
76
 
61
77
  const state = {
@@ -66,6 +82,10 @@
66
82
  timer: null,
67
83
  polling: false,
68
84
  logFilter: 'all',
85
+ activeTab: 'tab-execution',
86
+ // Só o id: o card que abriu o drawer é destruído no próximo render, então
87
+ // guardar o nó levaria a uma referência morta.
88
+ selectedStoryId: null,
69
89
  };
70
90
 
71
91
  // ---- Utilitários ----------------------------------------------------------
@@ -189,6 +209,61 @@
189
209
  return match ? match[1] : null;
190
210
  }
191
211
 
212
+ // ---- Camada de leitura das stories ----------------------------------------
213
+ // Único ponto de acesso a uma story individual: o Kanban e o drawer nunca
214
+ // varrem snapshot.stories por conta própria. Quando a escrita existir, é aqui
215
+ // que a leitura passa a conversar com ela, sem tocar na UI.
216
+
217
+ function text(value) {
218
+ return typeof value === 'string' ? value : '';
219
+ }
220
+
221
+ function list(value) {
222
+ return Array.isArray(value) ? value : [];
223
+ }
224
+
225
+ // Normaliza os campos que um session.json anterior pode não ter gravado, para
226
+ // que nenhum consumidor precise repetir a checagem de ausência.
227
+ function normalizeStory(story) {
228
+ const status = story.status !== null && story.status !== undefined ? story.status : 'backlog';
229
+ return {
230
+ ...story,
231
+ status: STORY_STATUS_LABELS[status] !== undefined ? status : 'backlog',
232
+ dependencies: list(story.dependencies),
233
+ description: text(story.description),
234
+ acceptanceCriteria: list(story.acceptanceCriteria),
235
+ };
236
+ }
237
+
238
+ function getStoryById(snapshot, id) {
239
+ if (!snapshot) return null;
240
+ const stories = list(snapshot.stories);
241
+ for (const story of stories) {
242
+ if (story.id === id) return normalizeStory(story);
243
+ }
244
+ return null;
245
+ }
246
+
247
+ function getStories(snapshot) {
248
+ if (!snapshot) return [];
249
+ return list(snapshot.stories).map(normalizeStory);
250
+ }
251
+
252
+ // ---- Abas -----------------------------------------------------------------
253
+ // Troca apenas visibilidade e estado ARIA. Não toca no polling: render()
254
+ // segue atualizando os dois painéis, então a aba inativa nunca fica defasada.
255
+
256
+ function setActiveTab(tabId) {
257
+ state.activeTab = tabId;
258
+ for (const tab of els.tabs) {
259
+ const active = tab.id === tabId;
260
+ tab.setAttribute('aria-selected', active ? 'true' : 'false');
261
+ tab.classList.toggle('is-active', active);
262
+ const panel = document.getElementById(tab.getAttribute('aria-controls'));
263
+ if (panel) panel.hidden = !active;
264
+ }
265
+ }
266
+
192
267
  // ---- Polling: intervalo configurável, aba oculta e backoff ----------------
193
268
 
194
269
  function readStoredRefresh() {
@@ -282,6 +357,9 @@
282
357
  renderPhases(snapshot);
283
358
  renderNextSteps(snapshot);
284
359
  renderStories(snapshot);
360
+ // Incondicional: a aba inativa não pode ficar defasada até ser aberta.
361
+ renderKanban(snapshot);
362
+ renderDrawer(snapshot);
285
363
  renderGit(snapshot);
286
364
  renderLogs(snapshot);
287
365
  renderMeta(snapshot);
@@ -544,6 +622,157 @@
544
622
  }
545
623
  }
546
624
 
625
+ // As stories chegam já normalizadas por getStories(), então aqui nenhum campo
626
+ // precisa de checagem de ausência.
627
+ // <button> em vez de <div role="button">: acionamento por Enter/Espaço e foco
628
+ // saem de graça. Por isso todo o conteúdo é <span> — <p>/<div> não são
629
+ // conteúdo válido dentro de um botão.
630
+ function storyCard(story) {
631
+ const card = el('button', 'kanban-card');
632
+ card.type = 'button';
633
+ card.dataset.storyId = story.id;
634
+
635
+ const head = el('span', 'kanban-card-head');
636
+ head.appendChild(
637
+ el('span', 'item-icon icon-' + (story.passes ? 'completed' : 'pending'), story.passes ? '✓' : '○'),
638
+ );
639
+ head.appendChild(el('span', 'story-id', story.id));
640
+ card.appendChild(head);
641
+
642
+ card.appendChild(el('span', 'kanban-card-title', story.title));
643
+ if (story.description) card.appendChild(el('span', 'kanban-card-desc', story.description));
644
+ card.appendChild(
645
+ el('span', 'badge story-status-' + story.status, STORY_STATUS_LABELS[story.status]),
646
+ );
647
+ card.addEventListener('click', () => openDrawer(story.id));
648
+ return card;
649
+ }
650
+
651
+ function renderKanban(snapshot) {
652
+ clear(els.kanban);
653
+ const stories = getStories(snapshot);
654
+ for (const column of KANBAN_COLUMNS) {
655
+ const entries = stories.filter((story) => story.status === column.status);
656
+
657
+ const node = el('section', 'kanban-column');
658
+ const head = el('div', 'kanban-column-head');
659
+ head.appendChild(el('h3', 'kanban-column-title', column.title));
660
+ head.appendChild(el('span', 'kanban-column-count', String(entries.length)));
661
+ node.appendChild(head);
662
+
663
+ if (entries.length === 0) {
664
+ node.appendChild(el('p', 'empty kanban-empty', 'Nenhuma story.'));
665
+ } else {
666
+ for (const story of entries) node.appendChild(storyCard(story));
667
+ }
668
+ els.kanban.appendChild(node);
669
+ }
670
+ }
671
+
672
+ // ---- Drawer de detalhes da story ------------------------------------------
673
+
674
+ function onDrawerKeydown(event) {
675
+ if (event.key === 'Escape') closeDrawer();
676
+ }
677
+
678
+ // Reidrata a partir do id a cada render: o drawer aberto sobrevive ao poll.
679
+ function renderDrawer(snapshot) {
680
+ if (state.selectedStoryId === null) return;
681
+ const story = getStoryById(snapshot, state.selectedStoryId);
682
+ // A story saiu do plano: manter o drawer aberto exibiria dados obsoletos.
683
+ if (story === null) {
684
+ closeDrawer();
685
+ return;
686
+ }
687
+ els.drawerTitle.textContent = story.id + ' · ' + story.title;
688
+
689
+ clear(els.drawerBody);
690
+ els.drawerBody.appendChild(
691
+ el('span', 'badge story-status-' + story.status, STORY_STATUS_LABELS[story.status]),
692
+ );
693
+
694
+ const timing = itemSideText([
695
+ story.completedAt ? 'concluída ' + formatClock(story.completedAt) : '',
696
+ metric(story.durationSeconds) !== null ? formatDuration(story.durationSeconds) : '',
697
+ ]);
698
+ if (timing) els.drawerBody.appendChild(el('p', 'muted drawer-timing', timing));
699
+
700
+ drawerSection('Descrição', (body) => {
701
+ body.appendChild(
702
+ story.description
703
+ ? el('p', 'drawer-text', story.description)
704
+ : el('p', 'empty', 'Sem descrição.'),
705
+ );
706
+ });
707
+
708
+ drawerSection('Critérios de aceite', (body) => {
709
+ if (story.acceptanceCriteria.length === 0) {
710
+ body.appendChild(el('p', 'empty', 'Nenhum critério declarado.'));
711
+ return;
712
+ }
713
+ const items = el('ul', 'drawer-list');
714
+ for (const criterion of story.acceptanceCriteria) items.appendChild(el('li', null, criterion));
715
+ body.appendChild(items);
716
+ });
717
+
718
+ drawerSection('Dependências', (body) => {
719
+ if (story.dependencies.length === 0) {
720
+ body.appendChild(el('p', 'empty', 'Nenhuma dependência.'));
721
+ return;
722
+ }
723
+ const row = el('div', 'badge-row');
724
+ for (const dependency of story.dependencies) {
725
+ row.appendChild(el('span', 'badge label-badge', dependency));
726
+ }
727
+ body.appendChild(row);
728
+ });
729
+
730
+ // Nenhum campo de histórico existe no snapshot hoje; a seção só aparece se
731
+ // uma versão futura publicar um.
732
+ const history = list(story.history);
733
+ if (history.length > 0) {
734
+ drawerSection('Histórico', (body) => {
735
+ const entries = el('ol', 'drawer-list');
736
+ for (const entry of history) {
737
+ const item = el('li');
738
+ if (entry.at) item.appendChild(el('span', 'log-time', formatClock(entry.at)));
739
+ item.appendChild(document.createTextNode(text(entry.message)));
740
+ entries.appendChild(item);
741
+ }
742
+ body.appendChild(entries);
743
+ });
744
+ }
745
+ }
746
+
747
+ function drawerSection(title, fill) {
748
+ const section = el('section', 'drawer-section');
749
+ section.appendChild(el('h3', 'drawer-section-title', title));
750
+ fill(section);
751
+ els.drawerBody.appendChild(section);
752
+ }
753
+
754
+ function openDrawer(id) {
755
+ state.selectedStoryId = id;
756
+ els.drawer.hidden = false;
757
+ els.drawerOverlay.hidden = false;
758
+ document.addEventListener('keydown', onDrawerKeydown);
759
+ renderDrawer(state.snapshot);
760
+ els.drawer.focus();
761
+ }
762
+
763
+ function closeDrawer() {
764
+ const id = state.selectedStoryId;
765
+ state.selectedStoryId = null;
766
+ els.drawer.hidden = true;
767
+ els.drawerOverlay.hidden = true;
768
+ document.removeEventListener('keydown', onDrawerKeydown);
769
+ clear(els.drawerBody);
770
+ // O card é recriado a cada render, então o foco volta pelo id, não por uma
771
+ // referência guardada na abertura.
772
+ const card = id ? els.kanban.querySelector('[data-story-id="' + id + '"]') : null;
773
+ if (card) card.focus();
774
+ }
775
+
547
776
  function renderGit(snapshot) {
548
777
  const repo = repoUrl(snapshot);
549
778
 
@@ -634,6 +863,14 @@
634
863
  // ---- Inicialização --------------------------------------------------------
635
864
 
636
865
  async function init() {
866
+ for (const tab of els.tabs) {
867
+ tab.addEventListener('click', () => setActiveTab(tab.id));
868
+ }
869
+ setActiveTab(state.activeTab);
870
+
871
+ els.drawerClose.addEventListener('click', closeDrawer);
872
+ els.drawerOverlay.addEventListener('click', closeDrawer);
873
+
637
874
  els.refreshSelect.addEventListener('change', () => {
638
875
  state.refreshSeconds = Number(els.refreshSelect.value);
639
876
  storeRefresh(state.refreshSeconds);
@@ -41,6 +41,14 @@
41
41
  <div id="alerts-body"></div>
42
42
  </section>
43
43
 
44
+ <div class="tabs" role="tablist" aria-label="Visualizações do painel">
45
+ <button type="button" class="tab" role="tab" id="tab-execution"
46
+ aria-controls="panel-execution" aria-selected="true">Execução</button>
47
+ <button type="button" class="tab" role="tab" id="tab-kanban"
48
+ aria-controls="panel-kanban" aria-selected="false">Kanban</button>
49
+ </div>
50
+
51
+ <div class="tab-panel" id="panel-execution" role="tabpanel" aria-labelledby="tab-execution" tabindex="0">
44
52
  <div class="columns">
45
53
  <section class="card">
46
54
  <h2>Resumo da issue</h2>
@@ -108,12 +116,30 @@
108
116
  </div>
109
117
  <ol id="logs" class="plain-list logs"></ol>
110
118
  </section>
119
+ </div>
120
+
121
+ <div class="tab-panel" id="panel-kanban" role="tabpanel" aria-labelledby="tab-kanban" tabindex="0" hidden>
122
+ <section class="card">
123
+ <h2>Kanban de user stories</h2>
124
+ <div id="kanban" class="kanban"></div>
125
+ </section>
126
+ </div>
111
127
 
112
128
  <footer class="muted footer">
113
129
  <span id="session-meta"></span>
114
130
  </footer>
115
131
  </main>
116
132
 
133
+ <div id="drawer-overlay" class="drawer-overlay" hidden></div>
134
+ <aside id="drawer" class="drawer" role="dialog" aria-modal="true"
135
+ aria-labelledby="drawer-title" tabindex="-1" hidden>
136
+ <div class="drawer-head">
137
+ <h2 id="drawer-title">Detalhes da user story</h2>
138
+ <button type="button" id="drawer-close" class="drawer-close" aria-label="Fechar detalhes">✕</button>
139
+ </div>
140
+ <div id="drawer-body" class="drawer-body"></div>
141
+ </aside>
142
+
117
143
  <noscript>
118
144
  <p class="noscript">Esta interface precisa de JavaScript. O estado bruto está disponível em
119
145
  <a href="status.json">status.json</a>.</p>