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

feat: highlight TK placeholder markers as a badge (TASK-045)

4a2593db718c12208fbde3fd836c06338fe85670
humdrum <me@humdrum.me> · 2026-07-14 11:04

parent 85cc4e9d

feat: highlight TK placeholder markers as a badge (TASK-045)

Stylize the journalism "TK" placeholder (to come) so unfinished spots
pop. A whole-word TK / tk / TKTK run (case-insensitive) renders as a
bold filled badge — page-colored text on a loud new theme.TK background,
added to all three palettes. The \b anchors keep it from firing inside
real words (catkin, Atkins, outkast).

Implemented as a tkPass in buildVisual, after the spell and grammar
passes and winning over them on the same runes. It splits prose spans
without adding or dropping characters, preserving the markup-visible
invariant (span-text concatenation still equals the raw line). Prose
only — code files, code spans, URLs, and frontmatter are skipped.
Always on, no config. Help and README updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

8 files changed

README.md +8 −0
@@ -225,6 +225,14 @@ `grammar = auto | on | off` (auto = on when `harper-ls` is on your `PATH`). With
 no `harper-ls` installed, grammar is silently inert and glint behaves exactly as
 before.
 
+## TK markers
+
+The journalism **TK** placeholder — "to come", the thing you'll fill in later —
+renders as a bold filled badge so unfinished spots are impossible to miss. `TK`,
+`tk`, or a `TKTK` run is matched as a **whole word**, case-insensitive, so real
+words like *catkin*, *Atkins*, and *outkast* are never touched. Prose only —
+code files, code spans, URLs, and frontmatter are skipped. Always on, no config.
+
 The curly underline uses the `4:3` SGR underline-style and `58` underline-color
 codes — supported by Ghostty, kitty, WezTerm, foot, and recent VTE terminals.
 Terminals without them degrade to a straight underline or none. Inside **tmux**,
