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.
- package/.claude/settings.local.json +8 -1
- package/.savepoint/Design.md +26 -17
- package/.savepoint/audit/v1/E01/proposals.md +168 -0
- package/.savepoint/audit/v1/E01/snapshot.md +78 -0
- package/.savepoint/audit/{E01-go-setup → v1/E01-go-setup}/proposals.md +7 -7
- package/.savepoint/audit/{E01-go-setup → v1/E01-go-setup}/snapshot.md +2 -2
- package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/AGENTS.md +5 -5
- package/.savepoint/audit/{E02-data-readers → v1/E02-data-readers}/proposals.md +20 -20
- package/.savepoint/audit/{E02-data-readers → v1/E02-data-readers}/snapshot.md +1 -1
- package/.savepoint/audit/{E03-board-tui-core → v1/E03-board-tui-core}/proposals.md +11 -11
- package/.savepoint/audit/{E03-board-tui-core → v1/E03-board-tui-core}/snapshot.md +1 -1
- package/.savepoint/audit/{E04-board-components → v1/E04-board-components}/proposals.md +14 -14
- package/.savepoint/audit/{E04-board-components → v1/E04-board-components}/snapshot.md +1 -1
- package/.savepoint/audit/{E05-init-command → v1/E05-init-command}/snapshot.md +1 -1
- package/.savepoint/audit/{E05-phase-transitions → v1/E05-phase-transitions}/proposals.md +4 -4
- package/.savepoint/audit/{E05-phase-transitions → v1/E05-phase-transitions}/snapshot.md +1 -1
- package/.savepoint/audit/v1/E06-atari-noir-layout/proposals.md +130 -0
- package/.savepoint/audit/v1/E06-atari-noir-layout/snapshot.md +84 -0
- package/.savepoint/audit/{E07-audit-pipeline → v1/E07-audit-pipeline}/snapshot.md +6 -6
- package/.savepoint/audit/v1.1/E02-cross-platform-compatibility/proposals.md +114 -0
- package/.savepoint/audit/v1.1/E02-cross-platform-compatibility/snapshot.md +41 -0
- package/.savepoint/audit/v1.1/E04-epic-navigation/proposals.md +156 -0
- package/.savepoint/audit/v1.1/E04-epic-navigation/snapshot.md +48 -0
- package/.savepoint/config.yml +3 -3
- package/.savepoint/releases/v1/epics/E01-go-setup/tasks/T001-init-module.md +1 -1
- package/.savepoint/releases/v1/epics/E03-board-tui-core/tasks/T005-layout.md +1 -1
- package/.savepoint/releases/v1/epics/E04-board-components/tasks/T002-card.md +1 -1
- package/.savepoint/releases/v1/epics/E04-board-components/tasks/T006-help-overlay.md +1 -1
- package/.savepoint/releases/v1/epics/E06-atari-noir-layout/E06-Detail.md +62 -0
- package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T002-header-and-dividers.md +1 -1
- package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T003-footer-status-bar.md +1 -1
- package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T004-component-refinement.md +1 -1
- package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T007-detail-card-fixes.md +7 -7
- package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T008-checkbox-states.md +10 -8
- package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T009-router-priority-marker.md +16 -9
- package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T010-auto-refresh-watcher.md +27 -22
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/E01-Detail.md +40 -0
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T001-next-activity-header.md +56 -0
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T002-rename-epic-design-files.md +38 -0
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T003-rename-release-prd.md +28 -0
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T004-update-instruction-files.md +51 -0
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T005-update-cross-references.md +45 -0
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T006-column-and-detail-scrolling.md +68 -0
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T007-column-focus-border-stability.md +57 -0
- package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/E02-Detail.md +49 -0
- package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T001-fix-makefile.md +37 -0
- package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T002-linux-build-target.md +38 -0
- package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T003-macos-build-target.md +36 -0
- package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T004-smoke-tests-and-artifacts.md +59 -0
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Detail.md +32 -0
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T001-border-resize-fix.md +40 -0
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T002-next-activity-below-header.md +64 -0
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T003-checkbox-rendering-fix.md +56 -0
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T005-unify-status-glyphs.md +65 -0
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T006-forced-256-color-profile.md +36 -0
- package/.savepoint/releases/v1.1/epics/E04-epic-navigation/E04-Detail.md +51 -0
- package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T001-sidebar-focusable-navigation.md +65 -0
- package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T002-epic-detail-overlay.md +73 -0
- package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T003-epic-status-glyphs.md +73 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Detail.md +45 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T001-update-agents-md.md +34 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T002-update-router-md.md +30 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T003-update-design-md.md +33 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T004-implement-m-hotkey.md +88 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T005-update-help-overlay.md +30 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T006-tests-and-quality-gates.md +46 -0
- package/.savepoint/releases/v1.1/v1.1-PRD.md +79 -0
- package/.savepoint/router.md +33 -105
- package/.savepoint/visual-identity.md +4 -3
- package/AGENTS.md +56 -113
- package/Makefile +19 -3
- package/README.md +7 -6
- package/agent-skills/savepoint-audit/SKILL.md +6 -6
- package/agent-skills/savepoint-build-task/SKILL.md +2 -2
- package/agent-skills/savepoint-create-plan/SKILL.md +3 -3
- package/agent-skills/savepoint-create-task/SKILL.md +2 -2
- package/agent-skills/savepoint-draft-prd/SKILL.md +1 -1
- package/agent-skills/savepoint-system-design/SKILL.md +1 -1
- package/go.mod +4 -1
- package/go.sum +2 -0
- package/internal/board/board.go +66 -14
- package/internal/board/board_test.go +124 -0
- package/internal/board/card.go +40 -3
- package/internal/board/card_test.go +121 -14
- package/internal/board/column.go +40 -5
- package/internal/board/column_test.go +65 -10
- package/internal/board/detail.go +115 -23
- package/internal/board/detail_test.go +132 -25
- package/internal/board/epic_panel.go +105 -8
- package/internal/board/epic_panel_test.go +343 -5
- package/internal/board/layout.go +12 -2
- package/internal/board/layout_test.go +17 -0
- package/internal/board/model.go +146 -23
- package/internal/board/render_policy_test.go +77 -0
- package/internal/board/status.go +23 -0
- package/internal/board/update.go +300 -9
- package/internal/board/update_test.go +166 -0
- package/internal/board/view.go +141 -17
- package/internal/board/view_test.go +161 -3
- package/internal/board/watch.go +100 -0
- package/internal/buildtool/main.go +219 -0
- package/internal/data/parser.go +39 -1
- package/internal/data/parser_test.go +43 -2
- package/internal/data/task.go +22 -2
- package/internal/styles/palette.go +9 -7
- package/internal/styles/styles.go +42 -25
- package/main.go +9 -0
- package/package.json +5 -4
- package/savepoint +0 -0
- package/savepoint.exe +0 -0
- package/templates/project/.savepoint/router.md +6 -5
- package/templates/project/AGENTS.md +47 -101
- package/templates/prompts/audit-reconciliation.prompt.md +6 -6
- package/templates/prompts/epic-design.prompt.md +3 -3
- package/templates/prompts/task-breakdown.prompt.md +1 -1
- package/templates/prompts/task-building.prompt.md +1 -1
- package/templates/prompts/task-planning.prompt.md +1 -1
- package/.savepoint/releases/v1/epics/E06-atari-noir-layout/Design.md +0 -42
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/Design.md +0 -26
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T001-border-resize-fix.md +0 -35
- package/main.exe +0 -0
- /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/Design.md +0 -0
- /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/epic-Design.md +0 -0
- /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/quality-review.md +0 -0
- /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/snapshot.md +0 -0
- /package/.savepoint/audit/{E02-data-model → v1/E02-data-model}/snapshot.md +0 -0
- /package/.savepoint/audit/{E03-cli-foundation → v1/E03-cli-foundation}/snapshot.md +0 -0
- /package/.savepoint/audit/{E04-templates-and-prompts → v1/E04-templates-and-prompts}/snapshot.md +0 -0
- /package/.savepoint/audit/{E06-tui-board → v1/E06-tui-board}/snapshot.md +0 -0
- /package/.savepoint/audit/{E08-board-workflow-cleanup → v1/E08-board-workflow-cleanup}/snapshot.md +0 -0
- /package/.savepoint/releases/v1/epics/E01-go-setup/{Design.md → E01-Detail.md} +0 -0
- /package/.savepoint/releases/v1/epics/E02-data-readers/{Design.md → E02-Detail.md} +0 -0
- /package/.savepoint/releases/v1/epics/E03-board-tui-core/{Design.md → E03-Detail.md} +0 -0
- /package/.savepoint/releases/v1/epics/E04-board-components/{Design.md → E04-Detail.md} +0 -0
- /package/.savepoint/releases/v1/epics/E05-phase-transitions/{Design.md → E05-Detail.md} +0 -0
- /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
|
+
}
|
package/internal/board/layout.go
CHANGED
|
@@ -3,7 +3,8 @@ package board
|
|
|
3
3
|
const (
|
|
4
4
|
colOverhead = 4 // rounded border (1) + padding (1) each side
|
|
5
5
|
|
|
6
|
-
minColWidth
|
|
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,
|
|
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
|