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"),
+ }
+}