- → Highlight-TK-placeholder-markers.md +27 −0
@@ -0,0 +1,27 @@
+---
+id: TASK-045
+title: Highlight TK placeholder markers
+status: "\U0001F3C1 Done"
+assignee: []
+created_date: '2026-07-14 15:48'
+updated_date: '2026-07-14 16:12'
+labels:
+  - feature
+dependencies: []
+priority: medium
+ordinal: 44000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Stylize journalism 'TK'/'tk' to-come placeholders so they pop as fill-in reminders. Decided: whole-word match (TK/tk/TKTK repeats), case-insensitive, prose only (skip code files, code spans, URLs, frontmatter, wikilink/link targets - same exclusions as spellcheck). Render as a filled 'badge' chip: padded ' TK ', bold, loud fg/bg pair. New theme token TK added to all 3 palettes (loud accent: magenta-ish dark, matching light/charm). Implement as a prose post-pass in buildVisual (like spellPass/grammarPass) splitting prose spans and re-emitting TK tokens with the badge style. TDD.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [x] #1 Standalone TK/tk (any case) and TKTK render as a bold filled badge
+- [x] #2 Words containing tk (catkin, Atkins, outkast) are NOT highlighted
+- [x] #3 TK inside code files, code spans, URLs, and frontmatter is not highlighted
+- [x] #4 theme.TK added to all three themes
+<!-- AC:END -->
internal/editor/tk.go +81 −0
@@ -0,0 +1,81 @@
+package editor
+
+import (
+	"regexp"
+
+	"github.com/charmbracelet/lipgloss"
+)
+
+// tkRe matches the journalism "TK" placeholder ("to come"): a whole word made of
+// one or more "tk" pairs, any case — "TK", "tk", "TKTK". The \b anchors keep it
+// from firing inside real words (catkin, Atkins, outkast), where no word boundary
+// precedes the "tk".
+var tkRe = regexp.MustCompile(`(?i)\b(?:tk)+\b`)
+
+// tkPass paints TK placeholder markers as a bold filled badge so they pop as
+// fill-in reminders. It runs after the spell and grammar passes (and wins over
+// them on the same runes), splitting each prose span so only the "TK" text is
+// restyled; the concatenation of span texts still equals the raw line, preserving
+// the markup-visible invariant. Non-prose spans (code, URLs, markup) are untouched.
+func (e *Editor) tkPass(all [][]Span) [][]Span {
+	for li, spans := range all {
+		var out []Span
+		changed := false
+		for _, sp := range spans {
+			if !sp.Prose {
+				out = append(out, sp)
+				continue
+			}
+			split := e.splitTK(sp)
+			if len(split) != 1 {
+				changed = true
+			}
+			out = append(out, split...)
+		}
+		if changed {
+			all[li] = out
+		}
+	}
+	return all
+}
+
+// splitTK partitions one prose span's text, re-emitting each TK marker as a badge
+// span (page-colored bold text on the loud theme.TK background). URLs are skipped.
+// When no marker is present the original span is returned unchanged.
+func (e *Editor) splitTK(sp Span) []Span {
+	text := sp.Text
+	locs := tkRe.FindAllStringIndex(text, -1)
+	if len(locs) == 0 {
+		return []Span{sp}
+	}
+	skips := urlRe.FindAllStringIndex(text, -1)
+	badge := lipgloss.NewStyle().Foreground(e.theme.Background).Background(e.theme.TK).Bold(true)
+	var out []Span
+	last := 0
+	emit := func(s string) {
+		if s == "" {
+			return
+		}
+		ns := sp
+		ns.Text = s
+		out = append(out, ns)
+	}
+	for _, m := range locs {
+		if overlapsAny(m, skips) {
+			continue
+		}
+		emit(text[last:m[0]])
+		badgeSpan := sp
+		badgeSpan.Text = text[m[0]:m[1]]
+		badgeSpan.Style = badge
+		badgeSpan.Wavy = false  // a badge never also carries a spell/grammar underline
+		badgeSpan.Prose = false // and is inert to any later prose pass
+		out = append(out, badgeSpan)
+		last = m[1]
+	}
+	if last == 0 {
+		return []Span{sp} // every match was inside a URL
+	}
+	emit(text[last:])
+	return out
+}
internal/editor/tk_test.go +107 −0
@@ -0,0 +1,107 @@
+package editor
+
+import (
+	"strings"
+	"testing"
+
+	"glint/internal/theme"
+
+	"github.com/charmbracelet/lipgloss"
+)
+
+// tkBadges returns the set of span texts rendered with the TK badge background
+// (theme.TK). A badge span carries theme.TK as its background color.
+func tkBadges(e *Editor) map[string]bool {
+	tk := e.theme.TK
+	got := map[string]bool{}
+	for _, vr := range e.buildVisual() {
+		for _, sp := range vr.spans {
+			if bg, ok := sp.Style.GetBackground().(lipgloss.Color); ok && bg == tk {
+				got[sp.Text] = true
+			}
+		}
+	}
+	return got
+}
+
+func tkEditor(t *testing.T, content string) *Editor {
+	t.Helper()
+	e := New()
+	e.SetTheme(theme.FlexokiDark())
+	e.SetContent([]byte(content))
+	return e
+}
+
+func TestTKHighlightsWholeWordMarkers(t *testing.T) {
+	e := tkEditor(t, "Interview TK people about the $TK budget. Deadline TKTK.")
+	badges := tkBadges(e)
+	for _, want := range []string{"TK", "TKTK"} {
+		if !badges[want] {
+			t.Errorf("expected %q rendered as a TK badge; got badges %v", want, badges)
+		}
+	}
+}
+
+func TestTKCaseInsensitive(t *testing.T) {
+	e := tkEditor(t, "fill in tk here and Tk there")
+	badges := tkBadges(e)
+	if !badges["tk"] || !badges["Tk"] {
+		t.Errorf("expected lowercase tk and Tk badged; got %v", badges)
+	}
+}
+
+func TestTKNotInsideRealWords(t *testing.T) {
+	e := tkEditor(t, "The catkin on the network near Atkins and outkast")
+	if badges := tkBadges(e); len(badges) != 0 {
+		t.Errorf("no whole-word TK here; expected no badges, got %v", badges)
+	}
+}
+
+func TestTKSkipsCodeFiles(t *testing.T) {
+	e := tkEditor(t, "TK placeholder")
+	e.SetLanguage("main.go") // code file: TK highlighting off, like spellcheck
+	if badges := tkBadges(e); len(badges) != 0 {
+		t.Errorf("TK should not render in code files; got %v", badges)
+	}
+}
+
+func TestTKSkipsFrontmatter(t *testing.T) {
+	e := tkEditor(t, "---\ntitle: TK\n---\nBody TK here")
+	badges := tkBadges(e)
+	if badges["title: TK"] || countTKBadges(e, 1) > 1 {
+		t.Errorf("frontmatter TK should not badge; got %v", badges)
+	}
+	if !badges["TK"] {
+		t.Errorf("body TK should still badge; got %v", badges)
+	}
+}
+
+// countTKBadges counts badge spans whose text is exactly "TK".
+func countTKBadges(e *Editor, _ int) int {
+	tk := e.theme.TK
+	n := 0
+	for _, vr := range e.buildVisual() {
+		for _, sp := range vr.spans {
+			if bg, ok := sp.Style.GetBackground().(lipgloss.Color); ok && bg == tk && sp.Text == "TK" {
+				n++
+			}
+		}
+	}
+	return n
+}
+
+// TestTKPreservesLineText guards the markup-visible invariant: badging TK must not
+// add or drop any characters, so the concatenated span text equals the raw line.
+func TestTKPreservesLineText(t *testing.T) {
+	const line = "Interview TK people; page TK; TKTK."
+	e := tkEditor(t, line)
+	var b strings.Builder
+	for _, vr := range e.buildVisual() {
+		for _, sp := range vr.spans {
+			b.WriteString(sp.Text)
+		}
+	}
+	if got := b.String(); got != line {
+		t.Errorf("span text concatenation = %q, want %q", got, line)
+	}
+}
internal/editor/wrap.go +3 −0
@@ -101,6 +101,9 @@ 	}
 	if e.grammarActive() {
 		all = e.grammarPass(all)
 	}
