savepoint 1.0.0 → 1.0.2

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 (136) hide show
  1. package/.claude/settings.local.json +8 -1
  2. package/.savepoint/Design.md +26 -17
  3. package/.savepoint/audit/v1/E01/proposals.md +168 -0
  4. package/.savepoint/audit/v1/E01/snapshot.md +78 -0
  5. package/.savepoint/audit/{E01-go-setup → v1/E01-go-setup}/proposals.md +7 -7
  6. package/.savepoint/audit/{E01-go-setup → v1/E01-go-setup}/snapshot.md +2 -2
  7. package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/AGENTS.md +5 -5
  8. package/.savepoint/audit/{E02-data-readers → v1/E02-data-readers}/proposals.md +20 -20
  9. package/.savepoint/audit/{E02-data-readers → v1/E02-data-readers}/snapshot.md +1 -1
  10. package/.savepoint/audit/{E03-board-tui-core → v1/E03-board-tui-core}/proposals.md +11 -11
  11. package/.savepoint/audit/{E03-board-tui-core → v1/E03-board-tui-core}/snapshot.md +1 -1
  12. package/.savepoint/audit/{E04-board-components → v1/E04-board-components}/proposals.md +14 -14
  13. package/.savepoint/audit/{E04-board-components → v1/E04-board-components}/snapshot.md +1 -1
  14. package/.savepoint/audit/{E05-init-command → v1/E05-init-command}/snapshot.md +1 -1
  15. package/.savepoint/audit/{E05-phase-transitions → v1/E05-phase-transitions}/proposals.md +4 -4
  16. package/.savepoint/audit/{E05-phase-transitions → v1/E05-phase-transitions}/snapshot.md +1 -1
  17. package/.savepoint/audit/v1/E06-atari-noir-layout/proposals.md +130 -0
  18. package/.savepoint/audit/v1/E06-atari-noir-layout/snapshot.md +84 -0
  19. package/.savepoint/audit/{E07-audit-pipeline → v1/E07-audit-pipeline}/snapshot.md +6 -6
  20. package/.savepoint/audit/v1.1/E02-cross-platform-compatibility/proposals.md +114 -0
  21. package/.savepoint/audit/v1.1/E02-cross-platform-compatibility/snapshot.md +41 -0
  22. package/.savepoint/audit/v1.1/E04-epic-navigation/proposals.md +156 -0
  23. package/.savepoint/audit/v1.1/E04-epic-navigation/snapshot.md +48 -0
  24. package/.savepoint/config.yml +3 -3
  25. package/.savepoint/releases/v1/epics/E01-go-setup/tasks/T001-init-module.md +1 -1
  26. package/.savepoint/releases/v1/epics/E03-board-tui-core/tasks/T005-layout.md +1 -1
  27. package/.savepoint/releases/v1/epics/E04-board-components/tasks/T002-card.md +1 -1
  28. package/.savepoint/releases/v1/epics/E04-board-components/tasks/T006-help-overlay.md +1 -1
  29. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/E06-Detail.md +62 -0
  30. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T002-header-and-dividers.md +1 -1
  31. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T003-footer-status-bar.md +1 -1
  32. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T004-component-refinement.md +1 -1
  33. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T007-detail-card-fixes.md +7 -7
  34. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T008-checkbox-states.md +10 -8
  35. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T009-router-priority-marker.md +16 -9
  36. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T010-auto-refresh-watcher.md +27 -22
  37. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/E01-Detail.md +40 -0
  38. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T001-next-activity-header.md +56 -0
  39. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T002-rename-epic-design-files.md +38 -0
  40. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T003-rename-release-prd.md +28 -0
  41. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T004-update-instruction-files.md +51 -0
  42. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T005-update-cross-references.md +45 -0
  43. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T006-column-and-detail-scrolling.md +68 -0
  44. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T007-column-focus-border-stability.md +57 -0
  45. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/E02-Detail.md +49 -0
  46. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T001-fix-makefile.md +37 -0
  47. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T002-linux-build-target.md +38 -0
  48. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T003-macos-build-target.md +36 -0
  49. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T004-smoke-tests-and-artifacts.md +59 -0
  50. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Detail.md +32 -0
  51. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T001-border-resize-fix.md +40 -0
  52. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T002-next-activity-below-header.md +64 -0
  53. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T003-checkbox-rendering-fix.md +56 -0
  54. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T005-unify-status-glyphs.md +65 -0
  55. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T006-forced-256-color-profile.md +36 -0
  56. package/.savepoint/releases/v1.1/epics/E04-epic-navigation/E04-Detail.md +51 -0
  57. package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T001-sidebar-focusable-navigation.md +65 -0
  58. package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T002-epic-detail-overlay.md +73 -0
  59. package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T003-epic-status-glyphs.md +73 -0
  60. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Detail.md +45 -0
  61. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T001-update-agents-md.md +34 -0
  62. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T002-update-router-md.md +30 -0
  63. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T003-update-design-md.md +33 -0
  64. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T004-implement-m-hotkey.md +88 -0
  65. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T005-update-help-overlay.md +30 -0
  66. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T006-tests-and-quality-gates.md +46 -0
  67. package/.savepoint/releases/v1.1/v1.1-PRD.md +79 -0
  68. package/.savepoint/router.md +33 -105
  69. package/.savepoint/visual-identity.md +4 -3
  70. package/AGENTS.md +56 -113
  71. package/Makefile +19 -3
  72. package/README.md +7 -6
  73. package/agent-skills/savepoint-audit/SKILL.md +6 -6
  74. package/agent-skills/savepoint-build-task/SKILL.md +2 -2
  75. package/agent-skills/savepoint-create-plan/SKILL.md +3 -3
  76. package/agent-skills/savepoint-create-task/SKILL.md +2 -2
  77. package/agent-skills/savepoint-draft-prd/SKILL.md +1 -1
  78. package/agent-skills/savepoint-system-design/SKILL.md +1 -1
  79. package/go.mod +4 -1
  80. package/go.sum +2 -0
  81. package/internal/board/board.go +66 -14
  82. package/internal/board/board_test.go +124 -0
  83. package/internal/board/card.go +40 -3
  84. package/internal/board/card_test.go +121 -14
  85. package/internal/board/column.go +40 -5
  86. package/internal/board/column_test.go +65 -10
  87. package/internal/board/detail.go +115 -23
  88. package/internal/board/detail_test.go +132 -25
  89. package/internal/board/epic_panel.go +105 -8
  90. package/internal/board/epic_panel_test.go +343 -5
  91. package/internal/board/layout.go +12 -2
  92. package/internal/board/layout_test.go +17 -0
  93. package/internal/board/model.go +146 -23
  94. package/internal/board/render_policy_test.go +77 -0
  95. package/internal/board/status.go +23 -0
  96. package/internal/board/update.go +300 -9
  97. package/internal/board/update_test.go +166 -0
  98. package/internal/board/view.go +141 -17
  99. package/internal/board/view_test.go +161 -3
  100. package/internal/board/watch.go +100 -0
  101. package/internal/buildtool/main.go +219 -0
  102. package/internal/data/parser.go +39 -1
  103. package/internal/data/parser_test.go +43 -2
  104. package/internal/data/task.go +22 -2
  105. package/internal/styles/palette.go +9 -7
  106. package/internal/styles/styles.go +42 -25
  107. package/main.go +9 -0
  108. package/package.json +5 -4
  109. package/savepoint +0 -0
  110. package/savepoint.exe +0 -0
  111. package/templates/project/.savepoint/router.md +6 -5
  112. package/templates/project/AGENTS.md +47 -101
  113. package/templates/prompts/audit-reconciliation.prompt.md +6 -6
  114. package/templates/prompts/epic-design.prompt.md +3 -3
  115. package/templates/prompts/task-breakdown.prompt.md +1 -1
  116. package/templates/prompts/task-building.prompt.md +1 -1
  117. package/templates/prompts/task-planning.prompt.md +1 -1
  118. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/Design.md +0 -42
  119. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/Design.md +0 -26
  120. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T001-border-resize-fix.md +0 -35
  121. package/main.exe +0 -0
  122. /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/Design.md +0 -0
  123. /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/epic-Design.md +0 -0
  124. /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/quality-review.md +0 -0
  125. /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/snapshot.md +0 -0
  126. /package/.savepoint/audit/{E02-data-model → v1/E02-data-model}/snapshot.md +0 -0
  127. /package/.savepoint/audit/{E03-cli-foundation → v1/E03-cli-foundation}/snapshot.md +0 -0
  128. /package/.savepoint/audit/{E04-templates-and-prompts → v1/E04-templates-and-prompts}/snapshot.md +0 -0
  129. /package/.savepoint/audit/{E06-tui-board → v1/E06-tui-board}/snapshot.md +0 -0
  130. /package/.savepoint/audit/{E08-board-workflow-cleanup → v1/E08-board-workflow-cleanup}/snapshot.md +0 -0
  131. /package/.savepoint/releases/v1/epics/E01-go-setup/{Design.md → E01-Detail.md} +0 -0
  132. /package/.savepoint/releases/v1/epics/E02-data-readers/{Design.md → E02-Detail.md} +0 -0
  133. /package/.savepoint/releases/v1/epics/E03-board-tui-core/{Design.md → E03-Detail.md} +0 -0
  134. /package/.savepoint/releases/v1/epics/E04-board-components/{Design.md → E04-Detail.md} +0 -0
  135. /package/.savepoint/releases/v1/epics/E05-phase-transitions/{Design.md → E05-Detail.md} +0 -0
  136. /package/.savepoint/releases/v1/{PRD.md → v1-PRD.md} +0 -0
