fix: preview table backgrounds + H1 highlight match theme paper (TASK-026)
fc8eb5a689b9fdc91496e869ba79abcdcaa05913
Kevin Kortum <kevinkortum@me.com> · 2026-06-29 15:55
parent efdd6b34
fix: preview table backgrounds + H1 highlight match theme paper (TASK-026) applyTheme clears all backgrounds and sets only Document/Code/CodeBlock/Table to paper, leaving Text bg unset so H1 heading text inherits the heading-bar bg instead of paper. fillBackground re-asserts paper after every ANSI reset and at line starts so glamour's unstyled table borders/padding render on paper rather than the terminal default (the mismatch-between-themes bug). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KjNrdAWUdkaxFyGdrPHaBj
3 files changed
- → Preview-table-backgrounds-H1-highlight-dont-match-theme-paper.md +31 −0
@@ -0,0 +1,31 @@
+---
+id: TASK-026
+title: 'Preview: table backgrounds + H1 highlight don''t match theme paper'
+status: "\U0001F3C1 Done"
+assignee: []
+created_date: '2026-06-29 22:55'
+updated_date: '2026-06-29 22:55'
+labels:
+ - bug
+dependencies: []
+priority: medium
+ordinal: 26000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Two glamour preview theming bugs:
+1) Table cell padding and border glyphs (│ ┼ ─) rendered with no background → fell back to the terminal-default bg, mismatching the theme paper (worse across themes). Inline-code cells also showed dark blocks.
+2) H1 heading background bar only painted the prefix/suffix margins, not behind the heading text — the word sat on paper bg because applyTheme forced the Text background to paper, overriding the H1 block bg for the heading's child text.
+
+Fix (internal/preview/preview.go): applyTheme now clears all backgrounds and sets only Document/Code/CodeBlock/Table = paper, leaving Text bg unset so heading text inherits the H1 heading bg. Added fillBackground() to re-assert paper bg after every ANSI reset and at line starts, so unstyled gaps (borders, padding) render on paper. Applied to the theme-driven styles only.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [x] #1 H1 heading background fills behind the heading text, not just the margins
+- [x] #2 Table cell padding and borders render on the theme paper across all themes
+- [x] #3 Inline code in cells uses paper bg (no dark blocks)
+- [x] #4 Regression tests cover H1 bg and post-reset paper re-assert
+<!-- AC:END -->
internal/preview/preview.go +63 −14
@@ -16,10 +16,14 @@ "github.com/charmbracelet/glamour/ansi"
"github.com/charmbracelet/glamour/styles"
)
-// applyTheme rewrites a glamour style so it matches the glint theme: every
-// element background becomes the theme paper (no glamour panels behind code or
-// tables), prose/headings/code/links use the theme colors, and H1 pops as a
-// filled bar whose text is chosen to stay legible on the heading color.
+// applyTheme rewrites a glamour style so it matches the glint theme: prose,
+// headings, code and links use the theme colors on the theme paper. Every
+// element background is first cleared so the only explicit backgrounds are the
+// document paper, code (also paper — no glamour panels), and the H1 heading bar.
+// Crucially the Text background is left unset: that lets heading text inherit the
+// H1 block's heading background (so the bar fills behind the words, not just the
+// margins). Regions glamour leaves unstyled (table borders, cell padding) are
+// repainted with paper by fillBackground at render time.
func applyTheme(cfg *ansi.StyleConfig, c Colors) {
var zero uint
cfg.Document.Margin = &zero
@@ -28,10 +32,16 @@ return
}
bg, text, heading, code, link := c.Background, c.Text, c.Heading, c.Code, c.Link
- // Disable chroma so code renders plainly, then force every background in the
- // whole config (document, code, tables, cells, …) to the theme paper.
+ // Disable chroma so code renders plainly, then clear every background so we
+ // can set only the few we want explicitly.
cfg.CodeBlock.Chroma = nil
- setAllBackgrounds(reflect.ValueOf(cfg).Elem(), &bg)
+ clearAllBackgrounds(reflect.ValueOf(cfg).Elem())
+
+ // Backgrounds: paper for the document and code; everything else inherits.
+ cfg.Document.BackgroundColor = &bg
+ cfg.Code.BackgroundColor = &bg
+ cfg.CodeBlock.BackgroundColor = &bg
+ cfg.Table.BackgroundColor = &bg
// Colors.
cfg.Document.Color = &text
@@ -44,7 +54,7 @@ }
cfg.Link.Color = &link
cfg.LinkText.Color = &link
- // H1: filled bar in the heading color, with legible text and bold.
+ // H1: filled bar in the heading color (text inherits this bg), bold.
yes := true
ht := legibleText(heading)
cfg.H1.Color = &ht
@@ -52,13 +62,13 @@ cfg.H1.BackgroundColor = &heading
cfg.H1.Bold = &yes
}
-// setAllBackgrounds sets every *string field named "BackgroundColor" in the
-// (recursively walked) value to bg.
-func setAllBackgrounds(v reflect.Value, bg *string) {
+// clearAllBackgrounds nils every *string field named "BackgroundColor" in the
+// (recursively walked) value.
+func clearAllBackgrounds(v reflect.Value) {
switch v.Kind() {
case reflect.Pointer:
if !v.IsNil() {
- setAllBackgrounds(v.Elem(), bg)
+ clearAllBackgrounds(v.Elem())
}
case reflect.Struct:
t := v.Type()
@@ -66,15 +76,49 @@ for i := 0; i < v.NumField(); i++ {
f := v.Field(i)
if t.Field(i).Name == "BackgroundColor" && f.Type() == reflect.TypeOf((*string)(nil)) {
if f.CanSet() {
- f.Set(reflect.ValueOf(bg))
+ f.Set(reflect.Zero(f.Type()))
}
continue
}
- setAllBackgrounds(f, bg)
+ clearAllBackgrounds(f)
}
}
}
+// fillBackground re-asserts the paper background after every ANSI reset and at
+// the start of every line, so glyphs glamour emits without a background (table
+// borders, cell padding, list bullets) render on the theme paper instead of the
+// terminal default. Spans that set their own background still override it
+// immediately, so colored text is unaffected.
+func fillBackground(s, bgHex string) string {
+ rgb := hexToRGB(bgHex)
+ if rgb == "" {
+ return s
+ }
+ paper := "\x1b[48;2;" + rgb + "m"
+ s = strings.ReplaceAll(s, "\x1b[0m", "\x1b[0m"+paper)
+ lines := strings.Split(s, "\n")
+ for i, ln := range lines {
+ lines[i] = paper + ln
+ }
+ return strings.Join(lines, "\n")
+}
+
+// hexToRGB converts "#RRGGBB" to the "R;G;B" decimal form used in SGR codes.
+func hexToRGB(hex string) string {
+ h := strings.TrimPrefix(hex, "#")
+ if len(h) != 6 {
+ return ""
+ }
+ r, err1 := strconv.ParseInt(h[0:2], 16, 0)
+ g, err2 := strconv.ParseInt(h[2:4], 16, 0)
+ b, err3 := strconv.ParseInt(h[4:6], 16, 0)
+ if err1 != nil || err2 != nil || err3 != nil {
+ return ""
+ }
+ return strconv.FormatInt(r, 10) + ";" + strconv.FormatInt(g, 10) + ";" + strconv.FormatInt(b, 10)
+}
+
// legibleText returns a near-black or near-paper text color, whichever contrasts
// better with the background hex (so H1 text stays readable on any heading color).
func legibleText(bgHex string) string {
@@ -158,6 +202,11 @@ }
out, err := r.Render(markdown)
if err != nil {
return err
+ }
+ // For the theme-driven styles, repaint unstyled gaps with the theme paper so
+ // table borders/padding don't fall back to the terminal background.
+ if m.colors.Background != "" && (m.style == "" || m.style == "light" || m.style == "dark") {
+ out = fillBackground(out, m.colors.Background)
}
m.vp.SetContent(out)
m.vp.GotoTop()
internal/preview/theme_test.go +49 −0
@@ -0,0 +1,49 @@
+package preview
+
+import (
+ "strings"
+ "testing"
+)
+
+var testColors = Colors{
+ Background: "#100F0F", Text: "#CECDC3", Heading: "#D14D41", Code: "#879A39", Link: "#4385BE",
+}
+
+func renderPreview(t *testing.T, md string) string {
+ t.Helper()
+ m := New("dark")
+ m.SetColors(testColors)
+ m.SetSize(40, 20)
+ if err := m.Render(md); err != nil {
+ t.Fatal(err)
+ }
+ return m.View()
+}
+
+// H1 text must sit on the heading background, not the paper background.
+func TestH1BackgroundBehindText(t *testing.T) {
+ out := renderPreview(t, "# glint\n")
+ if !strings.Contains(out, "48;2;209;77;65") {
+ t.Fatalf("H1 missing heading background (209;77;65):\n%q", out)
+ }
+ // the word "glint" itself must carry the heading bg, not paper (16;15;15)
+ if !strings.Contains(out, "48;2;209;77;65;1mglint") {
+ t.Fatalf("H1 text not painted on heading background:\n%q", out)
+ }
+}
+
+// Every ANSI reset must be followed by a re-assert of the paper background so
+// unstyled regions (table borders, cell padding) never fall back to the
+// terminal default — the table-background-between-themes bug.
+func TestPaperBackgroundReassertedAfterResets(t *testing.T) {
+ out := renderPreview(t, "| Key | Action |\n| --- | --- |\n| `Tab` | indent |\n")
+ paper := "\x1b[48;2;16;15;15m"
+ resets := strings.Count(out, "\x1b[0m")
+ reasserted := strings.Count(out, "\x1b[0m"+paper)
+ if resets == 0 {
+ t.Fatal("expected some resets in table output")
+ }
+ if resets != reasserted {
+ t.Fatalf("bare resets remain: %d resets, %d re-asserted paper bg", resets, reasserted)
+ }
+}