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
@@ -5,11 +5,12 @@ import (
5
5
  "testing"
6
6
 
7
7
  "github.com/opencode/savepoint/internal/data"
8
+ "github.com/opencode/savepoint/internal/styles"
8
9
  )
9
10
 
10
11
  func TestRenderCard_containsID(t *testing.T) {
11
12
  task := data.Task{ID: "E04/T002", Title: "Build card", Stage: data.StageBuild}
12
- got := RenderCard(task, 30, false)
13
+ got := RenderCard(task, 30, false, nil)
13
14
  if !strings.Contains(got, "T002") {
14
15
  t.Error("RenderCard missing short task ID")
15
16
  }
@@ -17,7 +18,7 @@ func TestRenderCard_containsID(t *testing.T) {
17
18
 
18
19
  func TestRenderCard_containsTitle(t *testing.T) {
19
20
  task := data.Task{ID: "T1", Title: "My title", Stage: data.StageBuild}
20
- got := RenderCard(task, 30, false)
21
+ got := RenderCard(task, 30, false, nil)
21
22
  if !strings.Contains(got, "My title") {
22
23
  t.Error("RenderCard missing task title")
23
24
  }
@@ -25,7 +26,7 @@ func TestRenderCard_containsTitle(t *testing.T) {
25
26
 
26
27
  func TestRenderCard_containsBuildGlyph(t *testing.T) {
27
28
  task := data.Task{ID: "T1", Stage: data.StageBuild}
28
- got := RenderCard(task, 30, false)
29
+ got := RenderCard(task, 30, false, nil)
29
30
  if !strings.Contains(got, glyphBuild) {
30
31
  t.Errorf("RenderCard missing build glyph %q", glyphBuild)
31
32
  }
@@ -33,7 +34,7 @@ func TestRenderCard_containsBuildGlyph(t *testing.T) {
33
34
 
34
35
  func TestRenderCard_containsTestGlyph(t *testing.T) {
35
36
  task := data.Task{ID: "T1", Stage: data.StageTest}
36
- got := RenderCard(task, 30, false)
37
+ got := RenderCard(task, 30, false, nil)
37
38
  if !strings.Contains(got, glyphTest) {
38
39
  t.Errorf("RenderCard missing test glyph %q", glyphTest)
39
40
  }
@@ -41,7 +42,7 @@ func TestRenderCard_containsTestGlyph(t *testing.T) {
41
42
 
42
43
  func TestRenderCard_containsAuditGlyph(t *testing.T) {
43
44
  task := data.Task{ID: "T1", Stage: data.StageAudit}
44
- got := RenderCard(task, 30, false)
45
+ got := RenderCard(task, 30, false, nil)
45
46
  if !strings.Contains(got, glyphAudit) {
46
47
  t.Errorf("RenderCard missing audit glyph %q", glyphAudit)
47
48
  }
@@ -49,28 +50,30 @@ func TestRenderCard_containsAuditGlyph(t *testing.T) {
49
50
 
50
51
  func TestRenderCard_focusedDoesNotPanic(t *testing.T) {
51
52
  task := data.Task{ID: "T1", Title: "hello", Stage: data.StageBuild}
52
- got := RenderCard(task, 30, true)
53
+ got := RenderCard(task, 30, true, nil)
53
54
  if got == "" {
54
55
  t.Error("RenderCard focused returned empty string")
55
56
  }
56
57
  }
57
58
 
58
- func TestRenderCard_titleTruncated(t *testing.T) {
59
- long := "This is a very long title that should be truncated for sure"
59
+ func TestRenderCard_titleWraps(t *testing.T) {
60
+ long := "This is a very long title that should be wrapped for sure"
60
61
  task := data.Task{ID: "T1", Title: long, Stage: data.StageBuild}
61
- got := RenderCard(task, 20, false)
62
+ got := RenderCard(task, 20, false, nil)
63
+ // full title as one line does not fit; it must be broken up
62
64
  if strings.Contains(got, long) {
63
- t.Error("RenderCard should truncate long title")
65
+ t.Error("RenderCard should wrap long title, not render it as one line")
64
66
  }
65
- if !strings.Contains(got, "…") {
66
- t.Error("RenderCard should include ellipsis when title truncated")
67
+ // words from the title must still appear somewhere in the output
68
+ if !strings.Contains(got, "This") {
69
+ t.Error("RenderCard wrapped title missing expected content")
67
70
  }
68
71
  }
69
72
 
70
73
  func TestRenderCard_idTruncated(t *testing.T) {
71
74
  long := "E04-board-components/T999-very-long-id"
72
75
  task := data.Task{ID: long, Stage: data.StageBuild}
73
- got := RenderCard(task, 20, false)
76
+ got := RenderCard(task, 20, false, nil)
74
77
  if strings.Contains(got, long) {
75
78
  t.Error("RenderCard should truncate long ID")
76
79
  }
@@ -104,8 +107,112 @@ func TestTruncate_maxOne(t *testing.T) {
104
107
 
105
108
  func TestRenderCard_defaultStageUsesBuildGlyph(t *testing.T) {
106
109
  task := data.Task{ID: "T1", Stage: ""}
107
- got := RenderCard(task, 30, false)
110
+ got := RenderCard(task, 30, false, nil)
108
111
  if !strings.Contains(got, glyphBuild) {
109
112
  t.Error("RenderCard with empty stage should use build glyph")
110
113
  }
111
114
  }
115
+
116
+ func TestRenderCard_routerPriorityUsesGreenGlyph(t *testing.T) {
117
+ task := data.Task{ID: "E06/T009", Release: "v1", Epic: "E06", Stage: data.StageTest}
118
+ router := &data.RouterState{Release: "v1", Epic: "E06", Task: "E06/T009"}
119
+ got := RenderCard(task, 30, false, router)
120
+ if !isRouterPriority(task, router) {
121
+ t.Error("router priority should match release, epic, and task")
122
+ }
123
+ if !strings.Contains(got, glyphBuild) {
124
+ t.Error("router priority card should use build glyph")
125
+ }
126
+ nonPriority := RenderCard(task, 30, false, nil)
127
+ if !strings.Contains(nonPriority, glyphTest) {
128
+ t.Error("non-priority test card should use test glyph")
129
+ }
130
+ }
131
+
132
+ func TestRenderCard_noBackgroundFillEscapes(t *testing.T) {
133
+ task := data.Task{ID: "E06/T009", Title: "Router priority", Release: "v1", Epic: "E06", Stage: data.StageTest}
134
+ router := &data.RouterState{Release: "v1", Epic: "E06", Task: "E06/T009"}
135
+ got := RenderCard(task, 30, false, router)
136
+ if strings.Contains(got, "\x1b[48;") || strings.Contains(got, "\x1b[40m") {
137
+ t.Fatalf("RenderCard should not emit background fills; got %q", got)
138
+ }
139
+ }
140
+
141
+ func TestRenderCard_routerPriorityMatchesShortID(t *testing.T) {
142
+ // Router stores short IDs ("T009"); task ID is full slug — must still match.
143
+ task := data.Task{ID: "E06-atari-noir-layout/T009-router-priority", Release: "v1", Epic: "E06-atari-noir-layout", Stage: data.StageTest}
144
+ router := &data.RouterState{Release: "v1", Epic: "E06", Task: "T009"}
145
+ got := RenderCard(task, 30, false, router)
146
+ if !isRouterPriority(task, router) {
147
+ t.Error("short router task ID should match full task ID slug")
148
+ }
149
+ if !strings.Contains(got, glyphBuild) {
150
+ t.Error("router priority card should use build glyph")
151
+ }
152
+ }
153
+
154
+ func TestRenderCard_staleRouterTaskNoMatch(t *testing.T) {
155
+ // Task moved to a new epic; router still has old epic path — should NOT match a different task number.
156
+ task := data.Task{ID: "E03-header-activity/T001-border-resize-fix", Release: "v1", Epic: "E03-header-activity", Stage: data.StageBuild}
157
+ router := &data.RouterState{Release: "v1", Epic: "E03", Task: "T002"}
158
+ got := RenderCard(task, 30, false, router)
159
+ if isRouterPriority(task, router) {
160
+ t.Error("stale router pointing to different task number should not show green glyph")
161
+ }
162
+ if !strings.Contains(got, styles.GlyphBuild.Render(glyphBuild)) {
163
+ t.Error("non-priority build task should use orange build glyph")
164
+ }
165
+ }
166
+
167
+ func TestRenderCard_routerSameTaskNumberDifferentEpicNoMatch(t *testing.T) {
168
+ task := data.Task{ID: "E03-header-activity/T001-border-resize-fix", Release: "v1", Epic: "E03-header-activity", Stage: data.StageTest}
169
+ router := &data.RouterState{Release: "v1", Epic: "E01", Task: "T001"}
170
+ got := RenderCard(task, 30, false, router)
171
+ if isRouterPriority(task, router) {
172
+ t.Error("router priority should not match same task number in a different epic")
173
+ }
174
+ if !strings.Contains(got, styles.GlyphTest.Render(glyphTest)) {
175
+ t.Error("non-priority test task should keep test glyph")
176
+ }
177
+ }
178
+
179
+ func TestRenderCard_doneTaskUsesOrangeBuildGlyph(t *testing.T) {
180
+ task := data.Task{ID: "E03/T001", Release: "v1", Epic: "E03", Column: data.ColumnDone, Stage: data.StageTest}
181
+ router := &data.RouterState{Release: "v1", Epic: "E03", Task: "T001"}
182
+ got := RenderCard(task, 30, false, router)
183
+ if !isRouterPriority(task, router) {
184
+ t.Error("router state should still identify the matching done task")
185
+ }
186
+ if !strings.Contains(got, styles.GlyphBuild.Render(glyphBuild)) {
187
+ t.Error("done task should use orange build glyph")
188
+ }
189
+ if strings.Contains(got, glyphTest) {
190
+ t.Error("done task should not use test glyph")
191
+ }
192
+ }
193
+
194
+ func TestRenderCard_explicitStatusUsesUnifiedGlyph(t *testing.T) {
195
+ tests := []struct {
196
+ name string
197
+ status data.TaskStatus
198
+ glyph string
199
+ }{
200
+ {"planned", data.StatusPlanned, "○"},
201
+ {"in progress", data.StatusInProgress, "▶"},
202
+ {"done", data.StatusDone, "◉"},
203
+ {"audited", data.StatusAudited, "✓"},
204
+ }
205
+
206
+ for _, tt := range tests {
207
+ t.Run(tt.name, func(t *testing.T) {
208
+ task := data.Task{ID: "T1", Status: string(tt.status), Stage: data.StageAudit}
209
+ got := RenderCard(task, 30, false, nil)
210
+ if !strings.Contains(got, tt.glyph) {
211
+ t.Errorf("RenderCard with status %q missing glyph %q", tt.status, tt.glyph)
212
+ }
213
+ if strings.Contains(got, glyphAudit) {
214
+ t.Errorf("RenderCard with status %q should not fall back to audit glyph", tt.status)
215
+ }
216
+ })
217
+ }
218
+ }
@@ -8,12 +8,13 @@ import (
8
8
  "github.com/opencode/savepoint/internal/styles"
9
9
  )
10
10
 
11
- // RenderColumn renders a board column: header with label+count, task list, bordered container.
12
- func RenderColumn(tasks []data.Task, col data.ColumnType, width, focusedTask int, focused bool) string {
11
+ // RenderColumn renders a board column: header with label+count, task viewport, bordered container.
12
+ func RenderColumn(tasks []data.Task, col data.ColumnType, width, maxHeight, offset, focusedTask int, focused bool, routerState *data.RouterState) string {
13
13
  inner := width - colOverhead
14
14
  if inner < minColWidth {
15
15
  inner = minColWidth
16
16
  }
17
+ offset = clampViewportOffset(offset, len(tasks))
17
18
 
18
19
  title := columnTitle(col)
19
20
  header := fmt.Sprintf("%s (%d)", title, len(tasks))
@@ -27,19 +28,53 @@ func RenderColumn(tasks []data.Task, col data.ColumnType, width, focusedTask int
27
28
  if len(tasks) == 0 {
28
29
  lines = append(lines, styles.TaskItem.Render("(empty)"))
29
30
  } else {
30
- for i, t := range tasks {
31
- lines = append(lines, RenderCard(t, inner, focused && i == focusedTask))
31
+ limit := visibleColumnTaskLimit(maxHeight)
32
+ end := min(offset+limit, len(tasks))
33
+ if offset > 0 {
34
+ lines = append(lines, renderScrollIndicator("↑", offset, "above"))
35
+ }
36
+ for i, t := range tasks[offset:end] {
37
+ taskIndex := offset + i
38
+ lines = append(lines, RenderCard(t, inner, focused && taskIndex == focusedTask, routerState))
39
+ }
40
+ if end < len(tasks) {
41
+ lines = append(lines, renderScrollIndicator("↓", len(tasks)-end, "more"))
32
42
  }
33
43
  }
34
44
 
35
45
  content := strings.Join(lines, "\n")
36
- st := styles.Column.Width(width)
46
+ st := styles.ColumnUnfocused.Width(width)
37
47
  if focused {
38
48
  st = styles.ColumnFocused.Width(width)
39
49
  }
40
50
  return st.Render(content)
41
51
  }
42
52
 
53
+ func visibleColumnTaskLimit(maxHeight int) int {
54
+ if maxHeight <= 0 {
55
+ return 999999
56
+ }
57
+ limit := (maxHeight - 2) / 3
58
+ if limit < 1 {
59
+ return 1
60
+ }
61
+ return limit
62
+ }
63
+
64
+ func clampViewportOffset(offset, total int) int {
65
+ if offset < 0 || total <= 0 {
66
+ return 0
67
+ }
68
+ if offset >= total {
69
+ return total - 1
70
+ }
71
+ return offset
72
+ }
73
+
74
+ func renderScrollIndicator(arrow string, count int, suffix string) string {
75
+ return styles.ScrollIndicator.Render(fmt.Sprintf("%s %d %s", arrow, count, suffix))
76
+ }
77
+
43
78
  func columnTitle(col data.ColumnType) string {
44
79
  switch col {
45
80
  case data.ColumnPlanned:
@@ -4,11 +4,12 @@ import (
4
4
  "strings"
5
5
  "testing"
6
6
 
7
+ "github.com/charmbracelet/lipgloss"
7
8
  "github.com/opencode/savepoint/internal/data"
8
9
  )
9
10
 
10
11
  func TestRenderColumn_headerContainsLabel(t *testing.T) {
11
- got := RenderColumn(nil, data.ColumnPlanned, 30, 0, false)
12
+ got := RenderColumn(nil, data.ColumnPlanned, 30, 0, 0, 0, false, nil)
12
13
  if !strings.Contains(got, "PLANNED") {
13
14
  t.Error("RenderColumn missing PLANNED label")
14
15
  }
@@ -16,14 +17,14 @@ func TestRenderColumn_headerContainsLabel(t *testing.T) {
16
17
 
17
18
  func TestRenderColumn_headerContainsCount(t *testing.T) {
18
19
  tasks := []data.Task{{ID: "T1", Title: "Task one", Column: data.ColumnPlanned}}
19
- got := RenderColumn(tasks, data.ColumnPlanned, 30, 0, false)
20
+ got := RenderColumn(tasks, data.ColumnPlanned, 30, 0, 0, 0, false, nil)
20
21
  if !strings.Contains(got, "(1)") {
21
22
  t.Error("RenderColumn missing task count")
22
23
  }
23
24
  }
24
25
 
25
26
  func TestRenderColumn_emptyShowsPlaceholder(t *testing.T) {
26
- got := RenderColumn(nil, data.ColumnDone, 30, 0, false)
27
+ got := RenderColumn(nil, data.ColumnDone, 30, 0, 0, 0, false, nil)
27
28
  if !strings.Contains(got, "(empty)") {
28
29
  t.Error("RenderColumn missing (empty) for empty column")
29
30
  }
@@ -31,7 +32,7 @@ func TestRenderColumn_emptyShowsPlaceholder(t *testing.T) {
31
32
 
32
33
  func TestRenderColumn_focusedDoesNotPanic(t *testing.T) {
33
34
  tasks := []data.Task{{ID: "T1", Column: data.ColumnInProgress}}
34
- got := RenderColumn(tasks, data.ColumnInProgress, 30, 0, true)
35
+ got := RenderColumn(tasks, data.ColumnInProgress, 30, 0, 0, 0, true, nil)
35
36
  if got == "" {
36
37
  t.Error("RenderColumn returned empty string for focused column")
37
38
  }
@@ -47,7 +48,7 @@ func TestRenderColumn_allColumnTitles(t *testing.T) {
47
48
  {data.ColumnDone, "DONE"},
48
49
  }
49
50
  for _, tc := range cases {
50
- got := RenderColumn(nil, tc.col, 30, 0, false)
51
+ got := RenderColumn(nil, tc.col, 30, 0, 0, 0, false, nil)
51
52
  if !strings.Contains(got, tc.label) {
52
53
  t.Errorf("RenderColumn missing label %q for col %q", tc.label, tc.col)
53
54
  }
@@ -56,7 +57,7 @@ func TestRenderColumn_allColumnTitles(t *testing.T) {
56
57
 
57
58
  func TestRenderColumn_taskTitleRendered(t *testing.T) {
58
59
  tasks := []data.Task{{ID: "T2", Title: "Build it", Column: data.ColumnPlanned}}
59
- got := RenderColumn(tasks, data.ColumnPlanned, 30, 0, false)
60
+ got := RenderColumn(tasks, data.ColumnPlanned, 30, 0, 0, 0, false, nil)
60
61
  if !strings.Contains(got, "Build it") {
61
62
  t.Error("RenderColumn missing task title")
62
63
  }
@@ -64,18 +65,72 @@ func TestRenderColumn_taskTitleRendered(t *testing.T) {
64
65
 
65
66
  func TestRenderColumn_rendersTaskCards(t *testing.T) {
66
67
  tasks := []data.Task{{ID: "T2", Title: "Build it", Column: data.ColumnPlanned, Stage: data.StageAudit}}
67
- got := RenderColumn(tasks, data.ColumnPlanned, 30, 0, true)
68
+ got := RenderColumn(tasks, data.ColumnPlanned, 30, 0, 0, 0, true, nil)
68
69
  if !strings.Contains(got, glyphAudit) {
69
70
  t.Error("RenderColumn should render task phase glyph from card")
70
71
  }
71
- if !strings.Contains(got, "") {
72
- t.Error("RenderColumn should render bordered card")
72
+ if !strings.Contains(got, "") {
73
+ t.Error("RenderColumn should render focused card border")
74
+ }
75
+ }
76
+
77
+ func TestRenderColumn_focusStatesUseStableBorderDimensions(t *testing.T) {
78
+ tasks := []data.Task{{ID: "T2", Title: "Build it", Column: data.ColumnPlanned, Stage: data.StageAudit}}
79
+ unfocused := RenderColumn(tasks, data.ColumnPlanned, 30, 0, 0, -1, false, nil)
80
+ focused := RenderColumn(tasks, data.ColumnPlanned, 30, 0, 0, -1, true, nil)
81
+
82
+ if !strings.Contains(unfocused, "┌") {
83
+ t.Error("unfocused column should render a single-line border")
84
+ }
85
+ if !strings.Contains(focused, "┌") {
86
+ t.Error("focused column should render a single-line border")
87
+ }
88
+
89
+ unfocusedLines := strings.Split(unfocused, "\n")
90
+ focusedLines := strings.Split(focused, "\n")
91
+ if len(unfocusedLines) != len(focusedLines) {
92
+ t.Fatalf("line count changed between focus states: unfocused=%d focused=%d", len(unfocusedLines), len(focusedLines))
93
+ }
94
+ for i := range unfocusedLines {
95
+ if lipgloss.Width(unfocusedLines[i]) != lipgloss.Width(focusedLines[i]) {
96
+ t.Fatalf("line %d width changed between focus states: unfocused=%d focused=%d", i, lipgloss.Width(unfocusedLines[i]), lipgloss.Width(focusedLines[i]))
97
+ }
73
98
  }
74
99
  }
75
100
 
76
101
  func TestRenderColumn_emptyCountIsZero(t *testing.T) {
77
- got := RenderColumn(nil, data.ColumnPlanned, 30, 0, false)
102
+ got := RenderColumn(nil, data.ColumnPlanned, 30, 0, 0, 0, false, nil)
78
103
  if !strings.Contains(got, "(0)") {
79
104
  t.Error("RenderColumn missing (0) count for empty column")
80
105
  }
81
106
  }
107
+
108
+ func TestRenderColumn_viewportShowsScrollIndicators(t *testing.T) {
109
+ tasks := []data.Task{
110
+ {ID: "T1", Title: "Task one", Column: data.ColumnPlanned},
111
+ {ID: "T2", Title: "Task two", Column: data.ColumnPlanned},
112
+ {ID: "T3", Title: "Task three", Column: data.ColumnPlanned},
113
+ {ID: "T4", Title: "Task four", Column: data.ColumnPlanned},
114
+ }
115
+
116
+ got := RenderColumn(tasks, data.ColumnPlanned, 30, 8, 1, 1, true, nil)
117
+
118
+ if !strings.Contains(got, "↑ 1 above") {
119
+ t.Error("RenderColumn missing above indicator")
120
+ }
121
+ if !strings.Contains(got, "↓ 1 more") {
122
+ t.Error("RenderColumn missing more indicator")
123
+ }
124
+ if strings.Contains(got, "Task one") {
125
+ t.Error("RenderColumn should not render tasks above viewport")
126
+ }
127
+ if strings.Contains(got, "Task four") {
128
+ t.Error("RenderColumn should not render tasks below viewport")
129
+ }
130
+ }
131
+
132
+ func TestVisibleColumnTaskLimitDefaultsToFourAtStandardHeight(t *testing.T) {
133
+ if got := visibleColumnTaskLimit(CalculateLayout(100, 24).ContentHeight); got != 4 {
134
+ t.Errorf("visibleColumnTaskLimit(standard height) = %d, want 4", got)
135
+ }
136
+ }
@@ -3,14 +3,17 @@ package board
3
3
  import (
4
4
  "strings"
5
5
 
6
+ "github.com/charmbracelet/lipgloss"
6
7
  "github.com/opencode/savepoint/internal/data"
7
8
  "github.com/opencode/savepoint/internal/styles"
8
9
  )
9
10
 
10
- const detailBorderPad = 4 // rounded border (2) + padding (2×1)
11
+ const detailBorderPad = 4 // rounded border (2) + padding (2×1)
12
+ const detailVerticalOverhead = 4 // overlay border (2) + fixed title/header rows (2)
11
13
 
12
14
  // RenderDetail renders a task detail overlay panel at the given display width.
13
- func RenderDetail(t data.Task, overlayW int) string {
15
+ // When router state matches t's release/epic/task, a "(router priority)" label is shown.
16
+ func RenderDetail(t data.Task, overlayW int, routerState *data.RouterState, maxHeight, offset int) string {
14
17
  inner := overlayW - detailBorderPad
15
18
  if inner < 4 {
16
19
  inner = 4
@@ -20,51 +23,140 @@ func RenderDetail(t data.Task, overlayW int) string {
20
23
  styles.ColumnTitleFocused.Render("TASK DETAIL"),
21
24
  strings.Repeat("─", inner),
22
25
  }
23
- lines = append(lines,
26
+ body := []string{
24
27
  detailRow("ID", t.ID, inner),
25
28
  detailRow("Title", t.Title, inner),
26
29
  detailRow("Epic", t.Epic, inner),
27
30
  detailRow("Release", t.Release, inner),
28
31
  detailRow("Status", string(t.Column), inner),
29
32
  detailRow("Phase", phaseLabel(t.Stage), inner),
30
- )
33
+ }
31
34
 
32
35
  if t.Description != "" {
33
- lines = append(lines,
36
+ body = append(body,
34
37
  "",
35
38
  styles.ColumnTitle.Render("Description:"),
36
39
  )
37
- for _, line := range wrapText(t.Description, inner) {
38
- lines = append(lines, styles.CardMeta.Render(line))
40
+ for _, line := range WrapText(t.Description, inner) {
41
+ body = append(body, styles.CardMeta.Render(line))
39
42
  }
40
43
  }
41
44
 
42
- if len(t.Acceptance) > 0 {
43
- lines = append(lines, "", styles.ColumnTitle.Render("Acceptance Criteria:"))
44
- for _, a := range t.Acceptance {
45
- for _, line := range wrapText(a, inner-2) {
46
- lines = append(lines, styles.CardMeta.Render(" • "+line))
45
+ if len(t.Checklist) > 0 {
46
+ body = append(body, "", styles.ColumnTitle.Render("Implementation Plan:"), "")
47
+ for _, item := range t.Checklist {
48
+ glyph := "[ ] "
49
+ style := styles.CardMeta
50
+ if item.Done {
51
+ glyph = "[x] "
52
+ style = styles.TagDone
47
53
  }
54
+ body = append(body, renderChecklistSentences(item.Text, glyph, inner, style)...)
48
55
  }
49
56
  }
50
57
 
51
- if len(t.Checklist) > 0 {
52
- lines = append(lines, "", styles.ColumnTitle.Render("Implementation Plan:"))
53
- for _, item := range t.Checklist {
54
- for _, line := range wrapText(item, inner-2) {
55
- lines = append(lines, styles.CardMeta.Render(" □ "+line))
58
+ if t.Column != data.ColumnDone && isRouterPriority(t, routerState) {
59
+ body = append(body, "", styles.TagDone.Render("(router priority)"))
60
+ }
61
+ body = append(body, "", styles.CardMeta.Render("esc:close"))
62
+ lines = append(lines, visibleDetailLines(body, maxHeight-detailVerticalOverhead, offset)...)
63
+
64
+ return styles.DetailOverlay.Width(overlayW).Render(strings.Join(lines, "\n"))
65
+ }
66
+
67
+ func renderChecklistSentences(text, glyph string, width int, style lipgloss.Style) []string {
68
+ textWidth := width - len(glyph)
69
+ if textWidth < 4 {
70
+ textWidth = 4
71
+ }
72
+
73
+ lines := []string{}
74
+ continuationIndent := strings.Repeat(" ", len(glyph))
75
+ for _, sentence := range splitChecklistSentences(text) {
76
+ wrapped := WrapText(sentence, textWidth)
77
+ for i, line := range wrapped {
78
+ if i == 0 {
79
+ lines = append(lines, style.Render(glyph+line))
80
+ continue
56
81
  }
82
+ lines = append(lines, style.Render(continuationIndent+line))
57
83
  }
58
84
  }
85
+ return lines
86
+ }
59
87
 
60
- lines = append(lines, "", styles.CardMeta.Render("esc:close"))
88
+ func splitChecklistSentences(text string) []string {
89
+ fields := strings.Fields(text)
90
+ if len(fields) == 0 {
91
+ return nil
92
+ }
93
+ normalized := strings.Join(fields, " ")
61
94
 
62
- return styles.DetailOverlay.Width(overlayW).Render(strings.Join(lines, "\n"))
95
+ sentences := []string{}
96
+ start := 0
97
+ for i, r := range normalized {
98
+ if r != '.' && r != '!' && r != '?' {
99
+ continue
100
+ }
101
+ end := i + len(string(r))
102
+ if end < len(normalized) && normalized[end] != ' ' {
103
+ continue
104
+ }
105
+ sentence := strings.TrimSpace(normalized[start:end])
106
+ if sentence != "" {
107
+ sentences = append(sentences, sentence)
108
+ }
109
+ start = end
110
+ }
111
+ if tail := strings.TrimSpace(normalized[start:]); tail != "" {
112
+ sentences = append(sentences, tail)
113
+ }
114
+ return sentences
115
+ }
116
+
117
+ func visibleDetailLines(lines []string, maxBodyHeight, offset int) []string {
118
+ total := len(lines)
119
+ if maxBodyHeight <= 0 || total <= maxBodyHeight {
120
+ return lines
121
+ }
122
+ offset = clampDetailOffset(offset, total)
123
+ available := maxBodyHeight
124
+ if offset > 0 {
125
+ available--
126
+ }
127
+ if available < 1 {
128
+ available = 1
129
+ }
130
+ end := min(offset+available, total)
131
+ if end < total && available > 1 {
132
+ available--
133
+ end = min(offset+available, total)
134
+ }
135
+
136
+ visible := make([]string, 0, available+2)
137
+ if offset > 0 {
138
+ visible = append(visible, renderScrollIndicator("↑", offset, "above"))
139
+ }
140
+ visible = append(visible, lines[offset:end]...)
141
+ if end < total {
142
+ visible = append(visible, renderScrollIndicator("↓", total-end, "more"))
143
+ }
144
+ return visible
145
+ }
146
+
147
+ func clampDetailOffset(offset, total int) int {
148
+ if offset < 0 || total <= 0 {
149
+ return 0
150
+ }
151
+ if offset >= total {
152
+ return total - 1
153
+ }
154
+ return offset
63
155
  }
64
156
 
65
157
  func detailRow(label, value string, width int) string {
66
158
  prefix := label + ": "
67
- wrapped := wrapText(value, width-len(prefix))
159
+ wrapped := WrapText(value, width-len(prefix))
68
160
  if len(wrapped) == 0 {
69
161
  wrapped = []string{""}
70
162
  }
@@ -90,7 +182,7 @@ func phaseLabel(s data.ProgressStage) string {
90
182
  }
91
183
  }
92
184
 
93
- func wrapText(s string, width int) []string {
185
+ func WrapText(s string, width int) []string {
94
186
  if width < 4 {
95
187
  width = 4
96
188
  }
@@ -106,7 +198,7 @@ func wrapText(s string, width int) []string {
106
198
  lines = append(lines, current)
107
199
  current = ""
108
200
  }
109
- lines = append(lines, splitLongWord(word, width)...)
201
+ lines = append(lines, SplitLongWord(word, width)...)
110
202
  continue
111
203
  }
112
204
  if current == "" {
@@ -126,7 +218,7 @@ func wrapText(s string, width int) []string {
126
218
  return lines
127
219
  }
128
220
 
129
- func splitLongWord(word string, width int) []string {
221
+ func SplitLongWord(word string, width int) []string {
130
222
  runes := []rune(word)
131
223
  lines := []string{}
132
224
  for len(runes) > width {