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