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

feat: PDF/printable HTML export in house style (TASK-021)

4a59f9253b3a45d41e9e5443a0ded8d15a620bf1
humdrum <me@humdrum.me> · 2026-06-29 20:23

parent 1d757ec1

feat: PDF/printable HTML export in house style (TASK-021)

Ctrl+E renders the buffer to a self-contained HTML doc in the
markdown-doc-kit house style and opens it in the browser → Print →
Save as PDF. No external converter required.

- internal/export: goldmark GFM render, YAML frontmatter strip,
  cover-wrap, task-list + {.page-break} postprocess, embedded doc.css
  (go:embed) with config-driven --font-* token override; MapTheme,
  Write/OutputPath/Title/OpenInBrowser.
- config: font_display/body/mono keys, portable defaults (Georgia /
  system-ui / ui-monospace), no licensed faces bundled; wizard
  preserves them.
- assets/sync.sh re-vendors doc.css from the kit, stripping the
  licensed Awke/Untitled Sans/Name Mono references.
- README Export section + help overlay document the path and fallback.

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

14 files changed

README.md +29 −0
@@ -62,6 +62,7 @@ | `Alt+Backspace` / `Ctrl+W` | delete the word before the cursor (`Alt+d` deletes the word after) |
 | `Ctrl+U` / `Ctrl+K` | delete to start / end of line |
 | `Ctrl+Z` / `Ctrl+Y` | undo / redo |
 | `Ctrl+S` | save (an unnamed buffer prompts for a name → inbox) |
