Skip to content

Commit 9cfed54

Browse files
authored
feat(tui): restyle the board view (#141) (#148)
Migrate the board onto the theme and widget foundation from #146, per the binding spec docs/design/tui-design-spec.md. Depth is carried entirely by background shade: Canvas page ground, Surface column panels and footer, Card and Raised surfaces, Zebra striping at compact density. No border is drawn anywhere on the board. Column header bands sit on the Raised tier with the column hue, and fill solid with that hue when the column is focused. Cards are borderless with a priority-hued rail that thickens on selection, a title with a right-aligned sequence, a muted description snippet, pill-capped meta chips and label pills. Compaction fires on both axes and drops the description, page padding, meta line, card gutter, inner padding and pill end caps together. board_view.go no longer constructs a lipgloss style: its priority, chip, label and status hexes are gone, and its seam allowlist entry with them. Interaction is unchanged: the v1.0.1 keymap, mouse hit regions, drag-and-drop, per-column scrolling and pointer press feedback all keep their contracts; only the coordinates the layout produces moved.
1 parent 1ba39a9 commit 9cfed54

17 files changed

Lines changed: 885 additions & 398 deletions

internal/tui/board_view.go

Lines changed: 417 additions & 323 deletions
Large diffs are not rendered by default.

internal/tui/board_view_test.go

Lines changed: 196 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package tui
22

33
import (
4+
"bytes"
45
"errors"
56
"fmt"
67
"image/color"
8+
"io"
79
"os"
810
"path/filepath"
911
"reflect"
@@ -12,10 +14,12 @@ import (
1214
"time"
1315

1416
tea "charm.land/bubbletea/v2"
15-
"charm.land/lipgloss/v2"
1617
"github.com/charmbracelet/x/ansi"
18+
"github.com/charmbracelet/x/exp/teatest/v2"
1719

1820
"github.com/RandomCodeSpace/kb/internal/board"
21+
"github.com/RandomCodeSpace/kb/internal/tui/theme"
22+
"github.com/RandomCodeSpace/kb/internal/tui/widget"
1923
)
2024

2125
func boardViewFixture(now time.Time) board.Board {
@@ -81,28 +85,34 @@ func TestDueChipParity(t *testing.T) {
8185
}
8286
}
8387

88+
// TestLabelColorUsesWebHash pins the label wheel the board has always used.
89+
// The hash and the pill vocabulary now live in the widget package (spec
90+
// sections 1.6 and 3.5), but the colors a tag lands on are unchanged.
8491
func TestLabelColorUsesWebHash(t *testing.T) {
85-
tests := []struct {
92+
styles := theme.New(true)
93+
for _, tag := range []string{
94+
"backend",
95+
"🙂", // JavaScript length is two UTF-16 units.
96+
"",
97+
} {
98+
if got := theme.LabelSlot(widget.LabelWheel(tag)); got != theme.Label1 {
99+
t.Errorf("label slot(%q) = %v, want %v", tag, got, theme.Label1)
100+
}
101+
}
102+
for _, test := range []struct {
86103
tag string
87-
want color.Color
104+
want string
88105
}{
89-
{"backend", lipgloss.Color("#ff7b54")},
90-
{"🙂", lipgloss.Color("#ff7b54")}, // JavaScript length is two UTF-16 units.
91-
{"", lipgloss.Color("#ff7b54")},
92-
}
93-
for _, test := range tests {
94-
if got := labelColor(test.tag); !reflect.DeepEqual(got, test.want) {
95-
t.Errorf("labelColor(%q) = %v, want %v", test.tag, got, test.want)
106+
{"type::feature", "▐type:feature▌"},
107+
{"backend", "▐#backend▌"},
108+
{"broken::", "▐#broken::▌"},
109+
} {
110+
if got := plain(widget.Label(styles, test.tag, theme.Card, false)); got != test.want {
111+
t.Errorf("label pill(%q) = %q, want %q", test.tag, got, test.want)
96112
}
97113
}
98-
if got := plain(labelChip("type::feature")); got != "[type:feature]" {
99-
t.Fatalf("scoped chip = %q", got)
100-
}
101-
if got := plain(labelChip("backend")); got != "[#backend]" {
102-
t.Fatalf("plain chip = %q", got)
103-
}
104-
if got := plain(labelChip("broken::")); got != "[#broken::]" {
105-
t.Fatalf("empty scoped value chip = %q", got)
114+
if got := plain(widget.Label(styles, "type::feature", theme.Card, true)); got != "feature" {
115+
t.Fatalf("compact scoped label = %q", got)
106116
}
107117
}
108118

@@ -239,20 +249,32 @@ func TestBoardRenderResponsiveFullCardsAndMouse(t *testing.T) {
239249
m.board = boardViewFixture(now)
240250
m.now = func() time.Time { return now }
241251
m.renderedAt = now
242-
m.width, m.height = 160, 22
252+
m.width, m.height = 160, 40
243253
m.boardView.showCancelled = true
244254

245255
content, hits := m.renderBoard()
246256
text := plain(content)
247257
for _, want := range []string{
248-
"[1 TO DO 2]", "2 DOING 1", "3 DONE 1", "4 CANCELLED 1",
249-
"🚀 Ship terminal board", "#7", "new", "P1", "[⛔ blocked]", "[today]", "[M]", "[#backend]", "[type:feature]",
258+
"1 TO DO", "2 DOING", "3 DONE", "4 CANCELLED", "2 cards · 1 blocked",
259+
"🚀 Ship terminal board", "#7", "new", "P1", "blocked", "today", "◇M", "#backend", "type:feature",
250260
"3h here", "shipped", "1d old", "c cancelled:on",
251261
} {
252262
if !strings.Contains(text, want) {
253263
t.Errorf("wide render missing %q:\n%s", want, text)
254264
}
255265
}
266+
// Compaction drops the description, the meta line and the pill end caps.
267+
m.height = 22
268+
compact := plain(m.render())
269+
for _, want := range []string{"P1 new ⛔ !today ◇M #backend feature", "P2 3h here !tomorrow"} {
270+
if !strings.Contains(compact, want) {
271+
t.Errorf("compact render missing %q:\n%s", want, compact)
272+
}
273+
}
274+
if strings.Contains(compact, "cards · ") || strings.Contains(compact, "▐blocked▌") {
275+
t.Errorf("compact render kept normal-density chrome:\n%s", compact)
276+
}
277+
m.height = 40
256278
if len(hits) < 9 { // four columns and five cards.
257279
t.Fatalf("render hits = %+v", hits)
258280
}
@@ -282,7 +304,7 @@ func TestBoardRenderResponsiveFullCardsAndMouse(t *testing.T) {
282304
m.width = 99
283305
m.boardView.column = 2
284306
narrow := plain(m.render())
285-
if !strings.Contains(narrow, "[3 DONE 1]") || strings.Contains(narrow, "TO DO") || strings.Contains(narrow, "CANCELLED") {
307+
if !strings.Contains(narrow, "3 DONE") || strings.Contains(narrow, "TO DO") || strings.Contains(narrow, "CANCELLED") {
286308
t.Fatalf("narrow focused column:\n%s", narrow)
287309
}
288310
for _, line := range strings.Split(m.render(), "\n") {
@@ -292,6 +314,82 @@ func TestBoardRenderResponsiveFullCardsAndMouse(t *testing.T) {
292314
}
293315
}
294316

317+
// TestBoardCardsColorGolden is the palette golden of spec section 6.4: the
318+
// depth model is background color, so the board's second golden pins truecolor
319+
// and records the shade tiers, the band hues, the priority rails and the pills.
320+
func TestBoardCardsColorGolden(t *testing.T) {
321+
now := time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC)
322+
fixture := boardViewFixture(now)
323+
fixture.Tasks[0].Desc = "Pointer capture leaks when the column scrolls under the drag ghost"
324+
m := NewModel(stubBoardReader{board: fixture}, nil, "alice")
325+
m.loading = false
326+
m.board = fixture
327+
m.now = func() time.Time { return now }
328+
m.renderedAt = now
329+
sized, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
330+
m = sized.(Model)
331+
tm := teatest.NewTestModel(t, m,
332+
teatest.WithInitialTermSize(120, 40),
333+
teatest.WithProgramOptions(theme.PinColor()),
334+
)
335+
t.Cleanup(func() { _ = tm.Quit() })
336+
var captured bytes.Buffer
337+
teatest.WaitFor(t, io.TeeReader(tm.Output(), &captured), func(output []byte) bool {
338+
return bytes.Contains(output, []byte("Ship terminal board"))
339+
}, teatest.WithDuration(5*time.Second), teatest.WithCheckInterval(10*time.Millisecond))
340+
frame, ok := finalFullScreenFrame(captured.Bytes())
341+
if !ok {
342+
t.Fatal("teatest output did not contain a full-screen frame")
343+
}
344+
grid, err := renderedCellGrid(frame, 120, 40)
345+
if err != nil {
346+
t.Fatal(err)
347+
}
348+
teatest.RequireEqualOutput(t, grid)
349+
tm.Send(tea.KeyPressMsg{Code: 'q'})
350+
tm.WaitFinished(t, teatest.WithFinalTimeout(5*time.Second))
351+
}
352+
353+
// TestNarrowTallBoardGolden is the 60x50 capture ticket #141 asked for to tune
354+
// the compaction width axis. Below the wide-frame threshold kb shows a single
355+
// column, and the spec's MaxColumnWidth clamp holds it at 52, so a card's inner
356+
// field is 47 cells and the width axis does not fire at this size: the frame is
357+
// tall, the description gets its second line, and the density stays normal. The
358+
// 22 threshold is therefore left at the spec's value; it fires between frame
359+
// widths 100 and ~116, where four columns share the frame.
360+
func TestNarrowTallBoardGolden(t *testing.T) {
361+
now := time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC)
362+
fixture := boardViewFixture(now)
363+
fixture.Tasks[0].Desc = "Pointer capture leaks when the column scrolls under the drag ghost and the row grid stays fixed"
364+
m := NewModel(stubBoardReader{board: fixture}, nil, "alice")
365+
m.loading = false
366+
m.board = fixture
367+
m.now = func() time.Time { return now }
368+
m.renderedAt = now
369+
sized, _ := m.Update(tea.WindowSizeMsg{Width: 60, Height: 50})
370+
m = sized.(Model)
371+
tm := teatest.NewTestModel(t, m,
372+
teatest.WithInitialTermSize(60, 50),
373+
teatest.WithProgramOptions(theme.PinStructure()),
374+
)
375+
t.Cleanup(func() { _ = tm.Quit() })
376+
var captured bytes.Buffer
377+
teatest.WaitFor(t, io.TeeReader(tm.Output(), &captured), func(output []byte) bool {
378+
return bytes.Contains(output, []byte("Ship terminal"))
379+
}, teatest.WithDuration(5*time.Second), teatest.WithCheckInterval(10*time.Millisecond))
380+
frame, ok := finalFullScreenFrame(captured.Bytes())
381+
if !ok {
382+
t.Fatal("teatest output did not contain a full-screen frame")
383+
}
384+
grid, err := renderedCellGrid(frame, 60, 50)
385+
if err != nil {
386+
t.Fatal(err)
387+
}
388+
teatest.RequireEqualOutput(t, grid)
389+
tm.Send(tea.KeyPressMsg{Code: 'q'})
390+
tm.WaitFinished(t, teatest.WithFinalTimeout(5*time.Second))
391+
}
392+
295393
func TestViewIsByteStableAcrossMovingWallClock(t *testing.T) {
296394
stamp := time.Date(2026, 8, 18, 12, 0, 0, 0, time.UTC)
297395
m := NewModel(stubBoardReader{}, nil, "alice")
@@ -348,7 +446,8 @@ func TestPollTickRefreshesRenderTimeAtRolloverBoundary(t *testing.T) {
348446
}
349447

350448
rolled := plain(m.View().Content)
351-
for _, want := range []string{"1d old", "overdue · 1d"} {
449+
// The frame is short, so the due pill is the compact "!" mark.
450+
for _, want := range []string{"1d old", "!1d"} {
352451
if !strings.Contains(rolled, want) {
353452
t.Fatalf("post-tick render missing %q:\n%s", want, rolled)
354453
}
@@ -448,22 +547,92 @@ func TestBoardViewSmallHelpers(t *testing.T) {
448547
if statusIndex("unknown") != 0 || statusLabel("unknown") != "" {
449548
t.Fatal("unknown status helpers changed")
450549
}
451-
if got := plain(priorityChip(99)); got != "P3" {
550+
if got := columnHue("unknown"); got != theme.HueTodo {
551+
t.Fatalf("unknown column hue = %v", got)
552+
}
553+
if got := plain(widget.Priority(theme.New(true), 99, theme.Card)); got != "P3" {
452554
t.Fatalf("invalid priority fallback = %q", got)
453555
}
454556
if got := padLine("abcdef", 3, "-"); got != "abc" {
455557
t.Fatalf("truncated pad = %q", got)
456558
}
457-
if got := wrapTokens([]string{"one", "two", "verylong"}, 4); !reflect.DeepEqual(got, []string{"one", "two", "very"}) {
458-
t.Fatalf("wrapTokens = %q", got)
559+
if got := compactDue("overdue · 2d"); got != "2d" {
560+
t.Fatalf("compact due = %q", got)
561+
}
562+
if got := columnMetaLine([]board.Task{{}}); got != "1 card" {
563+
t.Fatalf("single card meta = %q", got)
564+
}
565+
if got := hiddenCards([]string{"a", "a", "", "b"}, 2); got != 1 {
566+
t.Fatalf("hidden cards = %d", got)
459567
}
460568
if got := visibleCardStart([]string{"a", "", "b"}, []string{"a", "", "b"}, 1, 2); got != 1 {
461569
t.Fatalf("visible start = %d", got)
462570
}
571+
// A Model assembled field by field still renders: the board falls back to
572+
// the default dark palette when no theme was resolved for it.
463573
column := Model{board: board.Board{Tasks: []board.Task{{ID: "x", Title: "x", Status: board.StatusTodo}}}, boardView: boardViewState{}, renderedAt: time.Now()}.renderBoardColumn(board.StatusTodo, 2, 4)
464-
if len(column.lines) != 4 || !strings.Contains(column.lines[0], "TO") {
574+
if len(column.lines) != 4 || plain(column.lines[0]) != "▸ " {
465575
t.Fatalf("tiny column = %+v", column)
466576
}
577+
if empty := (Model{}).renderBoardColumnAt(board.StatusTodo, 0, 0, theme.DensityCompact); len(empty.lines) != 0 || len(empty.hits) != 2 {
578+
t.Fatalf("zero-sized column = %+v", empty)
579+
}
580+
}
581+
582+
// TestBoardStateCarriesSemanticHue pins the footer's state segment onto the
583+
// status colors of spec section 1.5.
584+
func TestBoardStateCarriesSemanticHue(t *testing.T) {
585+
base := NewModel(stubBoardReader{}, nil, "u")
586+
base.loading = false
587+
for _, test := range []struct {
588+
name string
589+
setup func(*Model)
590+
want string
591+
slot theme.Slot
592+
}{
593+
{"ready", func(*Model) {}, "ready", theme.StatusOK},
594+
{"action ok", func(m *Model) {
595+
m.actionNotice, m.actionStatus = true, "shipped one card"
596+
}, "shipped one card", theme.StatusOK},
597+
{"action error", func(m *Model) {
598+
m.actionNotice, m.actionStatus, m.actionStatusError = true, "ship failed", true
599+
}, "ship failed", theme.StatusDanger},
600+
{"load error", func(m *Model) { m.loadErr = errors.New("gone") }, "error: gone", theme.StatusDanger},
601+
{"poll error", func(m *Model) { m.pollErr = errors.New("stale") }, "error: stale", theme.StatusDanger},
602+
{"preference error", func(m *Model) { m.preferenceErr = errors.New("disk") }, "error: disk", theme.StatusDanger},
603+
{"move status", func(m *Model) { m.move.status = "moved" }, "moved", theme.StatusWarn},
604+
{"loading", func(m *Model) { m.loading, m.haveBoardSnapshot = true, false }, "loading board...", theme.FgMuted},
605+
} {
606+
t.Run(test.name, func(t *testing.T) {
607+
m := base
608+
test.setup(&m)
609+
state, slot := m.boardState()
610+
if state != test.want || slot != test.slot {
611+
t.Fatalf("board state = %q,%v want %q,%v", state, slot, test.want, test.slot)
612+
}
613+
if !strings.Contains(plain(m.render()), test.want) {
614+
t.Fatalf("footer dropped %q:\n%s", test.want, plain(m.render()))
615+
}
616+
})
617+
}
618+
}
619+
620+
// TestBackgroundColorRebuildsTheme is spec section 6.3: the palette defaults to
621+
// dark and is rebuilt exactly once, when the terminal answers.
622+
func TestBackgroundColorRebuildsTheme(t *testing.T) {
623+
m := NewModel(stubBoardReader{}, nil, "u")
624+
m.loading = false
625+
before := m.styles
626+
if before == nil {
627+
t.Fatal("constructed model carries no resolved theme")
628+
}
629+
updateTestModel(t, &m, tea.BackgroundColorMsg{Color: color.White})
630+
if m.styles == nil || m.styles == before {
631+
t.Fatal("background color answer did not rebuild the theme")
632+
}
633+
if !strings.Contains(plain(m.render()), "ready") {
634+
t.Fatal("rebuilt theme stopped rendering the board")
635+
}
467636
}
468637

469638
func TestCancelledPreferencePathAndIsolation(t *testing.T) {

internal/tui/carddetail/model.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,17 @@ func New(reader Reader, user string, styles *theme.Styles) Model {
128128
}
129129
}
130130

131+
// SetStyles adopts a rebuilt design system. Spec section 6.3: the root resolves
132+
// the palette again when tea.BackgroundColorMsg answers, and every pane it owns
133+
// has to follow it or the frame renders two palettes at once.
134+
func (m *Model) SetStyles(styles *theme.Styles) {
135+
if styles == nil {
136+
return
137+
}
138+
m.styles = styles
139+
m.renderMarkdown = markdownWith(styles)
140+
}
141+
131142
// IsOpen reports whether the overlay currently owns input and rendering.
132143
func (m Model) IsOpen() bool { return m.open }
133144

internal/tui/carddetail/model_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,27 @@ func TestNilReaderAndRenderingHelpers(t *testing.T) {
330330
}
331331
}
332332

333+
// TestSetStylesAdoptsRebuiltTheme is spec section 6.3: the root resolves the
334+
// palette again when the terminal answers with its background color, and the
335+
// pane follows it instead of holding the palette it was constructed with.
336+
func TestSetStylesAdoptsRebuiltTheme(t *testing.T) {
337+
m := New(nil, "u", testStyles())
338+
before := m.styles
339+
m.SetStyles(nil)
340+
if m.styles != before {
341+
t.Fatal("nil styles replaced the resolved palette")
342+
}
343+
_ = m.Open(board.Task{ID: "id", Title: "Bare", Status: board.StatusTodo, Prio: 3})
344+
rebuilt := theme.New(false)
345+
m.SetStyles(rebuilt)
346+
if m.styles != rebuilt || m.renderMarkdown == nil {
347+
t.Fatalf("rebuilt palette was not adopted: styles=%v renderer=%v", m.styles == rebuilt, m.renderMarkdown != nil)
348+
}
349+
if got := m.View(40, 12); got == "" {
350+
t.Fatal("pane stopped rendering after the rebuild")
351+
}
352+
}
353+
333354
func TestViewClampsScrollToContent(t *testing.T) {
334355
m := New(nil, "u", testStyles())
335356
task := fullTask()

internal/tui/filter_test.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,10 @@ func TestFilterBarSanitizesTerminalControlsWithoutChangingState(t *testing.T) {
292292
m.filter.restore(boardFilter{Text: hostileText, Tags: []string{hostileTag}})
293293
storedText := m.filter.input.Value()
294294
m.filter.focus = filterLabels
295-
view, hits := m.renderFilterBar(160)
295+
styled, hits := m.renderFilterBar(160)
296+
// The toolbar rows carry the Canvas and Surface tiers, so the scan runs on
297+
// the stripped text: the bar's own SGR runs go, an injected escape stays.
298+
view := ansi.Strip(styled)
296299
for _, r := range view {
297300
if r == '\n' {
298301
continue

internal/tui/model.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,12 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
563563
return m, m.mutateFilter(func(filter *boardFilterState) { filter.clear() })
564564
case preferenceSavedMsg:
565565
return m, m.finishPreferences(msg)
566+
case tea.BackgroundColorMsg:
567+
// Spec section 6.2: New is called on program start and here, nowhere
568+
// else. Every style in the tree is rebuilt exactly once per answer, and
569+
// the panes the root owns adopt the same instance.
570+
m.styles = theme.New(msg.IsDark())
571+
m.detail.SetStyles(m.styles)
566572
case tea.WindowSizeMsg:
567573
if msg.Width > 0 {
568574
m.width = msg.Width

0 commit comments

Comments
 (0)