@@ -9,22 +9,29 @@ import (
9
9
  )
10
10
 
11
11
  func TestRenderEpicSidebar_containsEpicsHeader(t *testing.T) {
12
- got := RenderEpicSidebar([]string{"E01", "E02"}, "E01", 28)
12
+ got := RenderEpicSidebar([]string{"E01", "E02"}, "E01", 28, false, 0, nil)
13
13
  if !strings.Contains(got, "EPICS") {
14
14
  t.Error("RenderEpicSidebar missing EPICS header")
15
15
  }
16
16
  }
17
17
 
18
18
  func TestRenderEpicSidebar_activeEpicMarked(t *testing.T) {
19
- got := RenderEpicSidebar([]string{"E01", "E02"}, "E01", 28)
19
+ got := RenderEpicSidebar([]string{"E01", "E02"}, "E01", 28, false, 0, nil)
20
20
  if !strings.Contains(got, epicActiveMarker) {
21
21
  t.Errorf("RenderEpicSidebar missing active marker %q", epicActiveMarker)
22
22
  }
23
23
  }
24
24
 
25
+ func TestRenderEpicSidebar_focusedCursorMarked(t *testing.T) {
26
+ got := RenderEpicSidebar([]string{"E01", "E02"}, "E01", 28, true, 1, nil)
27
+ if !strings.Contains(got, epicActiveMarker+" E02") {
28
+ t.Errorf("RenderEpicSidebar focused cursor missing marker, got %q", got)
29
+ }
30
+ }
31
+
25
32
  func TestRenderEpicSidebar_allEpicsPresent(t *testing.T) {
26
33
  epics := []string{"E01-foo", "E02-bar", "E03-baz"}
27
- got := RenderEpicSidebar(epics, "E01-foo", 32)
34
+ got := RenderEpicSidebar(epics, "E01-foo", 32, false, 0, nil)
28
35
  for _, e := range epics {
29
36
  if !strings.Contains(got, e) {
30
37
  t.Errorf("RenderEpicSidebar missing epic %q", e)
@@ -33,14 +40,14 @@ func TestRenderEpicSidebar_allEpicsPresent(t *testing.T) {
33
40
  }
34
41
 
35
42
  func TestRenderEpicSidebar_emptyEpicsFallback(t *testing.T) {
36
- got := RenderEpicSidebar(nil, "E03", 28)
43
+ got := RenderEpicSidebar(nil, "E03", 28, false, 0, nil)
37
44
  if !strings.Contains(got, "E03") {
38
45
  t.Error("RenderEpicSidebar with empty list should show selected epic")
39
46
  }
40
47
  }
41
48
 
42
49
  func TestRenderEpicSidebar_emptyBothShowsNone(t *testing.T) {
43
- got := RenderEpicSidebar(nil, "", 28)
50
+ got := RenderEpicSidebar(nil, "", 28, false, 0, nil)
44
51
  if !strings.Contains(got, "(none)") {
45
52
  t.Error("RenderEpicSidebar with no epics and no selected should show (none)")
46
53
  }
@@ -211,6 +218,211 @@ func TestUpdate_overlayBlocksColumnNav(t *testing.T) {
211
218
  }
212
219
  }
213
220
 
221
+ func TestUpdate_leftFromPlannedFocusesEpicPanelWide(t *testing.T) {
222
+ m := NewModel(nil, "v1", "E02")
223
+ m.Width = 120
224
+ m.Epics = []string{"E01", "E02", "E03"}
225
+
226
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("h")})
227
+ updated := requireModel(t, got)
228
+
229
+ if !updated.EpicPanelFocus {
230
+ t.Fatal("EpicPanelFocus = false, want true")
231
+ }
232
+ if updated.EpicPanelCursor != 1 {
233
+ t.Errorf("EpicPanelCursor = %d, want 1", updated.EpicPanelCursor)
234
+ }
235
+ if updated.FocusedColumn != data.ColumnPlanned {
236
+ t.Errorf("FocusedColumn = %q, want planned", updated.FocusedColumn)
237
+ }
238
+ }
239
+
240
+ func TestUpdate_leftFromPlannedDoesNotFocusEpicPanelNarrow(t *testing.T) {
241
+ m := NewModel(nil, "v1", "E02")
242
+ m.Width = 100
243
+ m.Epics = []string{"E01", "E02"}
244
+
245
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("h")})
246
+ updated := requireModel(t, got)
247
+
248
+ if updated.EpicPanelFocus {
249
+ t.Fatal("EpicPanelFocus = true, want false")
250
+ }
251
+ if updated.FocusedColumn != data.ColumnDone {
252
+ t.Errorf("FocusedColumn = %q, want done", updated.FocusedColumn)
253
+ }
254
+ }
255
+
256
+ func TestUpdate_leftFromPlannedDoesNotFocusEmptyEpicPanel(t *testing.T) {
257
+ m := NewModel(nil, "v1", "")
258
+ m.Width = 120
259
+
260
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("h")})
261
+ updated := requireModel(t, got)
262
+
263
+ if updated.EpicPanelFocus {
264
+ t.Fatal("EpicPanelFocus = true, want false with no epics")
265
+ }
266
+ if updated.EpicPanelCursor != 0 {
267
+ t.Errorf("EpicPanelCursor = %d, want 0", updated.EpicPanelCursor)
268
+ }
269
+ }
270
+
271
+ func TestUpdate_windowResizeClearsEpicPanelFocusWhenHidden(t *testing.T) {
272
+ m := NewModel(nil, "v1", "E01")
273
+ m.Width = 120
274
+ m.Epics = []string{"E01"}
275
+ m.EpicPanelFocus = true
276
+
277
+ got, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 24})
278
+ updated := requireModel(t, got)
279
+
280
+ if updated.EpicPanelFocus {
281
+ t.Fatal("EpicPanelFocus = true, want false when panel is hidden")
282
+ }
283
+ }
284
+
285
+ func TestUpdate_epicPanelDownUpClamped(t *testing.T) {
286
+ m := NewModel(nil, "v1", "E01")
287
+ m.Width = 120
288
+ m.EpicPanelFocus = true
289
+ m.Epics = []string{"E01", "E02"}
290
+
291
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown})
292
+ updated := requireModel(t, got)
293
+ if updated.EpicPanelCursor != 1 {
294
+ t.Errorf("EpicPanelCursor after down = %d, want 1", updated.EpicPanelCursor)
295
+ }
296
+
297
+ got, _ = updated.Update(tea.KeyMsg{Type: tea.KeyDown})
298
+ updated = requireModel(t, got)
299
+ if updated.EpicPanelCursor != 1 {
300
+ t.Errorf("EpicPanelCursor after clamped down = %d, want 1", updated.EpicPanelCursor)
301
+ }
302
+
303
+ got, _ = updated.Update(tea.KeyMsg{Type: tea.KeyUp})
304
+ updated = requireModel(t, got)
305
+ if updated.EpicPanelCursor != 0 {
306
+ t.Errorf("EpicPanelCursor after up = %d, want 0", updated.EpicPanelCursor)
307
+ }
308
+ }
309
+
310
+ func TestUpdate_epicPanelEnterOpensDetailOverlay(t *testing.T) {
311
+ tasks := []data.Task{
312
+ {ID: "T1", Epic: "E01", Release: "v1", Column: data.ColumnPlanned},
313
+ {ID: "T2", Epic: "E02", Release: "v1", Column: data.ColumnPlanned},
314
+ }
315
+ m := NewModel(tasks, "v1", "E01")
316
+ m.Width = 120
317
+ m.Epics = []string{"E01", "E02"}
318
+ m.EpicPanelFocus = true
319
+ m.EpicPanelCursor = 1
320
+ m.FocusedTask = 3
321
+ m.DetailOffset = 2
322
+
323
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
324
+ updated := requireModel(t, got)
325
+
326
+ if updated.Overlay != OverlayEpicDetail {
327
+ t.Errorf("Overlay = %q, want %q", updated.Overlay, OverlayEpicDetail)
328
+ }
329
+ if updated.EpicDetailOffset != 0 {
330
+ t.Errorf("EpicDetailOffset = %d, want 0", updated.EpicDetailOffset)
331
+ }
332
+ if !updated.EpicPanelFocus {
333
+ t.Error("EpicPanelFocus should remain true after enter")
334
+ }
335
+ // SelectedEpic unchanged; Enter now opens detail, not selects
336
+ if updated.SelectedEpic != "E01" {
337
+ t.Errorf("SelectedEpic = %q, want E01 (unchanged)", updated.SelectedEpic)
338
+ }
339
+ }
340
+
341
+ func TestUpdate_epicPanelRightReturnsToPlanned(t *testing.T) {
342
+ m := NewModel(nil, "v1", "E01")
343
+ m.Width = 120
344
+ m.EpicPanelFocus = true
345
+ m.Epics = []string{"E01"}
346
+ m.FocusedColumn = data.ColumnDone
347
+ m.FocusedTask = 2
348
+
349
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")})
350
+ updated := requireModel(t, got)
351
+
352
+ if updated.EpicPanelFocus {
353
+ t.Fatal("EpicPanelFocus = true, want false")
354
+ }
355
+ if updated.FocusedColumn != data.ColumnPlanned {
356
+ t.Errorf("FocusedColumn = %q, want planned", updated.FocusedColumn)
357
+ }
358
+ if updated.FocusedTask != 0 {
359
+ t.Errorf("FocusedTask = %d, want 0", updated.FocusedTask)
360
+ }
361
+ }
362
+
363
+ func TestUpdate_overlayBlocksEpicPanelNav(t *testing.T) {
364
+ m := NewModel(nil, "v1", "E01")
365
+ m.Width = 120
366
+ m.EpicPanelFocus = true
367
+ m.Epics = []string{"E01", "E02"}
368
+ m.Overlay = OverlayHelp
369
+
370
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown})
371
+ updated := requireModel(t, got)
372
+
373
+ if updated.EpicPanelCursor != 0 {
374
+ t.Errorf("EpicPanelCursor = %d, want 0 while overlay open", updated.EpicPanelCursor)
375
+ }
376
+ }
377
+
378
+ func TestUpdate_epicPanelFocusAllowsGlobalQuit(t *testing.T) {
379
+ m := NewModel(nil, "v1", "E01")
380
+ m.Width = 120
381
+ m.EpicPanelFocus = true
382
+ m.Epics = []string{"E01"}
383
+
384
+ _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")})
385
+ if cmd == nil {
386
+ t.Fatal("expected tea.Quit cmd from q while epic panel focused, got nil")
387
+ }
388
+ }
389
+
390
+ func TestUpdate_epicPanelFocusAllowsEpicDropdown(t *testing.T) {
391
+ m := NewModel(nil, "v1", "E02")
392
+ m.Width = 120
393
+ m.EpicPanelFocus = true
394
+ m.Epics = []string{"E01", "E02"}
395
+
396
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")})
397
+ updated := requireModel(t, got)
398
+
399
+ if updated.Overlay != OverlayEpic {
400
+ t.Errorf("Overlay = %q, want %q", updated.Overlay, OverlayEpic)
401
+ }
402
+ if updated.EpicCursor != 1 {
403
+ t.Errorf("EpicCursor = %d, want 1", updated.EpicCursor)
404
+ }
405
+ }
406
+
407
+ func TestUpdate_epicPanelFocusAllowsReleaseDropdown(t *testing.T) {
408
+ m := NewModel(nil, "v1", "E01")
409
+ m.Width = 120
410
+ m.EpicPanelFocus = true
411
+ m.Epics = []string{"E01"}
412
+ m.Releases = []string{"v1", "v1.1"}
413
+ m.SelectedRelease = "v1.1"
414
+
415
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("r")})
416
+ updated := requireModel(t, got)
417
+
418
+ if updated.Overlay != OverlayRelease {
419
+ t.Errorf("Overlay = %q, want %q", updated.Overlay, OverlayRelease)
420
+ }
421
+ if updated.ReleaseCursor != 1 {
422
+ t.Errorf("ReleaseCursor = %d, want 1", updated.ReleaseCursor)
423
+ }
424
+ }
425
+
214
426
  func TestView_epicDropdownOverlayRendered(t *testing.T) {
215
427
  m := NewModel(nil, "v1", "E01")
216
428
  m.Width = 80
@@ -244,3 +456,129 @@ func TestView_epicSidebarOnWide(t *testing.T) {
244
456
  t.Error("View() at width>=120 missing EPICS header in sidebar")
245
457
  }
246
458
  }
