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
@@ -1,6 +1,7 @@
1
1
  package board
2
2
 
3
3
  import (
4
+ "regexp"
4
5
  "strings"
5
6
  "testing"
6
7
 
@@ -8,6 +9,12 @@ import (
8
9
  "github.com/opencode/savepoint/internal/data"
9
10
  )
10
11
 
12
+ var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`)
13
+
14
+ func plainTerminal(s string) string {
15
+ return ansiPattern.ReplaceAllString(s, "")
16
+ }
17
+
11
18
  func sampleTask() data.Task {
12
19
  return data.Task{
13
20
  ID: "E04/T001",
@@ -20,49 +27,49 @@ func sampleTask() data.Task {
20
27
  }
21
28
 
22
29
  func TestRenderDetail_containsID(t *testing.T) {
23
- got := RenderDetail(sampleTask(), 60)
30
+ got := RenderDetail(sampleTask(), 60, nil, 0, 0)
24
31
  if !strings.Contains(got, "E04/T001") {
25
32
  t.Error("RenderDetail missing task ID")
26
33
  }
27
34
  }
28
35
 
29
36
  func TestRenderDetail_containsTitle(t *testing.T) {
30
- got := RenderDetail(sampleTask(), 60)
37
+ got := RenderDetail(sampleTask(), 60, nil, 0, 0)
31
38
  if !strings.Contains(got, "My Task") {
32
39
  t.Error("RenderDetail missing task title")
33
40
  }
34
41
  }
35
42
 
36
43
  func TestRenderDetail_containsEpic(t *testing.T) {
37
- got := RenderDetail(sampleTask(), 60)
44
+ got := RenderDetail(sampleTask(), 60, nil, 0, 0)
38
45
  if !strings.Contains(got, "E04-board-components") {
39
46
  t.Error("RenderDetail missing epic")
40
47
  }
41
48
  }
42
49
 
43
50
  func TestRenderDetail_containsRelease(t *testing.T) {
44
- got := RenderDetail(sampleTask(), 60)
51
+ got := RenderDetail(sampleTask(), 60, nil, 0, 0)
45
52
  if !strings.Contains(got, "v1") {
46
53
  t.Error("RenderDetail missing release")
47
54
  }
48
55
  }
49
56
 
50
57
  func TestRenderDetail_containsStatus(t *testing.T) {
51
- got := RenderDetail(sampleTask(), 60)
58
+ got := RenderDetail(sampleTask(), 60, nil, 0, 0)
52
59
  if !strings.Contains(got, "in_progress") {
53
60
  t.Error("RenderDetail missing status")
54
61
  }
55
62
  }
56
63
 
57
64
  func TestRenderDetail_containsPhase(t *testing.T) {
58
- got := RenderDetail(sampleTask(), 60)
65
+ got := RenderDetail(sampleTask(), 60, nil, 0, 0)
59
66
  if !strings.Contains(got, "build") {
60
67
  t.Error("RenderDetail missing phase")
61
68
  }
62
69
  }
63
70
 
64
71
  func TestRenderDetail_containsEscHint(t *testing.T) {
65
- got := RenderDetail(sampleTask(), 60)
72
+ got := RenderDetail(sampleTask(), 60, nil, 0, 0)
66
73
  if !strings.Contains(got, "esc") {
67
74
  t.Error("RenderDetail missing esc:close hint")
68
75
  }
@@ -71,35 +78,23 @@ func TestRenderDetail_containsEscHint(t *testing.T) {
71
78
  func TestRenderDetail_containsDescription(t *testing.T) {
72
79
  tk := sampleTask()
73
80
  tk.Description = "some description text"
74
- got := RenderDetail(tk, 60)
81
+ got := RenderDetail(tk, 60, nil, 0, 0)
75
82
  if !strings.Contains(got, "some description text") {
76
83
  t.Error("RenderDetail missing description text")
77
84
  }
78
85
  }
79
86
 
80
87
  func TestRenderDetail_noDescriptionSectionWhenEmpty(t *testing.T) {
81
- got := RenderDetail(sampleTask(), 60)
88
+ got := RenderDetail(sampleTask(), 60, nil, 0, 0)
82
89
  if strings.Contains(got, "Description:") {
83
90
  t.Error("RenderDetail should not show Description section when empty")
84
91
  }
85
92
  }
86
93
 
87
- func TestRenderDetail_containsAcceptanceCriteria(t *testing.T) {
88
- tk := sampleTask()
89
- tk.Acceptance = []string{"criterion one", "criterion two"}
90
- got := RenderDetail(tk, 60)
91
- if !strings.Contains(got, "criterion one") {
92
- t.Error("RenderDetail missing first acceptance criterion")
93
- }
94
- if !strings.Contains(got, "criterion two") {
95
- t.Error("RenderDetail missing second acceptance criterion")
96
- }
97
- }
98
-
99
94
  func TestRenderDetail_containsChecklist(t *testing.T) {
100
95
  tk := sampleTask()
101
- tk.Checklist = []string{"first implementation item", "second implementation item"}
102
- got := RenderDetail(tk, 60)
96
+ tk.Checklist = []data.CheckItem{{Text: "first implementation item"}, {Text: "second implementation item", Done: true}}
97
+ got := RenderDetail(tk, 60, nil, 0, 0)
103
98
  if !strings.Contains(got, "Implementation Plan:") {
104
99
  t.Error("RenderDetail missing implementation plan heading")
105
100
  }
@@ -111,10 +106,70 @@ func TestRenderDetail_containsChecklist(t *testing.T) {
111
106
  }
112
107
  }
113
108
 
109
+ func TestRenderDetail_checklistSingleSentenceGetsOneCheckbox(t *testing.T) {
110
+ tk := sampleTask()
111
+ tk.Checklist = []data.CheckItem{{Text: "single sentence task"}}
112
+
113
+ got := plainTerminal(RenderDetail(tk, 60, nil, 0, 0))
114
+
115
+ if count := strings.Count(got, "[ ]"); count != 1 {
116
+ t.Fatalf("RenderDetail checkbox count = %d, want 1\n%s", count, got)
117
+ }
118
+ if strings.Contains(got, "[x]") {
119
+ t.Fatal("RenderDetail should not render checked marker for unchecked item")
120
+ }
121
+ }
122
+
123
+ func TestRenderDetail_checklistMultiSentenceGetsOneCheckboxPerSentence(t *testing.T) {
124
+ tk := sampleTask()
125
+ tk.Checklist = []data.CheckItem{{Text: "First sentence. Second sentence! Third sentence?"}}
126
+
127
+ got := plainTerminal(RenderDetail(tk, 60, nil, 0, 0))
128
+
129
+ if count := strings.Count(got, "[ ]"); count != 3 {
130
+ t.Fatalf("RenderDetail checkbox count = %d, want 3\n%s", count, got)
131
+ }
132
+ for _, want := range []string{"[ ] First sentence.", "[ ] Second sentence!", "[ ] Third sentence?"} {
133
+ if !strings.Contains(got, want) {
134
+ t.Fatalf("RenderDetail missing sentence checkbox line %q\n%s", want, got)
135
+ }
136
+ }
137
+ }
138
+
139
+ func TestRenderDetail_checklistHardWrappedSentenceDoesNotDuplicateCheckbox(t *testing.T) {
140
+ tk := sampleTask()
141
+ tk.Checklist = []data.CheckItem{{
142
+ Text: "This sentence is intentionally long enough to wrap inside a narrow detail overlay while remaining one semantic sentence.",
143
+ }}
144
+
145
+ got := plainTerminal(RenderDetail(tk, 34, nil, 0, 0))
146
+
147
+ if count := strings.Count(got, "[ ]"); count != 1 {
148
+ t.Fatalf("RenderDetail checkbox count = %d, want 1\n%s", count, got)
149
+ }
150
+ if !strings.Contains(got, " intentionally long enough") {
151
+ t.Fatalf("RenderDetail continuation line should align under checkbox text\n%s", got)
152
+ }
153
+ }
154
+
155
+ func TestRenderDetail_checklistCheckedSentenceUsesCheckedMarker(t *testing.T) {
156
+ tk := sampleTask()
157
+ tk.Checklist = []data.CheckItem{{Text: "already done. still done.", Done: true}}
158
+
159
+ got := plainTerminal(RenderDetail(tk, 60, nil, 0, 0))
160
+
161
+ if count := strings.Count(got, "[x]"); count != 2 {
162
+ t.Fatalf("RenderDetail checked checkbox count = %d, want 2\n%s", count, got)
163
+ }
164
+ if strings.Contains(got, "[ ]") {
165
+ t.Fatal("RenderDetail should not render unchecked marker for checked item")
166
+ }
167
+ }
168
+
114
169
  func TestRenderDetail_wrapsLongDescription(t *testing.T) {
115
170
  tk := sampleTask()
116
171
  tk.Description = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda"
117
- got := RenderDetail(tk, 30)
172
+ got := RenderDetail(tk, 30, nil, 0, 0)
118
173
  if strings.Contains(got, tk.Description) {
119
174
  t.Error("RenderDetail should wrap long description text")
120
175
  }
@@ -124,7 +179,7 @@ func TestRenderDetail_wrapsLongDescription(t *testing.T) {
124
179
  }
125
180
 
126
181
  func TestRenderDetail_noAcceptanceSectionWhenEmpty(t *testing.T) {
127
- got := RenderDetail(sampleTask(), 60)
182
+ got := RenderDetail(sampleTask(), 60, nil, 0, 0)
128
183
  if strings.Contains(got, "Acceptance Criteria:") {
129
184
  t.Error("RenderDetail should not show Acceptance section when empty")
130
185
  }
@@ -214,6 +269,58 @@ func TestView_detailOverlayRendered(t *testing.T) {
214
269
  }
215
270
  }
216
271
 
272
+ func TestRenderDetail_routerPriorityLabel(t *testing.T) {
273
+ task := sampleTask()
274
+ router := &data.RouterState{Release: task.Release, Epic: task.Epic, Task: task.ID}
275
+ got := RenderDetail(task, 60, router, 0, 0)
276
+ if !strings.Contains(got, "(router priority)") {
277
+ t.Error("RenderDetail missing router priority label for matching task")
278
+ }
279
+ }
280
+
281
+ func TestRenderDetail_noRouterPriorityLabelWhenNoMatch(t *testing.T) {
282
+ task := sampleTask()
283
+ router := &data.RouterState{Release: task.Release, Epic: task.Epic, Task: "other-id"}
284
+ got := RenderDetail(task, 60, router, 0, 0)
285
+ if strings.Contains(got, "(router priority)") {
286
+ t.Error("RenderDetail should not show router priority label for non-matching task")
287
+ }
288
+ }
289
+
290
+ func TestRenderDetail_viewportShowsScrollIndicators(t *testing.T) {
291
+ task := sampleTask()
292
+ task.Description = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron"
293
+
294
+ got := RenderDetail(task, 32, nil, 8, 2)
295
+
296
+ if !strings.Contains(got, "↑ 2 above") {
297
+ t.Error("RenderDetail missing above indicator")
298
+ }
299
+ if !strings.Contains(got, "↓") || !strings.Contains(got, "more") {
300
+ t.Error("RenderDetail missing more indicator")
301
+ }
302
+ if strings.Contains(got, "ID:") {
303
+ t.Error("RenderDetail should not render body lines above viewport")
304
+ }
305
+ }
306
+
307
+ func TestUpdate_detailOverlayScrollsWithJK(t *testing.T) {
308
+ m := NewModel([]data.Task{sampleTask()}, "v1", "E04-board-components")
309
+ m.Overlay = OverlayDetail
310
+
311
+ got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
312
+ updated := requireModel(t, got)
313
+ if updated.DetailOffset != 1 {
314
+ t.Errorf("DetailOffset after j = %d, want 1", updated.DetailOffset)
315
+ }
316
+
317
+ got, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")})
318
+ updated = requireModel(t, got)
319
+ if updated.DetailOffset != 0 {
320
+ t.Errorf("DetailOffset after k = %d, want 0", updated.DetailOffset)
321
+ }
322
+ }
323
+
217
324
  func TestOverlayWidth_clampMax(t *testing.T) {
218
325
  if got := overlayWidth(120); got != 80 {
219
326
  t.Errorf("overlayWidth(120) = %d, want 80", got)
@@ -3,14 +3,85 @@ package board
3
3
  import (
4
4
  "strings"
5
5
 
6
+ "github.com/charmbracelet/lipgloss"
6
7
  "github.com/opencode/savepoint/internal/styles"
7
8
  )
8
9
 
10
+ // RenderEpicDetail renders an overlay showing the content of an E##-Detail.md file.
11
+ func RenderEpicDetail(epicSlug, content string, overlayW, maxHeight, offset int) string {
12
+ inner := overlayW - detailBorderPad
13
+ if inner < 4 {
14
+ inner = 4
15
+ }
16
+
17
+ lines := []string{
18
+ styles.EpicTitleFocused.Render("EPIC DETAIL"),
19
+ strings.Repeat("─", inner),
20
+ }
21
+
22
+ body := epicDetailBody(content, inner)
23
+ body = append(body, "", styles.CardMeta.Render("esc:close"))
24
+ lines = append(lines, visibleDetailLines(body, maxHeight-detailVerticalOverhead, offset)...)
25
+
26
+ return styles.EpicDetailOverlay.Width(overlayW).Render(strings.Join(lines, "\n"))
27
+ }
28
+
29
+ // epicDetailBody parses markdown content into display lines, stripping frontmatter.
30
+ func epicDetailBody(content string, width int) []string {
31
+ if strings.TrimSpace(content) == "" || content == "(no detail available)" {
32
+ return []string{styles.CardMeta.Render("(no detail available)")}
33
+ }
34
+
35
+ lines := strings.Split(content, "\n")
36
+
37
+ // Strip YAML frontmatter between leading --- markers.
38
+ start := 0
39
+ if len(lines) > 0 && strings.TrimSpace(lines[0]) == "---" {
40
+ for i := 1; i < len(lines); i++ {
41
+ if strings.TrimSpace(lines[i]) == "---" {
42
+ start = i + 1
43
+ break
44
+ }
45
+ }
46
+ }
47
+ lines = lines[start:]
48
+
49
+ var body []string
50
+ skip := false
51
+ for _, line := range lines {
52
+ trimmed := strings.TrimRight(line, " \t")
53
+ if strings.HasPrefix(trimmed, "## ") {
54
+ heading := strings.ToLower(strings.TrimPrefix(trimmed, "## "))
55
+ skip = strings.Contains(heading, "component") || strings.Contains(heading, "files")
56
+ }
57
+ if skip {
58
+ continue
59
+ }
60
+ switch {
61
+ case strings.HasPrefix(trimmed, "# "):
62
+ body = append(body, styles.EpicTitleFocused.Render(strings.TrimPrefix(trimmed, "# ")))
63
+ case strings.HasPrefix(trimmed, "## "):
64
+ body = append(body, "", styles.EpicItemFocused.Render(strings.TrimPrefix(trimmed, "## ")))
65
+ case strings.HasPrefix(trimmed, "### "):
66
+ body = append(body, styles.EpicItemFocused.Render(strings.TrimPrefix(trimmed, "### ")))
67
+ case strings.HasPrefix(trimmed, "|"):
68
+ body = append(body, styles.CardMeta.Render(trimmed))
69
+ case trimmed == "":
70
+ body = append(body, "")
71
+ default:
72
+ for _, wrapped := range WrapText(trimmed, width) {
73
+ body = append(body, styles.CardMeta.Render(wrapped))
74
+ }
75
+ }
76
+ }
77
+ return body
78
+ }
79
+
9
80
  const epicActiveMarker = "►"
10
81
 
11
82
  // RenderEpicSidebar renders the fixed left sidebar listing epics with active indicator.
12
83
  // If epics is empty and selected is non-empty, selected is shown as the sole entry.
13
- func RenderEpicSidebar(epics []string, selected string, width int) string {
84
+ func RenderEpicSidebar(epics []string, selected string, width int, focus bool, cursor int, status map[string]string) string {
14
85
  inner := width - epicPanelOverhead
15
86
  if inner < 2 {
16
87
  inner = 2
@@ -20,19 +91,45 @@ func RenderEpicSidebar(epics []string, selected string, width int) string {
20
91
  list = []string{selected}
21
92
  }
22
93
 
23
- lines := []string{styles.ColumnTitle.Render("EPICS"), strings.Repeat("─", inner)}
24
- for _, e := range list {
25
- label := truncate(e, inner-2)
26
- if e == selected {
27
- lines = append(lines, styles.TaskItemFocused.Render(epicActiveMarker+" "+label))
94
+ title := styles.ColumnTitle.Render("EPICS")
95
+ if focus {
96
+ title = styles.EpicTitleFocused.Render("EPICS")
97
+ }
98
+ lines := []string{title, strings.Repeat("─", inner)}
99
+ for i, e := range list {
100
+ g := epicSidebarGlyph(status, e)
101
+ gw := lipgloss.Width(g)
102
+ if gw < 1 {
103
+ gw = 1
104
+ }
105
+ label := truncate(e, inner-2-gw)
106
+ if focus && len(epics) > 0 && i == cursor {
107
+ lines = append(lines, styles.EpicItemFocused.Render(epicActiveMarker+" "+g+" "+label))
108
+ } else if !focus && e == selected {
109
+ lines = append(lines, styles.EpicItemFocused.Render(epicActiveMarker+" "+g+" "+label))
28
110
  } else {
29
- lines = append(lines, styles.TaskItem.Render(" "+label))
111
+ lines = append(lines, styles.TaskItem.Render(" "+g+" "+label))
30
112
  }
31
113
  }
32
114
  if len(list) == 0 {
33
115
  lines = append(lines, styles.TaskItem.Render("(none)"))
34
116
  }
35
- return styles.EpicPanel.Width(width).Render(strings.Join(lines, "\n"))
117
+ style := styles.EpicPanel.Width(width)
118
+ if focus && len(epics) > 0 {
119
+ style = styles.EpicPanelFocused.Width(width)
120
+ }
121
+ return style.Render(strings.Join(lines, "\n"))
122
+ }
123
+
124
+ func epicSidebarGlyph(status map[string]string, epicID string) string {
125
+ if status == nil {
126
+ return statusGlyphDefault
127
+ }
128
+ s, ok := status[epicID]
129
+ if !ok {
130
+ return statusGlyphDefault
131
+ }
132
+ return statusGlyph(s)
36
133
  }
37
134
 
38
135
  // RenderEpicDropdown renders the epic selection dropdown overlay.