+| `Ctrl+E` | export a printable HTML document and open it in the browser → Print → Save as PDF (see [Export](#export-pdf)) |
 | `Ctrl+P` | toggle the Glamour read preview |
 | `Ctrl+F` | fuzzy file picker (with live preview) |
 | `Ctrl+G` | find in document (`Enter`/`↓` next, `Shift+Tab`/`↑` prev, `Esc` close) |
@@ -89,6 +90,9 @@ daily_format  = "2006-01-02" # Go time layout for daily-note filenames
 theme         = "auto"       # auto | flexoki-light | flexoki-dark | charm  (auto detects macOS appearance)
 glamour_style = ""           # override the preview style; "" follows the theme
 spellcheck    = "auto"       # auto | on | off  (auto = on for prose/notes, off for code files)
+font_display  = ""           # PDF export heading/cover font stack; "" = Georgia serif
+font_body     = ""           # PDF export body font stack; "" = system-ui sans
+font_mono     = ""           # PDF export code font stack; "" = ui-monospace
 ```
 
 **Two roots.** Bare `glint`, `glint -n`, and `-d` operate on the **working
@@ -96,6 +100,31 @@ directory** — the folder you launched from (or `$GLINT_VAULT` if set), so glint
 works in whatever repo you're in. **`glint -v`** opens the picker on your
 **configured `vault_dir`** from anywhere — set it to your notes vault to reach it
 without `cd`-ing there.
+
+## Export (PDF)
+<a id="export-pdf"></a>
+
+`Ctrl+E` exports the current buffer to a clean, printable document in the Humdrum
+house style and opens it in your default browser — there you hit **Print → Save
+as PDF**. No external converter is required: the export is a single
+self-contained `.html` file (the house stylesheet and your rendered Markdown are
+baked in), written next to the source file (`note.md` → `note.html`; an unnamed
+buffer lands in the temp dir). If the browser can't be opened automatically,
+glint prints the file path so you can open it yourself — the export still
+succeeds.
+
+The print layout follows the kit's conventions: a leading `# Title` becomes a
+centered cover page, headings start new pages, `{.page-break}` on a heading
+forces a break, the page is US Letter with 1in margins, and printing collapses
+to black-on-white.
+
+The stylesheet ([`doc.css`](internal/export/assets/doc.css)) is vendored into the
+binary via `go:embed`, so a `brew install glint` on any machine has everything it
+needs. The kit's licensed display/body/mono faces are **not** bundled — the
+export ships portable open/system fallbacks (Georgia / system-ui / ui-monospace)
+and lets you point the three `--font-*` slots at any fonts you have via the
+`font_display` / `font_body` / `font_mono` config keys above. To refresh the
+embedded style after the kit changes, run `internal/export/assets/sync.sh`.
 
 ## Themes
 
- → Spellcheck-with-undercurl-underlines.md +11 −9
@@ -1,10 +1,10 @@
 ---
 id: TASK-020
 title: Spellcheck with undercurl underlines
-status: "\U0001F7E2 In progress"
+status: "\U0001F3C1 Done"
 assignee: []
 created_date: '2026-06-29 18:11'
-updated_date: '2026-06-30 01:20'
+updated_date: '2026-06-30 01:45'
 labels:
   - feature
   - release-1
@@ -52,13 +52,13 @@ <!-- SECTION:DESCRIPTION:END -->
 
 ## Acceptance Criteria
 <!-- AC:BEGIN -->
-- [ ] #1 Misspelled prose words show a red undercurl underline in Ghostty; unsupported terminals degrade to plain/no underline
-- [ ] #2 Dictionary is pure-Go embedded (no cgo); binary size increase is modest and 'go build .' + brew formula still work
-- [ ] #3 Code fences, inline code, URLs, wikilinks, link targets, and frontmatter values are never flagged
-- [ ] #4 Adding the word under the cursor appends to ~/.config/glint/dict.txt and removes the underline live; hand-editing dict.txt also works
-- [ ] #5 Spellcheck defaults ON for md/txt/unnamed buffers and OFF for code files, with a config override and a session toggle
-- [ ] #6 Triggering on a flagged word shows ~5 ranked suggestions; picking one replaces the word in place, marks the buffer dirty, and clears the underline
-- [ ] #7 Clicking a flagged (underlined) word with the mouse opens the same suggestion popup; clicking elsewhere just moves the cursor
+- [x] #1 Misspelled prose words show a red undercurl underline in Ghostty; unsupported terminals degrade to plain/no underline
+- [x] #2 Dictionary is pure-Go embedded (no cgo); binary size increase is modest and 'go build .' + brew formula still work
+- [x] #3 Code fences, inline code, URLs, wikilinks, link targets, and frontmatter values are never flagged
+- [x] #4 Adding the word under the cursor appends to ~/.config/glint/dict.txt and removes the underline live; hand-editing dict.txt also works
+- [x] #5 Spellcheck defaults ON for md/txt/unnamed buffers and OFF for code files, with a config override and a session toggle
+- [x] #6 Triggering on a flagged word shows ~5 ranked suggestions; picking one replaces the word in place, marks the buffer dirty, and clears the underline
+- [x] #7 Clicking a flagged (underlined) word with the mouse opens the same suggestion popup; clicking elsewhere just moves the cursor
 <!-- AC:END -->
 
 ## Implementation Plan
@@ -82,4 +82,6 @@ <!-- SECTION:NOTES:BEGIN -->
 Slice A/B/C done (commit 2c0a77e9): internal/spell — 60k embedded dict (freq∩curated, rejects common typos), Known() w/ possessive leniency, BK-tree Suggest() OSA-reranked + freq tie-break, personal dict load/Add at ~/.config/glint/dict.txt. 11 tests green.
 
 Slice D/E done (commits 92f9461e, +theme.Spell): undercurl Span rendering (raw SGR 4:3 + 58:2 color, invariant-preserving, graceful degrade), scanner Prose tagging, spellPass with skip rules (code/inline-code/URL/email/wikilink/link-target/frontmatter/acronym/camelcase/<3-char), word->ok cache, codeFile + toggle gates, AddToDictionary clears cache live. 13 editor tests green.
+
+Slice F/G done (commits 1221e42d, 1d757ec1): config spellcheck=auto|on|off + DictPath, dict loaded at app startup (embedded + personal), Alt+; + mouse-click-on-flagged-word open the popup (suggestions 1-9 / a Add / i Ignore / t Toggle, Esc), toggle-only popup when nothing flagged, glint -c/-h/help-overlay/README/CHANGELOG documented incl. tmux undercurl passthrough. Smoke test confirms App.View emits 4:3+58:2 SGR. All 7 ACs met. Full suite + vet + build green; binary +~250KB (embedded gz).
 <!-- SECTION:NOTES:END -->
- → PDF-printable-export-in-the-house-style.md +21 −8
@@ -4,7 +4,7 @@ title: PDF / printable export in the house style
 status: "\U0001F7E2 In progress"
 assignee: []
 created_date: '2026-06-29 20:49'
-updated_date: '2026-06-30 00:21'
+updated_date: '2026-06-30 03:23'
 labels:
   - feature
   - release-1
@@ -30,19 +30,32 @@ <!-- SECTION:DESCRIPTION:END -->
 
 ## Acceptance Criteria
 <!-- AC:BEGIN -->
-- [ ] #1 A glint command/keybind exports the current buffer to a printable artifact (PDF or print-ready self-contained HTML) in the markdown-doc-kit house style
-- [ ] #2 Output reuses the kit's doc.css + embedded fonts and its print conventions (cover page from '# Title', page-per '## Section', US Letter, black-on-white print)
-- [ ] #3 The chosen path is documented (browser print-to-PDF vs headless converter) and degrades gracefully when an optional external converter is absent
-- [ ] #4 README/help document the export command
-- [ ] #5 Fonts are user-configurable (config keys for display/body/mono), not hard-wired to the kit's bundled Awke/Untitled Sans/Name Mono — glint is distributed to other people who don't have those licensed fonts
-- [ ] #6 Export is portable off this machine: ships sane open/system-font fallbacks by default and works with no personal assets present; any bundled font must be redistributable, otherwise reference by name with fallbacks
-- [ ] #7 The needed kit assets (doc.css + print rules, HTML skeleton, any redistributable fonts) are vendored INTO this repo and embedded in the binary via go:embed — nothing is read from the external _shared-app-kit path at runtime, so a 'brew install glint' on another machine has everything it needs
+- [x] #1 A glint command/keybind exports the current buffer to a printable artifact (PDF or print-ready self-contained HTML) in the markdown-doc-kit house style
+- [x] #2 Output reuses the kit's doc.css + embedded fonts and its print conventions (cover page from '# Title', page-per '## Section', US Letter, black-on-white print)
+- [x] #3 The chosen path is documented (browser print-to-PDF vs headless converter) and degrades gracefully when an optional external converter is absent
+- [x] #4 README/help document the export command
+- [x] #5 Fonts are user-configurable (config keys for display/body/mono), not hard-wired to the kit's bundled Awke/Untitled Sans/Name Mono — glint is distributed to other people who don't have those licensed fonts
+- [x] #6 Export is portable off this machine: ships sane open/system-font fallbacks by default and works with no personal assets present; any bundled font must be redistributable, otherwise reference by name with fallbacks
+- [x] #7 The needed kit assets (doc.css + print rules, HTML skeleton, any redistributable fonts) are vendored INTO this repo and embedded in the binary via go:embed — nothing is read from the external _shared-app-kit path at runtime, so a 'brew install glint' on another machine has everything it needs
 <!-- AC:END -->
 
+## Implementation Plan
+
+<!-- SECTION:PLAN:BEGIN -->
+1. Vendor kit assets into internal/export/assets/: doc.css (neutralize the 3 --font-* :root tokens to open/system fallbacks, strip nothing else — no @font-face present) + sync.sh that re-copies from _shared-app-kit and re-neutralizes. go:embed them.
+2. config: add font_display/font_body/font_mono keys (Config fields + Default + loadFromFile overlay), defaults = Georgia serif / system-ui sans / ui-monospace.
+3. internal/export package (TDD): Document(md, Options)->html pure fn — goldmark GFM render, strip YAML frontmatter, postprocess (cover-wrap leading h1+subtitle p, task-list-item class, {.page-break} heading suffix->class), inject :root font-token override from Options, wrap in article.doc + embedded doc.css. MapTheme(glintTheme)->kit data-theme. ToFile writes <base>.html next to source. OpenInBrowser (darwin open / linux xdg-open / win start).
+4. Wire Ctrl+E in app.handleKey -> export current buffer, write html next to file, open browser, set status. Graceful: if no path (unnamed) save-as first or export to temp.
+5. README + help overlay: document Ctrl+E export, the browser Print->Save-as-PDF path, and the 3 font config keys.
+6. Tests: Document output contains doc.css, cover div, page-break class, configured fonts, mapped theme; frontmatter stripped.
+<!-- SECTION:PLAN:END -->
+
 ## Implementation Notes
 
 <!-- SECTION:NOTES:BEGIN -->
 Portability constraint (user): glint is installed by people who are NOT on this machine and do NOT have the kit's bundled fonts (Awke, Untitled Sans, Name Mono — personal/licensed). So the export must NOT hard-embed those. Fonts must be user-configurable (display/body/mono config keys, reusing the doc.css --font-* tokens) with safe open/system fallbacks (e.g. Georgia / system-ui / ui-monospace) so output looks fine with zero personal assets. Only redistributable fonts may be embedded by default.
 
 Packaging: glint must be self-contained. Copy/build the required kit pieces (doc.css + the @media print rules, the render HTML skeleton, only redistributable fonts) into the glint repo (e.g. internal/export/assets/) and embed them with go:embed so they ship inside the binary. Do NOT depend on /Users/kortum/Developer/Home/_shared-app-kit/markdown-doc-kit at runtime — that path doesn't exist on users' machines. Decide a vendor/sync story (a script that re-copies from the kit on update, or a one-time fork) so the embedded copy can be refreshed when the kit's house style changes; strip the licensed Awke/Untitled Sans/Name Mono @font-face blocks during that copy, leaving the --font-* tokens + fallbacks (ties to the configurable-fonts AC).
+
+Implemented: internal/export package (TDD, 17 tests). Document() renders md via goldmark GFM, strips YAML frontmatter, postprocesses (cover-wrap leading h1+subtitle, task-list-item class, {.page-break} heading suffix->class), embeds vendored doc.css (go:embed) + injects configured --font-* tokens. MapTheme glint->kit. Write()/OutputPath()/Title()/OpenInBrowser() for file+browser. Ctrl+E wired in app.exportPDF (writes <base>.html next to file, opens browser, graceful path-print fallback if open fails). config: font_display/body/mono keys w/ portable defaults (Georgia/system-ui/ui-monospace); wizard preserves them. assets/sync.sh re-vendors doc.css from kit + strips licensed faces (Awke/Untitled Sans/Name Mono). README Export section + help overlay updated. Fixed bug found in real output: heading {.class} regex used (?s) dotall and leaked page-break across to an earlier heading w/ mismatched close tag; removed dotall.
 <!-- SECTION:NOTES:END -->
go.mod +1 −1
@@ -12,6 +12,7 @@ 	github.com/charmbracelet/glamour v1.0.0
 	github.com/charmbracelet/huh v1.0.0
 	github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
 	github.com/muesli/termenv v0.16.0
+	github.com/yuin/goldmark v1.7.13
 )
 
 require (
@@ -42,7 +43,6 @@ 	github.com/muesli/cancelreader v0.2.2 // indirect
 	github.com/muesli/reflow v0.3.0 // indirect
 	github.com/rivo/uniseg v0.4.7 // indirect
 	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
-	github.com/yuin/goldmark v1.7.13 // indirect
 	github.com/yuin/goldmark-emoji v1.0.6 // indirect
 	golang.org/x/net v0.38.0 // indirect
 	golang.org/x/sys v0.38.0 // indirect
internal/app/app.go +30 −0
@@ -13,6 +13,7 @@ 	"time"
 
 	"glint/internal/config"
 	"glint/internal/editor"
+	"glint/internal/export"
 	"glint/internal/help"
 	"glint/internal/picker"
 	"glint/internal/preview"
@@ -275,6 +276,8 @@ 		}
 		return a, tea.Quit
 	case tea.KeyCtrlS:
 		return a.save()
+	case tea.KeyCtrlE:
+		return a.exportPDF()
 	case tea.KeyCtrlP:
 		return a.togglePreview()
 	case tea.KeyCtrlT:
@@ -380,6 +383,33 @@ 		return a, nil
 	}
 	a.editor.Dirty = false
 	a.status = "Saved " + a.path
