▍ humdrum codex / glint v1.0.2

Add glint editor v1 implementation plan

e6baa68389839285b9deefb81121ef02073fb57d
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-06-27 20:39

parent 736cd3bf

Add glint editor v1 implementation plan

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

1 files changed

docs/superpowers/plans/2026-06-27-glint-editor.md +2418 −0
@@ -0,0 +1,2418 @@
+# glint Editor (v1) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build a modeless terminal markdown editor with live inline styling (markup stays visible), a glamour read-preview, a fuzzy file picker, and a daily-note jump.
+
+**Architecture:** Bubbletea MVU. A top-level `app` model holds a `mode` enum (Editor | Picker | Preview) and routes messages to the active sub-model. The `editor` package is a self-contained text buffer plus a hand-rolled, line-oriented markdown styling scanner that emits Lipgloss-styled spans char-for-char aligned with the raw text (no concealment). `preview` wraps Glamour; `picker` walks a directory and fuzzy-filters; `config` loads TOML.
+
+**Tech Stack:** Go 1.26, Bubbletea v1.3.10, Bubbles v1.0.0 (viewport, textinput), Lipgloss v1.1.0, Glamour v1.0.0, BurntSushi/toml v1.6.0.
+
+## Global Constraints
+
+- Module path: `glint`. Internal packages import as `glint/internal/<pkg>`.
+- Go version floor: `go 1.26`.
+- Pinned dependency versions (exact): bubbletea `v1.3.10`, bubbles `v1.0.0`, lipgloss `v1.1.0`, glamour `v1.0.0`, BurntSushi/toml `v1.6.0`.
+- **Styling invariant:** every emitted span — including plain prose and empty lines — gets an explicit `Foreground` from the theme. Never `lipgloss.Style{}` / terminal default. This is the root-cause fix for the ekphos light-terminal bug.
+- **Markup-visible invariant:** the styling scanner never inserts or deletes characters. The concatenation of a line's span texts equals the raw line exactly.
+- Modeless editing only. No vim modes.
+- Config keys (TOML, `~/.config/glint/config.toml`): `vault_dir`, `daily_subdir`, `daily_format` (Go reference-time layout), `glamour_style`.
+- Config defaults: `vault_dir = "<home>/Humdrum"`, `daily_subdir = "Daily"`, `daily_format = "2006-01-02"`, `glamour_style = "dark"`.
+- Keybinds: `Ctrl+S` save, `Ctrl+P` picker, `Ctrl+D` daily, `Ctrl+R` preview toggle, `Esc` back to editor, `Ctrl+Q` quit (confirm if dirty).
+
+---
+
+## File Structure
+
+```
+glint/
+  go.mod
+  main.go                              entrypoint: flags, config load, build app, run program
+  internal/
+    config/
+      config.go                        Config struct, Load, Default, DailyPath
+      config_test.go
+    editor/
+      theme.go                         Theme struct + DefaultDarkTheme
+      span.go                          Span type + render helpers (with/without cursor)
+      scanner.go                       blockState, ScanLines, scanLine, scanInline, matchToken
+      scanner_test.go
+      editor.go                        Editor model: buffer, cursor, scroll, dirty, ops, View
+      editor_test.go
+    preview/
+      preview.go                       Glamour render -> viewport wrapper
+      preview_test.go
+    picker/
+      picker.go                        dir walk, fuzzy match, textinput query, selection
+      picker_test.go
+    app/
+      app.go                           top model: mode enum, routing, status bar, save/load
+      app_test.go
+```
+
+Each `internal/` package has one responsibility and is testable without a running terminal. `editor` knows nothing about files or the vault (the app wires save/load). `config`, the scanner, the editor ops, and the fuzzy matcher are all pure and carry the high-value unit tests.
+
+---
+
+## Task 1: Project scaffold + config package
+
+**Files:**
+- Create: `go.mod` (via `go mod init`)
+- Create: `internal/config/config.go`
+- Test: `internal/config/config_test.go`
+
+**Interfaces:**
+- Consumes: nothing (first task).
+- Produces:
+  - `config.Config` struct with fields `VaultDir, DailySubdir, DailyFormat, GlamourStyle string`.
+  - `config.Default() Config`
+  - `config.Load() (Config, error)` — reads `~/.config/glint/config.toml`, overlays onto defaults; absent file → defaults + nil error; malformed → defaults + non-nil error.
+  - `config.loadFromFile(path string) (Config, error)` — unexported, testable core of `Load`.
+  - `func (c Config) DailyPath(t time.Time) string`
+
+- [ ] **Step 1: Initialize the module and fetch pinned dependencies**
+
+Run:
+```bash
+cd /Users/kortum/Developer/Home/glint
+go mod init glint
+go get github.com/charmbracelet/bubbletea@v1.3.10
+go get github.com/charmbracelet/bubbles@v1.0.0
+go get github.com/charmbracelet/lipgloss@v1.1.0
+go get github.com/charmbracelet/glamour@v1.0.0
+go get github.com/BurntSushi/toml@v1.6.0
+```
+Expected: `go.mod` created with `module glint`, `go 1.26`, and the five `require` lines.
+
+- [ ] **Step 2: Write the failing test**
+
+Create `internal/config/config_test.go`:
+```go
+package config
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+	"time"
+)
+
+func TestDefaultHasSaneValues(t *testing.T) {
+	d := Default()
+	if d.DailySubdir != "Daily" {
+		t.Errorf("DailySubdir = %q, want Daily", d.DailySubdir)
+	}
+	if d.DailyFormat != "2006-01-02" {
+		t.Errorf("DailyFormat = %q, want 2006-01-02", d.DailyFormat)
+	}
+	if d.GlamourStyle != "dark" {
+		t.Errorf("GlamourStyle = %q, want dark", d.GlamourStyle)
+	}
+	if d.VaultDir == "" {
+		t.Error("VaultDir should not be empty")
+	}
+}
+
+func TestLoadFromFileMissingReturnsDefaults(t *testing.T) {
+	cfg, err := loadFromFile(filepath.Join(t.TempDir(), "nope.toml"))
+	if err != nil {
+		t.Fatalf("missing file should not error, got %v", err)
+	}
+	if cfg.DailyFormat != "2006-01-02" {
+		t.Errorf("DailyFormat = %q, want default", cfg.DailyFormat)
+	}
+}
+
+func TestLoadFromFileOverlays(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "config.toml")
+	body := `vault_dir = "/tmp/vault"
+daily_subdir = "journal"
+daily_format = "20060102"
+`
+	if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	cfg, err := loadFromFile(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if cfg.VaultDir != "/tmp/vault" {
+		t.Errorf("VaultDir = %q", cfg.VaultDir)
+	}
+	if cfg.DailySubdir != "journal" {
+		t.Errorf("DailySubdir = %q", cfg.DailySubdir)
+	}
+	if cfg.DailyFormat != "20060102" {
+		t.Errorf("DailyFormat = %q", cfg.DailyFormat)
+	}
+	// unset key keeps default
+	if cfg.GlamourStyle != "dark" {
+		t.Errorf("GlamourStyle = %q, want default dark", cfg.GlamourStyle)
+	}
+}
+
+func TestLoadFromFileMalformedErrors(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "bad.toml")
+	if err := os.WriteFile(path, []byte("vault_dir = = ="), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := loadFromFile(path); err == nil {
+		t.Error("malformed config should return an error")
+	}
+}
+
+func TestDailyPath(t *testing.T) {
+	c := Config{VaultDir: "/v", DailySubdir: "Daily", DailyFormat: "2006-01-02"}
+	got := c.DailyPath(time.Date(2026, 6, 27, 0, 0, 0, 0, time.UTC))
+	want := filepath.Join("/v", "Daily", "2026-06-27.md")
+	if got != want {
+		t.Errorf("DailyPath = %q, want %q", got, want)
+	}
+}
+```
+
+- [ ] **Step 3: Run the test to verify it fails**
+
+Run: `go test ./internal/config/`
+Expected: FAIL — `undefined: Default`, `undefined: loadFromFile`, `undefined: Config`.
+
+- [ ] **Step 4: Write the implementation**
+
+Create `internal/config/config.go`:
+```go
+// Package config loads glint's TOML configuration, falling back to defaults.
+package config
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"time"
+
+	"github.com/BurntSushi/toml"
+)
+
+// Config holds the user-tunable settings glint reads at startup.
+type Config struct {
+	VaultDir     string `toml:"vault_dir"`
+	DailySubdir  string `toml:"daily_subdir"`
+	DailyFormat  string `toml:"daily_format"`
+	GlamourStyle string `toml:"glamour_style"`
+}
+
+// Default returns the built-in configuration used when no file is present.
+func Default() Config {
+	home, _ := os.UserHomeDir()
+	return Config{
+		VaultDir:     filepath.Join(home, "Humdrum"),
+		DailySubdir:  "Daily",
+		DailyFormat:  "2006-01-02",
+		GlamourStyle: "dark",
+	}
+}
+
+// Load reads ~/.config/glint/config.toml and overlays it onto the defaults.
+func Load() (Config, error) {
+	home, err := os.UserHomeDir()
+	if err != nil {
+		return Default(), nil
+	}
+	return loadFromFile(filepath.Join(home, ".config", "glint", "config.toml"))
+}
+
+// loadFromFile is the testable core of Load. A missing file yields the
+// defaults with no error; a malformed file yields the defaults plus an error.
+func loadFromFile(path string) (Config, error) {
+	cfg := Default()
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return cfg, nil // absent config is fine
+	}
+	var fileCfg Config
+	if _, err := toml.Decode(string(data), &fileCfg); err != nil {
+		return cfg, fmt.Errorf("parse %s: %w", path, err)
+	}
+	if fileCfg.VaultDir != "" {
+		cfg.VaultDir = fileCfg.VaultDir
+	}
+	if fileCfg.DailySubdir != "" {
+		cfg.DailySubdir = fileCfg.DailySubdir
+	}
+	if fileCfg.DailyFormat != "" {
+		cfg.DailyFormat = fileCfg.DailyFormat
+	}
+	if fileCfg.GlamourStyle != "" {
+		cfg.GlamourStyle = fileCfg.GlamourStyle
+	}
+	return cfg, nil
+}
+
+// DailyPath builds the absolute path to the daily note for time t.
+func (c Config) DailyPath(t time.Time) string {
+	return filepath.Join(c.VaultDir, c.DailySubdir, t.Format(c.DailyFormat)+".md")
+}
+```
+
+- [ ] **Step 5: Run the test to verify it passes**
+
+Run: `go test ./internal/config/`
+Expected: PASS (all five tests).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add go.mod go.sum internal/config/
+git commit -m "feat: project scaffold + config package"
+```
+
+---
+
+## Task 2: Theme + styling scanner
+
+**Files:**
+- Create: `internal/editor/theme.go`
+- Create: `internal/editor/span.go`
+- Create: `internal/editor/scanner.go`
+- Test: `internal/editor/scanner_test.go`
+
+**Interfaces:**
+- Consumes: nothing from earlier tasks.
+- Produces:
+  - `editor.Theme` struct with `lipgloss.Color` fields: `Text, Heading, Code, Link, Wikilink, ListMarker, Blockquote, Accent`.
+  - `editor.DefaultDarkTheme() Theme`
+  - `editor.Span` struct `{ Text string; Style lipgloss.Style }`
+  - `editor.renderSpans(spans []Span) string`
+  - `editor.renderSpansCursor(spans []Span, col int, cursorStyle lipgloss.Style) string`
+  - `editor.ScanLines(lines []string, th Theme) [][]Span` — one span slice per input line, char-for-char aligned.
+
+- [ ] **Step 1: Write the failing test for the theme and span renderers**
+
+Create `internal/editor/scanner_test.go`:
+```go
+package editor
+
+import (
+	"testing"
+
+	"github.com/charmbracelet/lipgloss"
+)
+
+// spanText concatenates the raw text of a line's spans. It must equal the
+// original raw line exactly (markup-visible invariant).
+func spanText(spans []Span) string {
+	s := ""
+	for _, sp := range spans {
+		s += sp.Text
+	}
+	return s
+}
+
+func TestDefaultThemeAllColorsSet(t *testing.T) {
+	th := DefaultDarkTheme()
+	colors := []lipgloss.Color{
+		th.Text, th.Heading, th.Code, th.Link,
+		th.Wikilink, th.ListMarker, th.Blockquote, th.Accent,
+	}
+	for i, c := range colors {
+		if c == "" {
+			t.Errorf("theme color %d is empty", i)
+		}
+	}
+}
+
+func TestRenderSpansPreservesText(t *testing.T) {
+	spans := []Span{
+		{Text: "ab", Style: lipgloss.NewStyle()},
+		{Text: "cd", Style: lipgloss.NewStyle()},
+	}
+	// rendering may add ANSI, but stripped content is checked elsewhere;
+	// here we only assert it does not panic and is non-empty.
+	if renderSpans(spans) == "" {
+		t.Error("renderSpans returned empty for non-empty spans")
+	}
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `go test ./internal/editor/`
+Expected: FAIL — `undefined: Span`, `undefined: DefaultDarkTheme`, `undefined: renderSpans`.
+
+- [ ] **Step 3: Implement the theme**
+
+Create `internal/editor/theme.go`:
+```go
+package editor
+
+import "github.com/charmbracelet/lipgloss"
+
+// Theme holds the explicit foreground colors used by the styling scanner.
+// Every span gets one of these — none ever defaults to the terminal foreground.
+type Theme struct {
+	Text       lipgloss.Color
+	Heading    lipgloss.Color
+	Code       lipgloss.Color
+	Link       lipgloss.Color
+	Wikilink   lipgloss.Color
+	ListMarker lipgloss.Color
+	Blockquote lipgloss.Color
+	Accent     lipgloss.Color
+}
+
+// DefaultDarkTheme is a Tokyo-Night-ish palette that reads on dark terminals.
+func DefaultDarkTheme() Theme {
+	return Theme{
+		Text:       lipgloss.Color("#c0caf5"),
+		Heading:    lipgloss.Color("#7aa2f7"),
+		Code:       lipgloss.Color("#9ece6a"),
+		Link:       lipgloss.Color("#7dcfff"),
+		Wikilink:   lipgloss.Color("#bb9af7"),
+		ListMarker: lipgloss.Color("#bb9af7"),
+		Blockquote: lipgloss.Color("#565f89"),
+		Accent:     lipgloss.Color("#e0af68"),
+	}
+}
+```
+
+- [ ] **Step 4: Implement the span type and renderers**
+
+Create `internal/editor/span.go`:
+```go
+package editor
+
+import (
+	"strings"
+
+	"github.com/charmbracelet/lipgloss"
+)
+
+// Span is a run of text with a single Lipgloss style.
+type Span struct {
+	Text  string
+	Style lipgloss.Style
+}
+
+// renderSpans concatenates styled spans into a single string.
+func renderSpans(spans []Span) string {
+	var b strings.Builder
+	for _, s := range spans {
+		b.WriteString(s.Style.Render(s.Text))
+	}
+	return b.String()
+}
+
+// renderSpansCursor renders spans rune-by-rune, drawing the cursor cell at the
+// given rune column with cursorStyle. A cursor at or past the end of the line
+// is drawn as a styled space.
+func renderSpansCursor(spans []Span, col int, cursorStyle lipgloss.Style) string {
+	var b strings.Builder
+	idx := 0
+	for _, s := range spans {
+		for _, r := range s.Text {
+			if idx == col {
+				b.WriteString(cursorStyle.Render(string(r)))
+			} else {
+				b.WriteString(s.Style.Render(string(r)))
+			}
+			idx++
+		}
+	}
+	if col >= idx {
+		b.WriteString(cursorStyle.Render(" "))
+	}
+	return b.String()
+}
+```
+
+- [ ] **Step 5: Run to verify theme/renderer tests pass**
+
+Run: `go test ./internal/editor/ -run 'TestDefaultTheme|TestRenderSpans'`
+Expected: PASS.
+
+- [ ] **Step 6: Write the failing test for the scanner**
+
+Append to `internal/editor/scanner_test.go`:
+```go
+func TestScanPlainTextGetsExplicitForeground(t *testing.T) {
+	th := DefaultDarkTheme()
+	out := ScanLines([]string{"hello world"}, th)
+	if len(out) != 1 || len(out[0]) == 0 {
+		t.Fatalf("expected spans for one line, got %v", out)
+	}
+	for _, sp := range out[0] {
+		// A style with no foreground returns "" from GetForeground().
+		if sp.Style.GetForeground() == lipgloss.Color("") {
+			t.Errorf("plain span %q has no explicit foreground", sp.Text)
+		}
+	}
+}
+
+func TestScanPreservesRawTextAcrossConstructs(t *testing.T) {
+	th := DefaultDarkTheme()
+	lines := []string{
+		"# Heading",
+		"plain **bold** and *italic* and `code`",
+		"- a list item with a [link](http://x) and [[wikilink]]",
+		"> a quote",
+		"```",
+		"raw **not bold** here",
+		"```",
+		"---",
+	}
+	out := ScanLines(lines, th)
+	for i, raw := range lines {
+		if got := spanText(out[i]); got != raw {
+			t.Errorf("line %d: span text %q != raw %q", i, got, raw)
+		}
+	}
+}
+
+func TestScanFencedCodeSuppressesInline(t *testing.T) {
+	th := DefaultDarkTheme()
+	lines := []string{"```", "**x**", "```"}
+	out := ScanLines(lines, th)
+	// The fence body line should be a single code-colored span, not split
+	// into bold spans.
+	if len(out[1]) != 1 {
+		t.Errorf("fenced line split into %d spans, want 1", len(out[1]))
+	}
+	if out[1][0].Style.GetForeground() != th.Code {
+		t.Errorf("fenced body not code-colored")
+	}
+}
+
+func TestScanLeadingFrontmatter(t *testing.T) {
+	th := DefaultDarkTheme()
+	lines := []string{"---", "title: x", "---", "body"}
+	out := ScanLines(lines, th)
+	if out[1][0].Style.GetForeground() != th.Blockquote {
+		t.Errorf("frontmatter body not muted")
+	}
+	// after the closing ---, normal text resumes
+	if out[3][0].Style.GetForeground() != th.Text {
+		t.Errorf("post-frontmatter line should be plain text")
+	}
+}
+
+func TestScanEmptyLineYieldsNoSpans(t *testing.T) {
+	out := ScanLines([]string{""}, DefaultDarkTheme())
+	if len(out[0]) != 0 {
+		t.Errorf("empty line should yield no spans, got %d", len(out[0]))
+	}
+}
+
+func TestScanHeadingKeepsHashes(t *testing.T) {
+	th := DefaultDarkTheme()
+	out := ScanLines([]string{"### Title"}, th)
+	if spanText(out[0]) != "### Title" {
+		t.Errorf("heading hashes dropped: %q", spanText(out[0]))
+	}
+	if out[0][0].Style.GetForeground() != th.Heading {
+		t.Errorf("heading not heading-colored")
+	}
+}
+
+func TestScanListMarkerThenInline(t *testing.T) {
+	th := DefaultDarkTheme()
+	out := ScanLines([]string{"- **x**"}, th)
+	if out[0][0].Style.GetForeground() != th.ListMarker {
+		t.Errorf("first span should be the list marker, got fg %v", out[0][0].Style.GetForeground())
+	}
+	if spanText(out[0]) != "- **x**" {
+		t.Errorf("list line text altered: %q", spanText(out[0]))
+	}
+}
+```
+
+- [ ] **Step 7: Run to verify the scanner tests fail**
+
+Run: `go test ./internal/editor/ -run TestScan`
+Expected: FAIL — `undefined: ScanLines`.
+
+- [ ] **Step 8: Implement the scanner**
+
+Create `internal/editor/scanner.go`:
+```go
+package editor
+
+import (
+	"regexp"
+	"strings"
+
+	"github.com/charmbracelet/lipgloss"
+)
+
+var (
+	headingRe = regexp.MustCompile(`^\s*#{1,6}\s`)
+	listRe    = regexp.MustCompile(`^\s*([-*+]|\d+\.)\s`)
+)
+
+// blockState carries cross-line context (fenced code, leading frontmatter).
+type blockState struct {
+	inFence         bool
+	inFrontmatter   bool
+	frontmatterDone bool
+}
+
+// ScanLines styles every line, threading block state across lines. The returned
+// slice is parallel to the input: out[i] are the spans for lines[i], whose
+// concatenated text equals lines[i] exactly.
+func ScanLines(lines []string, th Theme) [][]Span {
+	st := &blockState{}
+	out := make([][]Span, len(lines))
+	for i, ln := range lines {
+		out[i] = scanLine(i, ln, st, th)
+	}
+	return out
+}
+
+func scanLine(row int, line string, st *blockState, th Theme) []Span {
+	code := lipgloss.NewStyle().Foreground(th.Code)
+	muted := lipgloss.NewStyle().Foreground(th.Blockquote)
+	trimmed := strings.TrimSpace(line)
+
+	// Fenced code block: body lines are entirely code-colored.
+	if st.inFence {
+		if strings.HasPrefix(trimmed, "```") {
+			st.inFence = false
+		}
+		return wholeLine(line, code)
+	}
+	if strings.HasPrefix(trimmed, "```") {
+		st.inFence = true
+		return wholeLine(line, code)
+	}
+
+	// Leading YAML frontmatter: only valid starting at row 0.
+	if st.inFrontmatter {
+		if trimmed == "---" {
+			st.inFrontmatter = false
+			st.frontmatterDone = true
+		}
+		return wholeLine(line, muted)
+	}
+	if row == 0 && trimmed == "---" {
+		st.inFrontmatter = true
+		return wholeLine(line, muted)
+	}
+
+	// Heading: color the whole line, hashes included.
+	if headingRe.MatchString(line) {
+		return wholeLine(line, lipgloss.NewStyle().Foreground(th.Heading).Bold(true))
+	}
+
+	// Blockquote.
+	if strings.HasPrefix(trimmed, ">") {
+		return wholeLine(line, muted)
+	}
+
+	// List marker, then inline-scan the remainder.
+	if loc := listRe.FindStringIndex(line); loc != nil {
+		marker := line[:loc[1]]
+		rest := line[loc[1]:]
+		spans := []Span{{Text: marker, Style: lipgloss.NewStyle().Foreground(th.ListMarker)}}
+		return append(spans, scanInline(rest, th)...)
+	}
+
+	return scanInline(line, th)
+}
+
+// wholeLine returns a single span for the whole line, or no spans if empty.
+func wholeLine(line string, style lipgloss.Style) []Span {
+	if line == "" {
+		return nil
+	}
+	return []Span{{Text: line, Style: style}}
+}
+
+// scanInline emits styled spans for inline constructs, keeping all markup
+// characters visible. Plain text gets the theme's Text foreground.
+func scanInline(text string, th Theme) []Span {
+	plain := lipgloss.NewStyle().Foreground(th.Text)
+	r := []rune(text)
+	var spans []Span
+	start, i := 0, 0
+	flush := func(end int) {
+		if end > start {
+			spans = append(spans, Span{Text: string(r[start:end]), Style: plain})
+		}
+	}
+	for i < len(r) {
+		if tok, style, n, ok := matchToken(r, i, th); ok {
+			flush(i)
+			spans = append(spans, Span{Text: tok, Style: style})
+			i += n
+			start = i
+			continue
+		}
+		i++
+	}
+	flush(len(r))
+	return spans
+}
+
+// matchToken tries to match an inline construct starting at r[i]. It returns the
+// token (including its markup), the style, the rune length, and whether matched.
+func matchToken(r []rune, i int, th Theme) (string, lipgloss.Style, int, bool) {
+	// Inline code: `...`
+	if r[i] == '`' {
+		if j := indexRune(r, '`', i+1); j > i {
+			return string(r[i : j+1]), lipgloss.NewStyle().Foreground(th.Code), j + 1 - i, true
+		}
+	}
+	// Wikilink: [[...]]
+	if r[i] == '[' && i+1 < len(r) && r[i+1] == '[' {
+		if j := indexSeq(r, "]]", i+2); j >= 0 {
+			end := j + 2
+			return string(r[i:end]), lipgloss.NewStyle().Foreground(th.Wikilink), end - i, true
+		}
+	}
+	// Link: [text](url)
+	if r[i] == '[' {
+		if c := indexRune(r, ']', i+1); c > i && c+1 < len(r) && r[c+1] == '(' {
+			if p := indexRune(r, ')', c+2); p > c {
+				end := p + 1
+				return string(r[i:end]), lipgloss.NewStyle().Foreground(th.Link), end - i, true
+			}
+		}
+	}
+	// Bold: ** or __
+	for _, d := range []string{"**", "__"} {
+		if hasPrefixRunes(r, i, d) {
+			if j := indexSeq(r, d, i+2); j >= 0 {
+				end := j + 2
+				return string(r[i:end]), lipgloss.NewStyle().Foreground(th.Text).Bold(true), end - i, true
+			}
+		}
+	}
+	// Italic: * or _
+	if r[i] == '*' || r[i] == '_' {
+		if j := indexRune(r, r[i], i+1); j > i {
+			end := j + 1
+			return string(r[i:end]), lipgloss.NewStyle().Foreground(th.Text).Italic(true), end - i, true
+		}
+	}
+	return "", lipgloss.Style{}, 0, false
+}
+
+func indexRune(r []rune, c rune, from int) int {
+	for i := from; i < len(r); i++ {
+		if r[i] == c {
+			return i
+		}
+	}
+	return -1
+}
+
+func indexSeq(r []rune, seq string, from int) int {
+	s := []rune(seq)
+	for i := from; i+len(s) <= len(r); i++ {
+		match := true
+		for k := range s {
+			if r[i+k] != s[k] {
+				match = false
+				break
+			}
+		}
+		if match {
+			return i
+		}
+	}
+	return -1
+}
+
+func hasPrefixRunes(r []rune, i int, prefix string) bool {
+	p := []rune(prefix)
+	if i+len(p) > len(r) {
+		return false
+	}
+	for k := range p {
+		if r[i+k] != p[k] {
+			return false
+		}
+	}
+	return true
+}
+```
+
+- [ ] **Step 9: Run all editor tests to verify they pass**
+
+Run: `go test ./internal/editor/`
+Expected: PASS (all theme, renderer, and scanner tests).
+
+- [ ] **Step 10: Commit**
+
+```bash
+git add internal/editor/theme.go internal/editor/span.go internal/editor/scanner.go internal/editor/scanner_test.go
+git commit -m "feat: markdown styling scanner with explicit foregrounds"
+```
+
+---
+
+## Task 3: Editor buffer, ops, and view
+
+**Files:**
+- Create: `internal/editor/editor.go`
+- Test: `internal/editor/editor_test.go`
+
+**Interfaces:**
+- Consumes: `Span`, `Theme`, `ScanLines`, `renderSpans`, `renderSpansCursor` (Task 2).
+- Produces:
+  - `editor.Position` struct `{ Row, Col int }`
+  - `editor.Editor` struct (exported fields `Lines []string`, `Cursor Position`, `Scroll int`, `Dirty bool`, `Width int`, `Height int`).
+  - `editor.New() *Editor`
+  - `func (e *Editor) SetContent(b []byte)` — resets buffer, cursor, scroll, dirty.
+  - `func (e *Editor) Bytes() []byte`
+  - `func (e *Editor) SetSize(w, h int)`
+  - Ops: `InsertRune(r rune)`, `InsertNewline()`, `Backspace()`, `Delete()`, `MoveLeft()`, `MoveRight()`, `MoveUp()`, `MoveDown()`, `MoveHome()`, `MoveEnd()`.
+  - `func (e *Editor) HandleKey(k tea.KeyMsg)` — maps keys to ops.
+  - `func (e *Editor) View() string`
+
+- [ ] **Step 1: Write the failing test for buffer ops**
+
+Create `internal/editor/editor_test.go`:
+```go
+package editor
+
+import (
+	"testing"
+
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+func newEditorWith(lines ...string) *Editor {
+	e := New()
+	e.Lines = append([]string{}, lines...)
+	e.SetSize(80, 10)
+	return e
+}
+
+func TestSetContentSplitsLines(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("a\nb\nc"))
+	if len(e.Lines) != 3 || e.Lines[1] != "b" {
+		t.Fatalf("Lines = %v", e.Lines)
+	}
+	if e.Dirty {
+		t.Error("SetContent should clear Dirty")
+	}
+}
+
+func TestBytesRoundTrip(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("x\ny"))
+	if string(e.Bytes()) != "x\ny" {
+		t.Errorf("Bytes = %q", string(e.Bytes()))
+	}
+}
+
+func TestInsertRune(t *testing.T) {
+	e := newEditorWith("ac")
+	e.Cursor = Position{Row: 0, Col: 1}
+	e.InsertRune('b')
+	if e.Lines[0] != "abc" {
+		t.Errorf("Lines[0] = %q, want abc", e.Lines[0])
+	}
+	if e.Cursor.Col != 2 {
+		t.Errorf("Cursor.Col = %d, want 2", e.Cursor.Col)
+	}
+	if !e.Dirty {
+		t.Error("insert should set Dirty")
+	}
+}
+
+func TestInsertNewlineSplitsLine(t *testing.T) {
+	e := newEditorWith("abcd")
+	e.Cursor = Position{Row: 0, Col: 2}
+	e.InsertNewline()
+	if len(e.Lines) != 2 || e.Lines[0] != "ab" || e.Lines[1] != "cd" {
+		t.Fatalf("Lines = %v", e.Lines)
+	}
+	if e.Cursor.Row != 1 || e.Cursor.Col != 0 {
+		t.Errorf("Cursor = %+v, want {1 0}", e.Cursor)
+	}
+}
+
+func TestBackspaceJoinsLines(t *testing.T) {
+	e := newEditorWith("ab", "cd")
+	e.Cursor = Position{Row: 1, Col: 0}
+	e.Backspace()
+	if len(e.Lines) != 1 || e.Lines[0] != "abcd" {
+		t.Fatalf("Lines = %v", e.Lines)
+	}
+	if e.Cursor.Row != 0 || e.Cursor.Col != 2 {
+		t.Errorf("Cursor = %+v, want {0 2}", e.Cursor)
+	}
+}
+
+func TestBackspaceWithinLine(t *testing.T) {
+	e := newEditorWith("abc")
+	e.Cursor = Position{Row: 0, Col: 2}
+	e.Backspace()
+	if e.Lines[0] != "ac" || e.Cursor.Col != 1 {
+		t.Errorf("Lines[0]=%q Cursor.Col=%d", e.Lines[0], e.Cursor.Col)
+	}
+}
+
+func TestMovementClampsAndCrossesLines(t *testing.T) {
+	e := newEditorWith("ab", "cde")
+	e.Cursor = Position{Row: 0, Col: 2} // end of "ab"
+	e.MoveRight()                       // wraps to start of next line
+	if e.Cursor != (Position{Row: 1, Col: 0}) {
+		t.Errorf("after MoveRight: %+v", e.Cursor)
+	}
+	e.MoveLeft() // back to end of "ab"
+	if e.Cursor != (Position{Row: 0, Col: 2}) {
+		t.Errorf("after MoveLeft: %+v", e.Cursor)
+	}
+	e.MoveEnd()
+	e.MoveDown() // col clamps to len("cde")=3
+	if e.Cursor != (Position{Row: 1, Col: 2}) {
+		t.Errorf("after MoveDown: %+v, want {1 2}", e.Cursor)
+	}
+}
+
+func TestScrollFollowsCursorDown(t *testing.T) {
+	e := New()
+	e.SetSize(80, 3) // 3 visible rows
+	for i := 0; i < 10; i++ {
+		e.Lines = append(e.Lines, "x")
+	}
+	e.Cursor = Position{Row: 0, Col: 0}
+	for i := 0; i < 9; i++ {
+		e.MoveDown()
+	}
+	// cursor at row 9 must be visible within [Scroll, Scroll+3)
+	if e.Cursor.Row < e.Scroll || e.Cursor.Row >= e.Scroll+e.Height {
+		t.Errorf("cursor row %d not in viewport [%d,%d)", e.Cursor.Row, e.Scroll, e.Scroll+e.Height)
+	}
+}
+
+func TestHandleKeyInsertsRunes(t *testing.T) {
+	e := newEditorWith("")
+	e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h', 'i'}})
+	if e.Lines[0] != "hi" {
+		t.Errorf("Lines[0] = %q, want hi", e.Lines[0])
+	}
+}
+
+func TestViewRendersVisibleRowsOnly(t *testing.T) {
+	e := New()
+	e.SetSize(80, 2)
+	e.Lines = []string{"one", "two", "three"}
+	e.Cursor = Position{Row: 0, Col: 0}
+	view := e.View()
+	// 2 visible rows -> exactly 2 newline-terminated lines
+	if got := countByte(view, '\n'); got != 2 {
+		t.Errorf("view has %d newlines, want 2", got)
+	}
+}
+
+func countByte(s string, b byte) int {
+	n := 0
+	for i := 0; i < len(s); i++ {
+		if s[i] == b {
+			n++
+		}
+	}
+	return n
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `go test ./internal/editor/ -run 'TestSetContent|TestBytes|TestInsert|TestBackspace|TestMovement|TestScroll|TestHandleKey|TestView'`
+Expected: FAIL — `undefined: New`, `undefined: Position`, etc.
+
+- [ ] **Step 3: Implement the editor**
+
+Create `internal/editor/editor.go`:
+```go
+package editor
+
+import (
+	"strings"
+
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+)
+
+// Position is a cursor location in rune coordinates.
+type Position struct {
+	Row, Col int
+}
+
+// Editor is a self-contained markdown text buffer. It knows nothing about
+// files or the vault; the app wires load and save around it.
+type Editor struct {
+	Lines  []string
+	Cursor Position
+	Scroll int
+	Dirty  bool
+	Width  int
+	Height int // visible text rows
+	theme  Theme
+}
+
+// New returns an empty editor with one blank line and the default theme.
+func New() *Editor {
+	return &Editor{
+		Lines:  []string{""},
+		theme:  DefaultDarkTheme(),
+		Width:  80,
+		Height: 24,
+	}
+}
+
+// SetContent replaces the buffer, resetting cursor, scroll, and dirty state.
+func (e *Editor) SetContent(b []byte) {
+	text := strings.ReplaceAll(string(b), "\r\n", "\n")
+	e.Lines = strings.Split(text, "\n")
+	if len(e.Lines) == 0 {
+		e.Lines = []string{""}
+	}
+	e.Cursor = Position{}
+	e.Scroll = 0
+	e.Dirty = false
+}
+
+// Bytes serializes the buffer with \n line separators.
+func (e *Editor) Bytes() []byte {
+	return []byte(strings.Join(e.Lines, "\n"))
+}
+
+// SetSize records the viewport dimensions (h = visible text rows).
+func (e *Editor) SetSize(w, h int) {
+	e.Width = w
+	if h < 1 {
+		h = 1
+	}
+	e.Height = h
+	e.followCursor()
+}
+
+func (e *Editor) curLine() []rune { return []rune(e.Lines[e.Cursor.Row]) }
+func (e *Editor) setLine(rs []rune) { e.Lines[e.Cursor.Row] = string(rs) }
+
+// InsertRune inserts r at the cursor and advances it.
+func (e *Editor) InsertRune(r rune) {
+	rs := e.curLine()
+	col := clamp(e.Cursor.Col, 0, len(rs))
+	rs = append(rs[:col], append([]rune{r}, rs[col:]...)...)
+	e.setLine(rs)
+	e.Cursor.Col = col + 1
+	e.Dirty = true
+}
+
+// InsertNewline splits the current line at the cursor.
+func (e *Editor) InsertNewline() {
+	rs := e.curLine()
+	col := clamp(e.Cursor.Col, 0, len(rs))
+	left, right := string(rs[:col]), string(rs[col:])
+	e.Lines[e.Cursor.Row] = left
+	rest := append([]string{right}, e.Lines[e.Cursor.Row+1:]...)
+	e.Lines = append(e.Lines[:e.Cursor.Row+1], rest...)
+	e.Cursor.Row++
+	e.Cursor.Col = 0
+	e.Dirty = true
+	e.followCursor()
+}
+
+// Backspace deletes the rune before the cursor, joining lines at column 0.
+func (e *Editor) Backspace() {
+	if e.Cursor.Col > 0 {
+		rs := e.curLine()
+		rs = append(rs[:e.Cursor.Col-1], rs[e.Cursor.Col:]...)
+		e.setLine(rs)
+		e.Cursor.Col--
+		e.Dirty = true
+		return
+	}
+	if e.Cursor.Row == 0 {
+		return
+	}
+	prev := []rune(e.Lines[e.Cursor.Row-1])
+	joinCol := len(prev)
+	merged := string(prev) + e.Lines[e.Cursor.Row]
+	e.Lines[e.Cursor.Row-1] = merged
+	e.Lines = append(e.Lines[:e.Cursor.Row], e.Lines[e.Cursor.Row+1:]...)
+	e.Cursor.Row--
+	e.Cursor.Col = joinCol
+	e.Dirty = true
+	e.followCursor()
+}
+
+// Delete removes the rune at the cursor, joining the next line at end-of-line.
+func (e *Editor) Delete() {
+	rs := e.curLine()
+	if e.Cursor.Col < len(rs) {
+		rs = append(rs[:e.Cursor.Col], rs[e.Cursor.Col+1:]...)
+		e.setLine(rs)
+		e.Dirty = true
+		return
+	}
+	if e.Cursor.Row >= len(e.Lines)-1 {
+		return
+	}
+	e.Lines[e.Cursor.Row] = e.Lines[e.Cursor.Row] + e.Lines[e.Cursor.Row+1]
+	e.Lines = append(e.Lines[:e.Cursor.Row+1], e.Lines[e.Cursor.Row+2:]...)
+	e.Dirty = true
+}
+
+// MoveLeft moves one rune left, wrapping to the end of the previous line.
+func (e *Editor) MoveLeft() {
+	if e.Cursor.Col > 0 {
+		e.Cursor.Col--
+	} else if e.Cursor.Row > 0 {
+		e.Cursor.Row--
+		e.Cursor.Col = len([]rune(e.Lines[e.Cursor.Row]))
+	}
+	e.followCursor()
+}
+
+// MoveRight moves one rune right, wrapping to the start of the next line.
+func (e *Editor) MoveRight() {
+	if e.Cursor.Col < len(e.curLine()) {
+		e.Cursor.Col++
+	} else if e.Cursor.Row < len(e.Lines)-1 {
+		e.Cursor.Row++
+		e.Cursor.Col = 0
+	}
+	e.followCursor()
+}
+
+// MoveUp moves to the previous line, clamping the column.
+func (e *Editor) MoveUp() {
+	if e.Cursor.Row > 0 {
+		e.Cursor.Row--
+		e.Cursor.Col = clamp(e.Cursor.Col, 0, len([]rune(e.Lines[e.Cursor.Row])))
+	}
+	e.followCursor()
+}
+
+// MoveDown moves to the next line, clamping the column.
+func (e *Editor) MoveDown() {
+	if e.Cursor.Row < len(e.Lines)-1 {
+		e.Cursor.Row++
+		e.Cursor.Col = clamp(e.Cursor.Col, 0, len([]rune(e.Lines[e.Cursor.Row])))
+	}
+	e.followCursor()
+}
+
+// MoveHome moves to column 0; MoveEnd to end of line.
+func (e *Editor) MoveHome() { e.Cursor.Col = 0 }
+func (e *Editor) MoveEnd()  { e.Cursor.Col = len(e.curLine()) }
+
+// followCursor scrolls the viewport so the cursor row stays visible.
+func (e *Editor) followCursor() {
+	if e.Cursor.Row < e.Scroll {
+		e.Scroll = e.Cursor.Row
+	}
+	if e.Cursor.Row >= e.Scroll+e.Height {
+		e.Scroll = e.Cursor.Row - e.Height + 1
+	}
+	if e.Scroll < 0 {
+		e.Scroll = 0
+	}
+}
+
+// HandleKey maps a key message to a buffer operation.
+func (e *Editor) HandleKey(k tea.KeyMsg) {
+	switch k.Type {
+	case tea.KeyRunes, tea.KeySpace:
+		for _, r := range k.Runes {
+			e.InsertRune(r)
+		}
+		if k.Type == tea.KeySpace {
+			e.InsertRune(' ')
+		}
+	case tea.KeyEnter:
+		e.InsertNewline()
+	case tea.KeyBackspace:
+		e.Backspace()
+	case tea.KeyDelete:
+		e.Delete()
+	case tea.KeyLeft:
+		e.MoveLeft()
+	case tea.KeyRight:
+		e.MoveRight()
+	case tea.KeyUp:
+		e.MoveUp()
+	case tea.KeyDown:
+		e.MoveDown()
+	case tea.KeyHome:
+		e.MoveHome()
+	case tea.KeyEnd:
+		e.MoveEnd()
+	case tea.KeyTab:
+		e.InsertRune('\t')
+	}
+}
+
+// View renders the visible rows, styled, with the cursor drawn on its row.
+func (e *Editor) View() string {
+	cursorStyle := lipgloss.NewStyle().Reverse(true)
+	all := ScanLines(e.Lines, e.theme)
+	var b strings.Builder
+	end := e.Scroll + e.Height
+	if end > len(e.Lines) {
+		end = len(e.Lines)
+	}
+	for row := e.Scroll; row < end; row++ {
+		if row == e.Cursor.Row {
+			b.WriteString(renderSpansCursor(all[row], e.Cursor.Col, cursorStyle))
+		} else {
+			b.WriteString(renderSpans(all[row]))
+		}
+		b.WriteByte('\n')
+	}
+	for row := end; row < e.Scroll+e.Height; row++ {
+		b.WriteByte('\n')
+	}
+	return b.String()
+}
+
+func clamp(v, lo, hi int) int {
+	if v < lo {
+		return lo
+	}
+	if v > hi {
+		return hi
+	}
+	return v
+}
+```
+
+- [ ] **Step 4: Run to verify all editor tests pass**
+
+Run: `go test ./internal/editor/`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/editor/editor.go internal/editor/editor_test.go
+git commit -m "feat: editor buffer, ops, and styled view"
+```
+
+---
+
+## Task 4: App model — routing, status bar, save/load, quit
+
+**Files:**
+- Create: `internal/app/app.go`
+- Test: `internal/app/app_test.go`
+
+**Interfaces:**
+- Consumes: `config.Config` (Task 1); `editor.New`, `editor.Editor.HandleKey/SetContent/Bytes/SetSize/View`, `editor.Editor.Dirty` (Tasks 2–3).
+- Produces:
+  - `app.Mode` int enum: `ModeEditor`, `ModePicker`, `ModePreview`.
+  - `app.App` struct implementing `tea.Model` (`Init`, `Update`, `View`).
+  - `app.New(cfg config.Config) *App`
+  - `func (a *App) Load(path string) error` — reads file into editor, sets `a.path`, switches to `ModeEditor`.
+  - `func (a *App) save() (tea.Model, tea.Cmd)` — writes editor bytes to `a.path`.
+  - Status-bar rendering in `View`.
+  - (Preview/picker wiring is stubbed here and filled in Tasks 5–6. `ModePreview`/`ModePicker` exist now; their keys become active later.)
+
+- [ ] **Step 1: Write the failing test**
+
+Create `internal/app/app_test.go`:
+```go
+package app
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"glint/internal/config"
+
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+func newApp() *App {
+	return New(config.Default())
+}
+
+func TestLoadReadsFileIntoEditor(t *testing.T) {
+	dir := t.TempDir()
+	p := filepath.Join(dir, "n.md")
+	os.WriteFile(p, []byte("# hi\nbody"), 0o644)
+
+	a := newApp()
+	if err := a.Load(p); err != nil {
+		t.Fatal(err)
+	}
+	if a.mode != ModeEditor {
+		t.Errorf("mode = %d, want ModeEditor", a.mode)
+	}
+	if string(a.editor.Bytes()) != "# hi\nbody" {
+		t.Errorf("editor content = %q", string(a.editor.Bytes()))
+	}
+}
+
+func TestCtrlSSavesToDisk(t *testing.T) {
+	dir := t.TempDir()
+	p := filepath.Join(dir, "n.md")
+	os.WriteFile(p, []byte("old"), 0o644)
+
+	a := newApp()
+	a.Load(p)
+	a.editor.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'!'}})
+	if !a.editor.Dirty {
+		t.Fatal("expected dirty after edit")
+	}
+	a.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
+
+	got, _ := os.ReadFile(p)
+	if string(got) != "!old" {
+		t.Errorf("file on disk = %q, want %q", string(got), "!old")
+	}
+	if a.editor.Dirty {
+		t.Error("save should clear dirty")
+	}
+}
+
+func TestTypingRoutesToEditor(t *testing.T) {
+	a := newApp()
+	a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
+	if a.editor.Lines[0] != "x" {
+		t.Errorf("editor first line = %q, want x", a.editor.Lines[0])
+	}
+}
+
+func TestCtrlQCleanQuits(t *testing.T) {
+	a := newApp()
+	_, cmd := a.Update(tea.KeyMsg{Type: tea.KeyCtrlQ})
+	if cmd == nil {
+		t.Error("clean buffer Ctrl+Q should return a quit command")
+	}
+}
+
+func TestCtrlQDirtyNeedsConfirm(t *testing.T) {
+	a := newApp()
+	a.editor.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'z'}})
+	_, cmd := a.Update(tea.KeyMsg{Type: tea.KeyCtrlQ})
+	if cmd != nil {
+		t.Error("first dirty Ctrl+Q should not quit")
+	}
+	_, cmd = a.Update(tea.KeyMsg{Type: tea.KeyCtrlQ})
+	if cmd == nil {
+		t.Error("second Ctrl+Q should quit")
+	}
+}
+
+func TestWindowSizePropagates(t *testing.T) {
+	a := newApp()
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
+	if a.editor.Width != 100 {
+		t.Errorf("editor width = %d, want 100", a.editor.Width)
+	}
+	if a.editor.Height != 29 { // minus status bar
+		t.Errorf("editor height = %d, want 29", a.editor.Height)
+	}
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `go test ./internal/app/`
+Expected: FAIL — `undefined: New`, `undefined: App`, `undefined: ModeEditor`.
+
+- [ ] **Step 3: Implement the app**
+
+Create `internal/app/app.go`:
+```go
+// Package app is glint's top-level Bubbletea model. It owns the active mode and
+// routes messages to the editor, picker, or preview sub-models.
+package app
+
+import (
+	"fmt"
+	"os"
+
+	"glint/internal/config"
+	"glint/internal/editor"
+
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+)
+
+// Mode selects which sub-view is active.
+type Mode int
+
+const (
+	ModeEditor Mode = iota
+	ModePicker
+	ModePreview
+)
+
+// App is the root model.
+type App struct {
+	mode   Mode
+	cfg    config.Config
+	editor *editor.Editor
+	path   string
+	status string
+	width  int
+	height int
+
+	quitArmed bool // true after a dirty Ctrl+Q, awaiting confirm
+}
+
+// New builds an App with an empty editor.
+func New(cfg config.Config) *App {
+	return &App{
+		mode:   ModeEditor,
+		cfg:    cfg,
+		editor: editor.New(),
+	}
+}
+
+// Load reads a file into the editor and switches to edit mode.
+func (a *App) Load(path string) error {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return err
+	}
+	a.editor.SetContent(data)
+	a.path = path
+	a.mode = ModeEditor
+	a.status = path
+	return nil
+}
+
+func (a *App) Init() tea.Cmd { return nil }
+
+// Update routes messages. Global keys are handled first, then mode-specific.
+func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		a.setSize(msg.Width, msg.Height)
+		return a, nil
+	case tea.KeyMsg:
+		return a.handleKey(msg)
+	}
+	return a, nil
+}
+
+func (a *App) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+	// Any key other than a second Ctrl+Q disarms the quit confirmation.
+	if msg.Type != tea.KeyCtrlQ {
+		a.quitArmed = false
+	}
+
+	switch msg.Type {
+	case tea.KeyCtrlQ:
+		if a.editor.Dirty && !a.quitArmed {
+			a.quitArmed = true
+			a.status = "Unsaved changes — Ctrl+Q again to quit"
+			return a, nil
+		}
+		return a, tea.Quit
+	case tea.KeyCtrlS:
+		return a.save()
+	}
+
+	// Mode-specific routing (picker/preview keys wired in later tasks).
+	switch a.mode {
+	case ModeEditor:
+		a.editor.HandleKey(msg)
+	}
+	return a, nil
+}
+
+func (a *App) save() (tea.Model, tea.Cmd) {
+	if a.path == "" {
+		a.status = "No file to save"
+		return a, nil
+	}
+	if err := os.WriteFile(a.path, a.editor.Bytes(), 0o644); err != nil {
+		a.status = "Save failed: " + err.Error()
+		return a, nil
+	}
+	a.editor.Dirty = false
+	a.status = "Saved " + a.path
+	return a, nil
+}
+
+func (a *App) setSize(w, h int) {
+	a.width = w
+	a.height = h
+	a.editor.SetSize(w, h-1) // reserve one row for the status bar
+}
+
+// View renders the active sub-view plus the status bar.
+func (a *App) View() string {
+	var body string
+	switch a.mode {
+	case ModeEditor:
+		body = a.editor.View()
+	default:
+		body = a.editor.View()
+	}
+	return body + a.statusBar()
+}
+
+func (a *App) statusBar() string {
+	bar := lipgloss.NewStyle().
+		Foreground(lipgloss.Color("#1a1a24")).
+		Background(lipgloss.Color("#7aa2f7")).
+		Width(maxInt(a.width, 1))
+	dirty := ""
+	if a.editor.Dirty {
+		dirty = " ●"
+	}
+	left := a.status
+	if left == "" {
+		left = "glint"
+	}
+	return bar.Render(fmt.Sprintf(" %s%s ", left, dirty))
+}
+
+func maxInt(a, b int) int {
+	if a > b {
+		return a
+	}
+	return b
+}
+```
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `go test ./internal/app/`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/app/
+git commit -m "feat: app model with routing, save/load, quit-confirm, status bar"
+```
+
+---
+
+## Task 5: Preview (Glamour read view)
+
+**Files:**
+- Create: `internal/preview/preview.go`
+- Test: `internal/preview/preview_test.go`
+- Modify: `internal/app/app.go` (wire `Ctrl+R` toggle and `Esc`)
+
+**Interfaces:**
+- Consumes: `config.Config.GlamourStyle`.
+- Produces:
+  - `preview.Model` struct.
+  - `preview.New(style string) *Model`
+  - `func (m *Model) SetSize(w, h int)`
+  - `func (m *Model) Render(markdown string) error` — Glamour-render into the viewport.
+  - `func (m *Model) Update(msg tea.Msg) tea.Cmd` — forwards scroll keys to the viewport.
+  - `func (m *Model) View() string`
+- App additions: `Ctrl+R` toggles `ModeEditor`↔`ModePreview` (rendering current buffer); `Esc` returns to `ModeEditor`.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `internal/preview/preview_test.go`:
+```go
+package preview
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestRenderProducesContent(t *testing.T) {
+	m := New("dark")
+	m.SetSize(80, 20)
+	if err := m.Render("# Title\n\nsome **bold** text"); err != nil {
+		t.Fatal(err)
+	}
+	out := m.View()
+	if strings.TrimSpace(out) == "" {
+		t.Error("preview view is empty after Render")
+	}
+	if !strings.Contains(out, "Title") {
+		t.Errorf("rendered output missing heading text: %q", out)
+	}
+}
+
+func TestRenderUnknownStyleFallsBack(t *testing.T) {
+	m := New("definitely-not-a-real-style")
+	m.SetSize(80, 20)
+	if err := m.Render("hello"); err != nil {
+		t.Errorf("unknown style should fall back, got error %v", err)
+	}
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `go test ./internal/preview/`
+Expected: FAIL — `undefined: New`.
+
+- [ ] **Step 3: Implement the preview**
+
+Create `internal/preview/preview.go`:
+```go
+// Package preview renders the current buffer through Glamour into a scrollable,
+// read-only viewport — the full glow read experience, markup concealed.
+package preview
+
+import (
+	"os"
+
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/bubbles/viewport"
+	"github.com/charmbracelet/glamour"
+)
+
+// Model wraps a Glamour renderer and a viewport.
+type Model struct {
+	vp     viewport.Model
+	style  string
+	width  int
+	height int
+}
+
+// New returns a preview using the given Glamour style (builtin name or a path
+// to a style JSON file).
+func New(style string) *Model {
+	return &Model{
+		vp:     viewport.New(0, 0),
+		style:  style,
+		width:  80,
+		height: 24,
+	}
+}
+
+// SetSize resizes the viewport.
+func (m *Model) SetSize(w, h int) {
+	m.width = w
+	if h < 1 {
+		h = 1
+	}
+	m.height = h
+	m.vp.Width = w
+	m.vp.Height = h
+}
+
+// Render runs markdown through Glamour and loads it into the viewport.
+func (m *Model) Render(markdown string) error {
+	r, err := m.renderer()
+	if err != nil {
+		return err
+	}
+	out, err := r.Render(markdown)
+	if err != nil {
+		return err
+	}
+	m.vp.SetContent(out)
+	m.vp.GotoTop()
+	return nil
+}
+
+// renderer builds a Glamour renderer, treating m.style as a file path when it
+// exists on disk and as a builtin style name otherwise.
+func (m *Model) renderer() (*glamour.TermRenderer, error) {
+	width := m.width
+	if width < 1 {
+		width = 80
+	}
+	opts := []glamour.TermRendererOption{glamour.WithWordWrap(width)}
+	if _, err := os.Stat(m.style); err == nil {
+		opts = append(opts, glamour.WithStylePath(m.style))
+	} else {
+		opts = append(opts, glamour.WithStandardStyle(m.style))
+	}
+	return glamour.NewTermRenderer(opts...)
+}
+
+// Update forwards scroll keys to the viewport.
+func (m *Model) Update(msg tea.Msg) tea.Cmd {
+	var cmd tea.Cmd
+	m.vp, cmd = m.vp.Update(msg)
+	return cmd
+}
+
+// View renders the viewport.
+func (m *Model) View() string { return m.vp.View() }
+```
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `go test ./internal/preview/`
+Expected: PASS.
+
+NOTE: if `glamour.WithStandardStyle` errors on an unknown name at render time rather than falling back, the test `TestRenderUnknownStyleFallsBack` will catch it. If it fails, change `renderer()` to fall back to `glamour.WithStandardStyle("dark")` when the requested builtin name is not one of glamour's known styles (`ascii`, `dark`, `light`, `dracula`, `tokyo-night`, `notty`, `pink`). Implement the fallback as: keep a `map[string]bool` of known names; if `m.style` is neither an existing file nor a known name, use `"dark"`.
+
+- [ ] **Step 5: Wire the preview into the app**
+
+In `internal/app/app.go`, add the import and a `preview` field, construct it in `New`, propagate size, and handle `Ctrl+R`/`Esc`.
+
+Add to imports:
+```go
+	"glint/internal/preview"
+```
+
+Add field to `App` struct (after `editor *editor.Editor`):
+```go
+	preview *preview.Model
+```
+
+In `New`, set it:
+```go
+func New(cfg config.Config) *App {
+	return &App{
+		mode:    ModeEditor,
+		cfg:     cfg,
+		editor:  editor.New(),
+		preview: preview.New(cfg.GlamourStyle),
+	}
+}
+```
+
+In `setSize`, also size the preview:
+```go
+func (a *App) setSize(w, h int) {
+	a.width = w
+	a.height = h
+	a.editor.SetSize(w, h-1)
+	a.preview.SetSize(w, h-1)
+}
+```
+
+In `handleKey`, add a `Ctrl+R` case and an `Esc` case before the mode switch. Replace the global-key switch with:
+```go
+	switch msg.Type {
+	case tea.KeyCtrlQ:
+		if a.editor.Dirty && !a.quitArmed {
+			a.quitArmed = true
+			a.status = "Unsaved changes — Ctrl+Q again to quit"
+			return a, nil
+		}
+		return a, tea.Quit
+	case tea.KeyCtrlS:
+		return a.save()
+	case tea.KeyCtrlR:
+		return a.togglePreview()
+	case tea.KeyEsc:
+		a.mode = ModeEditor
+		return a, nil
+	}
+
+	switch a.mode {
+	case ModeEditor:
+		a.editor.HandleKey(msg)
+	case ModePreview:
+		return a, a.preview.Update(msg)
+	}
+	return a, nil
+```
+
+Add the toggle method:
+```go
+// togglePreview switches between the editor and the Glamour read view.
+func (a *App) togglePreview() (tea.Model, tea.Cmd) {
+	if a.mode == ModePreview {
+		a.mode = ModeEditor
+		return a, nil
+	}
+	if err := a.preview.Render(string(a.editor.Bytes())); err != nil {
+		a.status = "Preview failed: " + err.Error()
+		return a, nil
+	}
+	a.mode = ModePreview
+	return a, nil
+}
+```
+
+Update `View` to render the preview when active:
+```go
+func (a *App) View() string {
+	var body string
+	switch a.mode {
+	case ModePreview:
+		body = a.preview.View()
+	default:
+		body = a.editor.View()
+	}
+	return body + a.statusBar()
+}
+```
+
+- [ ] **Step 6: Add an app test for the preview toggle**
+
+Append to `internal/app/app_test.go`:
+```go
+func TestCtrlRTogglesPreview(t *testing.T) {
+	a := newApp()
+	a.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
+	a.editor.SetContent([]byte("# hello"))
+	a.Update(tea.KeyMsg{Type: tea.KeyCtrlR})
+	if a.mode != ModePreview {
+		t.Errorf("mode = %d, want ModePreview", a.mode)
+	}
+	a.Update(tea.KeyMsg{Type: tea.KeyCtrlR})
+	if a.mode != ModeEditor {
+		t.Errorf("mode = %d, want ModeEditor after second toggle", a.mode)
+	}
+}
+
+func TestEscReturnsToEditor(t *testing.T) {
+	a := newApp()
+	a.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
+	a.editor.SetContent([]byte("x"))
+	a.Update(tea.KeyMsg{Type: tea.KeyCtrlR})
+	a.Update(tea.KeyMsg{Type: tea.KeyEsc})
+	if a.mode != ModeEditor {
+		t.Errorf("Esc should return to editor, mode = %d", a.mode)
+	}
+}
+```
+
+- [ ] **Step 7: Run preview and app tests**
+
+Run: `go test ./internal/preview/ ./internal/app/`
+Expected: PASS.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add internal/preview/ internal/app/
+git commit -m "feat: glamour read-preview with Ctrl+R toggle"
+```
+
+---
+
+## Task 6: Picker (fuzzy file browser) + daily note
+
+**Files:**
+- Create: `internal/picker/picker.go`
+- Test: `internal/picker/picker_test.go`
+- Modify: `internal/app/app.go` (wire `Ctrl+P` picker, `Ctrl+D` daily, `Enter` selection)
+
+**Interfaces:**
+- Consumes: `config.Config` (for `DailyPath` and `VaultDir`).
+- Produces:
+  - `picker.fuzzyMatch(query, candidate string) (score int, ok bool)` — unexported, pure, tested.
+  - `picker.walkMarkdown(root string) ([]string, error)` — unexported; absolute `.md` paths, skipping dot-directories.
+  - `picker.Model` struct.
+  - `picker.New(root string) (*Model, error)`
+  - `func (m *Model) SetSize(w, h int)`
+  - `func (m *Model) Update(msg tea.Msg) tea.Cmd` — updates the query input, recomputes matches, moves selection on Up/Down.
+  - `func (m *Model) Selected() string` — absolute path of the highlighted match, or `""`.
+  - `func (m *Model) View() string`
+- App additions: `Ctrl+P` enters `ModePicker`; `Ctrl+D` opens/creates today's daily note; in `ModePicker`, `Enter` loads `Selected()`.
+
+- [ ] **Step 1: Write the failing test for the pure helpers**
+
+Create `internal/picker/picker_test.go`:
+```go
+package picker
+
+import (
+	"os"
+	"path/filepath"
+	"sort"
+	"testing"
+)
+
+func TestFuzzyMatchSubsequence(t *testing.T) {
+	if _, ok := fuzzyMatch("abc", "axbxc"); !ok {
+		t.Error("abc should match axbxc as a subsequence")
+	}
+	if _, ok := fuzzyMatch("abc", "acb"); ok {
+		t.Error("abc should NOT match acb (out of order)")
+	}
+	if _, ok := fuzzyMatch("", "anything"); !ok {
+		t.Error("empty query should match anything")
+	}
+}
+
+func TestFuzzyMatchCaseInsensitive(t *testing.T) {
+	if _, ok := fuzzyMatch("DOC", "my-document"); !ok {
+		t.Error("DOC should match my-document case-insensitively")
+	}
+}
+
+func TestFuzzyMatchContiguousScoresHigher(t *testing.T) {
+	tight, ok1 := fuzzyMatch("ab", "abxx")
+	loose, ok2 := fuzzyMatch("ab", "axxb")
+	if !ok1 || !ok2 {
+		t.Fatal("both should match")
+	}
+	if tight <= loose {
+		t.Errorf("contiguous match (%d) should score higher than gapped (%d)", tight, loose)
+	}
+}
+
+func TestWalkMarkdownSkipsDotDirsAndNonMarkdown(t *testing.T) {
+	root := t.TempDir()
+	os.WriteFile(filepath.Join(root, "a.md"), nil, 0o644)
+	os.WriteFile(filepath.Join(root, "b.txt"), nil, 0o644)
+	os.MkdirAll(filepath.Join(root, ".git"), 0o755)
+	os.WriteFile(filepath.Join(root, ".git", "c.md"), nil, 0o644)
+	os.MkdirAll(filepath.Join(root, "sub"), 0o755)
+	os.WriteFile(filepath.Join(root, "sub", "d.md"), nil, 0o644)
+
+	got, err := walkMarkdown(root)
+	if err != nil {
+		t.Fatal(err)
+	}
+	sort.Strings(got)
+	want := []string{
+		filepath.Join(root, "a.md"),
+		filepath.Join(root, "sub", "d.md"),
+	}
+	if len(got) != len(want) {
+		t.Fatalf("walkMarkdown = %v, want %v", got, want)
+	}
+	for i := range want {
+		if got[i] != want[i] {
+			t.Errorf("got[%d] = %q, want %q", i, got[i], want[i])
+		}
+	}
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `go test ./internal/picker/`
+Expected: FAIL — `undefined: fuzzyMatch`, `undefined: walkMarkdown`.
+
+- [ ] **Step 3: Implement the picker**
+
+Create `internal/picker/picker.go`:
+```go
+// Package picker is a fuzzy file browser over a directory of markdown files.
+package picker
+
+import (
+	"io/fs"
+	"path/filepath"
+	"sort"
+	"strings"
+
+	"github.com/charmbracelet/bubbles/textinput"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+)
+
+// Model holds the query input and the current match list.
+type Model struct {
+	input   textinput.Model
+	all     []string // absolute .md paths
+	root    string
+	matches []string
+	sel     int
+	width   int
+	height  int
+}
+
+// New walks root for markdown files and returns a ready picker.
+func New(root string) (*Model, error) {
+	files, err := walkMarkdown(root)
+	if err != nil {
+		return nil, err
+	}
+	ti := textinput.New()
+	ti.Placeholder = "filter…"
+	ti.Focus()
+	m := &Model{input: ti, all: files, root: root, width: 80, height: 24}
+	m.recompute()
+	return m, nil
+}
+
+// SetSize records dimensions.
+func (m *Model) SetSize(w, h int) {
+	m.width = w
+	m.height = h
+}
+
+// Update handles query edits and selection movement.
+func (m *Model) Update(msg tea.Msg) tea.Cmd {
+	if key, ok := msg.(tea.KeyMsg); ok {
+		switch key.Type {
+		case tea.KeyUp:
+			if m.sel > 0 {
+				m.sel--
+			}
+			return nil
+		case tea.KeyDown:
+			if m.sel < len(m.matches)-1 {
+				m.sel++
+			}
+			return nil
+		}
+	}
+	var cmd tea.Cmd
+	m.input, cmd = m.input.Update(msg)
+	m.recompute()
+	return cmd
+}
+
+// recompute filters and ranks all paths against the current query.
+func (m *Model) recompute() {
+	q := m.input.Value()
+	type scored struct {
+		path  string
+		score int
+	}
+	var hits []scored
+	for _, p := range m.all {
+		label := m.label(p)
+		if s, ok := fuzzyMatch(q, label); ok {
+			hits = append(hits, scored{p, s})
+		}
+	}
+	sort.SliceStable(hits, func(i, j int) bool {
+		if hits[i].score != hits[j].score {
+			return hits[i].score > hits[j].score
+		}
+		return hits[i].path < hits[j].path
+	})
+	m.matches = m.matches[:0]
+	for _, h := range hits {
+		m.matches = append(m.matches, h.path)
+	}
+	if m.sel >= len(m.matches) {
+		m.sel = 0
+	}
+}
+
+// label is the vault-relative path shown to the user and matched against.
+func (m *Model) label(p string) string {
+	if rel, err := filepath.Rel(m.root, p); err == nil {
+		return rel
+	}
+	return p
+}
+
+// Selected returns the highlighted match's absolute path, or "".
+func (m *Model) Selected() string {
+	if m.sel < 0 || m.sel >= len(m.matches) {
+		return ""
+	}
+	return m.matches[m.sel]
+}
+
+// View renders the query line and the match list.
+func (m *Model) View() string {
+	var b strings.Builder
+	b.WriteString(m.input.View())
+	b.WriteByte('\n')
+	selStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#1a1a24")).Background(lipgloss.Color("#e0af68"))
+	plain := lipgloss.NewStyle().Foreground(lipgloss.Color("#c0caf5"))
+	rows := m.height - 2
+	if rows < 1 {
+		rows = 1
+	}
+	for i := 0; i < len(m.matches) && i < rows; i++ {
+		label := m.label(m.matches[i])
+		if i == m.sel {
+			b.WriteString(selStyle.Render("> " + label))
+		} else {
+			b.WriteString(plain.Render("  " + label))
+		}
+		b.WriteByte('\n')
+	}
+	return b.String()
+}
+
+// walkMarkdown returns absolute paths of .md files under root, skipping any
+// directory whose name begins with a dot.
+func walkMarkdown(root string) ([]string, error) {
+	var out []string
+	err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
+		if err != nil {
+			return nil // skip unreadable entries
+		}
+		if d.IsDir() {
+			if path != root && strings.HasPrefix(d.Name(), ".") {
+				return filepath.SkipDir
+			}
+			return nil
+		}
+		if strings.EqualFold(filepath.Ext(d.Name()), ".md") {
+			abs, aerr := filepath.Abs(path)
+			if aerr == nil {
+				out = append(out, abs)
+			}
+		}
+		return nil
+	})
+	return out, err
+}
+
+// fuzzyMatch reports whether query is a case-insensitive subsequence of
+// candidate, scoring contiguous matches higher (fewer gaps -> higher score).
+func fuzzyMatch(query, candidate string) (int, bool) {
+	if query == "" {
+		return 0, true
+	}
+	q := []rune(strings.ToLower(query))
+	c := []rune(strings.ToLower(candidate))
+	score, qi, last := 0, 0, -1
+	for ci := 0; ci < len(c) && qi < len(q); ci++ {
+		if c[ci] == q[qi] {
+			if last >= 0 {
+				score -= ci - last - 1 // penalize gaps
+			}
+			last = ci
+			qi++
+		}
+	}
+	if qi != len(q) {
+		return 0, false
+	}
+	return score, true
+}
+```
+
+- [ ] **Step 4: Run picker tests to verify they pass**
+
+Run: `go test ./internal/picker/`
+Expected: PASS.
+
+- [ ] **Step 5: Wire the picker and daily note into the app**
+
+In `internal/app/app.go`:
+
+Add imports:
+```go
+	"path/filepath"
+	"time"
+
+	"glint/internal/picker"
+```
+
+Add a field to `App` (after `preview`):
+```go
+	picker *picker.Model
+```
+
+Add `Ctrl+P`, `Ctrl+D` cases and picker `Enter` routing. In `handleKey`, extend the global switch:
+```go
+	case tea.KeyCtrlP:
+		return a.openPicker()
+	case tea.KeyCtrlD:
+		return a.openDaily()
+```
+
+And change the mode switch to handle picker keys, with `Enter` selecting:
+```go
+	switch a.mode {
+	case ModeEditor:
+		a.editor.HandleKey(msg)
+	case ModePreview:
+		return a, a.preview.Update(msg)
+	case ModePicker:
+		if msg.Type == tea.KeyEnter {
+			if sel := a.picker.Selected(); sel != "" {
+				if err := a.Load(sel); err != nil {
+					a.status = "Open failed: " + err.Error()
+				}
+			}
+			return a, nil
+		}
+		return a, a.picker.Update(msg)
+	}
+	return a, nil
+```
+
+Add the helper methods:
+```go
+// openPicker builds a fresh picker over the vault and switches to it.
+func (a *App) openPicker() (tea.Model, tea.Cmd) {
+	p, err := picker.New(a.cfg.VaultDir)
+	if err != nil {
+		a.status = "Picker failed: " + err.Error()
+		return a, nil
+	}
+	p.SetSize(a.width, a.height-1)
+	a.picker = p
+	a.mode = ModePicker
+	return a, nil
+}
+
+// openDaily opens today's daily note, creating the file and directory if needed.
+func (a *App) openDaily() (tea.Model, tea.Cmd) {
+	path := a.cfg.DailyPath(time.Now())
+	if _, err := os.Stat(path); os.IsNotExist(err) {
+		if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+			a.status = "Daily dir failed: " + err.Error()
+			return a, nil
+		}
+		if err := os.WriteFile(path, []byte{}, 0o644); err != nil {
+			a.status = "Daily create failed: " + err.Error()
+			return a, nil
+		}
+	}
+	if err := a.Load(path); err != nil {
+		a.status = "Daily open failed: " + err.Error()
+	}
+	return a, nil
+}
+```
+
+Update `View` to render the picker when active:
+```go
+func (a *App) View() string {
+	var body string
+	switch a.mode {
+	case ModePreview:
+		body = a.preview.View()
+	case ModePicker:
+		body = a.picker.View()
+	default:
+		body = a.editor.View()
+	}
+	return body + a.statusBar()
+}
+```
+
+Guard the `quitArmed` reset and `Esc` so they don't fire while typing in the picker query: the existing `Esc` case already returns to `ModeEditor`, which is the desired "cancel picker" behavior. Leave it.
+
+- [ ] **Step 6: Add app tests for picker and daily**
+
+Append to `internal/app/app_test.go`:
+```go
+func TestCtrlPOpensPicker(t *testing.T) {
+	dir := t.TempDir()
+	os.WriteFile(filepath.Join(dir, "note.md"), []byte("x"), 0o644)
+	cfg := config.Default()
+	cfg.VaultDir = dir
+	a := New(cfg)
+	a.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
+	a.Update(tea.KeyMsg{Type: tea.KeyCtrlP})
+	if a.mode != ModePicker {
+		t.Errorf("mode = %d, want ModePicker", a.mode)
+	}
+}
+
+func TestCtrlDCreatesAndOpensDaily(t *testing.T) {
+	dir := t.TempDir()
+	cfg := config.Default()
+	cfg.VaultDir = dir
+	cfg.DailySubdir = "Daily"
+	cfg.DailyFormat = "2006-01-02"
+	a := New(cfg)
+	a.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
+	a.Update(tea.KeyMsg{Type: tea.KeyCtrlD})
+	if a.mode != ModeEditor {
+		t.Errorf("mode = %d, want ModeEditor after daily open", a.mode)
+	}
+	if a.path == "" {
+		t.Error("daily path should be set")
+	}
+	if _, err := os.Stat(a.path); err != nil {
+		t.Errorf("daily file should exist on disk: %v", err)
+	}
+}
+```
+
+Add the missing import to the app test file's import block: `"path/filepath"` (already present from Task 4's tests) and `"glint/internal/config"` (already present).
+
+- [ ] **Step 7: Run picker and app tests**
+
+Run: `go test ./internal/picker/ ./internal/app/`
+Expected: PASS.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add internal/picker/ internal/app/
+git commit -m "feat: fuzzy file picker (Ctrl+P) and daily note (Ctrl+D)"
+```
+
+---
+
+## Task 7: Entrypoint and end-to-end wiring
+
+**Files:**
+- Create: `main.go`
+- Test: manual smoke (no automated test — `main` is thin glue over tested units).
+
+**Interfaces:**
+- Consumes: `config.Load`, `app.New`, `app.App.Load`, and (for `--daily`) the same daily-note creation path used by the app. To avoid duplicating the daily logic, `main` does NOT recreate the daily file; it sets a flag the app honors on the first frame.
+- Produces: the `glint` binary.
+
+To keep the daily logic in one place, add one small method to the app that `main` calls.
+
+- [ ] **Step 1: Add an app entry helper (test-first)**
+
+Append to `internal/app/app_test.go`:
+```go
+func TestStartInPickerWhenNoPath(t *testing.T) {
+	dir := t.TempDir()
+	os.WriteFile(filepath.Join(dir, "n.md"), []byte("x"), 0o644)
+	cfg := config.Default()
+	cfg.VaultDir = dir
+	a := New(cfg)
+	if err := a.Start("", false); err != nil {
+		t.Fatal(err)
+	}
+	if a.mode != ModePicker {
+		t.Errorf("no path/daily should start in picker, mode = %d", a.mode)
+	}
+}
+
+func TestStartWithPathLoadsFile(t *testing.T) {
+	dir := t.TempDir()
+	p := filepath.Join(dir, "n.md")
+	os.WriteFile(p, []byte("hello"), 0o644)
+	a := New(config.Default())
+	if err := a.Start(p, false); err != nil {
+		t.Fatal(err)
+	}
+	if a.mode != ModeEditor || string(a.editor.Bytes()) != "hello" {
+		t.Errorf("Start(path) should load file into editor")
+	}
+}
+
+func TestStartDailyCreatesFile(t *testing.T) {
+	dir := t.TempDir()
+	cfg := config.Default()
+	cfg.VaultDir = dir
+	a := New(cfg)
+	if err := a.Start("", true); err != nil {
+		t.Fatal(err)
+	}
+	if a.mode != ModeEditor || a.path == "" {
+		t.Errorf("Start(daily) should open the daily note")
+	}
+	if _, err := os.Stat(a.path); err != nil {
+		t.Errorf("daily file should exist: %v", err)
+	}
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `go test ./internal/app/ -run TestStart`
+Expected: FAIL — `undefined: (*App).Start`.
+
+- [ ] **Step 3: Implement `Start` and refactor daily creation**
+
+In `internal/app/app.go`, factor the daily file creation into a helper and add `Start`. Replace `openDaily` with a version that calls the shared helper, and add `Start`:
+```go
+// Start picks the initial view: an explicit path, today's daily note, or the
+// picker when neither is given.
+func (a *App) Start(path string, daily bool) error {
+	switch {
+	case path != "":
+		return a.Load(path)
+	case daily:
+		_, cmd := a.openDaily()
+		_ = cmd
+		return nil
+	default:
+		_, cmd := a.openPicker()
+		_ = cmd
+		return nil
+	}
+}
+```
+
+(`openDaily` and `openPicker` already set `a.status` on failure and never return an error; `Start` surfaces hard load errors only for the explicit-path case, which is the one a user can mistype.)
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `go test ./internal/app/`
+Expected: PASS.
+
+- [ ] **Step 5: Write the entrypoint**
+
+Create `main.go`:
+```go
+// Command glint is a modeless terminal markdown editor with live styling.
+package main
+
+import (
+	"flag"
+	"fmt"
+	"os"
+
+	"glint/internal/app"
+	"glint/internal/config"
+
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+func main() {
+	daily := flag.Bool("daily", false, "open today's daily note")
+	flag.Parse()
+
+	cfg, err := config.Load()
+	if err != nil {
+		fmt.Fprintln(os.Stderr, "glint: config:", err)
+	}
+
+	var path string
+	if args := flag.Args(); len(args) > 0 {
+		path = args[0]
+	}
+
+	a := app.New(cfg)
+	if err := a.Start(path, *daily); err != nil {
+		fmt.Fprintln(os.Stderr, "glint:", err)
+		os.Exit(1)
+	}
+
+	if _, err := tea.NewProgram(a, tea.WithAltScreen()).Run(); err != nil {
+		fmt.Fprintln(os.Stderr, "glint:", err)
+		os.Exit(1)
+	}
+}
+```
+
+- [ ] **Step 6: Build, vet, and run the full test suite**
+
+Run:
+```bash
+go vet ./...
+go build ./...
+go test ./...
+```
+Expected: no vet errors, a built binary, all tests PASS.
+
+- [ ] **Step 7: Manual smoke test**
+
+Run:
+```bash
+go run . README.md
+```
+Expected: README opens in the editor with headings/bold/links styled and the markup characters visible; arrows move the cursor; typing inserts; `Ctrl+R` shows the glamour preview; `Esc` returns; `Ctrl+P` opens the fuzzy picker; `Ctrl+D` opens today's daily note; `Ctrl+S` saves; `Ctrl+Q` quits (twice if dirty).
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add main.go internal/app/
+git commit -m "feat: glint entrypoint (path / --daily / picker) and Start wiring"
+```
+
+---
+
+## Self-Review Notes
+
+**Spec coverage check:**
+- Modeless editing → Task 3 (`HandleKey`, no modes).
+- Live inline styling, markup not concealed → Task 2 (scanner, markup-visible invariant test) + Task 3 (`View`).
+- Explicit foreground on every span (ekphos fix) → Task 2 (`TestScanPlainTextGetsExplicitForeground`, theme).
+- Editor knows nothing about files → Task 3 (no file I/O in `editor`); load/save in Task 4.
+- Block context (fences, frontmatter) → Task 2 (`blockState`, dedicated tests).
+- Glamour preview, markup concealed → Task 5.
+- Fuzzy picker → Task 6 (`fuzzyMatch`, `walkMarkdown`).
+- Daily note with configurable subdir/format → Tasks 1 (`DailyPath`) + 6/7 (`openDaily`, `Start`).
+- TOML config, defaults, malformed handling → Task 1.
+- Keybinds (Ctrl+S/P/D/R, Esc, Ctrl+Q confirm) → Tasks 4–6.
+- Error handling surfaced in status bar, never silent → Task 4 (`save`, `status`), Tasks 5–6 (status on failure).
+- Entrypoint flags (path, --daily, bare → picker) → Task 7.
+
+**Out of scope (v1), per spec — intentionally absent:** wikilink follow (styled only), in-content search, graph, tag browsing, sort persistence, vim modality, the later Vault TUI layer.
+
+**Type consistency:** `Editor` exported fields and method names are used identically across Tasks 3–7. `Mode` constants (`ModeEditor`, `ModePicker`, `ModePreview`) are defined once in Task 4 and referenced unchanged. `picker.Model.Selected()`, `preview.Model.Render/Update/View`, and `config.Config.DailyPath` signatures match their call sites.
+
+**Known follow-ups (not v1 blockers):** the heading scanner colors all heading levels identically (spec allows per-level later); `glamour` style-name fallback has a documented contingency in Task 5 Step 4; module path `glint` is local-only (change to a `github.com/...` path if the project is ever published).