Skip to content

Commit c5073eb

Browse files
authored
feat(tui): semantic button colors (#161)
The card-detail action row rendered every button on the same Raised surface, so a row of them said nothing about what any one of them did (dogfood finding, issue #157). widget.Button gains a Variant: Neutral, Primary, Success or Danger, with per-variant blurred, hovered and focused tokens built once by the theme factory. The hue carries the meaning and the state carries the elevation - a blurred button wears its variant as a tint on the resting surface, a hovered one wears the tint as a fill, a focused one wears the saturated hue. Neutral is the zero value, so a caller that states no meaning gets the calmest surface. Three tint slots and one alarm fill join the palette. The armed two-step gets its own deeper fill because a purge button arms from the Danger variant, and an armed button wearing the focused-Danger fill would be the one state a user must not misread. The 256-color audit covers the new slots, and a contrast audit is added beside it: every variant, in every state, clears 4.5:1 in truecolor and again after quantization, worst cell 4.82. A second test keeps the four variants separable at 256 colors. Variants are assigned across the card-detail action row, the editor, the settings pane, the ship guard, the kill and purge prompts, the ADR split and the issue import. huh's Confirm carries the Neutral pair: it exposes one button pair for two meanings, so the calmest variant is the only honest one there. Interaction is unchanged; the affected color goldens are regenerated.
1 parent 744f945 commit c5073eb

17 files changed

Lines changed: 419 additions & 100 deletions

internal/tui/adrsplit/view.go

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ type splitRow struct {
4141
rendered string
4242
button string
4343
target string
44+
variant theme.ButtonVariant
4445
kind rowKind
4546
}
4647

@@ -183,6 +184,7 @@ func (m *Model) renderRow(row splitRow, width int) string {
183184
marker := strings.TrimSuffix(line, label)
184185
return styles.Overlay.Surf.Render(marker) + widget.Button(styles, widget.ButtonOpts{
185186
Text: label,
187+
Variant: row.variant,
186188
Selected: m.focus == row.target,
187189
Pressed: m.pressed(row.target),
188190
UnderlineIndex: -1,
@@ -228,8 +230,8 @@ func (m *Model) inputRows(width int) []splitRow {
228230
rows = append(rows,
229231
m.choiceRow("max", "Max stories", storyCountChoices(), m.max-1, " (1-20)", width),
230232
splitRow{},
231-
m.actionRow("cancel", "Cancel"),
232-
m.actionRow("split", "Propose stories"),
233+
m.actionRow("cancel", "Cancel", theme.ButtonNeutral),
234+
m.actionRow("split", "Propose stories", theme.ButtonPrimary),
233235
)
234236
return rows
235237
}
@@ -265,9 +267,9 @@ func (m *Model) reviewRows(width int) []splitRow {
265267
return append(rows,
266268
m.choiceRow("dest", "Destination", statusChoices(), statusIndex(m.dest), "", width),
267269
splitRow{},
268-
m.actionRow("back", "Back to source"),
269-
m.actionRow("cancel", "Close"),
270-
m.actionRow("add", fmt.Sprintf("Add selected (%d)", m.selectedCount())),
270+
m.actionRow("back", "Back to source", theme.ButtonNeutral),
271+
m.actionRow("cancel", "Close", theme.ButtonNeutral),
272+
m.actionRow("add", fmt.Sprintf("Add selected (%d)", m.selectedCount()), theme.ButtonPrimary),
271273
)
272274
}
273275

@@ -356,13 +358,14 @@ func (m *Model) choiceRow(target, label string, choices []string, selected int,
356358
}
357359
}
358360

359-
func (m *Model) actionRow(target, label string) splitRow {
361+
func (m *Model) actionRow(target, label string, variant theme.ButtonVariant) splitRow {
360362
button := "[ " + sanitize(label) + " ]"
361363
return splitRow{
362-
text: m.controlPrefix(target) + button,
363-
button: button,
364-
target: target,
365-
kind: rowButton,
364+
text: m.controlPrefix(target) + button,
365+
button: button,
366+
target: target,
367+
variant: variant,
368+
kind: rowButton,
366369
}
367370
}
368371

internal/tui/carddetail/actions.go

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -565,9 +565,13 @@ func (m Model) actionFooter(width int) string {
565565
return fitDetailLine(hints, width)
566566
}
567567

568+
// detailPointerControl is one action of the pinned action row: its label, the
569+
// frozen message the control sends, and what the action means (issue #157).
570+
// The variant is a property of the action, not of the row it lands in.
568571
type detailPointerControl struct {
569572
label string
570573
message tea.Msg
574+
variant theme.ButtonVariant
571575
}
572576

573577
func detailFooterControlID(control detailPointerControl) pointer.ControlID {
@@ -590,62 +594,62 @@ func (m Model) pointerFooterControls(width int) []detailPointerControl {
590594
}
591595
if m.driftMode == driftSelect {
592596
return controls(
593-
detailPointerControl{label: "Check selected", message: tea.KeyPressMsg{Code: tea.KeyEnter}},
597+
detailPointerControl{label: "Check selected", message: tea.KeyPressMsg{Code: tea.KeyEnter}, variant: theme.ButtonPrimary},
594598
detailPointerControl{label: "Back", message: tea.KeyPressMsg{Code: tea.KeyEscape}},
595599
)
596600
}
597601
if m.driftResult.State == "drifted" {
598602
return controls(
599-
detailPointerControl{label: "Update baseline", message: key('u')},
603+
detailPointerControl{label: "Update baseline", message: key('u'), variant: theme.ButtonPrimary},
600604
detailPointerControl{label: "Back", message: tea.KeyPressMsg{Code: tea.KeyEscape}},
601605
)
602606
}
603607
return controls(detailPointerControl{label: "Back", message: tea.KeyPressMsg{Code: tea.KeyEscape}})
604608
}
605609
if m.confirm && (m.action == actionDeleteComment || m.action == actionDeleteLink) {
606610
return controls(
607-
detailPointerControl{label: "Confirm delete", message: tea.KeyPressMsg{Code: tea.KeyEnter}},
611+
detailPointerControl{label: "Confirm delete", message: tea.KeyPressMsg{Code: tea.KeyEnter}, variant: theme.ButtonDanger},
608612
detailPointerControl{label: "Cancel", message: tea.KeyPressMsg{Code: tea.KeyEscape}},
609613
)
610614
}
611615
switch m.action {
612616
case actionAddComment:
613617
return controls(
614-
detailPointerControl{label: "Save comment", message: tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModCtrl}},
618+
detailPointerControl{label: "Save comment", message: tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModCtrl}, variant: theme.ButtonPrimary},
615619
detailPointerControl{label: "Cancel", message: tea.KeyPressMsg{Code: tea.KeyEscape}},
616620
)
617621
case actionDeleteComment, actionDeleteLink:
618622
return controls(
619-
detailPointerControl{label: "Delete", message: tea.KeyPressMsg{Code: tea.KeyEnter}},
623+
detailPointerControl{label: "Delete", message: tea.KeyPressMsg{Code: tea.KeyEnter}, variant: theme.ButtonDanger},
620624
detailPointerControl{label: "Cancel", message: tea.KeyPressMsg{Code: tea.KeyEscape}},
621625
)
622626
case actionAddLink:
623627
return controls(
624628
detailPointerControl{label: "Toggle direction", message: tea.KeyPressMsg{Code: tea.KeyTab}},
625-
detailPointerControl{label: "Add link", message: tea.KeyPressMsg{Code: tea.KeyEnter}},
629+
detailPointerControl{label: "Add link", message: tea.KeyPressMsg{Code: tea.KeyEnter}, variant: theme.ButtonPrimary},
626630
detailPointerControl{label: "Cancel", message: tea.KeyPressMsg{Code: tea.KeyEscape}},
627631
)
628632
}
629633
taskControls := controls(
630-
detailPointerControl{label: "Check", message: key('t')},
631-
detailPointerControl{label: "Kill", message: key('x')},
634+
detailPointerControl{label: "Check", message: key('t'), variant: theme.ButtonSuccess},
635+
detailPointerControl{label: "Kill", message: key('x'), variant: theme.ButtonDanger},
632636
)
633637
if m.task.Status == board.StatusCancelled {
634638
taskControls = controls(
635-
detailPointerControl{label: "Restore", message: key('r')},
636-
detailPointerControl{label: "Purge", message: key('D')},
639+
detailPointerControl{label: "Restore", message: key('r'), variant: theme.ButtonSuccess},
640+
detailPointerControl{label: "Purge", message: key('D'), variant: theme.ButtonDanger},
637641
)
638642
}
639643
full := append([]detailPointerControl(nil), taskControls...)
640644
if m.task.Status != board.StatusCancelled {
641-
full = append(controls(detailPointerControl{label: "Edit", message: key('e')}), full...)
645+
full = append(controls(detailPointerControl{label: "Edit", message: key('e'), variant: theme.ButtonPrimary}), full...)
642646
}
643647
full = append(full, controls(
644648
detailPointerControl{label: "Drift", message: key('v')},
645649
detailPointerControl{label: "Comment", message: key('c')},
646-
detailPointerControl{label: "Del", message: key('d')},
650+
detailPointerControl{label: "Del", message: key('d'), variant: theme.ButtonDanger},
647651
detailPointerControl{label: "Link", message: key('b')},
648-
detailPointerControl{label: "Unlink", message: key('u')},
652+
detailPointerControl{label: "Unlink", message: key('u'), variant: theme.ButtonDanger},
649653
detailPointerControl{label: "Close", message: mouseDismissMsg{}},
650654
)...)
651655
if detailControlsWidth(full) <= width {
@@ -740,6 +744,7 @@ func (m Model) actionButtonRow(controls []detailPointerControl) string {
740744
label, underline := detailButtonLabel(control)
741745
buttons = append(buttons, widget.Button(m.styles, widget.ButtonOpts{
742746
Text: label,
747+
Variant: control.variant,
743748
Pressed: m.pointerState.IsPressed(detailFooterControlID(control)),
744749
UnderlineIndex: underline,
745750
Padding: [2]int{detailButtonPad, detailButtonPad},

internal/tui/carddetail/testdata/TestCardDetailColorGolden.golden

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,6 @@
1515
-- CHECKLIST 1/2 -
1616
-- ☑ done  -
1717
-- ☐ left  -
18-
--  Check (t)   Kill (x)   Comment   Link (b)   Close   -
18+
--  Check (t)   Kill (x)   Comment   Link (b)   Close   -
1919
-- up/down scroll | esc close 1/3 -
2020
--- -

internal/tui/cardeditor/view.go

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,11 @@ const (
3636
// matching the rendered text: card titles, descriptions and checklist text are
3737
// untrusted, and text matching let them impersonate a control.
3838
type editorRow struct {
39-
text string
40-
button string
41-
target string
42-
kind rowKind
39+
text string
40+
button string
41+
target string
42+
variant theme.ButtonVariant
43+
kind rowKind
4344
}
4445

4546
// plain is the row as unstyled text, the form the pointer geometry and the
@@ -255,6 +256,7 @@ func (m *Model) renderRow(row editorRow, width int) string {
255256
marker := strings.TrimSuffix(line, padded)
256257
return styles.Overlay.Surf.Render(marker) + widget.Button(styles, widget.ButtonOpts{
257258
Text: row.button,
259+
Variant: row.variant,
258260
Selected: m.focus == row.target,
259261
Pressed: m.pointerState.IsPressed(pointer.ControlID(row.target)),
260262
UnderlineIndex: -1,
@@ -341,7 +343,7 @@ func (m *Model) bodyRows(width int) []editorRow {
341343
if m.drafting {
342344
action = "Cancel draft (Esc)"
343345
}
344-
rows = append(rows, m.actionRow("ai-draft", action), editorRow{})
346+
rows = append(rows, m.actionRow("ai-draft", action, theme.ButtonNeutral), editorRow{})
345347
}
346348
rows = append(rows,
347349
m.inputRow("title", "Title", m.title, width),
@@ -380,7 +382,9 @@ func (m *Model) bodyRows(width int) []editorRow {
380382
}
381383
rows = append(rows, editorRow{text: prefix + sanitize(m.statusMessage), kind: kind})
382384
}
383-
return append(rows, editorRow{}, m.actionRow("cancel", "Cancel"), m.actionRow("save", "Save card"))
385+
return append(rows, editorRow{},
386+
m.actionRow("cancel", "Cancel", theme.ButtonNeutral),
387+
m.actionRow("save", "Save card", theme.ButtonPrimary))
384388
}
385389

386390
func (m *Model) inputRow(target, label string, input textinput.Model, width int, suffix ...string) editorRow {
@@ -405,12 +409,13 @@ func (m *Model) choiceRow(target, label, value string) editorRow {
405409
// actionRow is one visible button row (issue #152). The row's plain text spells
406410
// the button's own cells - one pad, the label, one pad - so the rendered button
407411
// and the text the pointer geometry measures stay the same width.
408-
func (m *Model) actionRow(target, label string) editorRow {
412+
func (m *Model) actionRow(target, label string, variant theme.ButtonVariant) editorRow {
409413
return editorRow{
410-
text: m.controlMarker(target) + buttonPadding + label + buttonPadding,
411-
button: label,
412-
target: target,
413-
kind: rowButton,
414+
text: m.controlMarker(target) + buttonPadding + label + buttonPadding,
415+
button: label,
416+
target: target,
417+
variant: variant,
418+
kind: rowButton,
414419
}
415420
}
416421

internal/tui/issueimport/testdata/TestIssueImportColorGolden.golden

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ bb ☐ already here [duplicate via link: exi
99
bb  b
1010
bb ████████████████████████ writing 1/2  b
1111
bb  b
12-
bb [ Import ] [ Back ] [ Close ]  b
12+
bb [ Import ] [ Back ] [ Close ]  b
1313
bb  b
1414
bb  b
1515
bb  b

internal/tui/issueimport/view.go

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,8 @@ func (m Model) inputRows(width int) []importRow {
170170
})
171171
}
172172
return append(rows, importRow{}, m.actionsRow(styles,
173-
[2]string{"import", "Import"}, [2]string{"cancel", "Cancel"}))
173+
importAction{target: "import", label: "Import", variant: theme.ButtonPrimary},
174+
importAction{target: "cancel", label: "Cancel"}))
174175
}
175176

176177
// sourceRow is the forge choice. Spec section 5.2 assigns huh's Select to the
@@ -262,7 +263,9 @@ func (m Model) reviewRows(width, height int) []importRow {
262263
rows = append(rows, importRow{}, bar)
263264
}
264265
return append(rows, importRow{}, m.actionsRow(styles,
265-
[2]string{"import", "Import"}, [2]string{"back", "Back"}, [2]string{"close", "Close"}))
266+
importAction{target: "import", label: "Import", variant: theme.ButtonPrimary},
267+
importAction{target: "back", label: "Back"},
268+
importAction{target: "close", label: "Close"}))
266269
}
267270

268271
// issueRow is one proposal. Spec section 5.1 assigns the checklist mark to the
@@ -329,22 +332,31 @@ func (m Model) fieldRow(target, text string) importRow {
329332
}
330333
}
331334

335+
// importAction is one button of an action row: its target, its frozen label,
336+
// and what it means (issue #157).
337+
type importAction struct {
338+
target string
339+
label string
340+
variant theme.ButtonVariant
341+
}
342+
332343
// actionsRow lays out the row's buttons and records where each one starts, so
333344
// the pointer map keys a rect to the button rather than to a matched label.
334-
func (m Model) actionsRow(styles *theme.Styles, specs ...[2]string) importRow {
345+
func (m Model) actionsRow(styles *theme.Styles, actions ...importAction) importRow {
335346
const gap = " "
336-
row := importRow{buttons: make([]importButton, 0, len(specs))}
337-
for index, spec := range specs {
347+
row := importRow{buttons: make([]importButton, 0, len(actions))}
348+
for index, action := range actions {
338349
if index > 0 {
339350
row.text += gap
340351
row.rendered += styles.Overlay.Surf.Render(gap)
341352
}
342-
label := "[ " + spec[1] + " ]"
343-
row.buttons = append(row.buttons, importButton{label: label, target: spec[0], x0: ansi.StringWidth(row.text)})
353+
label := "[ " + action.label + " ]"
354+
row.buttons = append(row.buttons, importButton{label: label, target: action.target, x0: ansi.StringWidth(row.text)})
344355
row.text += label
345356
row.rendered += widget.Button(styles, widget.ButtonOpts{
346357
Text: label,
347-
Pressed: m.pressed(spec[0]),
358+
Variant: action.variant,
359+
Pressed: m.pressed(action.target),
348360
UnderlineIndex: -1,
349361
})
350362
}

internal/tui/settings_view.go

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,16 @@ const (
3737
// width is only known once every label in the pane has been seen, and the input
3838
// display has to be cut to it.
3939
type settingsRenderRow struct {
40-
line string
41-
label string
42-
value string
43-
input *textinput.Model
44-
secret bool
45-
button string
46-
target string
47-
armed bool
48-
kind settingsRowKind
40+
line string
41+
label string
42+
value string
43+
input *textinput.Model
44+
secret bool
45+
button string
46+
target string
47+
variant theme.ButtonVariant
48+
armed bool
49+
kind settingsRowKind
4950
}
5051

5152
func settingsControlID(target string) pointer.ControlID {
@@ -111,8 +112,8 @@ func (m *settingsModel) Surface(background string, width, height int) pointer.Su
111112
m.inputModelRow("ai:base", "Base URL", &m.aiBase, false),
112113
m.inputModelRow("ai:model", "Model", &m.aiModel, false),
113114
m.inputModelRow("ai:key", keyLabel("API key", m.hasKey), &m.aiKey, true),
114-
m.actionRow("ai:test", "Test connection", inner),
115-
m.actionRow("ai:save", "Save AI settings", inner),
115+
m.actionRow("ai:test", "Test connection", theme.ButtonNeutral, inner),
116+
m.actionRow("ai:save", "Save AI settings", theme.ButtonPrimary, inner),
116117
settingsRenderRow{line: ""},
117118
settingsRenderRow{line: "FORGE INTEGRATIONS", kind: settingsRowSection},
118119
)
@@ -122,7 +123,7 @@ func (m *settingsModel) Surface(background string, width, height int) pointer.Su
122123
for i := range m.rows {
123124
body = append(body, m.renderForgeRow(&m.rows[i], inner)...)
124125
}
125-
body = append(body, m.actionRow("forge:add", "+ Add integration", inner))
126+
body = append(body, m.actionRow("forge:add", "+ Add integration", theme.ButtonNeutral, inner))
126127
}
127128
m.layoutSettingsTable(body, inner)
128129
status := m.status
@@ -224,6 +225,7 @@ func (m *settingsModel) renderSettingsRow(row settingsRenderRow, width int) stri
224225
marker := strings.TrimSuffix(line, padded)
225226
return styles.Overlay.Surf.Render(marker) + widget.Button(styles, widget.ButtonOpts{
226227
Text: row.button,
228+
Variant: row.variant,
227229
Selected: m.focus == row.target,
228230
Armed: row.armed,
229231
Pressed: m.pointerState.IsPressed(settingsControlID(row.target)),
@@ -277,15 +279,15 @@ func (m *settingsModel) renderForgeRow(row *integrationSettingsRow, width int) [
277279
m.inputModelRow(prefix+"base", "Base URL", &row.baseURL, false),
278280
m.inputModelRow(prefix+"project", "Project", &row.project, false),
279281
m.inputModelRow(prefix+"token", keyLabel("Token", row.hasToken), &row.token, true),
280-
m.actionRow(prefix+"test", "Test", width),
282+
m.actionRow(prefix+"test", "Test", theme.ButtonNeutral, width),
281283
)
282284
remove := "Remove"
283285
if m.armedRemove == row.id {
284286
remove = "Confirm remove"
285287
}
286-
removeRow := m.actionRow(prefix+"remove", remove, width)
288+
removeRow := m.actionRow(prefix+"remove", remove, theme.ButtonDanger, width)
287289
removeRow.armed = m.armedRemove == row.id
288-
return append(lines, m.actionRow(prefix+"save", "Save", width), removeRow)
290+
return append(lines, m.actionRow(prefix+"save", "Save", theme.ButtonPrimary, width), removeRow)
289291
}
290292

291293
func settingsInputDisplay(input textinput.Model, secret, focused bool, width int) string {
@@ -408,16 +410,17 @@ func (m *settingsModel) layoutSettingsTable(body []settingsRenderRow, width int)
408410
}
409411
}
410412

411-
func (m *settingsModel) actionRow(target, label string, width int) settingsRenderRow {
413+
func (m *settingsModel) actionRow(target, label string, variant theme.ButtonVariant, width int) settingsRenderRow {
412414
marker := " "
413415
if m.focus == target {
414416
marker = "> "
415417
}
416418
return settingsRenderRow{
417-
line: settingsFit(marker+settingsButtonPad+label+settingsButtonPad, width),
418-
button: label,
419-
target: target,
420-
kind: settingsRowButton,
419+
line: settingsFit(marker+settingsButtonPad+label+settingsButtonPad, width),
420+
button: label,
421+
target: target,
422+
variant: variant,
423+
kind: settingsRowButton,
421424
}
422425
}
423426

0 commit comments

Comments
 (0)