+	return a, nil
+}
+
+// exportPDF renders the buffer to a self-contained, printable HTML document in
+// the house style and opens it in the browser, where the user picks
+// Print → Save as PDF (TASK-021). No external converter is required.
+func (a *App) exportPDF() (tea.Model, tea.Cmd) {
+	md := string(a.editor.Bytes())
+	opts := export.Options{
+		Title:       export.Title(a.path, md),
+		Theme:       export.MapTheme(a.theme.Name),
+		FontDisplay: a.cfg.FontDisplay,
+		FontBody:    a.cfg.FontBody,
+		FontMono:    a.cfg.FontMono,
+		Cover:       true,
+	}
+	out, err := export.Write(a.path, md, opts)
+	if err != nil {
+		a.status = "Export failed: " + err.Error()
+		return a, nil
+	}
+	if err := export.OpenInBrowser(out); err != nil {
+		// The file is written; only the auto-open failed. Tell the user where.
+		a.status = "Exported " + out + " (open it, then Print → Save as PDF)"
+		return a, nil
+	}
+	a.status = "Exported " + out + " — Print → Save as PDF in the browser"
 	return a, nil
 }
 
internal/config/config.go +20 −0
@@ -20,6 +20,14 @@ 	GlamourStyle string `toml:"glamour_style"`
 	Theme        string `toml:"theme"`
 	InboxDir     string `toml:"inbox_dir"`
 	Spellcheck   string `toml:"spellcheck"` // auto | on | off (TASK-020)
+
+	// PDF/printable export fonts (TASK-021). CSS font-family stacks that
+	// override the house-style --font-* tokens. Defaults are portable
+	// open/system faces — glint ships to people without the kit's licensed
+	// fonts, so nothing licensed is baked in.
+	FontDisplay string `toml:"font_display"` // headings / cover
+	FontBody    string `toml:"font_body"`    // body prose
+	FontMono    string `toml:"font_mono"`    // code
 }
 
 // Default returns the built-in configuration used when no file is present.
@@ -31,6 +39,9 @@ 		DailySubdir: "Daily",
 		DailyFormat: "2006-01-02",
 		Theme:       "auto",
 		Spellcheck:  "auto",
+		FontDisplay: "Georgia, \"Times New Roman\", serif",
+		FontBody:    "system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif",
+		FontMono:    "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
 	}
 }
 
@@ -130,6 +141,15 @@ 		cfg.InboxDir = fileCfg.InboxDir
 	}
 	if fileCfg.Spellcheck != "" {
 		cfg.Spellcheck = fileCfg.Spellcheck
+	}
+	if fileCfg.FontDisplay != "" {
+		cfg.FontDisplay = fileCfg.FontDisplay
+	}
+	if fileCfg.FontBody != "" {
+		cfg.FontBody = fileCfg.FontBody
+	}
+	if fileCfg.FontMono != "" {
+		cfg.FontMono = fileCfg.FontMono
 	}
 	return cfg, nil
 }
internal/config/config_test.go +35 −0
@@ -3,6 +3,7 @@
 import (
 	"os"
 	"path/filepath"
+	"strings"
 	"testing"
 	"time"
 )
@@ -26,6 +27,40 @@ 		t.Errorf("VaultDir default = %q, want empty (set vault_dir for `glint -v`)", d.VaultDir)
 	}
 	if d.WorkingDir() == "" {
 		t.Error("WorkingDir should not be empty")
+	}
+}
+
+func TestDefaultExportFonts(t *testing.T) {
+	d := Default()
+	if d.FontDisplay == "" || d.FontBody == "" || d.FontMono == "" {
+		t.Fatalf("export font defaults must be non-empty: display=%q body=%q mono=%q",
+			d.FontDisplay, d.FontBody, d.FontMono)
+	}
+	for _, bad := range []string{"Awke", "Untitled Sans", "Name Mono"} {
+		for _, f := range []string{d.FontDisplay, d.FontBody, d.FontMono} {
+			if strings.Contains(f, bad) {
+				t.Errorf("default font stack contains licensed face %q: %q", bad, f)
+			}
+		}
+	}
+}
+
+func TestLoadFromFileOverlaysFonts(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "config.toml")
+	body := `font_display = "Charter, serif"
+font_body = "Inter, sans-serif"
+font_mono = "Fira Code, monospace"
+`
+	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.FontDisplay != "Charter, serif" || cfg.FontBody != "Inter, sans-serif" || cfg.FontMono != "Fira Code, monospace" {
+		t.Errorf("font overlay failed: %+v", cfg)
 	}
 }
 
internal/configui/configui.go +5 −0
@@ -121,6 +121,11 @@ 		DailySubdir:  dailySubdir,
 		DailyFormat:  dailyFmt,
 		GlamourStyle: glamour,
 		Spellcheck:   spellcheck,
