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

fix: keyboard shortcut for line and doc navigation.

25666a37f4e4d0b4fc8be0e1f1d27e0afefc4d78
Kevin Kortum <kevinkortum@me.com> · 2026-07-09 12:11

parent af905318

12 files changed

README.md +39 −0
@@ -57,6 +57,7 @@ | mouse click | move the cursor |
 | mouse drag | select text (press to anchor, drag to extend, release to keep) |
 | mouse wheel | scroll the view |
 | `Home` / `End` | smart Home (first non-blank, then column 0) / end of line |
+| `Ctrl+Home` / `Ctrl+End` | top / bottom of the document (`Ctrl+↑` / `Ctrl+↓` do the same, but macOS binds those to Mission Control — see below) |
 | type `(` `[` `` ` `` with no selection | auto-closes the pair with the cursor inside; type the closer over it to step past |
 | `Shift`+arrows · `Shift+Home/End` | select text (`Ctrl+Shift+←/→` selects by word) |
 | `Ctrl+C` / `Ctrl+X` / `Ctrl+V` | copy / cut / paste (system clipboard); pasting a URL over a selection makes a `[selection](url)` link |
@@ -80,6 +81,44 @@ | `Alt+;` · click | spellcheck popup on the misspelled word at the cursor (or click an underlined word): pick a suggestion `1`–`9`, `a` add to dictionary, `i` ignore, `t` toggle spellcheck, `Esc` close |
 | `Ctrl+/` | toggle the in-editor help overlay (keys + commands) |
 | `Ctrl+Q` | quit (press twice if there are unsaved changes) |
 | `Esc` | clear the selection, or close find / back to the editor |
+
+### Using Cmd+arrows on macOS
+
+Terminals speak ANSI escape sequences, which have no concept of a Cmd (Super)
+key — a terminal app never sees `Cmd+←`, only whatever bytes the terminal chose
+to send for it. glint therefore can't bind Cmd itself; your **terminal** has to
+translate. Every key in the table above works out of the box with no config.
+
+To get the macOS muscle memory (`Cmd+←/→` for line edges, `Cmd+↑/↓` for document
+edges), map the four sequences glint listens for:
+
+| Cmd key | Sequence to send | glint sees |
+| --- | --- | --- |
+| `Cmd+←` | `ESC [ H` | `Home` |
+| `Cmd+→` | `ESC [ F` | `End` |
+| `Cmd+↑` | `ESC [ 1;5H` | `Ctrl+Home` |
+| `Cmd+↓` | `ESC [ 1;5F` | `Ctrl+End` |
+
+**Ghostty** — in `~/.config/ghostty/config`:
+
+```
+keybind = cmd+left=csi:H
+keybind = cmd+right=csi:F
+keybind = cmd+up=csi:1;5H
+keybind = cmd+down=csi:1;5F
+```
+
+**iTerm2** — Settings → Profiles → Keys → Key Mappings, add each as *Send Escape
+Sequence* with the text after the `ESC` (`[H`, `[F`, `[1;5H`, `[1;5F`).
+
+**Apple Terminal** — Settings → Profiles → Keyboard → `+`, same escape sequences.
+
+On a Mac, `Ctrl+↑` and `Ctrl+↓` never reach any terminal app — macOS claims them
+for Mission Control (System Settings → Keyboard → Keyboard Shortcuts → Mission
+Control). Use `Ctrl+Home`/`Ctrl+End`, or the `Cmd+↑`/`Cmd+↓` mapping above.
+
+Unsure what your terminal actually sends? `glint --keys` prints the key glint
+decodes for each press — press `Cmd+↑` and see whether anything arrives.
 
 ## Configuration
 
- → Preview-doc-name-H1-bar-tight-styled-properties.md +27 −0
@@ -0,0 +1,27 @@
+---
+id: TASK-040
+title: 'Preview: doc-name H1 bar + tight styled properties'
+status: "\U0001F3C1 Done"
+assignee: []
+created_date: '2026-07-09 04:36'
+updated_date: '2026-07-09 04:37'
+labels:
+  - feature
+dependencies: []
+priority: medium
+ordinal: 39000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Preview rendered YAML frontmatter as junk (--- rules, mashed key/value paragraph, stray bullets). Now: frontmatter is peeled off before glamour; doc name (frontmatter title, else filename) renders as a full-width bold bar in the heading color; properties render tight (no blank lines, no rules) with dim aligned keys and italic values.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Frontmatter --- fences never render as horizontal rules
+- [ ] #2 Doc name shows as a full-width color-block H1 bar
+- [ ] #3 Frontmatter title overrides the filename and is not repeated as a property
+- [ ] #4 Properties are consecutive lines, aligned, dim keys, italic values
+<!-- AC:END -->
- → Swap-Home-End-and-CtrlUp-Down-line-edges-vs-document-edges.md +30 −0
@@ -0,0 +1,30 @@
+---
+id: TASK-041
+title: 'Swap Home/End and Ctrl+Up/Down: line edges vs document edges'
+status: "\U0001F3C1 Done"
+assignee: []
+created_date: '2026-07-09 04:42'
+updated_date: '2026-07-09 05:13'
+labels:
+  - bug
+dependencies: []
+priority: medium
+ordinal: 40000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Cmd+Left/Right (arriving as Home/End) jumped to the top/bottom of the document, and Ctrl+Up/Down moved to line start/end — backwards from macOS convention. Home/End now move to line edges; Ctrl+Home/Ctrl+End (what Ghostty's cmd+up/cmd+down send: CSI 1;5H / CSI 1;5F) move to document edges, with Ctrl+Up/Ctrl+Down as aliases. Shift variants follow. Bubbletea's Key struct has no Cmd/Super modifier, so Cmd can only ever reach glint as a terminal-emitted escape sequence — README now documents the four sequences plus Ghostty/iTerm2/Terminal.app config.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Home/End move to line start (smart, toggling first non-blank / column 0) and line end
+- [ ] #2 Ctrl+Up/Ctrl+Down move to document start/end
+- [ ] #3 Shift+Home/Shift+End select within the line
+- [ ] #4 Ctrl+Shift+Up/Down select to the document edges
+- [ ] #5 Help text reflects the new mapping
+- [ ] #6 Ctrl+Home/Ctrl+End move to document start/end
+- [ ] #7 README documents the terminal config needed for Cmd+arrows
+<!-- AC:END -->
internal/app/app.go +17 −3
@@ -142,7 +142,21 @@ 		Text:       string(th.Text),
 		Heading:    string(th.Heading),
 		Code:       string(th.Code),
 		Link:       string(th.Link),
+		Muted:      string(th.Muted),
 	}
+}
+
+// renderPreview re-renders the read view from the current buffer, naming the
+// document after its file (a frontmatter "title" wins over the filename).
+// Untitled buffers get no name bar.
+func (a *App) renderPreview() error {
+	name := ""
+	if a.path != "" {
+		base := filepath.Base(a.path)
+		name = strings.TrimSuffix(base, filepath.Ext(base))
+	}
+	a.preview.SetTitle(name)
+	return a.preview.Render(string(a.editor.Bytes()))
 }
 
 // Load reads a file into the editor and switches to edit mode.
@@ -798,7 +812,7 @@ 	a.preview.SetColors(previewColors(a.theme))
 	// Re-render an open preview so its glamour style follows the new theme
 	// (otherwise it keeps the old light/dark block until the next toggle).
 	if a.mode == ModePreview {
-		_ = a.preview.Render(string(a.editor.Bytes()))
+		_ = a.renderPreview()
 	}
 	if a.picker != nil {
 		a.picker.SetTheme(a.theme)
@@ -832,7 +846,7 @@ 	if a.mode == ModePreview {
 		a.mode = ModeEditor
 		return a, tea.EnableMouseCellMotion
 	}
-	if err := a.preview.Render(string(a.editor.Bytes())); err != nil {
+	if err := a.renderPreview(); err != nil {
 		a.status = "Preview failed: " + err.Error()
 		return a, nil
 	}
@@ -886,7 +900,7 @@ 	if a.picker != nil {
 		a.picker.SetSize(w, h-1) // picker keeps its full-width split
 	}
 	if a.mode == ModePreview {
-		_ = a.preview.Render(string(a.editor.Bytes())) // re-wrap at the new width
+		_ = a.renderPreview() // re-wrap at the new width
 	}
 }
 
internal/editor/editor.go +15 −10
@@ -729,16 +729,21 @@ 			return
 		}
 		e.ClearSelection()
 		e.MoveDown()
-	case tea.KeyCtrlUp:
+	// Line edges. A terminal that maps Cmd+Left/Right to CSI H / CSI F (see the
+	// README) delivers them here.
+	case tea.KeyHome:
 		e.ClearSelection()
 		e.MoveHome()
-	case tea.KeyCtrlDown:
+	case tea.KeyEnd:
 		e.ClearSelection()
 		e.MoveEnd()
-	case tea.KeyHome:
+	// Document edges. Ctrl+Home/Ctrl+End is the cross-platform convention; a
+	// terminal mapping Cmd+Up/Down to CSI 1;5H / CSI 1;5F lands here too. Ctrl+Up
+	// and Ctrl+Down are aliases for keyboards without Home/End.
+	case tea.KeyCtrlHome, tea.KeyCtrlUp:
 		e.ClearSelection()
 		e.MoveDocStart()
-	case tea.KeyEnd:
+	case tea.KeyCtrlEnd, tea.KeyCtrlDown:
 		e.ClearSelection()
 		e.MoveDocEnd()
 
@@ -757,22 +762,22 @@ 		e.startSelection()
 		e.MoveDown()
 	case tea.KeyShiftHome:
 		e.startSelection()
-		e.MoveDocStart()
+		e.MoveHome()
 	case tea.KeyShiftEnd:
 		e.startSelection()
-		e.MoveDocEnd()
+		e.MoveEnd()
 	case tea.KeyCtrlShiftLeft:
 		e.startSelection()
 		e.MoveWordLeft()
 	case tea.KeyCtrlShiftRight:
 		e.startSelection()
 		e.MoveWordRight()
-	case tea.KeyCtrlShiftUp:
+	case tea.KeyCtrlShiftHome, tea.KeyCtrlShiftUp:
 		e.startSelection()
-		e.MoveHome()
-	case tea.KeyCtrlShiftDown:
+		e.MoveDocStart()
+	case tea.KeyCtrlShiftEnd, tea.KeyCtrlShiftDown:
 		e.startSelection()
-		e.MoveEnd()
+		e.MoveDocEnd()
 	}
 }
 
internal/editor/editor_test.go +88 −18
@@ -413,33 +413,103 @@ 		t.Errorf("Alt+f → col %d, want 16 (end of gamma)", e.Cursor.Col)
 	}
 }
 
+// Ctrl+Home/Ctrl+End (and the Ctrl+Up/Ctrl+Down aliases) jump to the top and
+// bottom of the document.
 func TestDocStartEnd(t *testing.T) {
-	e := newEditorWith("first", "middle", "last line")
-	e.Cursor = Position{Row: 1, Col: 2}
+	for _, tc := range []struct {
+		name       string
+		start, end tea.KeyType
+	}{
+		{"ctrl+home/ctrl+end", tea.KeyCtrlHome, tea.KeyCtrlEnd},
+		{"ctrl+up/ctrl+down", tea.KeyCtrlUp, tea.KeyCtrlDown},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			e := newEditorWith("first", "middle", "last line")
+			e.Cursor = Position{Row: 1, Col: 2}
+			e.HandleKey(tea.KeyMsg{Type: tc.end})
+			if e.Cursor != (Position{Row: 2, Col: 9}) {
+				t.Errorf("end key → %+v, want {2 9} (end of document)", e.Cursor)
+			}
+			e.HandleKey(tea.KeyMsg{Type: tc.start})
+			if e.Cursor != (Position{Row: 0, Col: 0}) {
+				t.Errorf("start key → %+v, want {0 0} (start of document)", e.Cursor)
+			}
+		})
+	}
+}
+
+// Home/End move within the current line, Home toggling between the first
+// non-blank column and column 0.
+func TestLineStartEndViaHomeEnd(t *testing.T) {
+	e := newEditorWith("  indented line", "second")
+	e.Cursor = Position{Row: 0, Col: 8}
 	e.HandleKey(tea.KeyMsg{Type: tea.KeyEnd})
-	if e.Cursor != (Position{Row: 2, Col: 9}) {
-		t.Errorf("End → %+v, want {2 9}", e.Cursor)
+	if e.Cursor != (Position{Row: 0, Col: len("  indented line")}) {
+		t.Errorf("End → %+v, want row 0 col %d (line end)", e.Cursor, len("  indented line"))
 	}
 	e.HandleKey(tea.KeyMsg{Type: tea.KeyHome})
-	if e.Cursor != (Position{Row: 0, Col: 0}) {
-		t.Errorf("Home → %+v, want {0 0}", e.Cursor)
+	if e.Cursor != (Position{Row: 0, Col: 2}) {
+		t.Errorf("Home → %+v, want row 0 col 2 (first non-blank)", e.Cursor)
+	}
+	e.HandleKey(tea.KeyMsg{Type: tea.KeyHome})
+	if e.Cursor.Col != 0 {
+		t.Errorf("Home again → col %d, want 0 (toggle to column 0)", e.Cursor.Col)
 	}
 }
 
-func TestLineStartEndViaCtrlUpDown(t *testing.T) {
-	e := newEditorWith("  indented line")
-	e.Cursor = Position{Row: 0, Col: 8}
-	e.HandleKey(tea.KeyMsg{Type: tea.KeyCtrlDown})
-	if e.Cursor.Col != len("  indented line") {
-		t.Errorf("Ctrl+Down → col %d, want %d (line end)", e.Cursor.Col, len("  indented line"))
+// The key types the README's terminal config targets. Bubbletea's Key struct
+// carries no Cmd/Super modifier, so a Cmd+arrow only reaches glint as whichever
+// escape sequence the terminal is configured to emit; these are the four the
+// README tells users to map, named by the type bubbletea decodes them to.
+func TestCmdArrowTargetsAreBound(t *testing.T) {
+	for _, tc := range []struct {
+		key  tea.KeyType
+		seq  string // what the terminal must send
+		cmd  string // the Cmd+arrow mapped to it
+		want Position
+	}{
+		{tea.KeyEnd, "\x1b[F", "cmd+right", Position{Row: 1, Col: 15}},
+		{tea.KeyHome, "\x1b[H", "cmd+left", Position{Row: 1, Col: 2}},
+		{tea.KeyCtrlEnd, "\x1b[1;5F", "cmd+down", Position{Row: 2, Col: 4}},
+		{tea.KeyCtrlHome, "\x1b[1;5H", "cmd+up", Position{Row: 0, Col: 0}},
+	} {
+		e := newEditorWith("first", "  indented line", "last")
+		e.Cursor = Position{Row: 1, Col: 8}
+		e.HandleKey(tea.KeyMsg{Type: tc.key})
+		if e.Cursor != tc.want {
+			t.Errorf("%s (%q → %s) → %+v, want %+v", tc.cmd, tc.seq, tc.key, e.Cursor, tc.want)
+		}
 	}
-	e.HandleKey(tea.KeyMsg{Type: tea.KeyCtrlUp})
-	if e.Cursor.Col != 2 {
-		t.Errorf("Ctrl+Up → col %d, want 2 (first non-blank)", e.Cursor.Col)
+}
+
+// The Shift variants extend a selection over the same spans their plain keys move.
+func TestShiftHomeEndSelectsWithinLine(t *testing.T) {
+	e := newEditorWith("hello world", "second")
+	e.Cursor = Position{Row: 0, Col: 6}
+	e.HandleKey(tea.KeyMsg{Type: tea.KeyShiftEnd})
+	if got := e.SelectedText(); got != "world" {
+		t.Errorf("Shift+End selected %q, want world", got)
+	}
+	e.ClearSelection()
+	e.Cursor = Position{Row: 0, Col: 6}
+	e.HandleKey(tea.KeyMsg{Type: tea.KeyShiftHome})
+	if got := e.SelectedText(); got != "hello " {
+		t.Errorf("Shift+Home selected %q, want %q", got, "hello ")
 	}
-	e.HandleKey(tea.KeyMsg{Type: tea.KeyCtrlUp})
-	if e.Cursor.Col != 0 {
-		t.Errorf("Ctrl+Up again → col %d, want 0 (toggle to column 0)", e.Cursor.Col)
+}
+
+func TestCtrlShiftUpDownSelectsToDocEdges(t *testing.T) {
+	e := newEditorWith("first", "middle", "last")
+	e.Cursor = Position{Row: 1, Col: 0}
+	e.HandleKey(tea.KeyMsg{Type: tea.KeyCtrlShiftDown})
+	if got := e.SelectedText(); got != "middle\nlast" {
+		t.Errorf("Ctrl+Shift+Down selected %q, want %q", got, "middle\nlast")
+	}
+	e.ClearSelection()
+	e.Cursor = Position{Row: 1, Col: 0}
+	e.HandleKey(tea.KeyMsg{Type: tea.KeyCtrlShiftUp})
+	if got := e.SelectedText(); got != "first\n" {
+		t.Errorf("Ctrl+Shift+Up selected %q, want %q", got, "first\n")
 	}
 }
 
internal/help/help.go +3 −2
@@ -44,8 +44,9 @@   Ctrl+F                fuzzy file picker (prefix the query with / to full-text
                         search note contents; results open at the matching line)
   Ctrl+G                find in document (Enter/down next, Shift+Tab/up prev)
   Ctrl+L                go to line (type a number, Enter to jump)
-  Home / End            top / bottom of the document
-  Ctrl+Up / Ctrl+Down   line start (first non-blank, then column 0) / line end
+  Home / End            line start (first non-blank, then column 0) / line end
+  Ctrl+Home / Ctrl+End  top / bottom of the document (also Ctrl+Up / Ctrl+Down,
+                        which macOS steals for Mission Control)
   Ctrl+D                today's daily note
   Ctrl+N                new note in the current directory
   Ctrl+B                new note in the inbox
internal/preview/frontmatter.go +128 −0
@@ -0,0 +1,128 @@
+package preview
+
+import "strings"
+
+// prop is one frontmatter key and its flattened display value.
+type prop struct {
+	key string
+	val string
+}
+
+// listSep joins the items of a YAML sequence into one display value.
+const listSep = " · "
+
+// splitFrontmatter peels a leading YAML frontmatter block (--- … ---) off the
+// document, returning its properties and the remaining markdown. A block that
+// doesn't start on the first line, or never closes, is left in the body: it's a
+// horizontal rule, not metadata.
+func splitFrontmatter(md string) ([]prop, string) {
+	if !strings.HasPrefix(md, "---\n") && !strings.HasPrefix(md, "---\r\n") {
+		return nil, md
+	}
+	lines := strings.Split(md[strings.IndexByte(md, '\n')+1:], "\n")
+	for i, ln := range lines {
+		if t := strings.TrimRight(ln, "\r"); t == "---" || t == "..." {
+			return parseProps(lines[:i]), strings.Join(lines[i+1:], "\n")
+		}
+	}
+	return nil, md
+}
+
+// parseProps reads the lines between the frontmatter fences. It handles the
+// shapes Obsidian writes — scalars, inline arrays, and indented "- item"
+// sequences — flattening each to a single line. Anything more exotic (nested
+// maps, block scalars) degrades to its raw text rather than erroring.
+func parseProps(lines []string) []prop {
+	var props []prop
+	var items []string
+	flush := func() {
+		if len(items) > 0 && len(props) > 0 {
+			props[len(props)-1].val = strings.Join(items, listSep)
+		}
+		items = nil
+	}
+	for _, raw := range lines {
+		ln := strings.TrimRight(raw, "\r")
+		t := strings.TrimSpace(ln)
+		if t == "" || strings.HasPrefix(t, "#") {
+			continue
+		}
+		// Indented or dashed lines belong to the property above.
+		if ln != t || strings.HasPrefix(t, "-") {
+			if strings.HasPrefix(t, "-") {
+				items = append(items, cleanScalar(strings.TrimSpace(t[1:])))
+			} else if len(props) > 0 {
+				items = append(items, cleanScalar(t))
+			}
+			continue
+		}
+		flush()
+		k, v, ok := strings.Cut(ln, ":")
+		if !ok {
+			continue
+		}
+		props = append(props, prop{strings.TrimSpace(k), cleanValue(strings.TrimSpace(v))})
+	}
+	flush()
+	return props
+}
+
+// cleanValue flattens one scalar or inline array ("[a, b]") to display text.
+func cleanValue(v string) string {
+	if len(v) >= 2 && strings.HasPrefix(v, "[") && strings.HasSuffix(v, "]") {
+		// A wikilink ("[[Note]]") is a scalar, not an array.
+		if !strings.HasPrefix(v, "[[") {
+			var out []string
+			for _, item := range splitItems(v[1 : len(v)-1]) {
+				if s := cleanScalar(item); s != "" {
+					out = append(out, s)
+				}
+			}
+			return strings.Join(out, listSep)
+		}
+	}
+	return cleanScalar(v)
+}
+
+// splitItems splits an inline array's contents on commas outside quotes.
+func splitItems(s string) []string {
+	var out []string
+	var quote rune
+	start := 0
+	for i, r := range s {
+		switch {
+		case quote != 0:
+			if r == quote {
+				quote = 0
+			}
+		case r == '"' || r == '\'':
+			quote = r
+		case r == ',':
+			out = append(out, s[start:i])
+			start = i + 1
+		}
+	}
+	return append(out, s[start:])
+}
+
+// cleanScalar trims a value and drops one layer of matching quotes.
+func cleanScalar(s string) string {
+	s = strings.TrimSpace(s)
+	if len(s) >= 2 {
+		if q := s[0]; (q == '"' || q == '\'') && s[len(s)-1] == q {
+			return s[1 : len(s)-1]
+		}
+	}
+	return s
+}
+
+// takeTitle pulls the "title" property out of props, so a doc titled in its
+// frontmatter uses that name for the heading instead of repeating it below.
+func takeTitle(props []prop) (string, []prop) {
+	for i, p := range props {
+		if p.key == "title" && p.val != "" {
+			return p.val, append(props[:i:i], props[i+1:]...)
+		}
+	}
+	return "", props
+}
internal/preview/frontmatter_test.go +193 −0
@@ -0,0 +1,193 @@
+package preview
+
+import (
+	"reflect"
+	"strings"
+	"testing"
+)
+
+func TestSplitFrontmatter(t *testing.T) {
+	md := "---\ncreated: 2026-07-08\nstatus: \"🟢 In progress\"\ntags:\n  - daily\n  - tui\ncategories: [\"[[Projects]]\", Notes]\n---\n\n# Body\n\ntext\n"
+	props, body := splitFrontmatter(md)
+	want := []prop{
+		{"created", "2026-07-08"},
+		{"status", "🟢 In progress"},
+		{"tags", "daily · tui"},
+		{"categories", "[[Projects]] · Notes"},
+	}
+	if !reflect.DeepEqual(props, want) {
+		t.Errorf("props = %#v, want %#v", props, want)
+	}
+	if body != "\n# Body\n\ntext\n" {
+		t.Errorf("body = %q", body)
+	}
+}
+
+func TestSplitFrontmatterNone(t *testing.T) {
+	md := "# Body\n\ntext\n"
+	props, body := splitFrontmatter(md)
+	if len(props) != 0 || body != md {
+		t.Errorf("no frontmatter: props=%v body=%q", props, body)
+	}
+	// A "---" that isn't on line one is a horizontal rule, not frontmatter.
+	md2 := "intro\n\n---\nkey: value\n---\n"
+	props, body = splitFrontmatter(md2)
+	if len(props) != 0 || body != md2 {
+		t.Errorf("mid-document rule treated as frontmatter: props=%v", props)
+	}
+}
+
+func TestSplitFrontmatterUnterminated(t *testing.T) {
+	md := "---\nkey: value\n\n# Body\n"
+	props, body := splitFrontmatter(md)
+	if len(props) != 0 || body != md {
+		t.Errorf("unterminated block should be left alone: props=%v body=%q", props, body)
+	}
+}
+
+func TestSplitFrontmatterEmptyValues(t *testing.T) {
+	md := "---\naliases:\ntitle: Note\n---\nbody\n"
+	props, _ := splitFrontmatter(md)
+	want := []prop{{"aliases", ""}, {"title", "Note"}}
+	if !reflect.DeepEqual(props, want) {
+		t.Errorf("props = %#v, want %#v", props, want)
+	}
+}
+
+// A colon inside a value (a URL, a time) must not split the key twice.
+func TestSplitFrontmatterColonInValue(t *testing.T) {
+	md := "---\nsource: https://example.com/a\nat: 09:30\n---\nbody\n"
+	props, _ := splitFrontmatter(md)
+	want := []prop{{"source", "https://example.com/a"}, {"at", "09:30"}}
+	if !reflect.DeepEqual(props, want) {
+		t.Errorf("props = %#v, want %#v", props, want)
+	}
+}
+
+func TestHeaderTitleFromFrontmatter(t *testing.T) {
+	m := New("")
+	m.SetColors(testColors)
+	m.SetSize(60, 20)
+	m.SetTitle("filename-title")
+	if err := m.Render("---\ntitle: Frontmatter Title\ncreated: 2026-07-08\n---\n\nbody\n"); err != nil {
+		t.Fatal(err)
+	}
+	out := strings.Join(stripANSI(m.vp.View()), "\n")
+	if !strings.Contains(out, "Frontmatter Title") {
+		t.Errorf("frontmatter title not used as doc name:\n%s", out)
+	}
+	if strings.Contains(out, "filename-title") {
+		t.Errorf("filename should lose to frontmatter title:\n%s", out)
+	}
+	// The title key is consumed by the heading, not repeated as a property.
+	if strings.Contains(out, "title ") {
+		t.Errorf("title still listed as a property:\n%s", out)
+	}
+}
+
+func TestHeaderPropsAreTightAndRuleFree(t *testing.T) {
+	m := New("")
+	m.SetColors(testColors)
+	m.SetSize(60, 30)
+	m.SetTitle("My Note")
+	if err := m.Render("---\ncreated: 2026-07-08\nstatus: In progress\ntags:\n  - daily\n---\n\nbody text\n"); err != nil {
+		t.Fatal(err)
+	}
+	lines := stripANSI(m.vp.View())
+	find := func(want string) int {
+		for i, ln := range lines {
+			if strings.HasPrefix(strings.TrimSpace(ln), want) {
+				return i
+			}
+		}
+		t.Fatalf("line starting %q not found in:\n%s", want, strings.Join(lines, "\n"))
+		return -1
+	}
+	title := find("My Note")
+	created := find("created")
+	// Doc name sits above the properties, with one blank line between.
+	if created != title+2 {
+		t.Errorf("properties start at %d, want %d (one blank line under the title)", created, title+2)
+	}
+	// Properties are consecutive: no blank lines between them.
+	if got := strings.TrimSpace(lines[created+1]); !strings.HasPrefix(got, "status") {
+		t.Errorf("line after created = %q, want status", got)
+	}
+	if got := strings.TrimSpace(lines[created+2]); !strings.HasPrefix(got, "tags") {
+		t.Errorf("line after status = %q, want tags", got)
+	}
+	// The --- fences never render as horizontal rules.
+	for _, ln := range lines {
+		if strings.HasPrefix(strings.TrimSpace(ln), "---") {
+			t.Errorf("frontmatter fence rendered as a rule:\n%s", strings.Join(lines, "\n"))
+			break
+		}
+	}
+	// Values are aligned in a column, keys padded to the widest key.
+	for _, ln := range lines[created : created+3] {
+		if !strings.Contains(ln, "  2026-07-08") && !strings.Contains(ln, "  In progress") && !strings.Contains(ln, "  daily") {
+			t.Errorf("property value not in an aligned column: %q", ln)
+		}
+	}
+}
+
+func TestHeaderTitleBarIsStyled(t *testing.T) {
+	m := New("")
+	m.SetColors(testColors)
+	m.SetSize(60, 20)
+	m.SetTitle("My Note")
+	if err := m.Render("body\n"); err != nil {
+		t.Fatal(err)
+	}
+	raw := m.vp.View()
+	line := strings.Split(raw, "\n")[0]
+	if !strings.Contains(line, "My Note") {
+		t.Fatalf("title not on the first line: %q", line)
+	}
+	// The heading color paints the bar background, with legible text on top.
+	if !strings.Contains(line, "48;2;209;77;65") {
+		t.Errorf("title bar missing the heading background: %q", line)
+	}
+	if !strings.Contains(line, "38;2;255;252;240") {
+		t.Errorf("title bar missing legible foreground: %q", line)
+	}
+	// The bar spans the full preview width, like a filled H1 block.
+	if w := len(stripANSI(line)[0]); w != 60 {
+		t.Errorf("title bar width = %d, want 60", w)
+	}
+}
+
+func TestHeaderAbsentWithoutTitleOrProps(t *testing.T) {
+	m := New("")
+	m.SetColors(testColors)
+	m.SetSize(60, 20)
+	if err := m.Render("# Heading\n\nbody\n"); err != nil {
+		t.Fatal(err)
+	}
+	if h := m.renderHeader("", nil); h != "" {
+		t.Errorf("renderHeader with nothing to show = %q", h)
+	}
+	// The document opens on glamour's own H1, not a doc-name bar above it.
+	lines := stripANSI(m.vp.View())
+	if got := strings.TrimSpace(lines[0]); got != "" {
+		t.Errorf("untitled doc grew a header: %q", got)
+	}
+	if got := strings.TrimSpace(lines[1]); got != "Heading" {
+		t.Errorf("body H1 should be the first thing rendered, got %q", got)
+	}
+}
+
+func TestHeaderWrapsLongValues(t *testing.T) {
+	m := New("")
+	m.SetColors(testColors)
+	m.SetSize(40, 20)
+	m.SetTitle("N")
+	if err := m.Render("---\ntags: alpha beta gamma delta epsilon zeta eta theta\n---\nbody\n"); err != nil {
+		t.Fatal(err)
+	}
+	for _, ln := range stripANSI(m.vp.View()) {
+		if w := len([]rune(ln)); w > 40 {
+			t.Errorf("line exceeds width 40 (%d): %q", w, ln)
+		}
+	}
+}
internal/preview/header.go +158 −0
@@ -0,0 +1,158 @@
+package preview
+
+import (
+	"strings"
+
+	"github.com/charmbracelet/lipgloss"
+)
+
+// SGR escapes. The header writes them by hand rather than through lipgloss so
+// the colors survive a non-TTY renderer (tests, pipes) exactly like the rest of
+// this package's output, which glamour also emits unconditionally.
+const (
+	sgrReset  = "\x1b[0m"
+	sgrBold   = "\x1b[1m"
+	sgrItalic = "\x1b[3m"
+)
+
+// propIndent is the left inset of the properties block, and propGap the space
+// between the key column and the value column.
+const (
+	propIndent = "  "
+	propGap    = "  "
+)
+
+// renderHeader draws the document header that sits above the glamour body: the
+// doc name as a full-width bar in the heading color (an H1 block that spans the
+// column), then the frontmatter properties as a tight, aligned, italic list —
+// dim keys, italic values, one line each, no rules and no blank lines between.
+// Returns "" when there is neither a name nor a property.
+func (m *Model) renderHeader(title string, props []prop) string {
+	if title == "" && len(props) == 0 {
+		return ""
+	}
+	width := m.width
+	if width < 1 {
+		width = 80
+	}
+	var b strings.Builder
+	if title != "" {
+		b.WriteString(m.titleBar(title, width))
+		b.WriteByte('\n')
+	}
+	if len(props) > 0 {
+		if title != "" {
+			b.WriteByte('\n') // one blank line under the name
+		}
+		b.WriteString(m.propList(props, width))
+	}
+	return b.String()
+}
+
+// titleBar renders the doc name as a bold, full-width filled block: the heading
+// color behind, a legible text color on top. Without a themed heading color it
+// falls back to bold text.
+func (m *Model) titleBar(title string, width int) string {
+	pad := width - lipgloss.Width(title) - 2 // one space each side
+	if pad < 0 {
+		title = truncate(title, width-2)
+		pad = 0
+	}
+	line := " " + title + " " + strings.Repeat(" ", pad)
+	open := sgrBold
+	if bg := hexToRGB(m.colors.Heading); bg != "" {
+		open += "\x1b[38;2;" + hexToRGB(legibleText(m.colors.Heading)) + ";48;2;" + bg + "m"
+	}
+	return open + line + sgrReset
+}
+
+// propList renders the aligned key/value rows. Keys are padded to the widest
+// key so the values form a column; long values wrap with a hanging indent that
+// keeps them inside that column.
+func (m *Model) propList(props []prop, width int) string {
+	keyw := 0
+	for _, p := range props {
+		if w := lipgloss.Width(p.key); w > keyw {
+			keyw = w
+		}
+	}
+	valLeft := len(propIndent) + keyw + len(propGap)
+	valWidth := max(width-valLeft, 8)
+	keyOpen, valOpen := m.propStyles()
+
+	var b strings.Builder
+	for _, p := range props {
+		b.WriteString(propIndent)
+		b.WriteString(keyOpen)
+		b.WriteString(p.key)
+		b.WriteString(strings.Repeat(" ", keyw-lipgloss.Width(p.key)))
+		b.WriteString(sgrReset)
+		b.WriteString(propGap)
+		for i, ln := range wrapWords(p.val, valWidth) {
+			if i > 0 {
+				b.WriteString(strings.Repeat(" ", valLeft))
+			}
+			b.WriteString(valOpen)
+			b.WriteString(ln)
+			b.WriteString(sgrReset)
+			b.WriteByte('\n')
+		}
+		if p.val == "" {
+			b.WriteByte('\n')
+		}
+	}
+	return b.String()
+}
+
+// propStyles returns the SGR prefixes for a property key (dim) and value
+// (italic), falling back to plain text when the theme colors are unset.
+func (m *Model) propStyles() (key, val string) {
+	if c := hexToRGB(m.colors.Muted); c != "" {
+		key = "\x1b[38;2;" + c + "m"
+	}
+	val = sgrItalic
+	if c := hexToRGB(m.colors.Text); c != "" {
+		val += "\x1b[38;2;" + c + "m"
+	}
+	return key, val
+}
+
+// wrapWords greedily wraps text to width, breaking on spaces. An empty string
+// yields no lines.
+func wrapWords(s string, width int) []string {
+	if s == "" {
+		return nil
+	}
+	var lines []string
+	cur := ""
+	for _, w := range strings.Fields(s) {
+		switch {
+		case cur == "":
+			cur = w
+		case lipgloss.Width(cur)+1+lipgloss.Width(w) <= width:
+			cur += " " + w
+		default:
+			lines = append(lines, cur)
+			cur = w
+		}
+	}
+	if cur != "" {
+		lines = append(lines, cur)
+	}
+	return lines
+}
+
+// truncate cuts s to at most width display cells, ending in an ellipsis.
+func truncate(s string, width int) string {
+	if width < 1 {
+		return ""
+	}
+	if lipgloss.Width(s) <= width {
+		return s
+	}
+	r := []rune(s)
+	for len(r) > 0 && lipgloss.Width(string(r))+1 > width {
+		r = r[:len(r)-1]
+	}
+	return string(r) + "…"
+}
internal/preview/preview.go +15 −3
@@ -173,12 +173,14 @@ 	Text       string
 	Heading    string
 	Code       string
 	Link       string
+	Muted      string // frontmatter property keys
 }
 
 type Model struct {
 	vp     viewport.Model
 	style  string
 	colors Colors
+	title  string
 	width  int
 	height int
 }
@@ -212,17 +214,27 @@
 // SetStyle changes the glamour style used by the next Render.
 func (m *Model) SetStyle(s string) { m.style = s }
 
-// Render runs markdown through Glamour and loads it into the viewport.
+// SetTitle sets the document name shown as the preview's heading bar — the
+// filename, which a frontmatter "title" property overrides at render time.
+func (m *Model) SetTitle(t string) { m.title = t }
+
+// Render peels the frontmatter off the document, runs the remaining markdown
+// through Glamour, and loads the doc-name bar and property block above it.
 func (m *Model) Render(markdown string) error {
 	r, err := m.renderer()
 	if err != nil {
 		return err
 	}
-	out, err := r.Render(markdown)
+	props, body := splitFrontmatter(markdown)
+	title, props := takeTitle(props)
+	if title == "" {
+		title = m.title
+	}
+	out, err := r.Render(body)
 	if err != nil {
 		return err
 	}
-	m.vp.SetContent(out)
+	m.vp.SetContent(m.renderHeader(title, props) + out)
 	m.vp.GotoTop()
 	return nil
 }
internal/preview/theme_test.go +1 −0
@@ -7,6 +7,7 @@ )
 
 var testColors = Colors{
 	Background: "#100F0F", Text: "#CECDC3", Heading: "#D14D41", Code: "#879A39", Link: "#4385BE",
+	Muted: "#6F6E69",
 }
 
 func renderPreview(t *testing.T, md string) string {