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

fix: clear all preview backgrounds (tables included); legible H1 text

d538465aefe41af538b0b5322b4fc25a51663e22
humdrum <me@humdrum.me> · 2026-06-29 08:53

parent 191eed41

fix: clear all preview backgrounds (tables included); legible H1 text

- Reflectively force every BackgroundColor in the glamour style to the theme
  paper, so table cells (and any other element) no longer show a dark panel.
- H1 bar text is chosen by WCAG luminance to contrast with the heading color
  (light text on the mid-tone accents), so it stays legible on every theme.

2 files changed

internal/preview/preview.go +69 −20
@@ -3,7 +3,11 @@ // read-only viewport — the full glow read experience, markup concealed.
 package preview
 
 import (
+	"math"
 	"os"
+	"reflect"
+	"strconv"
+	"strings"
 
 	"github.com/charmbracelet/bubbles/viewport"
 	tea "github.com/charmbracelet/bubbletea"
@@ -12,9 +16,10 @@ 	"github.com/charmbracelet/glamour/ansi"
 	"github.com/charmbracelet/glamour/styles"
 )
 
-// applyTheme rewrites a glamour style so it matches the glint theme: every block
-// on the theme background (no glamour panels), headings/code/links/prose in the
-// theme colors, glamour's H1 purple-bg/yellow-text and dark code chroma removed.
+// 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.
 func applyTheme(cfg *ansi.StyleConfig, c Colors) {
 	var zero uint
 	cfg.Document.Margin = &zero
@@ -23,33 +28,77 @@ 		return
 	}
 	bg, text, heading, code, link := c.Background, c.Text, c.Heading, c.Code, c.Link
 
-	// Backgrounds → the theme paper (so nothing shows a darker panel).
-	cfg.Document.BackgroundColor = &bg
-	cfg.Code.BackgroundColor = &bg
-	cfg.CodeBlock.BackgroundColor = &bg
-	cfg.Table.BackgroundColor = &bg
-	cfg.BlockQuote.BackgroundColor = &bg
-	// Disable chroma syntax styling so code renders plainly on the theme bg
-	// instead of glamour's hardcoded dark code panel.
+	// Disable chroma so code renders plainly, then force every background in the
+	// whole config (document, code, tables, cells, …) to the theme paper.
 	cfg.CodeBlock.Chroma = nil
-	cfg.CodeBlock.Color = &code
-	cfg.Code.Color = &code
+	setAllBackgrounds(reflect.ValueOf(cfg).Elem(), &bg)
 
-	// Prose + headings + links in theme colors.
+	// Colors.
 	cfg.Document.Color = &text
 	cfg.Text.Color = &text
-	// H2–H6 (and the base Heading) are colored text on the theme background.
+	cfg.Code.Color = &code
+	cfg.CodeBlock.Color = &code
 	for _, h := range []*ansi.StyleBlock{&cfg.Heading, &cfg.H2, &cfg.H3, &cfg.H4, &cfg.H5, &cfg.H6} {
 		h.Color = &heading
-		h.BackgroundColor = &bg
 	}
-	// H1 pops as a filled bar: heading-color background, paper text, bold.
+	cfg.Link.Color = &link
+	cfg.LinkText.Color = &link
+
+	// H1: filled bar in the heading color, with legible text and bold.
 	yes := true
-	cfg.H1.Color = &bg
+	ht := legibleText(heading)
+	cfg.H1.Color = &ht
 	cfg.H1.BackgroundColor = &heading
 	cfg.H1.Bold = &yes