+		// Export fonts aren't part of the wizard; preserve any existing values
+		// so a `glint -c` run doesn't silently drop them.
+		FontDisplay: cfg.FontDisplay,
+		FontBody:    cfg.FontBody,
+		FontMono:    cfg.FontMono,
 	}
 	path := config.Path()
 	if err := config.Save(out, path); err != nil {
internal/export/assets/doc.css +524 −0
@@ -0,0 +1,524 @@
+/*
+ * doc.css — the Humdrum Preview document house style.
+ *
+ * One stylesheet for documents that come from Kevin. Built on the AppKit
+ * token contract (tokens.css), the AppKit type tokens (display / body /
+ * mono), and the seven themes. Reused across:
+ *   - the standalone markdown renderer (index.html)
+ *   - the iA Writer template
+ *   - the Obsidian export
+ *
+ * It does NOT declare @font-face — the host supplies the fonts (embedded in
+ * the standalone HTML, bundled in the iA template, present in Obsidian). It
+ * only references the three families by name.
+ *
+ * Print target: US Letter, tuned for black-and-white. See @media print.
+ */
+
+/* ─── Tokens: global ──────────────────────────────────────────────── */
+
+:root {
+  --font-display: Georgia, serif;
+  --font-body:    -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+  --font-mono:    ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
+
+  /* Document measure + rhythm */
+  --doc-measure: 42rem;       /* ~68ch comfortable reading column */
+  --doc-pad:     2.5rem;
+  --leading:     1.62;
+  --leading-tight: 1.18;
+
+  --radius-sm: 4px;
+  --radius:    8px;
+}
+
+/* ─── Tokens: themes (from _shared-app-kit/tokens.css) ────────────── */
+
+:root,
+[data-theme="flexoki"] {
+  --bg:#FFFCF0; --bg-card:#F2F0E5; --bg-elevated:#F2F0E5;
+  --border:#E6E4D9; --border-strong:#CECDC3;
+  --text:#100F0F; --text-muted:#6F6E69; --text-faint:#B7B5AC;
+  --text-on-accent:#FFFCF0; --selection:#DDF1E4; --accent:#24837B;
+  --red:#AF3029; --orange:#BC5215; --yellow:#AD8301; --green:#66800B;
+  --cyan:#24837B; --blue:#205EA6; --purple:#5E409D; --pink:#A02F6F;
+}
+[data-theme="flexoki-dark"] {
+  --bg:#100F0F; --bg-card:#1C1B1A; --bg-elevated:#282726;
+  --border:#282726; --border-strong:#403E3C;
+  --text:#CECDC3; --text-muted:#878580; --text-faint:#575653;
+  --text-on-accent:#100F0F; --selection:#122F2C; --accent:#3AA99F;
+  --red:#D14D41; --orange:#DA702C; --yellow:#D0A215; --green:#879A39;
+  --cyan:#3AA99F; --blue:#4385BE; --purple:#8B7EC8; --pink:#CE5D97;
+}
+[data-theme="uchu"] {
+  --bg:#FDFCFB; --bg-card:#FFFFFF; --bg-elevated:#FFFFFF;
+  --border:#E9E7E3; --border-strong:#C9C6BF;
+  --text:#17161B; --text-muted:#605D6A; --text-faint:#A8A5B0;
+  --text-on-accent:#FDFCFB; --selection:#E4DCFC; --accent:#6032E8;
+  --red:#E5484D; --orange:#F76808; --yellow:#FFB224; --green:#30A46C;
+  --cyan:#00A2C7; --blue:#0D74CE; --purple:#6032E8; --pink:#D6409F;
+}
+[data-theme="uchu-dark"] {
+  --bg:#17161B; --bg-card:#1F1D23; --bg-elevated:#2A2730;
+  --border:#2A2730; --border-strong:#3F3B48;
+  --text:#F2EFF7; --text-muted:#A8A5B0; --text-faint:#605D6A;
+  --text-on-accent:#17161B; --selection:#2E1B66; --accent:#A786FF;
+  --red:#FF6369; --orange:#FF8B3E; --yellow:#FFD15E; --green:#63E094;
+  --cyan:#59D3E8; --blue:#6CAEFF; --purple:#A786FF; --pink:#F36DBF;
+}
+/* Humdrum — accent locked to #0F80EA per spec (tokens.css). Color families
+   derived in OKLCH at L0.60 C0.18, hue per family. Same accent in dark. */
+[data-theme="humdrum"] {
+  --bg:#F5F3EE; --bg-card:#FFFFFF; --bg-elevated:#FFFFFF;
+  --border:#E2DFD7; --border-strong:#C3BFB3;
+  --text:#2A2825; --text-muted:#6D6A63; --text-faint:#ADA99F;
+  --text-on-accent:#FFFFFF; --selection:oklch(0.85 0.07 253); --accent:#0F80EA;
+  --red:oklch(0.60 0.180 22); --orange:oklch(0.60 0.180 50);
+  --yellow:oklch(0.60 0.180 85); --green:oklch(0.60 0.180 140);
+  --cyan:oklch(0.60 0.180 205); --blue:#0F80EA;
+  --purple:oklch(0.60 0.180 295); --pink:oklch(0.60 0.180 350);
+}
+[data-theme="humdrum-dark"] {
+  --bg:#1F1D1A; --bg-card:#282622; --bg-elevated:#32302C;
+  --border:#32302C; --border-strong:#4A4740;
+  --text:#E8E5DD; --text-muted:#A8A49B; --text-faint:#6D6A63;
+  --text-on-accent:#FFFFFF; --selection:oklch(0.35 0.13 253); --accent:#0F80EA;
+  --red:oklch(0.60 0.180 22); --orange:oklch(0.60 0.180 50);
+  --yellow:oklch(0.60 0.180 85); --green:oklch(0.60 0.180 140);
+  --cyan:oklch(0.60 0.180 205); --blue:#0F80EA;
+  --purple:oklch(0.60 0.180 295); --pink:oklch(0.60 0.180 350);
+}
+[data-theme="eink"] {
+  --bg:#FFFFFF; --bg-card:#FFFFFF; --bg-elevated:#FFFFFF;
+  --border:#000000; --border-strong:#000000;
+  --text:#000000; --text-muted:#000000; --text-faint:#000000;
+  --text-on-accent:#FFFFFF; --selection:#000000; --accent:#000000;
+  --red:#000; --orange:#000; --yellow:#000; --green:#000;
+  --cyan:#000; --blue:#000; --purple:#000; --pink:#000;
+}
+
+/* ─── The document ────────────────────────────────────────────────── */
+
+.doc {
+  font-family: var(--font-body);
+  font-weight: 400;
+  font-size: 1.0625rem;          /* 17px screen baseline */
+  line-height: var(--leading);
+  color: var(--text);
+  background: var(--bg);
+  max-width: var(--doc-measure);
+  margin: 0 auto;
+  padding: var(--doc-pad);
+  -webkit-font-smoothing: antialiased;
+  text-rendering: optimizeLegibility;
+  font-feature-settings: "kern" 1, "liga" 1;
+  hanging-punctuation: first;
+}
+
+.doc > :first-child { margin-top: 0; }
+.doc > :last-child  { margin-bottom: 0; }
+
+/* Frontmatter properties, shown under the title when enabled. */
+.doc .doc-props {
+  display: grid;
+  grid-template-columns: max-content 1fr;
+  gap: 0.25em 1.2em;
+  margin: 0 0 1.8em;
+  padding: 0.7em 0 0.9em;
+  border-top: 1px solid var(--border);
+  border-bottom: 1px solid var(--border);
+  font-size: 0.88rem;
+  break-inside: avoid;
+}
+.doc .doc-props dt {
+  font-family: var(--font-mono);
+  font-size: 0.74rem;
+  text-transform: uppercase;
+  letter-spacing: 0.04em;
+  color: var(--text-muted);
+  align-self: baseline;
+}
+.doc .doc-props dd { margin: 0; color: var(--text); }
+
+/* Headings — display face */
+.doc h1, .doc h2, .doc h3, .doc h4, .doc h5, .doc h6 {
+  font-family: var(--font-display);
+  line-height: var(--leading-tight);
+  color: var(--text);
+  margin: 2.4em 0 0.6em;
+  text-wrap: balance;
+}
+.doc h1 { font-size: 3rem;    font-weight: 700; letter-spacing: -0.01em; margin-top: 0; }
+.doc h2 { font-size: 2.2rem;  font-weight: 700; letter-spacing: -0.005em; }
+.doc h3 { font-size: 1.6rem;  font-weight: 400; }
+.doc h4 { font-size: 1.18rem; font-weight: 700; font-family: var(--font-body); }
+.doc h5 { font-size: 1rem;    font-weight: 700; font-family: var(--font-body); text-transform: uppercase; letter-spacing: 0.06em; }
+.doc h6 { font-size: 0.9rem;  font-weight: 500; font-family: var(--font-body); color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.08em; }
+
+/* Cover wrapper (leading H1 + subtitle). On screen it's just a title block
+   with breathing room; in print it becomes a full centered cover page. */
+.doc .cover { margin-bottom: 2.5em; }
+
+/* A subtitle: first paragraph right after h1 reads lighter */
+.doc h1 + p {
+  font-size: 1.2rem;
+  font-weight: 300;
+  color: var(--text-muted);
+  line-height: 1.45;
+}
+
+.doc p { margin: 0 0 1.1em; }
+
+/* Links */
+.doc a {
+  color: var(--accent);
+  text-decoration: underline;
+  text-decoration-thickness: 1px;
+  text-underline-offset: 0.15em;
+  text-decoration-color: color-mix(in oklab, var(--accent) 45%, transparent);
+}
+.doc a:hover { text-decoration-color: var(--accent); }
+
+/* Emphasis */
+.doc strong { font-weight: 700; }
+.doc em { font-style: italic; }
+.doc mark {
+  background: color-mix(in oklab, var(--yellow) 32%, transparent);
+  color: inherit; padding: 0 0.15em; border-radius: 2px;
+}
+.doc del { color: var(--text-faint); }
+
+/* Lists */
+.doc ul, .doc ol { margin: 0 0 1.1em; padding-left: 1.5em; }
+.doc li { margin: 0.3em 0; }
+.doc li::marker { color: var(--text-muted); }
+.doc ul ul, .doc ol ol, .doc ul ol, .doc ol ul { margin-bottom: 0; }
+
+/* Task lists (GitHub-style checkboxes) */
+.doc li.task-list-item { list-style: none; margin-left: -1.3em; }
+.doc li.task-list-item input { margin-right: 0.5em; accent-color: var(--accent); }
+
+/* Blockquote */
+.doc blockquote {
+  margin: 1.4em 0;
+  padding: 0.2em 0 0.2em 1.2em;
+  border-left: 3px solid var(--accent);
+  color: var(--text-muted);
+  font-style: italic;
+}
+.doc blockquote p:last-child { margin-bottom: 0; }
+
+/* Callouts — `> [!type] Title`. Body fully rendered. Each type is the theme
+   accent, only the hue nudged (and a couple muted), so the set stays one family
+   instead of a rainbow. --hue rotates, --chroma mutes; both relative to accent. */
+.doc .callout {
+  --hue: 0;
+  --chroma: 1;
+  --co: oklch(from var(--accent) l calc(c * var(--chroma)) calc(h + var(--hue)));
+  margin: 1.4em 0;
+  padding: 0.7em 1em;
+  border: 1px solid var(--border);
+  border-left: 4px solid var(--co);
+  border-radius: var(--radius);
+  background: color-mix(in oklab, var(--co) 7%, var(--bg-card));
+  color: var(--text);
+  font-style: normal;
+}
+.doc .callout-title {
+  font-family: var(--font-body);
+  font-weight: 700;
+  color: var(--co);
+  margin-bottom: 0.35em;
+  line-height: 1.3;
+}
+.doc .callout > p:last-child { margin-bottom: 0; }
+/* Per-type --hue / --chroma are set inline by DocKit.styleCallouts so custom
+   callout types get distinct hues too. */
+
+/* Bare callouts (e.g. `> [!nav]` day navigation) are screen chrome — hide them
+   in every output (screen, HTML, PDF, print). */
+.doc .doc-nav { display: none !important; }
+
+/* ─── Book (compiled multi-note document) ─────────────────────────── */
+.doc.book .book-toc { margin: 0 0 2.2em; }
+.doc.book .book-toc h2 { margin-top: 0; }
+.doc.book .book-toc ol { font-size: 1.05rem; line-height: 1.9; padding-left: 1.4em; }
+.doc.book .book-toc a { text-decoration: none; color: var(--text); }
+.doc.book .book-toc a:hover { color: var(--accent); }
+.doc.book .chapter { margin: 0; }
+/* In continuous mode, separate chapters with a rule instead of a page break. */
+.doc.book.continuous .chapter + .chapter {
+  border-top: 1px solid var(--border-strong);
+  margin-top: 2.5em;
+  padding-top: 2em;
+}
+
+/* Base snapshot — a frozen table standing in for an Obsidian Base view.
+   Presented as a labelled card so it clearly reads as a data table. */
+.doc .base-snapshot {
+  margin: 1.6em 0;
+  border: 1px solid var(--border-strong);
+  border-radius: var(--radius);
+  background: var(--bg-card);
+  overflow: hidden;
+}
+.doc .base-head {
+  display: flex;
+  align-items: baseline;
+  gap: 0.5em;
+  padding: 0.5em 0.9em;
+  background: color-mix(in oklab, var(--accent) 9%, var(--bg-card));
+  border-bottom: 1px solid var(--border-strong);
+  font-family: var(--font-mono);
+  font-size: 0.72rem;
+  letter-spacing: 0.05em;
+  text-transform: uppercase;
+  color: var(--text-muted);
+}
+.doc .base-head .base-name { font-weight: 600; color: var(--text); letter-spacing: 0.02em; }
+.doc .base-head .base-count { margin-left: auto; text-transform: none; letter-spacing: 0; }
+.doc .base-snapshot table { margin: 0; width: 100%; font-size: 0.9rem; }
+.doc .base-snapshot th { background: transparent; }
+.doc .base-snapshot th, .doc .base-snapshot td { padding: 0.45em 0.9em; }
+.doc .base-snapshot tbody tr:nth-child(even) { background: color-mix(in oklab, var(--text) 3.5%, transparent); }
+.doc .base-snapshot tbody tr:last-child td { border-bottom: none; }
+.doc .base-list { margin: 0; padding: 0.4em 0.9em 0.5em 1.9em; }
+.doc .base-list li { margin: 0.2em 0; }
+.doc .base-list li::marker { color: var(--text-faint); }
+
+/* Code */
+.doc code {
+  font-family: var(--font-mono);
+  font-size: 0.88em;
+  background: var(--bg-card);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  padding: 0.08em 0.35em;
+}
+.doc pre {
+  font-family: var(--font-mono);
+  font-size: 0.84rem;
+  line-height: 1.55;
+  background: var(--bg-card);
+  border: 1px solid var(--border);
+  border-radius: var(--radius);
+  padding: 1em 1.2em;
+  margin: 1.4em 0;
+  overflow-x: auto;
+}
+.doc pre code { background: none; border: none; padding: 0; font-size: inherit; }
+
+/* Tables */
+.doc table {
+  width: 100%;
+  border-collapse: collapse;
+  margin: 1.5em 0;
+  font-size: 0.94rem;
+}
+.doc th, .doc td {
+  text-align: left;
+  padding: 0.5em 0.8em;
+  border-bottom: 1px solid var(--border);
+}
+.doc thead th {
+  font-weight: 700;
+  border-bottom: 2px solid var(--border-strong);
+  background: var(--bg-card);
+}
+.doc tbody tr:last-child td { border-bottom: 1px solid var(--border-strong); }
+
+/* Rules + images + figures */
+.doc hr {
+  border: none;
+  border-top: 1px solid var(--border-strong);
+  margin: 2.5em 0;
+}
+.doc img { max-width: 100%; height: auto; border-radius: var(--radius); }
+.doc figure { margin: 1.6em 0; }
+.doc figcaption { font-size: 0.86rem; color: var(--text-muted); margin-top: 0.5em; text-align: center; }
+
+.doc kbd {
+  font-family: var(--font-mono);
+  font-size: 0.8em;
+  background: var(--bg-card);
+  border: 1px solid var(--border-strong);
+  border-bottom-width: 2px;
+  border-radius: var(--radius-sm);
+  padding: 0.1em 0.4em;
+}
+
+::selection { background: var(--selection); }
+
+/* eink: differentiate by weight + border, never colour */
+[data-theme="eink"] .doc { font-weight: 500; }
+[data-theme="eink"] .doc h1,
+[data-theme="eink"] .doc h2,
+[data-theme="eink"] .doc h3,
+[data-theme="eink"] .doc strong { font-weight: 800; }
+[data-theme="eink"] .doc a { text-decoration-color: #000; }
+[data-theme="eink"] .doc .callout { background: transparent; border: 2px solid #000; }
+[data-theme="eink"] .doc .callout-title { color: #000; }
+[data-theme="eink"] .doc mark { background: transparent; border-bottom: 3px solid #000; border-radius: 0; }
+
+/* ─── Interactive affordances (screen only) ───────────────────────── */
+
+/* Clickable task checkboxes. Force native, clickable inputs so a host theme
+   (e.g. Obsidian's Minimal) can't restyle them into something that swallows
+   the click — that would stop the change event and break write-back. */
+.doc input[type="checkbox"] {
+  -webkit-appearance: checkbox !important;
+  appearance: checkbox !important;
+  pointer-events: auto !important;
+  opacity: 1 !important;
+  position: static !important;
+  cursor: pointer;
+  width: auto;
+  height: auto;
+  margin: 0 0.4em 0 0;
+  background-image: none !important;
+  box-shadow: none !important;
+  -webkit-mask: none !important;
+  mask: none !important;
+}
+/* Kill any host-theme pseudo-element checkmark (e.g. Minimal) that would
+   render on top of the native box. */
+.doc input[type="checkbox"]::before,
+.doc input[type="checkbox"]::after {
+  content: none !important;
+  display: none !important;
+  background: none !important;
+}
+
+/* Drag handles for reorderable list items + table rows. Absolutely placed in
+   the gutter so they never shift the content; fade in on hover. */
+.doc li, .doc td:first-child { position: relative; }
+.doc .dk-h {
+  position: absolute;
+  left: -1.25em;
+  top: 0.15em;
+  width: 1em;
+  text-align: center;
+  color: var(--text-faint);
+  font-family: var(--font-mono);
+  cursor: grab;
+  opacity: 0;
+  transition: opacity 0.12s ease;
+  user-select: none;
+  touch-action: none;
+}
+.doc td:first-child > .dk-h { left: -0.9em; top: 0.5em; }
+.doc li:hover > .dk-h,
+.doc tr:hover .dk-h { opacity: 1; }
+.doc .dk-h:active { cursor: grabbing; }
+.doc li.dk-drag, .doc tr.dk-drag { background: var(--bg-hover, rgba(0,0,0,0.05)); opacity: 0.7; }
+
+[data-theme="eink"] .doc .dk-h { color: #000; }
+
+/* ─── Print: US Letter, black-and-white first ─────────────────────── */
+
+@page {
+  size: letter;          /* 8.5 × 11 in */
+  margin: 0.9in 1in;
+}
+
+@media print {
+  html, body { background: #fff !important; }
+
+  /* Drag handles and day-nav never print. */
+  .doc .dk-h, .doc .doc-nav { display: none !important; }
+
+  /* Layout for every printed doc (colour or B&W). */
+  .doc {
+    max-width: none;
+    margin: 0;
+    padding: 0;
+    font-size: 11pt;
+    line-height: 1.5;
+  }
+
+  /* ── Colour mode (.pdf-color): keep the theme's colours, force a white page.
+     Used by the plugin's "Save as PDF" (Humdrum Light). ── */
+  .doc.pdf-color {
+    --bg: #fff;
+    background: #fff;
+  }
+  .doc.pdf-color, .doc.pdf-color * {
+    -webkit-print-color-adjust: exact;
+    print-color-adjust: exact;
+  }
+
+  /* ── B&W mode (default browser print): collapse to black ink. ── */
+  .doc:not(.pdf-color) {
+    --bg:#fff; --bg-card:#fff; --bg-elevated:#fff;
+    --text:#000; --text-muted:#1a1a1a; --text-faint:#444;
+    --border:#bbb; --border-strong:#000; --accent:#000;
+    --selection:#000;
+    color: #000;
+    background: #fff;
+  }
+  .doc:not(.pdf-color) a { color: #000; text-decoration-color: #000; }
+  /* Surface link targets in B&W print (skip with .no-print-urls). */
+  .doc:not(.pdf-color) a[href^="http"]::after {
+    content: " (" attr(href) ")";
+    font-size: 0.82em;
+    color: #444;
+    word-break: break-all;
+  }
+  .doc.no-print-urls a::after { content: none; }
+  .doc:not(.pdf-color) code, .doc:not(.pdf-color) pre, .doc:not(.pdf-color) kbd { background: #fff !important; border: 1px solid #999; }
+  .doc:not(.pdf-color) thead th { background: #f2f2f2 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
+  .doc:not(.pdf-color) blockquote { border-left: 3px solid #000; color: #1a1a1a; }
+  .doc:not(.pdf-color) .callout { background: #fff !important; border: 1px solid #000; border-left: 4px solid #000; }
+  .doc:not(.pdf-color) .callout-title { color: #000; }
+  .doc:not(.pdf-color) .base-snapshot { border: 1px solid #000; background: #fff !important; }
+  .doc:not(.pdf-color) .base-head { background: #f2f2f2 !important; border-bottom: 1px solid #000; color: #000; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
+  .doc:not(.pdf-color) .base-snapshot tbody tr:nth-child(even) { background: #f6f6f6 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
+  .doc:not(.pdf-color) mark { background: transparent; border-bottom: 2px solid #000; }
+
+  /* Pagination hygiene */
+  .doc h1, .doc h2, .doc h3, .doc h4, .doc h5, .doc h6 {
+    break-after: avoid;
+    break-inside: avoid;
+  }
+  .doc p, .doc blockquote, .doc li { orphans: 3; widows: 3; }
+  .doc pre, .doc table, .doc figure, .doc img, .doc blockquote { break-inside: avoid; }
+  .doc thead { display: table-header-group; }   /* repeat header on each page */
+  .doc tr { break-inside: avoid; }
+
+  /* Manual extra break: any element carrying .page-break.
+     Both properties for cross-engine support (modern break-* + legacy). */
+  .doc .page-break { break-before: page; page-break-before: always; }
+
+  /* Single-note pagination: each H1 starts a new page; H2 and below just flow
+     with their normal spacing. (Gated to :not(.book) — books break on
+     chapters, not headings.) */
+  .doc:not(.book) h1 {
+    break-before: page; page-break-before: always;
+    margin-top: 0;        /* an H1 opening a page sits at the top */
+  }
+  /* A leading H1 (cover off) just leads page one — no break before it. */
+  .doc:not(.book) > h1:first-child { break-before: avoid; page-break-before: avoid; }
+
+  /* ── Book mode: break on chapters, not headings ── */
+  .doc.book .chapter { break-before: page; page-break-before: always; }
+  .doc.book.continuous .chapter { break-before: auto; page-break-before: auto; }
+  .doc.book .book-toc { break-after: page; page-break-after: always; break-inside: avoid; }
+  /* No double break right after the cover or the TOC. */
+  .doc.book .cover + .chapter,
+  .doc.book .book-toc + .chapter,
+  .doc.book .cover + .book-toc { break-before: avoid; page-break-before: avoid; }
+
+  /* Cover page: the leading H1 (+ subtitle) begins ~25% down the sheet — the
+     classic title-page drop. A fixed top padding instead of flex centering,
+     because Chromium's printToPDF ignores flex min-height in paged context.
+     Page is 11in; content top sits at the 0.9in @page margin, so 1.85in of
+     padding lands the title at ~2.75in ≈ 25%. */
+  .doc .cover {
+    break-before: auto; page-break-before: auto;
+    break-after: page;  page-break-after: always;
+    break-inside: avoid;
+    padding-top: 1.85in;
+  }
+  .doc .cover h1 { break-before: auto; page-break-before: auto; margin-top: 0; }
+}
internal/export/assets/sync.sh +37 −0
@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+# sync.sh — re-vendor the markdown-doc-kit house style into glint.
+#
+# glint embeds doc.css via go:embed so `brew install glint` ships the full
+# print house style with no runtime dependency on the kit. This script
+# re-copies doc.css from the shared kit and neutralizes the three licensed
+# font families (Awke / Untitled Sans / Name Mono) in the :root --font-*
+# tokens, leaving open/system fallbacks. glint then overrides those tokens
+# per-export from the user's config (font_display / font_body / font_mono).
+#
+# Run this when the kit's house style changes:
+#   internal/export/assets/sync.sh
+#
+# Source of truth (this machine only — NOT present on users' machines):
+KIT="${KIT:-/Users/kortum/Developer/Home/_shared-app-kit/markdown-doc-kit}"
+DEST="$(cd "$(dirname "$0")" && pwd)"
+
+set -euo pipefail
+
+src="$KIT/doc.css"
+if [[ ! -f "$src" ]]; then
+  echo "kit doc.css not found at $src" >&2
+  exit 1
+fi
+
+# Copy doc.css, then strip the licensed family names from the three font
+# tokens so the vendored copy references only redistributable/system fonts.
+sed \
+  -e 's/--font-display: *"Awke", *"Untitled Sans", */--font-display: /' \
+  -e 's/--font-body: *"Untitled Sans", */--font-body:    /' \
+  -e 's/--font-mono: *"Name Mono", */--font-mono:    /' \
+  -e 's|the three locked faces (Awke / Untitled Sans /|the AppKit type tokens (display / body /|' \
+  -e 's|Name Mono), and the seven themes|mono), and the seven themes|' \
+  -e 's|/\* Headings — Awke display \*/|/* Headings — display face */|' \
+  "$src" > "$DEST/doc.css"
+
+echo "vendored doc.css ($(wc -l < "$DEST/doc.css") lines) — licensed faces neutralized"
internal/export/export.go +162 −0
@@ -0,0 +1,162 @@
+// Package export renders the current buffer to a self-contained, printable
+// HTML document in the markdown-doc-kit house style (TASK-021).
+//
+// The output bakes in the vendored doc.css (go:embed — no runtime dependency
+// on the external kit) plus the user's configured fonts, so opening it in a
+// browser and choosing Print → Save as PDF yields a clean US-Letter PDF:
+// a centered cover page from the leading `# Title`, page breaks per the kit's
+// print rules, and black-on-white when printed.
+package export
+
+import (
+	"bytes"
+	_ "embed"
+	"fmt"
+	"html"
+	"regexp"
+	"strings"
+
+	"github.com/yuin/goldmark"
+	"github.com/yuin/goldmark/extension"
+)
+
+//go:embed assets/doc.css
+var docCSS string
+
+// Options controls a single export. Theme is a kit data-theme name (use
+// MapTheme to convert a glint theme); the three font fields are CSS
+// font-family stacks that override doc.css's --font-* tokens.
+type Options struct {
+	Title       string
+	Theme       string
+	FontDisplay string
+	FontBody    string
+	FontMono    string
+	Cover       bool // wrap a leading H1 (+ subtitle) into a print cover page
+	PrintURLs   bool // surface link URLs when printing in black-and-white
+}
+
+var md = goldmark.New(goldmark.WithExtensions(extension.GFM))
+
+// Document renders markdown into a complete, self-contained HTML document.
+func Document(markdown string, opts Options) (string, error) {
+	body, err := renderBody(markdown, opts)
+	if err != nil {
+		return "", err
+	}
+
+	theme := opts.Theme
+	if theme == "" {
+		theme = "flexoki"
+	}
+	cls := "doc"
+	if !opts.PrintURLs {
+		cls += " no-print-urls"
+	}
+
+	var b strings.Builder
+	b.WriteString(`<!DOCTYPE html>` + "\n")
+	fmt.Fprintf(&b, `<html lang="en" data-theme="%s">`+"\n", html.EscapeString(theme))
+	b.WriteString("<head>\n")
+	b.WriteString(`<meta charset="utf-8">` + "\n")
+	b.WriteString(`<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">` + "\n")
+	fmt.Fprintf(&b, "<title>%s</title>\n", html.EscapeString(opts.Title))
+	fmt.Fprintf(&b, "<style>%s</style>\n", docCSS)
+	fmt.Fprintf(&b, "<style>%s</style>\n", fontOverride(opts))
+	b.WriteString(`<style>html,body{margin:0;background:var(--bg);}</style>` + "\n")
+	b.WriteString("</head>\n<body>\n")
+	fmt.Fprintf(&b, `<article class="%s">`+"\n", cls)
+	b.WriteString(body)
+	b.WriteString("\n</article>\n</body>\n</html>\n")
+	return b.String(), nil
+}
+
+// renderBody converts markdown to the document inner HTML, applying the kit's
+// postprocessing conventions (task-list classes, {.class} heading suffixes,
+// cover wrap).
+func renderBody(markdown string, opts Options) (string, error) {
+	var buf bytes.Buffer
+	if err := md.Convert([]byte(stripFrontmatter(markdown)), &buf); err != nil {
+		return "", err
+	}
+	out := buf.String()
+	out = markTaskListItems(out)
+	out = applyHeadingClasses(out)
+	if opts.Cover {
+		out = wrapCover(out)
+	}
+	return out, nil
+}
+
+// fontOverride is a :root block that overrides doc.css's three font tokens
+// with the configured stacks — so the export never depends on the kit's
+// licensed faces.
+func fontOverride(opts Options) string {
+	return fmt.Sprintf(
+		":root{--font-display:%s;--font-body:%s;--font-mono:%s;}",
+		opts.FontDisplay, opts.FontBody, opts.FontMono,
+	)
+}
+
+// stripFrontmatter drops a leading YAML frontmatter block (--- ... ---) so it
+// doesn't render as a horizontal rule and stray text.
+func stripFrontmatter(s string) string {
+	if !strings.HasPrefix(s, "---\n") && !strings.HasPrefix(s, "---\r\n") {
+		return s
+	}
+	rest := s[strings.IndexByte(s, '\n')+1:]
+	lines := strings.Split(rest, "\n")
+	for i, ln := range lines {
+		t := strings.TrimRight(ln, "\r")
+		if t == "---" || t == "..." {
+			return strings.Join(lines[i+1:], "\n")
+		}
+	}
+	return s // no closing fence — leave as-is
+}
+
+// markTaskListItems adds the task-list-item class to list items that hold a
+// checkbox, matching the kit's postprocess + doc.css selector.
+func markTaskListItems(html string) string {
+	return strings.ReplaceAll(html, "<li><input ", `<li class="task-list-item"><input `)
+}
+
+// Headings are single-line in goldmark output, so match without dotall: this
+// keeps a `{.class}` suffix from spanning across newlines into a later heading.
+var headingClassRE = regexp.MustCompile(`<h([1-6])>(.*?)\s*\{\.([\w-]+)\}</h[1-6]>`)
+
+// applyHeadingClasses turns a trailing `{.class}` suffix on a heading into a
+// real class attribute (e.g. `## Section {.page-break}`), matching the kit.
+func applyHeadingClasses(html string) string {
+	return headingClassRE.ReplaceAllString(html, `<h$1 class="$3">$2</h$1>`)
+}
+
+var leadingH1RE = regexp.MustCompile(`(?s)^\s*(<h1>.*?</h1>)\n?(\s*<p>.*?</p>\n?)?`)
+
+// wrapCover wraps a leading H1 (and an immediately following subtitle
+// paragraph) in a `.cover` div, matching the kit's postprocess so doc.css's
+// print rules render it as a centered cover page.
+func wrapCover(html string) string {
+	loc := leadingH1RE.FindStringSubmatchIndex(html)
+	if loc == nil {
+		return html
+	}
+	matched := html[loc[0]:loc[1]]
+	inner := strings.TrimRight(matched, "\n")
+	return `<div class="cover">` + "\n" + inner + "\n</div>\n" + html[loc[1]:]
+}
+
+// MapTheme maps a glint theme name to the closest kit data-theme. Unknown or
+// empty names fall back to flexoki (light).
+func MapTheme(glintTheme string) string {
+	switch glintTheme {
+	case "flexoki-light":
+		return "flexoki"
+	case "flexoki-dark":
+		return "flexoki-dark"
+	case "charm":
+		return "flexoki-dark"
+	default:
+		return "flexoki"
+	}
+}
internal/export/export_test.go +265 −0
@@ -0,0 +1,265 @@
+package export
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func testOpts() Options {
+	return Options{
+		Title:       "Doc",
+		Theme:       "flexoki",
+		FontDisplay: "Georgia, serif",
+		FontBody:    "system-ui, sans-serif",
+		FontMono:    "ui-monospace, monospace",
+		Cover:       true,
+	}
+}
+
+func TestDocumentEmbedsHouseStyle(t *testing.T) {
+	html, err := Document("Hello world.", testOpts())
+	if err != nil {
+		t.Fatal(err)
+	}
+	// The vendored doc.css must be baked into the output (self-contained).
+	for _, want := range []string{`<article class="doc`, ".doc {", "@media print", "@page"} {
+		if !strings.Contains(html, want) {
+			t.Errorf("output missing %q", want)
+		}
+	}
+}
+
+func TestDocumentSetsTheme(t *testing.T) {
+	opts := testOpts()
+	opts.Theme = "humdrum-dark"
+	html, err := Document("x", opts)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !strings.Contains(html, `data-theme="humdrum-dark"`) {
+		t.Errorf("theme not applied to <html>:\n%s", firstLines(html, 3))
+	}
+}
+
+func TestDocumentRendersMarkdown(t *testing.T) {
+	html, err := Document("# Title\n\nA **bold** word.", testOpts())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !strings.Contains(html, "<strong>bold</strong>") {
+		t.Errorf("markdown not rendered to HTML")
+	}
+}
+
+func TestDocumentWrapsLeadingH1InCover(t *testing.T) {
+	html, err := Document("# Title\n\nSubtitle line.\n\nBody.", testOpts())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !strings.Contains(html, `<div class="cover">`) {
+		t.Fatalf("no cover wrapper:\n%s", html)
+	}
+	// The h1 and the immediately following subtitle paragraph live inside cover.
+	cover := between(html, `<div class="cover">`, `</div>`)
+	if !strings.Contains(cover, "<h1") || !strings.Contains(cover, "Subtitle line.") {
+		t.Errorf("cover should hold h1 + subtitle, got:\n%s", cover)
+	}
+}
+
+func TestDocumentNoCoverWhenDisabled(t *testing.T) {
+	opts := testOpts()
+	opts.Cover = false
+	html, err := Document("# Title\n\nBody.", opts)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if strings.Contains(html, `<div class="cover">`) {
+		t.Errorf("cover wrapper present though disabled")
+	}
+}
+
+func TestDocumentPageBreakHeadingSuffix(t *testing.T) {
+	html, err := Document("# T\n\n## Section {.page-break}\n\nx", testOpts())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !strings.Contains(html, `class="page-break"`) {
+		t.Errorf("{.page-break} suffix not turned into a class:\n%s", html)
+	}
+	if strings.Contains(html, "{.page-break}") {
+		t.Errorf("literal {.page-break} suffix left in heading text")
+	}
+}
+
+func TestPageBreakSuffixDoesNotLeakToEarlierHeading(t *testing.T) {
+	// A leading H1 with no suffix, followed by an H2 carrying {.page-break}:
+	// the suffix must attach to the H2 only, never span to the H1.
+	html, err := Document("# Title\n\nbody\n\n## Section {.page-break}\n\nmore", testOpts())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if strings.Contains(html, `<h1 class="page-break"`) {
+		t.Errorf("page-break leaked onto the leading H1:\n%s", html)
+	}
+	if strings.Contains(html, "</h1>\n<p") && strings.Contains(html, "<h2>Section</h1>") {
+		t.Errorf("mismatched heading close tags produced")
+	}
+	if !strings.Contains(html, `<h2 class="page-break">Section</h2>`) {
+		t.Errorf("H2 did not get a clean page-break class:\n%s", html)
+	}
+}
+
+func TestDocumentTaskListItem(t *testing.T) {
+	html, err := Document("- [ ] todo\n- [x] done", testOpts())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !strings.Contains(html, "task-list-item") {
+		t.Errorf("checkbox list item missing task-list-item class:\n%s", html)
+	}
+}
+
+func TestDocumentInjectsConfiguredFonts(t *testing.T) {
+	opts := testOpts()
+	opts.FontDisplay = "Charter, serif"
+	opts.FontBody = "Inter, sans-serif"
+	opts.FontMono = "Fira Code, monospace"
+	html, err := Document("x", opts)
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, want := range []string{"Charter, serif", "Inter, sans-serif", "Fira Code, monospace"} {
+		if !strings.Contains(html, want) {
+			t.Errorf("configured font %q not injected", want)
+		}
+	}
+	// No licensed face should leak into the output.
+	for _, bad := range []string{"Awke", "Untitled Sans", "Name Mono"} {
+		if strings.Contains(html, bad) {
+			t.Errorf("licensed font %q leaked into export", bad)
+		}
+	}
+}
+
+func TestDocumentStripsFrontmatter(t *testing.T) {
+	md := "---\ntitle: Hi\ntags: [a, b]\n---\n\n# Real Title\n\nBody."
+	html, err := Document(md, testOpts())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if strings.Contains(html, "tags: [a, b]") {
+		t.Errorf("YAML frontmatter rendered into body:\n%s", html)
+	}
+	if !strings.Contains(html, "Real Title") {
+		t.Errorf("content after frontmatter lost")
+	}
+}
+
+func TestMapTheme(t *testing.T) {
+	cases := map[string]string{
+		"flexoki-light": "flexoki",
+		"flexoki-dark":  "flexoki-dark",
+		"charm":         "flexoki-dark",
+		"":              "flexoki",
+		"nonexistent":   "flexoki",
+	}
+	for in, want := range cases {
+		if got := MapTheme(in); got != want {
+			t.Errorf("MapTheme(%q) = %q, want %q", in, got, want)
+		}
+	}
+}
+
+func TestOutputPathFromSource(t *testing.T) {
+	if got := OutputPath("/x/y/note.md", "Title"); got != "/x/y/note.html" {
+		t.Errorf("OutputPath = %q, want /x/y/note.html", got)
+	}
+	if got := OutputPath("/x/y/note.markdown", "Title"); got != "/x/y/note.html" {
+		t.Errorf("OutputPath = %q, want /x/y/note.html", got)
+	}
+}
+
+func TestOutputPathUnnamedUsesTempSlug(t *testing.T) {
+	got := OutputPath("", "My Great Note!")
+	if filepath.Dir(got) != strings.TrimRight(os.TempDir(), string(os.PathSeparator)) {
+		t.Errorf("unnamed export should land in temp dir, got %q", got)
+	}
+	base := filepath.Base(got)
+	if base != "my-great-note.html" {
+		t.Errorf("slug base = %q, want my-great-note.html", base)
+	}
+}
+
+func TestWriteCreatesSelfContainedFile(t *testing.T) {
+	dir := t.TempDir()
+	src := filepath.Join(dir, "doc.md")
+	if err := os.WriteFile(src, []byte("# Hi\n\nbody"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	out, err := Write(src, "# Hi\n\nbody", testOpts())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if out != filepath.Join(dir, "doc.html") {
+		t.Errorf("out = %q, want doc.html alongside source", out)
+	}
+	data, err := os.ReadFile(out)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !strings.Contains(string(data), `<article class="doc`) {
+		t.Errorf("written file is not a rendered document")
+	}
+}
+
+func TestTitle(t *testing.T) {
+	cases := []struct {
+		src, md, want string
+	}{
+		{"/x/note.md", "# Real Heading\n\nbody", "Real Heading"},
+		{"/x/note.md", "no heading here", "note"},
+		{"", "no heading", "Untitled"},
+		{"/x/draft.md", "---\ntitle: y\n---\n# After Frontmatter\n", "After Frontmatter"},
+	}
+	for _, c := range cases {
+		if got := Title(c.src, c.md); got != c.want {
+			t.Errorf("Title(%q, %q) = %q, want %q", c.src, c.md, got, c.want)
+		}
+	}
+}
+
+func TestBrowserCommandNonEmpty(t *testing.T) {
+	name, args := browserCommand("/tmp/x.html")
+	if name == "" {
+		t.Fatal("browserCommand returned empty program")
+	}
+	joined := strings.Join(append([]string{name}, args...), " ")
+	if !strings.Contains(joined, "/tmp/x.html") {
+		t.Errorf("browser command %q does not reference the file", joined)
+	}
+}
+
+// --- helpers ---
+
+func firstLines(s string, n int) string {
+	lines := strings.SplitN(s, "\n", n+1)
+	if len(lines) > n {
+		lines = lines[:n]
+	}
+	return strings.Join(lines, "\n")
+}
+
+func between(s, start, end string) string {
+	i := strings.Index(s, start)
+	if i < 0 {
+		return ""
+	}
+	i += len(start)
+	j := strings.Index(s[i:], end)
+	if j < 0 {
+		return s[i:]
+	}
+	return s[i : i+j]
+}
internal/export/file.go +95 −0
@@ -0,0 +1,95 @@
+package export
+
+import (
+	"os"
+	"os/exec"
+	"path/filepath"
+	"regexp"
+	"runtime"
+	"strings"
+)
+
+// OutputPath is where an export of srcPath lands: the source path with its
+// extension swapped for .html. An unnamed buffer (empty srcPath) goes to the
+// OS temp dir, named from a slug of the title.
+func OutputPath(srcPath, title string) string {
+	if srcPath == "" {
+		name := slug(title)
+		if name == "" {
+			name = "document"
+		}
+		return filepath.Join(os.TempDir(), name+".html")
+	}
+	ext := filepath.Ext(srcPath)
+	return strings.TrimSuffix(srcPath, ext) + ".html"
+}
+
+// Write renders markdown to a self-contained HTML document and writes it to
+// the export path for srcPath, returning that path.
+func Write(srcPath, markdown string, opts Options) (string, error) {
+	doc, err := Document(markdown, opts)
+	if err != nil {
+		return "", err
+	}
+	out := OutputPath(srcPath, opts.Title)
+	if err := os.WriteFile(out, []byte(doc), 0o644); err != nil {
+		return "", err
+	}
+	return out, nil
+}
+
+var atxH1RE = regexp.MustCompile(`(?m)^#\s+(.+?)\s*#*\s*$`)
+
+// Title is the document title for an export: the first `# H1` in the markdown,
+// else the source filename without extension, else "Untitled".
+func Title(srcPath, markdown string) string {
+	if m := atxH1RE.FindStringSubmatch(stripFrontmatter(markdown)); m != nil {
+		if t := strings.TrimSpace(m[1]); t != "" {
+			return t
+		}
+	}
+	if srcPath != "" {
+		base := filepath.Base(srcPath)
+		return strings.TrimSuffix(base, filepath.Ext(base))
+	}
+	return "Untitled"
+}
+
+// OpenInBrowser opens path in the user's default browser, best-effort. It
+// returns any error starting the program; the browser then runs detached.
+func OpenInBrowser(path string) error {
+	name, args := browserCommand(path)
+	return exec.Command(name, args...).Start()
+}
+
+// browserCommand returns the platform program (and args) that opens path in
+// the user's default browser.
+func browserCommand(path string) (string, []string) {
+	switch runtime.GOOS {
+	case "darwin":
+		return "open", []string{path}
+	case "windows":
+		return "cmd", []string{"/c", "start", "", path}
+	default: // linux, *bsd
+		return "xdg-open", []string{path}
+	}
+}
+
+// slug turns a title into a filename-safe lowercase token.
+func slug(title string) string {
+	var b strings.Builder
+	prevDash := false
+	for _, r := range strings.ToLower(title) {
+		switch {
+		case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
+			b.WriteRune(r)
+			prevDash = false
+		default:
+			if !prevDash && b.Len() > 0 {
+				b.WriteByte('-')
+				prevDash = true
+			}
+		}
+	}
+	return strings.Trim(b.String(), "-")
+}
internal/help/help.go +2 −0
@@ -27,6 +27,8 @@   Enter (on a list)     continue the list marker (numbers increment, checkboxes
                         reset); Enter on an empty item exits the list
   Tab / Shift+Tab       indent / outdent the current list item
   Ctrl+S                save (an unnamed buffer prompts for a name)
+  Ctrl+E                export a printable HTML doc (house style) and open it in
+                        the browser → Print → Save as PDF
   Ctrl+P                toggle the read preview
   Ctrl+F                fuzzy file picker
   Ctrl+G                find in document (Enter/down next, Shift+Tab/up prev)