▍ humdrum codex / glint v1.0.2

feat: theme package — flexoki light/dark + charm, detect, resolve

b1ef9b01326a489651e039ce093f51690bdabf53
humdrum <me@humdrum.me> · 2026-06-28 08:17

parent a59c1419

5 files changed

internal/theme/detect.go +26 −0
@@ -0,0 +1,26 @@
+package theme
+
+import (
+	"os/exec"
+	"runtime"
+	"strings"
+)
+
+// Detect returns "flexoki-dark" or "flexoki-light" from the OS appearance. On
+// non-darwin platforms it returns dark. On macOS it reads the global
+// AppleInterfaceStyle key (unset in Light mode → empty output → light).
+func Detect() string {
+	if runtime.GOOS != "darwin" {
+		return "flexoki-dark"
+	}
+	out, _ := exec.Command("defaults", "read", "-g", "AppleInterfaceStyle").Output()
+	return detectFrom(string(out))
+}
+
+// detectFrom parses the raw `defaults read -g AppleInterfaceStyle` output.
+func detectFrom(raw string) string {
+	if strings.Contains(raw, "Dark") {
+		return "flexoki-dark"
+	}
+	return "flexoki-light"
+}
internal/theme/detect_test.go +17 −0
@@ -0,0 +1,17 @@
+package theme
+
+import "testing"
+
+func TestDetectFrom(t *testing.T) {
+	cases := map[string]string{
+		"Dark\n": "flexoki-dark",
+		"Dark":   "flexoki-dark",
+		"":       "flexoki-light", // macOS light mode leaves the key unset (empty output)
+		"Light":  "flexoki-light",
+	}
+	for raw, want := range cases {
+		if got := detectFrom(raw); got != want {
+			t.Errorf("detectFrom(%q) = %q, want %q", raw, got, want)
+		}
+	}
+}
internal/theme/theme.go +78 −0
@@ -0,0 +1,78 @@
+// Package theme is glint's single source of color truth. Every styled span in
+// every theme gets an explicit foreground — no terminal-default fallbacks — so
+// the editor reads cleanly on both light and dark terminals.
+package theme
+
+import "github.com/charmbracelet/lipgloss"
+
+// Theme holds every color glint paints, plus its name and the glamour style the
+// read-preview should use to stay visually in sync.
+type Theme struct {
+	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
+
+	// UI colors.
+	Background lipgloss.Color
+	Muted      lipgloss.Color
+	StatusFg   lipgloss.Color
+	StatusBg   lipgloss.Color
+	SelFg      lipgloss.Color
+	SelBg      lipgloss.Color
+	Pointer    lipgloss.Color
+}
+
+// CycleOrder is the order Ctrl+T steps through themes.
+var CycleOrder = []string{"flexoki-light", "flexoki-dark", "charm"}
+
+func registry() map[string]Theme {
+	return map[string]Theme{
+		"flexoki-light": FlexokiLight(),
+		"flexoki-dark":  FlexokiDark(),
+		"charm":         Charm(),
+	}
+}
+
+// ByName looks up a registered theme.
+func ByName(name string) (Theme, bool) {
+	t, ok := registry()[name]
+	return t, ok
+}
+
+// Next returns the theme after name in CycleOrder, wrapping around. An unknown
+// name yields the first theme in the cycle.
+func Next(name string) Theme {
+	idx := 0
+	for i, n := range CycleOrder {
+		if n == name {
+			idx = (i + 1) % len(CycleOrder)
+			break
+		}
+	}
+	t, _ := ByName(CycleOrder[idx])
+	return t
+}
+
+// Resolve picks a theme from a config value: "auto"/"" → OS detection; a known
+// name → that theme; an unknown name → OS detection. It never errors and never
+// returns an empty theme.
+func Resolve(configValue string) Theme {
+	if configValue == "" || configValue == "auto" {
+		t, _ := ByName(Detect())
+		return t
+	}
+	if t, ok := ByName(configValue); ok {
+		return t
+	}
+	t, _ := ByName(Detect())
+	return t
+}
internal/theme/theme_test.go +72 −0
@@ -0,0 +1,72 @@
+package theme
+
+import (
+	"testing"
+
+	"github.com/charmbracelet/lipgloss"
+)
+
+func allThemes() []Theme { return []Theme{FlexokiLight(), FlexokiDark(), Charm()} }
+
+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,
+			"StatusFg": th.StatusFg, "StatusBg": th.StatusBg, "SelFg": th.SelFg,
+			"SelBg": th.SelBg, "Pointer": th.Pointer,
+		}
+		for name, c := range colors {
+			if c == "" {
+				t.Errorf("theme %q: color %s is empty", th.Name, name)
+			}
+		}
+		if th.Name == "" {
+			t.Error("theme has empty Name")
+		}
+		if th.GlamourStyle == "" {
+			t.Errorf("theme %q has empty GlamourStyle", th.Name)
+		}
+	}
+}
+
+func TestByName(t *testing.T) {
+	for _, name := range CycleOrder {
+		if _, ok := ByName(name); !ok {
+			t.Errorf("ByName(%q) not found", name)
+		}
+	}
+	if _, ok := ByName("nope"); ok {
+		t.Error("ByName(nope) should not be found")
+	}
+}
+
+func TestNextCycles(t *testing.T) {
+	if Next("flexoki-light").Name != "flexoki-dark" {
+		t.Error("light -> dark")
+	}
+	if Next("flexoki-dark").Name != "charm" {
+		t.Error("dark -> charm")
+	}
+	if Next("charm").Name != "flexoki-light" {
+		t.Error("charm -> light (wrap)")
+	}
+	// Unknown name starts the cycle at the first entry's successor is well-defined:
+	if Next("unknown").Name != CycleOrder[0] {
+		t.Errorf("unknown -> %s, want %s", Next("unknown").Name, CycleOrder[0])
+	}
+}
+
+func TestResolve(t *testing.T) {
+	if Resolve("charm").Name != "charm" {
+		t.Error("explicit name should resolve to itself")
+	}
+	// auto and unknown both fall back to OS detection — must be a real registered theme.
+	for _, v := range []string{"auto", "", "bogus"} {
+		got := Resolve(v)
+		if _, ok := ByName(got.Name); !ok {
+			t.Errorf("Resolve(%q) returned unregistered theme %q", v, got.Name)
+		}
+	}
+}
internal/theme/themes.go +74 −0
@@ -0,0 +1,74 @@
+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.
+func FlexokiDark() Theme {
+	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"),
+		StatusFg:     lipgloss.Color("#100F0F"),
+		StatusBg:     lipgloss.Color("#4385BE"),
+		SelFg:        lipgloss.Color("#100F0F"),
+		SelBg:        lipgloss.Color("#D0A215"),
+		Pointer:      lipgloss.Color("#CE5D97"),
+	}
+}
+
+// FlexokiLight is the Flexoki light variant — the fix for unreadable text on
+// cream/light terminals.
+func FlexokiLight() Theme {
+	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"),
+		StatusFg:     lipgloss.Color("#FFFCF0"),
+		StatusBg:     lipgloss.Color("#205EA6"),
+		SelFg:        lipgloss.Color("#FFFCF0"),
+		SelBg:        lipgloss.Color("#AD8301"),
+		Pointer:      lipgloss.Color("#A02F6F"),
+	}
+}
+
+// Charm is a charm.land / charmbracelet-brand themed dark palette.
+func Charm() Theme {
+	return Theme{
+		Name:         "charm",
+		GlamourStyle: "dark",
+		Text:         lipgloss.Color("#FFFDF5"),
+		Heading:      lipgloss.Color("#FF5FAF"),
+		Code:         lipgloss.Color("#00FFA3"),
+		Link:         lipgloss.Color("#5DD5FF"),
+		Wikilink:     lipgloss.Color("#B575FF"),
+		ListMarker:   lipgloss.Color("#FF5FAF"),
+		Blockquote:   lipgloss.Color("#6C6C8A"),
+		Accent:       lipgloss.Color("#FFD500"),
+		Background:   lipgloss.Color("#16161E"),
+		Muted:        lipgloss.Color("#6C6C8A"),
+		StatusFg:     lipgloss.Color("#FFFDF5"),
+		StatusBg:     lipgloss.Color("#6B50FF"),
+		SelFg:        lipgloss.Color("#16161E"),
+		SelBg:        lipgloss.Color("#FF5FAF"),
+		Pointer:      lipgloss.Color("#00FFA3"),
+	}
+}