459
+
460
+ func TestUpdate_epicDetailOverlayEscCloses(t *testing.T) {
461
+ m := NewModel(nil, "v1", "E01")
462
+ m.Width = 120
463
+ m.EpicPanelFocus = true
464
+ m.Epics = []string{"E01"}
465
+ m.Overlay = OverlayEpicDetail
466
+ m.EpicDetailContent = "# E01 Detail"
467
+ m.EpicDetailOffset = 3
468
+
469
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
470
+ updated := requireModel(t, got)
471
+
472
+ if updated.Overlay != OverlayNone {
473
+ t.Errorf("Overlay = %q, want none after esc", updated.Overlay)
474
+ }
475
+ if !updated.EpicPanelFocus {
476
+ t.Error("EpicPanelFocus should remain true after closing detail overlay")
477
+ }
478
+ }
479
+
480
+ func TestUpdate_epicDetailOverlayScrollUpDown(t *testing.T) {
481
+ m := NewModel(nil, "v1", "E01")
482
+ m.Width = 120
483
+ m.EpicPanelFocus = true
484
+ m.Epics = []string{"E01"}
485
+ m.Overlay = OverlayEpicDetail
486
+ m.EpicDetailOffset = 2
487
+
488
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown})
489
+ updated := requireModel(t, got)
490
+ if updated.EpicDetailOffset != 3 {
491
+ t.Errorf("EpicDetailOffset after down = %d, want 3", updated.EpicDetailOffset)
492
+ }
493
+
494
+ got, _ = updated.Update(tea.KeyMsg{Type: tea.KeyUp})
495
+ updated = requireModel(t, got)
496
+ if updated.EpicDetailOffset != 2 {
497
+ t.Errorf("EpicDetailOffset after up = %d, want 2", updated.EpicDetailOffset)
498
+ }
499
+
500
+ got, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")})
501
+ updated = requireModel(t, got)
502
+ if updated.EpicDetailOffset != 1 {
503
+ t.Errorf("EpicDetailOffset after k = %d, want 1", updated.EpicDetailOffset)
504
+ }
505
+
506
+ // Clamp at 0
507
+ updated.EpicDetailOffset = 0
508
+ got, _ = updated.Update(tea.KeyMsg{Type: tea.KeyUp})
509
+ updated = requireModel(t, got)
510
+ if updated.EpicDetailOffset != 0 {
511
+ t.Errorf("EpicDetailOffset should not go below 0, got %d", updated.EpicDetailOffset)
512
+ }
513
+ }
514
+
515
+ func TestUpdate_epicDetailOverlayPgUpDown(t *testing.T) {
516
+ m := NewModel(nil, "v1", "E01")
517
+ m.Width = 120
518
+ m.Height = 30
519
+ m.Epics = []string{"E01"}
520
+ m.Overlay = OverlayEpicDetail
521
+ m.EpicDetailOffset = 20
522
+
523
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyPgUp})
524
+ updated := requireModel(t, got)
525
+ if updated.EpicDetailOffset >= 20 {
526
+ t.Errorf("EpicDetailOffset after pgup = %d, should decrease from 20", updated.EpicDetailOffset)
527
+ }
528
+ if updated.EpicDetailOffset < 0 {
529
+ t.Errorf("EpicDetailOffset = %d, should not go below 0", updated.EpicDetailOffset)
530
+ }
531
+
532
+ updated.EpicDetailOffset = 0
533
+ got, _ = updated.Update(tea.KeyMsg{Type: tea.KeyPgDown})
534
+ updated = requireModel(t, got)
535
+ if updated.EpicDetailOffset <= 0 {
536
+ t.Errorf("EpicDetailOffset after pgdown = %d, should increase from 0", updated.EpicDetailOffset)
537
+ }
538
+ }
539
+
540
+ func TestView_epicDetailOverlayRendered(t *testing.T) {
541
+ m := NewModel(nil, "v1", "E01")
542
+ m.Width = 120
543
+ m.Height = 30
544
+ m.Epics = []string{"E01"}
545
+ m.Overlay = OverlayEpicDetail
546
+ m.EpicDetailContent = "# My Epic\n\n## Purpose\nDoes things."
547
+
548
+ got := m.View()
549
+ if !strings.Contains(got, "EPIC DETAIL") {
550
+ t.Error("View() with OverlayEpicDetail missing EPIC DETAIL header")
551
+ }
552
+ }
553
+
554
+ func TestView_epicDetailOverlayNoContent(t *testing.T) {
555
+ m := NewModel(nil, "v1", "E01")
556
+ m.Width = 120
557
+ m.Height = 30
558
+ m.Epics = []string{"E01"}
559
+ m.Overlay = OverlayEpicDetail
560
+ m.EpicDetailContent = "(no detail available)"
561
+
562
+ got := m.View()
563
+ if !strings.Contains(got, "no detail available") {
564
+ t.Error("View() with missing epic detail should show 'no detail available'")
565
+ }
566
+ }
567
+
568
+ func TestRenderEpicDetail_stripsMarkdownHeadings(t *testing.T) {
569
+ content := "---\ntype: epic-design\n---\n# Epic E01\n\n## Purpose\nDoes things."
570
+ got := RenderEpicDetail("E01-test", content, 60, 40, 0)
571
+ if !strings.Contains(got, "EPIC DETAIL") {
572
+ t.Error("RenderEpicDetail missing EPIC DETAIL header")
573
+ }
574
+ if strings.Contains(got, "# Epic E01") {
575
+ t.Error("RenderEpicDetail should strip raw markdown heading prefix")
576
+ }
577
+ }
578
+
579
+ func TestRenderEpicDetail_noDetailFallback(t *testing.T) {
580
+ got := RenderEpicDetail("E01-test", "(no detail available)", 60, 40, 0)
581
+ if !strings.Contains(got, "no detail available") {
582
+ t.Error("RenderEpicDetail fallback message missing")
583
+ }
584
+ }
@@ -3,7 +3,8 @@ package board
3
3
  const (
4
4
  colOverhead = 4 // rounded border (1) + padding (1) each side
5
5
 
6
- minColWidth = 10
6
+ minColWidth = 10
7
+ minContentHeight = 5
7
8
 
8
9
  epicPanelWidth = 28
9
10
  epicPanelOverhead = 4
@@ -20,6 +21,7 @@ type Layout struct {
20
21
  EpicPanelWidth int
21
22
  ColCount int
22
23
  ColWidths []int
24
+ ContentHeight int
23
25
  }
24
26
 
25
27
  // CalculateLayout returns the board layout for the given terminal dimensions.
@@ -27,7 +29,12 @@ type Layout struct {
27
29
  // - >=120 cols: epic panel (28w) + 3 columns
28
30
  // - 80–119 cols: 3 columns only
29
31
  // - <80 cols: 1 column
30
- func CalculateLayout(width, _ int) Layout {
32
+ func CalculateLayout(width, height int) Layout {
33
+ return CalculateLayoutWithChrome(width, height, 0)
34
+ }
35
+
36
+ func CalculateLayoutWithChrome(width, height, extraHeaderLines int) Layout {
37
+ contentHeight := max(height-10-extraHeaderLines, minContentHeight)
31
38
  inner := width - boardFrameOverhead
32
39
  switch {
33
40
  case width >= breakpointWide:
@@ -38,6 +45,7 @@ func CalculateLayout(width, _ int) Layout {
38
45
  EpicPanelWidth: epicPanelWidth,
39
46
  ColCount: 3,
40
47
  ColWidths: []int{cw, cw, cw},
48
+ ContentHeight: contentHeight,
41
49
  }
42
50
  case width >= breakpointNarrow:
43
51
  available := inner - 3*colOverhead
@@ -46,6 +54,7 @@ func CalculateLayout(width, _ int) Layout {
46
54
  EpicPanelVisible: false,
47
55
  ColCount: 3,
48
56
  ColWidths: []int{cw, cw, cw},
57
+ ContentHeight: contentHeight,
49
58
  }
50
59
  default:
51
60
  cw := max(inner-colOverhead, minColWidth)
@@ -53,6 +62,7 @@ func CalculateLayout(width, _ int) Layout {
53
62
  EpicPanelVisible: false,
54
63
  ColCount: 1,
55
64
  ColWidths: []int{cw},
65
+ ContentHeight: contentHeight,
56
66
  }
57
67
  }
58
68
  }
@@ -22,6 +22,9 @@ func TestCalculateLayout_wide(t *testing.T) {
22
22
  t.Errorf("ColWidths[%d] = %d, want 24", i, w)
23
23
  }
24
24
  }
25
+ if l.ContentHeight != 30 {
26
+ t.Errorf("ContentHeight = %d, want 30", l.ContentHeight)
27
+ }
25
28
  }
26
29
 
27
30
  func TestCalculateLayout_medium(t *testing.T) {
@@ -66,6 +69,20 @@ func TestCalculateLayout_tinyWidth_floorsAtMinColWidth(t *testing.T) {
66
69
  }
67
70
  }
68
71
 
72
+ func TestCalculateLayout_tinyHeight_floorsAtMinContentHeight(t *testing.T) {
73
+ l := CalculateLayout(100, 8)
74
+ if l.ContentHeight != minContentHeight {
75
+ t.Errorf("ContentHeight = %d, want %d", l.ContentHeight, minContentHeight)
76
+ }
77
+ }
78
+
79
+ func TestCalculateLayoutWithChrome_accountsForExtraHeaderLine(t *testing.T) {
80
+ l := CalculateLayoutWithChrome(120, 40, 1)
81
+ if l.ContentHeight != 29 {
82
+ t.Errorf("ContentHeight = %d, want 29", l.ContentHeight)
83
+ }
84
+ }
85
+
69
86
  func TestCalculateLayout_breakpointBoundaries(t *testing.T) {
70
87
  cases := []struct {
71
88
  width int