▍ humdrum codex / glint v1.0.2
license AGPL-3.0

feat: inline spellcheck rendering + engine in the editor (TASK-020)

92f9461e5066aca5b617130fed7c0eee8750c885
Kevin Kortum <kevinkortum@me.com> · 2026-06-29 18:20

parent 0b443645

feat: inline spellcheck rendering + engine in the editor (TASK-020)

Wire the spell package into the editor's visual model:
- Span gains Prose (spellcheck-eligible) and Wavy/UnderColor (undercurl). The
  markdown scanner tags prose-bearing spans (plain text, heading/blockquote text,
  bold/italic/highlight content); code, markup, URLs, wikilinks, link targets,
  and frontmatter values stay non-prose and are never flagged.
- renderSpans/renderSpansCursor emit raw undercurl SGR (ESC[4:3m + ESC[58:2 color)
  around misspelled spans, preserving the markup-visible invariant; terminals
  without 4:3 degrade gracefully. sliceSpans carries the undercurl through wrapping;
  selection highlight supersedes it.
- spellPass splits prose spans into words, skipping URLs/emails, acronyms,
  camel/brand casing, and sub-3-letter tokens, flagging the rest via a cached
  membership test. Gated off for code files and when toggled off.
- theme.Spell red added to all three palettes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjNrdAWUdkaxFyGdrPHaBj

10 files changed

internal/editor/editor.go +5 −0
@@ -4,6 +4,7 @@ import (
 	"path/filepath"
 	"strings"
 
+	"glint/internal/spell"
 	"glint/internal/theme"
 
 	tea "github.com/charmbracelet/bubbletea"
@@ -39,6 +40,10 @@
 	codeFile string // filename for the chroma lexer; "" = prose/markdown scanner (TASK-018)
 
 	buildCount int // count of buildVisual scans; perf guard for tests (TASK-004)
+
+	dict       *spell.Dict     // loaded spellchecker; nil = inert (TASK-020)
+	spellOn    bool            // session spellcheck toggle
+	spellCache map[string]bool // word -> known, cleared when the personal dict changes
 }
 
 // SetLanguage selects the scanner from the file's extension: markdown/text/no
internal/editor/scanner.go +19 −6
@@ -89,7 +89,7 @@ 	// Heading: dim the hashes, bold + accent the text.
 	if loc := headingRe.FindStringIndex(line); loc != nil {
 		spans := []Span{{Text: line[:loc[1]], Style: muted}}
 		if rest := line[loc[1]:]; rest != "" {
-			spans = append(spans, Span{Text: rest, Style: lipgloss.NewStyle().Foreground(th.Heading).Bold(true)})
+			spans = append(spans, Span{Text: rest, Style: lipgloss.NewStyle().Foreground(th.Heading).Bold(true), Prose: true})
 		}
 		return spans
 	}
@@ -98,7 +98,7 @@ 	// Blockquote: dim the > marker, italicize the quoted text at base color.
 	if loc := blockquoteRe.FindStringIndex(line); loc != nil {
 		spans := []Span{{Text: line[:loc[1]], Style: muted}}
 		if rest := line[loc[1]:]; rest != "" {
-			spans = append(spans, Span{Text: rest, Style: lipgloss.NewStyle().Foreground(th.Text).Italic(true)})
+			spans = append(spans, Span{Text: rest, Style: lipgloss.NewStyle().Foreground(th.Text).Italic(true), Prose: true})
 		}
 		return spans
 	}
@@ -174,7 +174,7 @@ 	var spans []Span
 	start, i := 0, 0
 	flush := func(end int) {
 		if end > start {
-			spans = append(spans, Span{Text: string(r[start:end]), Style: plain})
+			spans = append(spans, Span{Text: string(r[start:end]), Style: plain, Prose: true})
 		}
 	}
 	for i < len(r) {
@@ -207,7 +207,7 @@ 	}
 	// Highlight: ==...==
 	if r[i] == '=' && i+1 < len(r) && r[i+1] == '=' {
 		if j := indexSeq(r, "==", i+2); j >= 0 {
-			return wrap("==", string(r[i+2:j]), "==", muted,
+			return wrapProse("==", string(r[i+2:j]), "==", muted,
 				lipgloss.NewStyle().Foreground(th.Text).Background(th.Highlight)), j + 2 - i, true
 		}
 	}
@@ -259,7 +259,7 @@ 	// Bold: ** or __
 	for _, d := range []string{"**", "__"} {
 		if hasPrefixRunes(r, i, d) {
 			if j := indexSeq(r, d, i+2); j >= 0 {
-				return wrap(d, string(r[i+2:j]), d, muted,
+				return wrapProse(d, string(r[i+2:j]), d, muted,
 					lipgloss.NewStyle().Foreground(th.Emphasis).Bold(true)), j + 2 - i, true
 			}
 		}
@@ -268,7 +268,7 @@ 	// Italic: * or _
 	if r[i] == '*' || r[i] == '_' {
 		if j := indexRune(r, r[i], i+1); j > i {
 			m := string(r[i])
-			return wrap(m, string(r[i+1:j]), m, muted,
+			return wrapProse(m, string(r[i+1:j]), m, muted,
 				lipgloss.NewStyle().Foreground(th.Emphasis).Italic(true)), j + 1 - i, true
 		}
 	}
@@ -283,6 +283,19 @@ 	if content != "" {
 		spans = append(spans, Span{Text: content, Style: role})
 	}
 	return append(spans, Span{Text: closing, Style: markup})
+}
+
+// wrapProse is wrap for constructs whose content is natural-language prose
+// (bold, italic, ==highlight==), tagging the content span so spellcheck examines
+// it. The markup delimiters stay non-prose.
+func wrapProse(open, content, closing string, markup, role lipgloss.Style) []Span {
+	spans := wrap(open, content, closing, markup, role)
+	for i := range spans {
+		if spans[i].Text == content && content != "" {
+			spans[i].Prose = true
+		}
+	}
+	return spans
 }
 
 func indexRune(r []rune, c rune, from int) int {
internal/editor/selection.go +1 −0
@@ -37,6 +37,7 @@ 	out := sliceSpans(spans, 0, a)
 	mid := sliceSpans(spans, a, b)
 	for i := range mid {
 		mid[i].Style = sel
+		mid[i].Wavy = false // selection highlight supersedes the misspell undercurl
 	}
 	out = append(out, mid...)
 	return append(out, sliceSpans(spans, b, total)...)
internal/editor/span.go +52 −7
@@ -1,37 +1,69 @@
 package editor
 
 import (
+	"fmt"
 	"strings"
 
 	"github.com/charmbracelet/lipgloss"
 )
 
-// Span is a run of text with a single Lipgloss style.
+// Span is a run of text with a single Lipgloss style. A misspelled span also
+// carries a curly red underline (undercurl), which lipgloss/termenv can't
+// express, so it is emitted as raw SGR around the styled text.
 type Span struct {
-	Text  string
-	Style lipgloss.Style
+	Text       string
+	Style      lipgloss.Style
+	Prose      bool           // natural-language text eligible for spellcheck (TASK-020)
+	Wavy       bool           // draw a curly underline (misspelled word; TASK-020)
+	UnderColor lipgloss.Color // undercurl color when Wavy
+}
+
+// undercurlOpen returns the SGR that turns on a curly underline in the span's
+// color: ESC[4:3m (curly) + ESC[58:2::R:G:Bm (underline color). Terminals
+// without 4:3 support ignore the subparameter or fall back to a straight
+// underline, so unsupported terminals degrade gracefully. The trailing reset
+// from the lipgloss style (or an explicit reset) clears it.
+func undercurlOpen(c lipgloss.Color) string {
+	r, g, b := hexRGB(string(c))
+	return fmt.Sprintf("\x1b[4:3m\x1b[58:2::%d:%d:%dm", r, g, b)
+}
+
+const sgrReset = "\x1b[0m"
+
+// styled renders one span's text, wrapping it in undercurl SGR when Wavy. The
+// span's own text is untouched, preserving the markup-visible invariant: the
+// concatenation of span texts still equals the raw line.
+func (s Span) styled() string {
+	if !s.Wavy {
+		return s.Style.Render(s.Text)
+	}
+	return undercurlOpen(s.UnderColor) + s.Style.Render(s.Text) + sgrReset
 }
 
 // renderSpans concatenates styled spans into a single string.
 func renderSpans(spans []Span) string {
 	var b strings.Builder
 	for _, s := range spans {
-		b.WriteString(s.Style.Render(s.Text))
+		b.WriteString(s.styled())
 	}
 	return b.String()
 }
 
 // renderSpansCursor renders spans rune-by-rune, drawing the cursor cell at the
 // given rune column with cursorStyle. A cursor at or past the end of the line
-// is drawn as a styled space.
+// is drawn as a styled space. Wavy spans keep their undercurl on every rune
+// except the cursor cell.
 func renderSpansCursor(spans []Span, col int, cursorStyle lipgloss.Style) string {
 	var b strings.Builder
 	idx := 0
 	for _, s := range spans {
 		for _, r := range s.Text {
-			if idx == col {
+			switch {
+			case idx == col:
 				b.WriteString(cursorStyle.Render(string(r)))
-			} else {
+			case s.Wavy:
+				b.WriteString(undercurlOpen(s.UnderColor) + s.Style.Render(string(r)) + sgrReset)
+			default:
 				b.WriteString(s.Style.Render(string(r)))
 			}
 			idx++
@@ -42,3 +74,16 @@ 		b.WriteString(cursorStyle.Render(" "))
 	}
 	return b.String()
 }
+
+// hexRGB parses a "#RRGGBB" color into 8-bit components. A malformed or empty
+// color yields the theme-agnostic fallback of pure red so an underline still
+// shows rather than vanishing.
+func hexRGB(hex string) (int, int, int) {
+	if len(hex) == 7 && hex[0] == '#' {
+		var r, g, b int
+		if _, err := fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b); err == nil {
+			return r, g, b
+		}
+	}
+	return 217, 54, 42
+}
internal/editor/span_test.go +53 −0
@@ -0,0 +1,53 @@
+package editor
+
+import (
+	"regexp"
+	"strings"
+	"testing"
+
+	"github.com/charmbracelet/lipgloss"
+)
+
+var ansiRe = regexp.MustCompile("\x1b\\[[0-9;:]*m")
+
+func stripANSI(s string) string { return ansiRe.ReplaceAllString(s, "") }
+
+const undercurlSGR = "\x1b[4:3m" // curly underline
+
+func TestWavySpanEmitsUndercurl(t *testing.T) {
+	sp := []Span{{Text: "teh", Style: lipgloss.NewStyle().Foreground(lipgloss.Color("#CECDC3")), Wavy: true, UnderColor: lipgloss.Color("#D14D41")}}
+	out := renderSpans(sp)
+	if !strings.Contains(out, undercurlSGR) {
+		t.Errorf("wavy span missing undercurl SGR %q in %q", undercurlSGR, out)
+	}
+	// Underline-color SGR: 58:2::R:G:B (D14D41 = 209,77,65).
+	if !strings.Contains(out, "58:2::209:77:65") {
+		t.Errorf("wavy span missing underline-color SGR in %q", out)
+	}
+	if got := stripANSI(out); got != "teh" {
+		t.Errorf("invariant broken: stripped %q != %q", got, "teh")
+	}
+}
+
+func TestNonWavySpanHasNoUndercurl(t *testing.T) {
+	sp := []Span{{Text: "the", Style: lipgloss.NewStyle().Foreground(lipgloss.Color("#CECDC3"))}}
+	out := renderSpans(sp)
+	if strings.Contains(out, undercurlSGR) {
+		t.Errorf("non-wavy span unexpectedly emitted undercurl: %q", out)
+	}
+	if got := stripANSI(out); got != "the" {
+		t.Errorf("stripped %q != %q", got, "the")
+	}
+}
+
+func TestWavySpanCursorRenderKeepsText(t *testing.T) {
+	sp := []Span{{Text: "teh", Style: lipgloss.NewStyle().Foreground(lipgloss.Color("#CECDC3")), Wavy: true, UnderColor: lipgloss.Color("#D14D41")}}
+	cur := lipgloss.NewStyle().Foreground(lipgloss.Color("#100F0F")).Background(lipgloss.Color("#CE5D97"))
+	out := renderSpansCursor(sp, 1, cur)
+	if got := stripANSI(out); got != "teh" {
+		t.Errorf("cursor render invariant broken: stripped %q != %q", got, "teh")
+	}
+	if !strings.Contains(out, undercurlSGR) {
+		t.Errorf("wavy cursor render missing undercurl on non-cursor runes: %q", out)
+	}
+}
internal/editor/spellcheck.go +157 −0
@@ -0,0 +1,157 @@
+package editor
+
+import (
+	"regexp"
+	"strings"
+
+	"glint/internal/spell"
+)
+
+// urlRe matches URLs and bare emails so their letters aren't spellchecked.
+var urlRe = regexp.MustCompile(`(?:https?://|www\.|mailto:)\S+|\S+@\S+\.\S+`)
+
+// wordRe matches a candidate word: a letter followed by letters or apostrophes
+// (straight or curly), so possessives ride along with their base.
+var wordRe = regexp.MustCompile(`\p{L}[\p{L}'’]*`)
+
+// SetDict gives the editor a loaded dictionary; spellcheck is inert until one is
+// set. SetSpell toggles the user preference.
+func (e *Editor) SetDict(d *spell.Dict) {
+	e.dict = d
+	e.spellCache = map[string]bool{}
+}
+
+// SetSpell sets whether spellcheck is enabled for the session.
+func (e *Editor) SetSpell(on bool) { e.spellOn = on }
+
+// ToggleSpell flips spellcheck and returns the new state.
+func (e *Editor) ToggleSpell() bool { e.spellOn = !e.spellOn; return e.spellOn }
+
+// SpellEnabled reports the user's session toggle (independent of whether a dict
+// is loaded or the file is code).
+func (e *Editor) SpellEnabled() bool { return e.spellOn }
+
+// spellActive reports whether spellcheck should actually run: enabled, a
+// dictionary is loaded, and the buffer is prose (not a recognized code file,
+// which reuses the TASK-018 extension routing via codeFile).
+func (e *Editor) spellActive() bool {
+	return e.spellOn && e.dict != nil && e.codeFile == ""
+}
+
+// AddToDictionary adds the word to the personal dictionary and invalidates the
+// spell cache so its underline clears on the next render.
+func (e *Editor) AddToDictionary(word string) error {
+	if e.dict == nil {
+		return nil
+	}
+	err := e.dict.Add(word)
+	e.spellCache = map[string]bool{} // membership changed; drop cached verdicts
+	return err
+}
+
+// spellKnown is a cached membership test; the cache is dropped when the personal
+// dictionary changes.
+func (e *Editor) spellKnown(word string) bool {
+	lw := strings.ToLower(word)
+	if v, ok := e.spellCache[lw]; ok {
+		return v
+	}
+	k := e.dict.Known(lw)
+	if e.spellCache == nil {
+		e.spellCache = map[string]bool{}
+	}
+	e.spellCache[lw] = k
+	return k
+}
+
+// spellPass splits every prose span into words and re-emits misspelled ones as
+// Wavy (undercurl) spans, leaving all other spans untouched. The per-line span
+// text is unchanged, so the markup-visible invariant holds.
+func (e *Editor) spellPass(all [][]Span) [][]Span {
+	for li, spans := range all {
+		var out []Span
+		changed := false
+		for _, sp := range spans {
+			if !sp.Prose {
+				out = append(out, sp)
+				continue
+			}
+			split := e.splitProse(sp)
+			if len(split) != 1 {
+				changed = true
+			}
+			out = append(out, split...)
+		}
+		if changed {
+			all[li] = out
+		}
+	}
+	return all
+}
+
+// splitProse partitions one prose span's text, marking misspelled words with the
+// theme's undercurl. Words inside URLs/emails, acronyms, camel/brand casing, and
+// words under three letters are left alone. When nothing is misspelled the
+// original span is returned unchanged.
+func (e *Editor) splitProse(sp Span) []Span {
+	text := sp.Text
+	skips := urlRe.FindAllStringIndex(text, -1)
+	var out []Span
+	last := 0
+	emit := func(s string, wavy bool) {
+		if s == "" {
+			return
+		}
+		ns := sp
+		ns.Text = s
+		ns.Wavy = wavy
+		if wavy {
+			ns.UnderColor = e.theme.Spell
+		}
+		out = append(out, ns)
+	}
+	for _, w := range wordRe.FindAllStringIndex(text, -1) {
+		word := text[w[0]:w[1]]
+		if overlapsAny(w, skips) || !checkableWord(word) || e.spellKnown(word) {
+			continue
+		}
+		emit(text[last:w[0]], false)
+		emit(word, true)
+		last = w[1]
+	}
+	if last == 0 {
+		return []Span{sp} // nothing flagged
+	}
+	emit(text[last:], false)
+	return out
+}
+
+// overlapsAny reports whether byte range r overlaps any range in rs.
+func overlapsAny(r []int, rs [][]int) bool {
+	for _, s := range rs {
+		if r[0] < s[1] && s[0] < r[1] {
+			return true
+		}
+	}
+	return false
+}
+
+// checkableWord filters out tokens that aren't ordinary prose words: anything
+// under three letters, all-caps acronyms (NASA, API), and internal-capital
+// brand/camelCase (iOS, GitHub). Only the first letter may be uppercase.
+func checkableWord(w string) bool {
+	r := []rune(w)
+	if len(r) < 3 {
+		return false
+	}
+	allUpper := true
+	for i, c := range r {
+		if c >= 'a' && c <= 'z' || c == '\'' || c == '’' {
+			allUpper = false
+		}
+		if i > 0 && c >= 'A' && c <= 'Z' {
+			return false // internal uppercase: brand / camelCase
+		}
+	}
+	return !allUpper
+}
internal/editor/spellcheck_test.go +108 −0
@@ -0,0 +1,108 @@
+package editor
+
+import (
+	"testing"
+
+	"glint/internal/spell"
+)
+
+func spellEditor(t *testing.T, content string) *Editor {
+	t.Helper()
+	d, err := spell.Load()
+	if err != nil {
+		t.Fatalf("spell.Load: %v", err)
+	}
+	e := New()
+	e.SetContent([]byte(content))
+	e.SetDict(d)
+	e.SetSpell(true)
+	return e
+}
+
+// wavyWords returns the set of span texts rendered with the misspell undercurl.
+func wavyWords(e *Editor) map[string]bool {
+	got := map[string]bool{}
+	for _, vr := range e.buildVisual() {
+		for _, sp := range vr.spans {
+			if sp.Wavy {
+				got[sp.Text] = true
+			}
+		}
+	}
+	return got
+}
+
+func TestSpellFlagsMisspelledProse(t *testing.T) {
+	e := spellEditor(t, "This sentence has a recieve typo")
+	w := wavyWords(e)
+	if !w["recieve"] {
+		t.Errorf("misspelled \"recieve\" not flagged; wavy=%v", w)
+	}
+	if w["sentence"] || w["typo"] || w["This"] {
+		t.Errorf("correct words flagged; wavy=%v", w)
+	}
+}
+
+func TestSpellSkipsInlineCode(t *testing.T) {
+	e := spellEditor(t, "use the `recieve` function")
+	if wavyWords(e)["recieve"] {
+		t.Error("inline code content should not be spellchecked")
+	}
+}
+
+func TestSpellSkipsURLs(t *testing.T) {
+	e := spellEditor(t, "see https://recieve.example.com/seperate for more")
+	w := wavyWords(e)
+	if w["recieve"] || w["seperate"] {
+		t.Errorf("URL words should not be flagged; wavy=%v", w)
+	}
+}
+
+func TestSpellSkipsWikilinks(t *testing.T) {
+	e := spellEditor(t, "linked [[recieve note]] here")
+	if wavyWords(e)["recieve"] {
+		t.Error("wikilink target should not be spellchecked")
+	}
+}
+
+func TestSpellSkipsFrontmatter(t *testing.T) {
+	e := spellEditor(t, "---\ntitle: recieve draft\n---\nbody text")
+	if wavyWords(e)["recieve"] {
+		t.Error("frontmatter value should not be spellchecked")
+	}
+}
+
+func TestSpellSkipsAcronymsAndShortWords(t *testing.T) {
+	e := spellEditor(t, "the NASA API is ok")
+	w := wavyWords(e)
+	if w["NASA"] || w["API"] || w["ok"] || w["is"] {
+		t.Errorf("acronyms/short words should not be flagged; wavy=%v", w)
+	}
+}
+
+func TestSpellOffWhenDisabled(t *testing.T) {
+	e := spellEditor(t, "a recieve typo")
+	e.SetSpell(false)
+	if len(wavyWords(e)) != 0 {
+		t.Error("no words should be flagged when spellcheck is off")
+	}
+}
+
+func TestSpellOffForCodeFiles(t *testing.T) {
+	e := spellEditor(t, "recieve := 1")
+	e.SetLanguage("main.go") // code file: spellcheck must stay off
+	if len(wavyWords(e)) != 0 {
+		t.Error("code files should not be spellchecked")
+	}
+}
+
+func TestSpellHonorsPersonalAdd(t *testing.T) {
+	e := spellEditor(t, "my zzplonk word")
+	if !wavyWords(e)["zzplonk"] {
+		t.Fatal("precondition: zzplonk should flag")
+	}
+	e.AddToDictionary("zzplonk")
+	if wavyWords(e)["zzplonk"] {
+		t.Error("word added to dictionary should clear its underline live")
+	}
+}
internal/editor/wrap.go +10 −1
@@ -60,7 +60,13 @@ 		pos = spEnd
 		lo := max(spStart, start)
 		hi := min(spEnd, end)
 		if lo < hi {
-			out = append(out, Span{Text: string(r[lo-spStart : hi-spStart]), Style: sp.Style})
+			out = append(out, Span{
+				Text:       string(r[lo-spStart : hi-spStart]),
+				Style:      sp.Style,
+				Prose:      sp.Prose,
+				Wavy:       sp.Wavy,
+				UnderColor: sp.UnderColor,
+			})
 		}
 	}
 	return out
@@ -85,6 +91,9 @@ 	if e.codeFile != "" {
 		all = ScanCode(e.Lines, e.codeFile, e.theme)
 	} else {
 		all = ScanLines(e.Lines, e.theme)
+	}
+	if e.spellActive() {
+		all = e.spellPass(all)
 	}
 	var rows []vrow
 	for li := range e.Lines {
internal/theme/theme.go +1 −0
@@ -23,6 +23,7 @@ 	Blockquote lipgloss.Color // blockquote marker + border (muted tone)
 	Comment    lipgloss.Color // HTML / %% comments — visible, not dimmed
 	Accent     lipgloss.Color // frontmatter keys, selection
 	Highlight  lipgloss.Color // ==highlight== background tint
+	Spell      lipgloss.Color // misspelled-word undercurl (red)
 
 	// UI colors.
 	Background lipgloss.Color
internal/theme/themes.go +3 −0
@@ -28,6 +28,7 @@ 		Blockquote:   lipgloss.Color("#878580"), // base-500 — quote marker/border
 		Comment:      lipgloss.Color("#DA702C"), // orange-400 — visible meta
 		Accent:       lipgloss.Color("#D0A215"), // yellow-400
 		Highlight:    lipgloss.Color("#3A3517"), // deep olive — ==highlight== bg
+		Spell:        lipgloss.Color("#D14D41"), // red-400 — misspell undercurl
 		StatusFg:     lipgloss.Color("#100F0F"),
 		StatusBg:     lipgloss.Color("#4385BE"),
 		SelFg:        lipgloss.Color("#100F0F"),
@@ -55,6 +56,7 @@ 		Blockquote:   lipgloss.Color("#6F6E69"), // base-600 — quote marker/border
 		Comment:      lipgloss.Color("#BC5215"), // orange-600 — visible meta
 		Accent:       lipgloss.Color("#AD8301"), // yellow-600
 		Highlight:    lipgloss.Color("#F0E6BE"), // pale yellow — ==highlight== bg
+		Spell:        lipgloss.Color("#AF3029"), // red-600 — misspell undercurl
 		StatusFg:     lipgloss.Color("#FFFCF0"),
 		StatusBg:     lipgloss.Color("#205EA6"),
 		SelFg:        lipgloss.Color("#FFFCF0"),
@@ -82,6 +84,7 @@ 		Blockquote:   lipgloss.Color("#6C6C8A"),
 		Comment:      lipgloss.Color("#FFB454"),
 		Accent:       lipgloss.Color("#FFD500"),
 		Highlight:    lipgloss.Color("#3A2E4D"),
+		Spell:        lipgloss.Color("#FF5F87"), // pink-red — misspell undercurl
 		StatusFg:     lipgloss.Color("#16161E"),
 		StatusBg:     lipgloss.Color("#6B50FF"),
 		SelFg:        lipgloss.Color("#16161E"),