+	if e.codeFile == "" {
+		all = e.tkPass(all) // TK placeholder badges — prose only, always on (TASK-045)
+	}
 	applyConflictHighlight(all, e.Lines, e.theme)
 	var rows []vrow
 	for li := range e.Lines {
internal/help/help.go +4 −0
@@ -81,6 +81,10 @@   install harper-ls (brew install harper) and glint uses it automatically. A
   word that is both misspelled and in a grammar span keeps its red underline.
   Config key grammar = auto | on | off (auto = on when harper-ls is present).
 
+  TK markers: the journalism "TK" placeholder (to come) — TK, tk, or TKTK as a
+  whole word — renders as a bold filled badge so unfinished spots pop. Prose
+  only; whole-word, so catkin/Atkins/outkast are left alone.
+
 CONFIG
   ~/.config/glint/config.toml   (run 'glint -c' to set it up)
 `
internal/theme/theme.go +1 −0
@@ -25,6 +25,7 @@ 	Accent     lipgloss.Color // frontmatter keys, selection
 	Highlight  lipgloss.Color // ==highlight== background tint
 	Spell      lipgloss.Color // misspelled-word undercurl (red)
 	Grammar    lipgloss.Color // grammar-issue undercurl (green; Harper, TASK-043)
+	TK         lipgloss.Color // 'TK'/'tk' placeholder badge background (fill-in marker, TASK-045)
 
 	// Merge-conflict highlighting (git markers <<<<<<< ||||||| ======= >>>>>>>).
 	ConflictMarker lipgloss.Color // bold marker lines
internal/theme/themes.go +3 −0
@@ -30,6 +30,7 @@ 		Accent:       lipgloss.Color("#D0A215"), // yellow-400
 		Highlight:    lipgloss.Color("#3A3517"), // deep olive — ==highlight== bg
 		Spell:        lipgloss.Color("#D14D41"), // red-400 — misspell undercurl
 		Grammar:      lipgloss.Color("#879A39"), // green-400 — grammar undercurl (Harper)
+		TK:           lipgloss.Color("#CE5D97"), // magenta-400 — TK placeholder badge
 
 		ConflictMarker: lipgloss.Color("#8B7EC8"), // purple-400 — conflict marker lines
 		ConflictOurs:   lipgloss.Color("#1A1E0C"), // green-950 — "ours" block tint
@@ -64,6 +65,7 @@ 		Accent:       lipgloss.Color("#AD8301"), // yellow-600
 		Highlight:    lipgloss.Color("#F0E6BE"), // pale yellow — ==highlight== bg
 		Spell:        lipgloss.Color("#AF3029"), // red-600 — misspell undercurl
 		Grammar:      lipgloss.Color("#66800B"), // green-600 — grammar undercurl (Harper)
+		TK:           lipgloss.Color("#A02F6F"), // magenta-600 — TK placeholder badge
 
 		ConflictMarker: lipgloss.Color("#5E409D"), // purple-600 — conflict marker lines
 		ConflictOurs:   lipgloss.Color("#EDEECF"), // green-50 — "ours" block tint
@@ -98,6 +100,7 @@ 		Accent:       lipgloss.Color("#FFD500"),
 		Highlight:    lipgloss.Color("#3A2E4D"),
 		Spell:        lipgloss.Color("#FF5F87"), // pink-red — misspell undercurl
 		Grammar:      lipgloss.Color("#A6E22E"), // lime — grammar undercurl (Harper)
+		TK:           lipgloss.Color("#FF5FAF"), // hot pink — TK placeholder badge
 
 		ConflictMarker: lipgloss.Color("#B794F6"), // soft purple — conflict marker lines
 		ConflictOurs:   lipgloss.Color("#17251C"), // dark green — "ours" block tint