feat(tui): add cards view, mode switching, promote picker, and card detail

Stream/cards toggle with 1/2 keys. Cards view with intent filtering
(tab cycles grab/read/fill/all), sort cycling (s key), pinned-first
ordering, and affordance badges. Promote picker (p key) with card type
selection and auto-detection from body content. Detail view renders
card_data per type: checklist steps, template slots, decision fields,
link URLs.

Extracts generateCardData to internal/carddata for reuse across cmd
and tui packages.
This commit is contained in:
2026-05-17 21:14:14 -04:00
parent c2ea63dd16
commit ce335cabd6
12 changed files with 786 additions and 112 deletions
+262
View File
@@ -0,0 +1,262 @@
package tui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/lerko/nib/internal/db"
"github.com/lerko/nib/internal/display"
)
type intent int
const (
intentAll intent = iota
intentGrab
intentRead
intentFill
)
func (i intent) String() string {
switch i {
case intentGrab:
return "grab"
case intentRead:
return "read"
case intentFill:
return "fill"
default:
return "all"
}
}
func (i intent) next() intent {
switch i {
case intentAll:
return intentGrab
case intentGrab:
return intentRead
case intentRead:
return intentFill
default:
return intentAll
}
}
func matchesIntent(e *db.Entity, i intent) bool {
if i == intentAll {
return true
}
ct := e.CardType
if ct == nil {
return i == intentGrab
}
switch i {
case intentGrab:
return *ct == db.CardSnippet
case intentRead:
return *ct == db.CardNote || *ct == db.CardLink || *ct == db.CardDecision
case intentFill:
return *ct == db.CardTemplate || *ct == db.CardChecklist
}
return false
}
type cardsModel struct {
entities []*db.Entity
filtered []*db.Entity
cursor int
offset int
height int
width int
intent intent
}
func newCardsModel() cardsModel {
return cardsModel{}
}
func (c *cardsModel) setEntities(entities []*db.Entity) {
c.entities = entities
c.applyFilter()
}
func (c *cardsModel) setIntent(i intent) {
c.intent = i
c.cursor = 0
c.offset = 0
c.applyFilter()
}
func (c *cardsModel) applyFilter() {
c.filtered = nil
var pinned, rest []*db.Entity
for _, e := range c.entities {
if !matchesIntent(e, c.intent) {
continue
}
if e.Pinned {
pinned = append(pinned, e)
} else {
rest = append(rest, e)
}
}
c.filtered = append(pinned, rest...)
if c.cursor >= len(c.filtered) {
c.cursor = max(0, len(c.filtered)-1)
}
}
func (c *cardsModel) setSize(width, height int) {
c.width = width
c.height = height
}
func (c cardsModel) selected() *db.Entity {
if len(c.filtered) == 0 || c.cursor >= len(c.filtered) {
return nil
}
return c.filtered[c.cursor]
}
func (c cardsModel) update(msg tea.KeyMsg) cardsModel {
switch msg.String() {
case "up", "k":
if c.cursor > 0 {
c.cursor--
if c.cursor < c.offset {
c.offset = c.cursor
}
}
case "down", "j":
if c.cursor < len(c.filtered)-1 {
c.cursor++
visible := c.visibleCount()
if c.cursor >= c.offset+visible {
c.offset = c.cursor - visible + 1
}
}
case "home", "g":
c.cursor = 0
c.offset = 0
case "end", "G":
c.cursor = max(0, len(c.filtered)-1)
visible := c.visibleCount()
if c.cursor >= visible {
c.offset = c.cursor - visible + 1
}
case "pgup", "ctrl+u":
c.cursor = max(0, c.cursor-c.visibleCount())
if c.cursor < c.offset {
c.offset = c.cursor
}
case "pgdown", "ctrl+d":
c.cursor = min(len(c.filtered)-1, c.cursor+c.visibleCount())
visible := c.visibleCount()
if c.cursor >= c.offset+visible {
c.offset = c.cursor - visible + 1
}
}
return c
}
func (c cardsModel) view(width int) string {
if len(c.filtered) == 0 {
return statusStyle.Render("no cards")
}
var b strings.Builder
visible := c.visibleCount()
end := min(c.offset+visible, len(c.filtered))
for i := c.offset; i < end; i++ {
e := c.filtered[i]
line := renderCard(e, width-4)
if i == c.cursor {
b.WriteString(selectedItemStyle.Render(" " + line))
} else {
b.WriteString(listItemStyle.Render(line))
}
if i < end-1 {
b.WriteString("\n")
}
}
return b.String()
}
func (c cardsModel) visibleCount() int {
if c.height <= 0 {
return 20
}
return c.height
}
func renderCard(e *db.Entity, maxWidth int) string {
glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType))
id := idStyle.Render("[" + display.FormatID(e.ID) + "]")
body := e.Body
if e.Title != nil {
body = *e.Title
}
affordance := detectAffordance(e)
affordStr := ""
if affordance != "" {
affordStr = " " + affordanceStyle.Render(affordance)
}
var extras []string
if e.Pinned {
extras = append(extras, pinnedStyle.Render("•"))
}
if len(e.Tags) > 0 {
limit := min(2, len(e.Tags))
for _, t := range e.Tags[:limit] {
extras = append(extras, tagStyle.Render("#"+t))
}
}
extraStr := ""
if len(extras) > 0 {
extraStr = " " + strings.Join(extras, " ")
}
useStr := ""
if e.UseCount > 0 {
useStr = " " + useCountStyle.Render(fmt.Sprintf("%d×", e.UseCount))
}
line := fmt.Sprintf("%s %s%s%s%s %s", glyph, body, affordStr, extraStr, useStr, id)
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
body = truncate(body, maxWidth-30)
line = fmt.Sprintf("%s %s%s%s%s %s", glyph, body, affordStr, extraStr, useStr, id)
}
return line
}
func detectAffordance(e *db.Entity) string {
if e.CardType == nil {
return ""
}
switch *e.CardType {
case db.CardSnippet:
return "code"
case db.CardTemplate:
return "fill"
case db.CardChecklist:
return "steps"
case db.CardDecision:
return "decide"
case db.CardLink:
return "link"
default:
return ""
}
}
+7 -4
View File
@@ -8,6 +8,7 @@ import (
"github.com/atotto/clipboard"
tea "github.com/charmbracelet/bubbletea"
"github.com/lerko/nib/internal/carddata"
"github.com/lerko/nib/internal/db"
)
@@ -29,7 +30,8 @@ type entityUpdatedMsg struct {
}
type entityPromotedMsg struct {
id string
id string
cardType db.CardType
}
type entityDemotedMsg struct {
@@ -122,12 +124,13 @@ func pinEntity(store *db.Store, e *db.Entity) tea.Cmd {
}
}
func promoteEntity(store *db.Store, id string) tea.Cmd {
func promoteEntity(store *db.Store, id string, ct db.CardType, body string) tea.Cmd {
return func() tea.Msg {
if err := store.Promote(id, db.CardSnippet, nil); err != nil {
cd := carddata.GenerateCardData(ct, body)
if err := store.Promote(id, ct, cd); err != nil {
return errMsg{err}
}
return entityPromotedMsg{id}
return entityPromotedMsg{id, ct}
}
}
+99 -1
View File
@@ -54,6 +54,9 @@ func (d detailModel) view(width int) string {
glyph := display.DisplayGlyph(e.Glyph, e.CardType)
header := fmt.Sprintf("%s %s", glyph, display.FormatID(e.ID))
if e.CardType != nil {
header += " " + affordanceStyle.Render(string(*e.CardType))
}
b.WriteString(detailHeaderStyle.Render(header))
b.WriteString("\n\n")
@@ -65,6 +68,14 @@ func (d detailModel) view(width int) string {
b.WriteString(detailBodyStyle.Render(e.Body))
b.WriteString("\n")
if e.CardType != nil {
cardSection := renderCardData(e)
if cardSection != "" {
b.WriteString("\n")
b.WriteString(cardSection)
}
}
if len(e.Tags) > 0 {
tagParts := make([]string, len(e.Tags))
for i, t := range e.Tags {
@@ -84,11 +95,14 @@ func (d detailModel) view(width int) string {
meta += fmt.Sprintf("\nanchored @%s", *e.TimeAnchor)
}
if e.Pinned {
meta += "\npinned"
meta += "\n" + pinnedStyle.Render("pinned")
}
if e.CardType != nil {
meta += fmt.Sprintf("\ncard %s", *e.CardType)
}
if e.UseCount > 0 {
meta += fmt.Sprintf("\nused %d×", e.UseCount)
}
if e.CompletedAt != nil {
meta += fmt.Sprintf("\ndone %s", e.CompletedAt.Format(time.DateTime))
}
@@ -104,3 +118,87 @@ func (d detailModel) view(width int) string {
return strings.Join(lines, "\n")
}
func renderCardData(e *db.Entity) string {
if e.CardData == nil {
return ""
}
data, err := e.CardDataJSON()
if err != nil || data == nil {
return ""
}
var b strings.Builder
switch *e.CardType {
case db.CardChecklist:
steps, ok := data["steps"].([]interface{})
if !ok {
break
}
done := 0
for _, s := range steps {
step, ok := s.(map[string]interface{})
if !ok {
continue
}
text, _ := step["text"].(string)
isDone, _ := step["done"].(bool)
if isDone {
done++
b.WriteString(" " + checkDoneStyle.Render("[✓] "+text) + "\n")
} else {
b.WriteString(" " + checkPendingStyle.Render("[ ] "+text) + "\n")
}
}
progress := fmt.Sprintf(" %d/%d steps", done, len(steps))
b.WriteString(detailLabelStyle.Render(progress))
case db.CardTemplate:
slots, ok := data["slots"].([]interface{})
if !ok {
break
}
b.WriteString(detailLabelStyle.Render(" slots:") + "\n")
for _, s := range slots {
slot, ok := s.(map[string]interface{})
if !ok {
continue
}
name, _ := slot["name"].(string)
def, _ := slot["default"].(string)
line := " ${" + name + "}"
if def != "" {
line += " " + detailValueStyle.Render("default: "+def)
}
b.WriteString(line + "\n")
}
case db.CardDecision:
if chose, ok := data["chose"].(string); ok && chose != "" {
b.WriteString(" " + detailLabelStyle.Render("chose: ") + detailValueStyle.Render(chose) + "\n")
}
if why, ok := data["why"].(string); ok && why != "" {
b.WriteString(" " + detailLabelStyle.Render("why: ") + detailValueStyle.Render(why) + "\n")
}
if rejected, ok := data["rejected"].([]interface{}); ok && len(rejected) > 0 {
items := make([]string, 0, len(rejected))
for _, r := range rejected {
if s, ok := r.(string); ok {
items = append(items, s)
}
}
if len(items) > 0 {
b.WriteString(" " + detailLabelStyle.Render("rejected: ") + detailValueStyle.Render(strings.Join(items, ", ")) + "\n")
}
}
case db.CardLink:
if url, ok := data["url"].(string); ok && url != "" {
b.WriteString(" " + detailLabelStyle.Render("↗ ") + detailValueStyle.Render(url) + "\n")
}
}
return b.String()
}
+8 -1
View File
@@ -12,7 +12,13 @@ func renderHelp(width, height int) string {
{"g/G home/end", "top / bottom"},
{"pgup/pgdn", "page up / down"},
{"enter", "view detail"},
{"esc", "back / cancel"},
{"esc", "back / clear filter"},
}},
{"Views", [][2]string{
{"1", "stream view"},
{"2", "cards view"},
{"s", "cycle sort (cards)"},
{"tab", "cycle intent (cards)"},
}},
{"Actions", [][2]string{
{"a", "add entity"},
@@ -20,6 +26,7 @@ func renderHelp(width, height int) string {
{"x", "toggle todo completion"},
{"!", "toggle pin"},
{"#", "filter by tag"},
{"p", "promote to card"},
}},
{"Detail View", [][2]string{
{"p", "promote to card"},
+8
View File
@@ -22,6 +22,10 @@ type keyMap struct {
Demote key.Binding
Copy key.Binding
Edit key.Binding
Stream key.Binding
Cards key.Binding
Sort key.Binding
Intent key.Binding
}
var keys = keyMap{
@@ -44,4 +48,8 @@ var keys = keyMap{
Demote: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "demote")),
Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy")),
Edit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")),
Stream: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "stream")),
Cards: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "cards")),
Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")),
Intent: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "intent")),
}
+167 -18
View File
@@ -1,6 +1,8 @@
package tui
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/lerko/nib/internal/db"
@@ -14,22 +16,64 @@ const (
stateInput
stateTagFilter
stateConfirm
statePromote
)
type viewMode int
const (
modeStream viewMode = iota
modeCards
)
type cardsSort int
const (
sortNewest cardsSort = iota
sortOldest
sortMostUsed
)
func (s cardsSort) String() string {
switch s {
case sortOldest:
return "oldest"
case sortMostUsed:
return "most used"
default:
return "newest"
}
}
func (s cardsSort) next() cardsSort {
switch s {
case sortNewest:
return sortOldest
case sortOldest:
return sortMostUsed
default:
return sortNewest
}
}
type model struct {
store *db.Store
state viewState
mode viewMode
width int
height int
list listModel
cards cardsModel
detail detailModel
input inputModel
filter filterModel
promote promoteModel
showHelp bool
filterTag string
confirmID string
cardsSort cardsSort
status string
err error
@@ -39,7 +83,9 @@ func newModel(store *db.Store) model {
return model{
store: store,
state: stateList,
mode: modeStream,
list: newListModel(),
cards: newCardsModel(),
detail: newDetailModel(),
input: newInputModel(),
filter: newFilterModel(),
@@ -55,6 +101,20 @@ func (m model) listParams() db.ListParams {
if m.filterTag != "" {
p.Tag = &m.filterTag
}
if m.mode == modeCards {
p.CardsOnly = true
switch m.cardsSort {
case sortNewest:
p.Sort = "created"
p.Order = "desc"
case sortOldest:
p.Sort = "created"
p.Order = "asc"
case sortMostUsed:
p.Sort = "use_count"
p.Order = "desc"
}
}
return p
}
@@ -64,12 +124,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.width = msg.Width
m.height = msg.Height
m.list.setSize(m.width, m.contentHeight())
m.cards.setSize(m.width, m.contentHeight())
m.detail.setSize(m.width, m.contentHeight())
m.filter.setHeight(m.contentHeight())
return m, nil
case entitiesLoadedMsg:
m.list.setEntities(msg.entities)
if m.mode == modeCards {
m.cards.setEntities(msg.entities)
} else {
m.list.setEntities(msg.entities)
}
m.err = nil
return m, nil
@@ -92,8 +157,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, loadEntities(m.store, m.listParams())
case entityPromotedMsg:
m.status = "promoted → snippet"
return m, m.reloadDetail(msg.id)
m.status = fmt.Sprintf("promoted → %s", msg.cardType)
m.state = stateList
return m, loadEntities(m.store, m.listParams())
case entityDemotedMsg:
m.status = "demoted → fluid"
@@ -136,6 +202,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateTagFilter(msg)
case stateConfirm:
return m.updateConfirm(msg)
case statePromote:
return m.updatePromote(msg)
default:
return m.updateKeys(msg)
}
@@ -166,6 +234,39 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.showHelp = true
return m, nil
case "1":
if m.mode != modeStream {
m.mode = modeStream
m.state = stateList
m.status = ""
return m, loadEntities(m.store, m.listParams())
}
return m, nil
case "2":
if m.mode != modeCards {
m.mode = modeCards
m.state = stateList
m.status = ""
return m, loadEntities(m.store, m.listParams())
}
return m, nil
case "s":
if m.mode == modeCards && m.state == stateList {
m.cardsSort = m.cardsSort.next()
m.status = "sort: " + m.cardsSort.String()
return m, loadEntities(m.store, m.listParams())
}
return m, nil
case "tab":
if m.mode == modeCards && m.state == stateList {
m.cards.setIntent(m.cards.intent.next())
return m, nil
}
return m, nil
case "a":
if m.state == stateList {
m.state = stateInput
@@ -187,7 +288,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "enter":
if m.state == stateList {
if e := m.list.selected(); e != nil {
if e := m.selectedEntity(); e != nil {
m.detail.setEntity(e)
m.state = stateDetail
}
@@ -196,7 +297,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "d":
if m.state == stateList {
if e := m.list.selected(); e != nil {
if e := m.selectedEntity(); e != nil {
m.confirmID = e.ID
m.state = stateConfirm
return m, confirmTimeout()
@@ -206,7 +307,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "x":
if m.state == stateList {
if e := m.list.selected(); e != nil && e.Glyph == db.GlyphTodo {
if e := m.selectedEntity(); e != nil && e.Glyph == db.GlyphTodo {
return m, toggleTodo(m.store, e)
}
}
@@ -231,12 +332,15 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
case "p":
if m.state == stateDetail && m.detail.entity != nil {
if m.detail.entity.CardType != nil {
e := m.selectedEntity()
if e != nil {
if e.CardType != nil {
m.status = "already a card"
return m, nil
}
return m, promoteEntity(m.store, m.detail.entity.ID)
m.promote = newPromoteModel(e.ID, e.Body)
m.state = statePromote
return m, nil
}
return m, nil
@@ -265,7 +369,11 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch m.state {
case stateList:
m.list = m.list.update(msg)
if m.mode == modeCards {
m.cards = m.cards.update(msg)
} else {
m.list = m.list.update(msg)
}
case stateDetail:
m.detail = m.detail.update(msg)
}
@@ -319,6 +427,20 @@ func (m model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m model) updatePromote(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc", "q":
m.state = stateList
return m, nil
case "enter":
ct := m.promote.selectedType()
return m, promoteEntity(m.store, m.promote.entityID, ct, m.promote.body)
default:
m.promote = m.promote.update(msg.String())
return m, nil
}
}
func (m model) View() string {
if m.showHelp {
return renderHelp(m.width, m.height)
@@ -327,21 +449,47 @@ func (m model) View() string {
var content string
switch m.state {
case stateList, stateInput, stateConfirm:
content = m.list.view(m.width)
if m.mode == modeCards {
content = m.cards.view(m.width)
} else {
content = m.list.view(m.width)
}
case stateDetail:
content = m.detail.view(m.width)
case stateTagFilter:
content = m.filter.view(m.width)
case statePromote:
content = m.promote.view(m.width)
}
header := m.headerView()
footer := m.footerView()
return header + "\n" + content + "\n" + footer
}
func (m model) headerView() string {
header := titleStyle.Render("nib")
modeName := "stream"
if m.mode == modeCards {
modeName = "cards"
}
header += " " + modeStyle.Render(modeName)
if m.filterTag != "" {
header += " " + filterPillStyle.Render("#"+m.filterTag)
}
footer := m.footerView()
if m.mode == modeCards && m.cards.intent != intentAll {
header += " " + affordanceStyle.Render(m.cards.intent.String())
}
return header + "\n" + content + "\n" + footer
if m.mode == modeCards {
header += " " + idStyle.Render("("+m.cardsSort.String()+")")
}
return header
}
func (m model) footerView() string {
@@ -369,13 +517,14 @@ func (m model) contentHeight() int {
}
func (m model) selectedEntity() *db.Entity {
switch m.state {
case stateList:
return m.list.selected()
case stateDetail:
switch {
case m.state == stateDetail:
return m.detail.entity
case m.mode == modeCards:
return m.cards.selected()
default:
return m.list.selected()
}
return nil
}
func (m model) reloadDetail(id string) tea.Cmd {
+84
View File
@@ -0,0 +1,84 @@
package tui
import (
"github.com/lerko/nib/internal/carddata"
"github.com/lerko/nib/internal/db"
"github.com/lerko/nib/internal/display"
)
type promoteOption struct {
cardType db.CardType
label string
group string
}
var promoteOptions = []promoteOption{
{db.CardSnippet, "snippet", "grab"},
{db.CardNote, "note", "read"},
{db.CardLink, "link", "read"},
{db.CardDecision, "decision", "read"},
{db.CardTemplate, "template", "fill"},
{db.CardChecklist, "checklist", "fill"},
}
type promoteModel struct {
cursor int
entityID string
body string
suggested *db.CardType
}
func newPromoteModel(entityID, body string) promoteModel {
return promoteModel{
entityID: entityID,
body: body,
suggested: carddata.DetectCardType(body),
}
}
func (p promoteModel) selectedType() db.CardType {
return promoteOptions[p.cursor].cardType
}
func (p promoteModel) update(key string) promoteModel {
switch key {
case "up", "k":
if p.cursor > 0 {
p.cursor--
}
case "down", "j":
if p.cursor < len(promoteOptions)-1 {
p.cursor++
}
}
return p
}
func (p promoteModel) view(width int) string {
var b string
b += titleStyle.Render("promote to card") + "\n\n"
currentGroup := ""
for i, opt := range promoteOptions {
if opt.group != currentGroup {
currentGroup = opt.group
b += dateHeaderStyle.Render("── "+currentGroup+" ──") + "\n"
}
glyph := display.DisplayGlyph(db.GlyphNote, &opt.cardType)
label := glyph + " " + opt.label
if p.suggested != nil && *p.suggested == opt.cardType {
label += " " + affordanceStyle.Render("*")
}
if i == p.cursor {
b += selectedItemStyle.Render(" "+label) + "\n"
} else {
b += listItemStyle.Render(label) + "\n"
}
}
b += "\n" + helpStyle.Render("enter:select esc:cancel")
return b
}
+12 -2
View File
@@ -23,7 +23,12 @@ func renderStatusBar(m model, width int) string {
}
func countText(m model) string {
total := len(m.list.entities)
var total int
if m.mode == modeCards {
total = len(m.cards.filtered)
} else {
total = len(m.list.entities)
}
if m.filterTag != "" {
return fmt.Sprintf("%d entities #%s", total, m.filterTag)
}
@@ -40,7 +45,12 @@ func contextHints(m model) string {
return "j/k:nav enter:select esc:cancel"
case stateConfirm:
return "y:confirm n:cancel"
case statePromote:
return "j/k:nav enter:select esc:cancel"
default:
return "a:add d:del x:todo #:filter ?:help q:quit"
if m.mode == modeCards {
return "1:stream 2:cards s:sort tab:intent a:add ?:help q:quit"
}
return "1:stream 2:cards a:add d:del x:todo #:filter ?:help q:quit"
}
}
+24
View File
@@ -77,4 +77,28 @@ var (
helpDescStyle = lipgloss.NewStyle().
Foreground(dim)
affordanceStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#5B8EF0", Dark: "#7AAFFF"}).
Bold(true)
useCountStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#B07D3A", Dark: "#D4A54A"})
modeStyle = lipgloss.NewStyle().
Foreground(dim).
Bold(true)
detailLabelStyle = lipgloss.NewStyle().
Foreground(highlight).
Bold(true)
detailValueStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#555555", Dark: "#BBBBBB"})
checkDoneStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"})
checkPendingStyle = lipgloss.NewStyle().
Foreground(dim)
)