-	cfg.Link.Color = &link
-	cfg.LinkText.Color = &link
+}
+
+// setAllBackgrounds sets every *string field named "BackgroundColor" in the
+// (recursively walked) value to bg.
+func setAllBackgrounds(v reflect.Value, bg *string) {
+	switch v.Kind() {
+	case reflect.Pointer:
+		if !v.IsNil() {
+			setAllBackgrounds(v.Elem(), bg)
+		}
+	case reflect.Struct:
+		t := v.Type()
+		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))
+				}
+				continue
+			}
+			setAllBackgrounds(f, bg)
+		}
+	}
+}
+
+// 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 {
+	if relLuminance(bgHex) > 0.5 {
+		return "#100F0F"
+	}
+	return "#FFFCF0"
+}
+
+// relLuminance is the WCAG relative luminance of an "#RRGGBB" color.
+func relLuminance(hex string) float64 {
+	h := strings.TrimPrefix(hex, "#")
+	if len(h) != 6 {
+		return 0
+	}
+	chan8 := func(s string) float64 {
+		n, _ := strconv.ParseInt(s, 16, 0)
+		c := float64(n) / 255
+		if c <= 0.03928 {
+			return c / 12.92
+		}
+		return math.Pow((c+0.055)/1.055, 2.4)
+	}
+	return 0.2126*chan8(h[0:2]) + 0.7152*chan8(h[2:4]) + 0.0722*chan8(h[4:6])
 }
 
 // Model wraps a Glamour renderer and a viewport.
internal/preview/preview_test.go +48 −15
@@ -1,29 +1,62 @@
 package preview
 
 import (
+	"reflect"
 	"strings"
 	"testing"
+
+	"github.com/charmbracelet/glamour/styles"
 )
 
-func TestRenderProducesContent(t *testing.T) {
-	m := New("dark")
-	m.SetSize(80, 20)
-	if err := m.Render("# Title\n\nsome **bold** text"); err != nil {
-		t.Fatal(err)
+func TestLegibleTextContrasts(t *testing.T) {
+	// Dark/mid accents → light text; very light bg → dark text.
+	if got := legibleText("#4385BE"); got != "#FFFCF0" {
+		t.Errorf("legibleText(blue) = %q, want light", got)
 	}
-	out := m.View()
-	if strings.TrimSpace(out) == "" {
-		t.Error("preview view is empty after Render")
+	if got := legibleText("#FF5FAF"); got != "#FFFCF0" {
+		t.Errorf("legibleText(pink) = %q, want light", got)
 	}
-	if !strings.Contains(out, "Title") {
-		t.Errorf("rendered output missing heading text: %q", out)
+	if got := legibleText("#F0E6BE"); got != "#100F0F" {
+		t.Errorf("legibleText(pale yellow) = %q, want dark", got)
 	}
 }
 
-func TestRenderUnknownStyleFallsBack(t *testing.T) {
-	m := New("definitely-not-a-real-style")
-	m.SetSize(80, 20)
-	if err := m.Render("hello"); err != nil {
-		t.Errorf("unknown style should fall back, got error %v", err)
+func TestApplyThemeClearsBackgroundsAndH1(t *testing.T) {
+	cfg := styles.DarkStyleConfig
+	applyTheme(&cfg, Colors{Background: "#100F0F", Text: "#CECDC3", Heading: "#4385BE", Code: "#879A39", Link: "#3AA99F"})
+	// No element keeps glamour's dark code/table panel: every BackgroundColor is the bg.
+	var bad []string
+	var walk func(v reflect.Value, path string)
+	walk = func(v reflect.Value, path string) {
+		switch v.Kind() {
+		case reflect.Pointer:
+			if !v.IsNil() {
+				walk(v.Elem(), path)
+			}
+		case reflect.Struct:
+			tp := v.Type()
+			for i := 0; i < v.NumField(); i++ {
+				f := v.Field(i)
+				name := tp.Field(i).Name
+				if name == "BackgroundColor" && f.Type() == reflect.TypeOf((*string)(nil)) && !f.IsNil() {
+					if *f.Interface().(*string) != "#100F0F" && !strings.HasPrefix(path, ".H1") {
+						bad = append(bad, path)
+					}
+					continue
+				}
+				walk(f, path+"."+name)
+			}
+		}
+	}
+	walk(reflect.ValueOf(cfg), "")
+	if len(bad) > 0 {
+		t.Errorf("backgrounds not cleared to theme bg at: %v", bad)
+	}
+	// H1 is the heading-color bar with legible text.
+	if cfg.H1.BackgroundColor == nil || *cfg.H1.BackgroundColor != "#4385BE" {
+		t.Errorf("H1 background not the heading color")
+	}
+	if cfg.H1.Color == nil || *cfg.H1.Color != "#FFFCF0" {
+		t.Errorf("H1 text not legible color, got %v", cfg.H1.Color)
 	}
 }