feat: Flexoki-meets-Alabaster highlighting model (first pass)
127243797a8ab6658a3521e3199e1a00488bc60f
humdrum <me@humdrum.me> · 2026-06-28 21:55
parent 90e7280d
feat: Flexoki-meets-Alabaster highlighting model (first pass) Reworks markdown highlighting per the design: prose stays at base Text, all markup punctuation is Muted, and color is reserved for headings (bold + accent, hashes dimmed), code, links/URLs/wikilinks (shared reference color, brackets dimmed), list markers, comments (visible), and an ==highlight== background. Bold/italic render as real styling in a higher-contrast Emphasis tone with dimmed markers; blockquotes dim the > marker and italicize the text. Themes: Flexoki light + dark repainted with proper base/accent roles (cyan links, green code, blue headings, magenta markers, orange comments, yellow highlight); adds Emphasis/Comment/Highlight roles; Wikilink folds into Link. buildVisual preserves per-span highlight backgrounds when painting the canvas. Deferred: per-level heading deepening, the blockquote left-border + text inset (display layer), and the broader contrast-tuning pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6 files changed
internal/editor/scanner.go +85 −36
@@ -10,8 +10,9 @@ "github.com/charmbracelet/lipgloss"
)
var (
- headingRe = regexp.MustCompile(`^\s*#{1,6}\s`)
- listRe = regexp.MustCompile(`^\s*([-*+]|\d+\.)\s`)
+ headingRe = regexp.MustCompile(`^\s*#{1,6}\s`)
+ listRe = regexp.MustCompile(`^\s*([-*+]|\d+\.)\s`)
+ blockquoteRe = regexp.MustCompile(`^\s*>\s?`)
)
// blockState carries cross-line context (fenced code, leading frontmatter).
@@ -23,7 +24,11 @@ }
// ScanLines styles every line, threading block state across lines. The returned
// slice is parallel to the input: out[i] are the spans for lines[i], whose
-// concatenated text equals lines[i] exactly.
+// concatenated text equals lines[i] exactly (the markup-visible invariant).
+//
+// Highlighting model: prose stays at Text; all markup punctuation is Muted;
+// color is reserved for headings, code, links, list markers, comments, and a
+// highlight background; bold/italic render as real styling in the Emphasis tone.
func ScanLines(lines []string, th theme.Theme) [][]Span {
st := &blockState{}
out := make([][]Span, len(lines))
@@ -34,20 +39,21 @@ return out
}
func scanLine(row int, line string, st *blockState, th theme.Theme) []Span {
+ muted := lipgloss.NewStyle().Foreground(th.Muted)
code := lipgloss.NewStyle().Foreground(th.Code)
- muted := lipgloss.NewStyle().Foreground(th.Blockquote)
trimmed := strings.TrimSpace(line)
- // Fenced code block: body lines are entirely code-colored.
+ // Fenced code block: delimiters muted, body entirely code-colored.
if st.inFence {
if strings.HasPrefix(trimmed, "```") {
st.inFence = false
+ return wholeLine(line, muted)
}
return wholeLine(line, code)
}
if strings.HasPrefix(trimmed, "```") {
st.inFence = true
- return wholeLine(line, code)
+ return wholeLine(line, muted)
}
// Leading YAML frontmatter: only valid starting at row 0.
@@ -64,17 +70,30 @@ st.inFrontmatter = true
return wholeLine(line, muted)
}
- // Heading: color the whole line, hashes included.
- if headingRe.MatchString(line) {
- return wholeLine(line, lipgloss.NewStyle().Foreground(th.Heading).Bold(true))
+ // Comment line: visible, not dimmed.
+ if strings.HasPrefix(trimmed, "<!--") || strings.HasPrefix(trimmed, "%%") {
+ return wholeLine(line, lipgloss.NewStyle().Foreground(th.Comment))
+ }
+
+ // 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)})
+ }
+ return spans
}
- // Blockquote.
- if strings.HasPrefix(trimmed, ">") {
- return wholeLine(line, muted)
+ // 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)})
+ }
+ return spans
}
- // List marker, then inline-scan the remainder.
+ // List marker (colored), then inline-scan the remainder.
if loc := listRe.FindStringIndex(line); loc != nil {
marker := line[:loc[1]]
rest := line[loc[1]:]
@@ -94,13 +113,10 @@ return []Span{{Text: line, Style: style}}
}
// scanFrontmatter highlights one YAML frontmatter line, keeping every character
-// visible: the spans concatenate back to the raw line exactly.
-// - "# comment" -> whole line muted
-// - " - item" -> leading ws + dash (ListMarker) + item (Text)
-// - "key: value" -> "key:" (Accent) + " value" (Text)
-// - anything else -> whole line Text
+// visible. Frontmatter is metadata: keys lightly accented, values base, markers
+// muted.
func scanFrontmatter(line string, th theme.Theme) []Span {
- muted := lipgloss.NewStyle().Foreground(th.Blockquote)
+ muted := lipgloss.NewStyle().Foreground(th.Muted)
accent := lipgloss.NewStyle().Foreground(th.Accent)
plain := lipgloss.NewStyle().Foreground(th.Text)
marker := lipgloss.NewStyle().Foreground(th.ListMarker)
@@ -139,7 +155,8 @@ return wholeLine(line, plain)
}
// scanInline emits styled spans for inline constructs, keeping all markup
-// characters visible. Plain text gets the theme's Text foreground.
+// characters visible (markers Muted, content in its role color). Plain text gets
+// the theme's Text foreground.
func scanInline(text string, th theme.Theme) []Span {
plain := lipgloss.NewStyle().Foreground(th.Text)
r := []rune(text)
@@ -151,9 +168,9 @@ spans = append(spans, Span{Text: string(r[start:end]), Style: plain})
}
}
for i < len(r) {
- if tok, style, n, ok := matchToken(r, i, th); ok {
+ if toks, n, ok := matchToken(r, i, th); ok {
flush(i)
- spans = append(spans, Span{Text: tok, Style: style})
+ spans = append(spans, toks...)
i += n
start = i
continue
@@ -164,28 +181,49 @@ flush(len(r))
return spans
}
-// matchToken tries to match an inline construct starting at r[i]. It returns the
-// token (including its markup), the style, the rune length, and whether matched.
-func matchToken(r []rune, i int, th theme.Theme) (string, lipgloss.Style, int, bool) {
+// matchToken tries to match an inline construct starting at r[i], returning its
+// spans (markup Muted, content in its role color), the rune length, and whether
+// matched. The spans' concatenated text equals the original token exactly.
+func matchToken(r []rune, i int, th theme.Theme) ([]Span, int, bool) {
+ muted := lipgloss.NewStyle().Foreground(th.Muted)
+
// Inline code: `...`
if r[i] == '`' {
if j := indexRune(r, '`', i+1); j > i {
- return string(r[i : j+1]), lipgloss.NewStyle().Foreground(th.Code), j + 1 - i, true
+ return wrap("`", string(r[i+1:j]), "`", muted,
+ lipgloss.NewStyle().Foreground(th.Code)), j + 1 - i, true
+ }
+ }
+ // 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,
+ lipgloss.NewStyle().Foreground(th.Text).Background(th.Highlight)), j + 2 - i, true
}
}
// Wikilink: [[...]]
if r[i] == '[' && i+1 < len(r) && r[i+1] == '[' {
if j := indexSeq(r, "]]", i+2); j >= 0 {
- end := j + 2
- return string(r[i:end]), lipgloss.NewStyle().Foreground(th.Wikilink), end - i, true
+ return wrap("[[", string(r[i+2:j]), "]]", muted,
+ lipgloss.NewStyle().Foreground(th.Link)), j + 2 - i, true
}
}
- // Link: [text](url)
+ // Link: [text](url) — text stays prose, the url is the colored reference.
if r[i] == '[' {
if c := indexRune(r, ']', i+1); c > i && c+1 < len(r) && r[c+1] == '(' {
if p := indexRune(r, ')', c+2); p > c {
- end := p + 1
- return string(r[i:end]), lipgloss.NewStyle().Foreground(th.Link), end - i, true
+ plain := lipgloss.NewStyle().Foreground(th.Text)
+ link := lipgloss.NewStyle().Foreground(th.Link)
+ spans := []Span{{Text: "[", Style: muted}}
+ if text := string(r[i+1 : c]); text != "" {
+ spans = append(spans, Span{Text: text, Style: plain})
+ }
+ spans = append(spans, Span{Text: "](", Style: muted})
+ if url := string(r[c+2 : p]); url != "" {
+ spans = append(spans, Span{Text: url, Style: link})
+ }
+ spans = append(spans, Span{Text: ")", Style: muted})
+ return spans, p + 1 - i, true
}
}
}
@@ -193,19 +231,30 @@ // Bold: ** or __
for _, d := range []string{"**", "__"} {
if hasPrefixRunes(r, i, d) {
if j := indexSeq(r, d, i+2); j >= 0 {
- end := j + 2
- return string(r[i:end]), lipgloss.NewStyle().Foreground(th.Text).Bold(true), end - i, true
+ return wrap(d, string(r[i+2:j]), d, muted,
+ lipgloss.NewStyle().Foreground(th.Emphasis).Bold(true)), j + 2 - i, true
}
}
}
// Italic: * or _
if r[i] == '*' || r[i] == '_' {
if j := indexRune(r, r[i], i+1); j > i {
- end := j + 1
- return string(r[i:end]), lipgloss.NewStyle().Foreground(th.Text).Italic(true), end - i, true
+ m := string(r[i])
+ return wrap(m, string(r[i+1:j]), m, muted,
+ lipgloss.NewStyle().Foreground(th.Emphasis).Italic(true)), j + 1 - i, true
}
}
- return "", lipgloss.Style{}, 0, false
+ return nil, 0, false
+}
+
+// wrap builds [open(markup), content(role), close(markup)] spans, dropping an
+// empty content span. The texts concatenate to open+content+close.
+func wrap(open, content, closing string, markup, role lipgloss.Style) []Span {
+ spans := []Span{{Text: open, Style: markup}}
+ if content != "" {
+ spans = append(spans, Span{Text: content, Style: role})
+ }
+ return append(spans, Span{Text: closing, Style: markup})
}
func indexRune(r []rune, c rune, from int) int {
internal/editor/scanner_test.go +93 −2
@@ -106,8 +106,99 @@ out := ScanLines([]string{"### Title"}, th)
if spanText(out[0]) != "### Title" {
t.Errorf("heading hashes dropped: %q", spanText(out[0]))
}
- if out[0][0].Style.GetForeground() != th.Heading {
- t.Errorf("heading not heading-colored")
+ // The hashes are muted; the heading text is heading-colored and bold.
+ if out[0][0].Style.GetForeground() != th.Muted {
+ t.Errorf("heading marker not muted, got %v", out[0][0].Style.GetForeground())
+ }
+ last := out[0][len(out[0])-1]
+ if last.Style.GetForeground() != th.Heading || !last.Style.GetBold() {
+ t.Errorf("heading text not bold heading color")
+ }
+}
+
+func TestScanEmphasisUsesEmphasisToneDimMarkers(t *testing.T) {
+ th := theme.FlexokiDark()
+ out := ScanLines([]string{"a **bold** b"}, th)
+ if spanText(out[0]) != "a **bold** b" {
+ t.Fatalf("text altered: %q", spanText(out[0]))
+ }
+ var sawMutedMarker, sawBoldContent bool
+ for _, sp := range out[0] {
+ if sp.Text == "**" && sp.Style.GetForeground() == th.Muted {
+ sawMutedMarker = true
+ }
+ if sp.Text == "bold" && sp.Style.GetForeground() == th.Emphasis && sp.Style.GetBold() {
+ sawBoldContent = true
+ }
+ }
+ if !sawMutedMarker {
+ t.Error("** markers not muted")
+ }
+ if !sawBoldContent {
+ t.Error("bold content not Emphasis+bold")
+ }
+}
+
+func TestScanLinkTargetColoredBracketsMuted(t *testing.T) {
+ th := theme.FlexokiDark()
+ out := ScanLines([]string{"see [text](http://x)"}, th)
+ if spanText(out[0]) != "see [text](http://x)" {
+ t.Fatalf("text altered: %q", spanText(out[0]))
+ }
+ var url, brackets bool
+ for _, sp := range out[0] {
+ if sp.Text == "http://x" && sp.Style.GetForeground() == th.Link {
+ url = true
+ }
+ if (sp.Text == "[" || sp.Text == "](" || sp.Text == ")") && sp.Style.GetForeground() == th.Muted {
+ brackets = true
+ }
+ }
+ if !url {
+ t.Error("link target not Link-colored")
+ }
+ if !brackets {
+ t.Error("link brackets not muted")
+ }
+}
+
+func TestScanHighlightGetsBackground(t *testing.T) {
+ th := theme.FlexokiDark()
+ out := ScanLines([]string{"a ==mark== b"}, th)
+ if spanText(out[0]) != "a ==mark== b" {
+ t.Fatalf("text altered: %q", spanText(out[0]))
+ }
+ found := false
+ for _, sp := range out[0] {
+ if sp.Text == "mark" && sp.Style.GetBackground() == th.Highlight {
+ found = true
+ }
+ }
+ if !found {
+ t.Error("highlight content has no Highlight background")
+ }
+}
+
+func TestScanCommentVisible(t *testing.T) {
+ th := theme.FlexokiDark()
+ out := ScanLines([]string{"<!-- a note -->"}, th)
+ if out[0][0].Style.GetForeground() != th.Comment {
+ t.Errorf("comment not Comment-colored, got %v", out[0][0].Style.GetForeground())
+ }
+}
+
+func TestScanBlockquoteItalicTextDimMarker(t *testing.T) {
+ th := theme.FlexokiDark()
+ out := ScanLines([]string{"> quoted"}, th)
+ if spanText(out[0]) != "> quoted" {
+ t.Fatalf("text altered: %q", spanText(out[0]))
+ }
+ if out[0][0].Style.GetForeground() != th.Muted {
+ t.Error("blockquote marker not muted")
+ }
+ last := out[0][len(out[0])-1]
+ if last.Style.GetForeground() != th.Text || !last.Style.GetItalic() {
+ t.Error("blockquote text not italic base color")
}
}
internal/editor/wrap.go +5 −2
@@ -96,10 +96,13 @@ return rows
}
// withBackground stamps the theme background onto every span's style so the
-// rendered text has no terminal-default gaps between or under glyphs.
+// rendered text has no terminal-default gaps between or under glyphs. Spans that
+// already carry a background (e.g. ==highlight==) keep theirs.
func withBackground(spans []Span, bg lipgloss.Color) []Span {
for i := range spans {
- spans[i].Style = spans[i].Style.Background(bg)
+ if _, unset := spans[i].Style.GetBackground().(lipgloss.NoColor); unset {
+ spans[i].Style = spans[i].Style.Background(bg)
+ }
}
return spans
}
internal/theme/theme.go +12 −9
@@ -12,18 +12,21 @@ Name string
GlamourStyle string
// Markdown element colors.
- Text lipgloss.Color
- Heading lipgloss.Color
- Code lipgloss.Color
- Link lipgloss.Color
- Wikilink lipgloss.Color
- ListMarker lipgloss.Color
- Blockquote lipgloss.Color
- Accent lipgloss.Color
+ Text lipgloss.Color // base prose
+ Emphasis lipgloss.Color // bold/italic — higher contrast than Text
+ Heading lipgloss.Color // heading text (bold)
+ Code lipgloss.Color // inline + fenced code
+ Link lipgloss.Color // links, URLs, and wikilink targets
+ Wikilink lipgloss.Color // retained == Link (kept for the all-colors check)
+ ListMarker lipgloss.Color // list bullets / numbers
+ 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
// UI colors.
Background lipgloss.Color
- Muted lipgloss.Color
+ Muted lipgloss.Color // markup punctuation, dimmed
StatusFg lipgloss.Color
StatusBg lipgloss.Color
SelFg lipgloss.Color
internal/theme/theme_test.go +4 −3
@@ -11,9 +11,10 @@
func TestEveryThemeHasAllColorsSet(t *testing.T) {
for _, th := range allThemes() {
colors := map[string]lipgloss.Color{
- "Text": th.Text, "Heading": th.Heading, "Code": th.Code, "Link": th.Link,
- "Wikilink": th.Wikilink, "ListMarker": th.ListMarker, "Blockquote": th.Blockquote,
- "Accent": th.Accent, "Background": th.Background, "Muted": th.Muted,
+ "Text": th.Text, "Emphasis": th.Emphasis, "Heading": th.Heading, "Code": th.Code,
+ "Link": th.Link, "Wikilink": th.Wikilink, "ListMarker": th.ListMarker,
+ "Blockquote": th.Blockquote, "Comment": th.Comment, "Accent": th.Accent,
+ "Highlight": th.Highlight, "Background": th.Background, "Muted": th.Muted,
"StatusFg": th.StatusFg, "StatusBg": th.StatusBg, "SelFg": th.SelFg,
"SelBg": th.SelBg, "Pointer": th.Pointer,
}
internal/theme/themes.go +47 −30
@@ -2,22 +2,32 @@ package theme
import "github.com/charmbracelet/lipgloss"
-// FlexokiDark is Steph Ango's Flexoki palette, dark variant. Hexes match the
-// user's `md` navigator and the vault ontology.
+// Flexoki by Steph Ango (https://stephango.com/flexoki). Accent hexes use the
+// 400 variants on dark backgrounds and the 600 variants on light, per Flexoki's
+// guidance. The role assignments follow the glint highlighting model: prose at
+// base, markup muted, color reserved for headings, code, links, list markers,
+// comments, and a highlight background; bold/italic use the higher-contrast
+// Emphasis tone.
+
+// FlexokiDark is the Flexoki dark variant: warm near-black paper, soft prose.
func FlexokiDark() Theme {
+ link := lipgloss.Color("#3AA99F") // cyan-400
return Theme{
Name: "flexoki-dark",
GlamourStyle: "dark",
- Text: lipgloss.Color("#CECDC3"),
- Heading: lipgloss.Color("#4385BE"),
- Code: lipgloss.Color("#879A39"),
- Link: lipgloss.Color("#3AA99F"),
- Wikilink: lipgloss.Color("#8B7EC8"),
- ListMarker: lipgloss.Color("#CE5D97"),
- Blockquote: lipgloss.Color("#878580"),
- Accent: lipgloss.Color("#D0A215"),
- Background: lipgloss.Color("#100F0F"),
- Muted: lipgloss.Color("#878580"),
+ Background: lipgloss.Color("#100F0F"), // black
+ Text: lipgloss.Color("#CECDC3"), // base-200 — prose
+ Emphasis: lipgloss.Color("#E6E4D9"), // base-100 — brighter for bold/italic
+ Muted: lipgloss.Color("#878580"), // base-500 — markup, dim but readable
+ Heading: lipgloss.Color("#4385BE"), // blue-400
+ Code: lipgloss.Color("#879A39"), // green-400
+ Link: link,
+ Wikilink: link, // merged into Link
+ ListMarker: lipgloss.Color("#CE5D97"), // magenta-400
+ 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
StatusFg: lipgloss.Color("#100F0F"),
StatusBg: lipgloss.Color("#4385BE"),
SelFg: lipgloss.Color("#100F0F"),
@@ -26,22 +36,25 @@ Pointer: lipgloss.Color("#CE5D97"),
}
}
-// FlexokiLight is the Flexoki light variant — the fix for unreadable text on
-// cream/light terminals.
+// FlexokiLight is the Flexoki light variant: cream paper, near-black prose.
func FlexokiLight() Theme {
+ link := lipgloss.Color("#24837B") // cyan-600
return Theme{
Name: "flexoki-light",
GlamourStyle: "light",
- Text: lipgloss.Color("#100F0F"),
- Heading: lipgloss.Color("#205EA6"),
- Code: lipgloss.Color("#66800B"),
- Link: lipgloss.Color("#24837B"),
- Wikilink: lipgloss.Color("#5E409D"),
- ListMarker: lipgloss.Color("#A02F6F"),
- Blockquote: lipgloss.Color("#6F6E69"),
- Accent: lipgloss.Color("#AD8301"),
- Background: lipgloss.Color("#FFFCF0"),
- Muted: lipgloss.Color("#6F6E69"),
+ Background: lipgloss.Color("#FFFCF0"), // paper
+ Text: lipgloss.Color("#1C1B1A"), // base-950 — prose (leaves room below for Emphasis)
+ Emphasis: lipgloss.Color("#100F0F"), // black — darker for bold/italic
+ Muted: lipgloss.Color("#6F6E69"), // base-600 — markup, dim but readable
+ Heading: lipgloss.Color("#205EA6"), // blue-600
+ Code: lipgloss.Color("#66800B"), // green-600
+ Link: link,
+ Wikilink: link, // merged into Link
+ ListMarker: lipgloss.Color("#A02F6F"), // magenta-600
+ 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
StatusFg: lipgloss.Color("#FFFCF0"),
StatusBg: lipgloss.Color("#205EA6"),
SelFg: lipgloss.Color("#FFFCF0"),
@@ -52,20 +65,24 @@ }
// Charm is a charm.land / charmbracelet-brand themed dark palette.
func Charm() Theme {
+ link := lipgloss.Color("#5DD5FF")
return Theme{
Name: "charm",
GlamourStyle: "dark",
- Text: lipgloss.Color("#FFFDF5"),
+ Background: lipgloss.Color("#16161E"),
+ Text: lipgloss.Color("#DDDDE6"),
+ Emphasis: lipgloss.Color("#FFFDF5"),
+ Muted: lipgloss.Color("#6C6C8A"),
Heading: lipgloss.Color("#FF5FAF"),
Code: lipgloss.Color("#00FFA3"),
- Link: lipgloss.Color("#5DD5FF"),
- Wikilink: lipgloss.Color("#B575FF"),
+ Link: link,
+ Wikilink: link,
ListMarker: lipgloss.Color("#FF5FAF"),
Blockquote: lipgloss.Color("#6C6C8A"),
+ Comment: lipgloss.Color("#FFB454"),
Accent: lipgloss.Color("#FFD500"),
- Background: lipgloss.Color("#16161E"),
- Muted: lipgloss.Color("#6C6C8A"),
- StatusFg: lipgloss.Color("#FFFDF5"),
+ Highlight: lipgloss.Color("#3A2E4D"),
+ StatusFg: lipgloss.Color("#16161E"),
StatusBg: lipgloss.Color("#6B50FF"),
SelFg: lipgloss.Color("#16161E"),
SelBg: lipgloss.Color("#FF5FAF"),