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)
}
}