docs: glint v1.1 design — themes (flexoki light/dark + charm), picker overhaul, yaml frontmatter formatting
e4e20ae8872756c3b9ddae89a28667a1961f8ec2
Kevin Kortum <kevinkortum@me.com> · 2026-06-28 07:50
parent 4b56fb79
1 files changed
docs/superpowers/specs/2026-06-28-glint-theme-picker-design.md +157 −0
@@ -0,0 +1,157 @@
+# glint v1.1 — Theme System + Picker Overhaul (Design)
+
+**Status:** Approved design, pending implementation plan.
+
+**Motivation.** v1 shipped with a single hardcoded dark theme (`editor.DefaultDarkTheme`). On a light terminal the plain-text foreground (`#c0caf5`, near-white) is nearly invisible — confirmed by a screenshot of cream-background text. This adds light/dark themes, a third charm.land theme, OS auto-detection, a live toggle, and reworks the file picker to match the behavior of the user's existing `md` fzf navigator (modified-date sort, today's daily floated on top) plus a glamour preview pane and a new-note shortcut.
+
+This is a follow-on to the v1 design (`docs/superpowers/specs/2026-06-27-glint-design.md`) and editor plan (`docs/superpowers/plans/2026-06-27-glint-editor.md`); it preserves both v1 invariants.
+
+## Preserved invariants
+
+1. **Explicit foreground:** every emitted styled span — in every theme, light or dark — gets an explicit `Foreground`. No span ever falls back to the terminal default. (The light themes make this load-bearing for readability, not just correctness.)
+2. **Markup-visible:** the editor styling scanner still never adds or removes characters; the concatenation of a line's span texts equals the raw line. (Unchanged — this work does not touch the scanner's tokenizing logic, only the colors it pulls from.)
+
+## A. Theme system
+
+### A.1 New `internal/theme` package
+
+Theming moves out of `internal/editor` into a standalone `internal/theme` package so `editor`, `picker`, `app`, and `preview` can all consume themes without import cycles. (`editor.Theme`, `editor.DefaultDarkTheme`, and the editor's internal color references are replaced by `theme.Theme` and theme constructors. The scanner takes a `theme.Theme` argument exactly as it took `editor.Theme` before — a mechanical type move.)
+
+### A.2 `theme.Theme` struct
+
+Carries the eight markdown colors from v1 plus UI colors, all `lipgloss.Color`, all explicitly set:
+
+| Field | Use |
+|---|---|
+| `Text` | plain prose, default foreground |
+| `Heading` | headings (bold) |
+| `Code` | inline + fenced code |
+| `Link` | `[text](url)` |
+| `Wikilink` | `[[wikilink]]` |
+| `ListMarker` | list bullets / numbers |
+| `Blockquote` | blockquotes, frontmatter (muted) |
+| `Accent` | highlight — YAML frontmatter keys (section D) |
+| `Background` | app background |
+| `Muted` | secondary text (picker header, hints) |
+| `StatusFg` / `StatusBg` | status bar |
+| `SelFg` / `SelBg` | picker selected row |
+| `Pointer` | picker `▸` pointer, cursor accents |
+| `Name` (string) | theme id, e.g. `"flexoki-dark"` |
+| `GlamourStyle` (string) | glamour style this theme maps to (preview sync) |
+
+Every constructor sets every color field. A theme with any empty color field is a bug; a test asserts all fields non-empty for every registered theme.
+
+### A.3 Registered themes (concrete palettes)
+
+**Flexoki** (Steph Ango's palette — matches the user's `md` picker and vault ontology). Hexes lifted from the `md` `_md_palette` function and Flexoki's accent set.
+
+`FlexokiDark()` — `Name: "flexoki-dark"`, `GlamourStyle: "dark"`:
+- Background `#100F0F`, Text `#CECDC3`, Muted `#878580`, Blockquote `#878580`
+- Heading `#4385BE` (blue), Code `#879A39` (green), Link `#3AA99F` (cyan), Wikilink `#8B7EC8` (purple), ListMarker `#CE5D97` (magenta), Accent `#D0A215` (yellow)
+- StatusBg `#4385BE`, StatusFg `#100F0F`, SelBg `#D0A215`, SelFg `#100F0F`, Pointer `#CE5D97`
+
+`FlexokiLight()` — `Name: "flexoki-light"`, `GlamourStyle: "light"`:
+- Background `#FFFCF0`, Text `#100F0F`, Muted `#6F6E69`, Blockquote `#6F6E69`
+- Heading `#205EA6` (blue), Code `#66800B` (green), Link `#24837B` (cyan), Wikilink `#5E409D` (purple), ListMarker `#A02F6F` (magenta), Accent `#AD8301` (yellow)
+- StatusBg `#205EA6`, StatusFg `#FFFCF0`, SelBg `#AD8301`, SelFg `#FFFCF0`, Pointer `#A02F6F`
+
+**Charm** (charm.land / charmbracelet brand aesthetic). `Charm()` — `Name: "charm"`, `GlamourStyle: "dark"`:
+- Background `#16161E`, Text `#FFFDF5`, Muted `#6C6C8A`, Blockquote `#6C6C8A`
+- Heading `#FF5FAF` (charm hot pink), Code `#00FFA3` (mint), Link `#5DD5FF` (malibu cyan), Wikilink `#B575FF` (charple), ListMarker `#FF5FAF`, Accent `#FFD500` (citron)
+- StatusBg `#6B50FF` (charple), StatusFg `#FFFDF5`, SelBg `#FF5FAF`, SelFg `#16161E`, Pointer `#00FFA3`
+
+### A.4 Resolution and detection
+
+- `theme.ByName(name string) (Theme, bool)` — registry lookup over the three names above.
+- `theme.Detect() string` — returns `"flexoki-dark"` or `"flexoki-light"` from the OS. On macOS, runs `defaults read -g AppleInterfaceStyle`; output containing `Dark` → dark, anything else (including the non-zero exit when the key is unset, which means Light) → light. On non-macOS or on error → `"flexoki-dark"`.
+- `theme.Resolve(configValue string) Theme` — `"auto"` (or empty) → `ByName(Detect())`; a known name → that theme; an unknown name → `Detect()`'s theme (never errors, never returns an empty theme).
+
+### A.5 Config
+
+`config.Config` gains `Theme string` with toml key `theme`, default `"auto"`. The existing `GlamourStyle` key stays but changes role: it is now an **explicit override** of the theme's `GlamourStyle`. Preview style = `cfg.GlamourStyle` if the user set it non-empty in the file, else the active theme's `GlamourStyle`. (Default config leaves `glamour_style` effectively unset so the theme drives it; to preserve back-compat with v1's `glamour_style = "dark"` default, the default becomes empty string and the preview falls back to the theme.)
+
+### A.6 Live toggle
+
+`Ctrl+T` (currently unused; distinct from Tab) cycles the active theme in registry order `flexoki-light → flexoki-dark → charm → flexoki-light …` and re-renders. The app holds the active `theme.Theme`, passes it to the editor (which re-scans with the new colors), the status bar, and the picker. Toggling also updates the glamour style used by the next preview render.
+
+## B. Picker overhaul
+
+### B.1 Behavior (mirrors the `md` navigator)
+
+- **Default order (empty query): modified-date descending.** `walkMarkdown` returns each file with its mtime; the picker sorts newest-first.
+- **Today's daily note floated to the top** of the default order when the file exists (matches `md`'s `today_note` float). "Today's daily" = `cfg.DailyPath(time.Now())`.
+- **Query typed:** fuzzy rank as in v1 (`fuzzyMatch` subsequence + gap penalty), tie-broken by mtime descending.
+- Selection moves with Up/Down; `Enter` opens the highlighted file (existing dirty-guard from v1's confirm-then-discard still applies at the app layer).
+
+### B.2 Preview pane
+
+- Split layout via `lipgloss.JoinHorizontal`: match list on the left (~40% width), a rounded-bordered **glamour preview of the highlighted file** on the right (~60% width).
+- The preview reuses the `preview` package's glamour renderer, using the active theme's glamour style. It re-renders when the highlighted selection changes (read the file, render, load into a viewport). File-read or render errors show a short message in the preview pane, never crash.
+- The list column shows `▸` pointer on the selected row, `note ›` prompt above (the textinput), active-theme colors (SelFg/SelBg for the selected row, Muted for the header), rounded border around the whole picker.
+- Header hint line: ` ⌃N new · <vault-relative dir>` (keep it short; theme `Muted`).
+
+### B.3 New note (`Ctrl+N`)
+
+- `Ctrl+N` in the picker creates a note named after the current query (supports `Folder/Name`, appends `.md` if absent), under `cfg.VaultDir`, then opens it in the editor. Mirrors `md`'s Ctrl-N. Creates intermediate directories. If the file already exists, opens it rather than erroring. Empty query → no-op with a status message.
+- No template seeding in v1.1 (the `md` `_md_seed` behavior is out of scope); a new note opens empty.
+
+### B.4 Out of scope (v1.1)
+
+Full-text / ripgrep search mode (`md`'s ⌃F) is explicitly deferred. The picker remains title/path fuzzy only.
+
+## D. YAML frontmatter formatting
+
+v1 styles the entire leading YAML frontmatter block as flat muted text. v1.1 syntax-highlights it while preserving the markup-visible invariant (every character stays; concatenated spans equal the raw line). Highlighting reuses existing theme colors — **no new theme fields**:
+
+- The `---` open/close delimiters → `Muted`.
+- A `key:` line → the key (up to and including the colon) in `Accent`, a single space, then the value in `Text`. (Concrete use for the previously-reserved `Accent` field.)
+- A block/list value line (` - item`) → leading whitespace preserved, the `-` marker in `ListMarker`, the item text in `Text`.
+- A `#` comment line inside frontmatter → `Muted`.
+- A bare line with no `:` (e.g. a multi-line value continuation) → `Text`.
+
+Mechanics: this is handled inside the existing frontmatter branch of `scanLine` (the block-state path that already knows "we are between the leading `---` fences"). It splits each frontmatter line into spans at the first `": "` (or trailing `:`) boundary; the split is by rune index so every character is retained and the span texts concatenate back to the raw line exactly. Frontmatter is detected exactly as in v1 (`---` on row 0 until the next `---`); only its internal coloring changes. A test asserts, over a frontmatter corpus (keys, list values, comments, the delimiters), that `concat(spans).Text == rawLine` for every line and that keys/values/markers get the expected colors.
+
+This is independent of the editor body scanner (`scanInline`), which is unchanged.
+
+## C. Structure & testing
+
+New / changed files:
+
+```
+internal/theme/
+ theme.go Theme struct, registry, ByName, Resolve, cycle order
+ themes.go FlexokiLight, FlexokiDark, Charm constructors (concrete hexes)
+ detect.go Detect() — macOS AppleInterfaceStyle, cross-platform fallback
+ theme_test.go all-fields-set per theme; ByName; Resolve(auto/name/unknown); cycle order
+ detect_test.go parse logic (inject the raw `defaults` output / a stubbed reader)
+internal/editor/
+ scanner.go, editor.go take theme.Theme instead of editor.Theme (mechanical move; delete editor/theme.go)
+ scanner.go frontmatter branch now syntax-highlights keys/values/markers/comments (section D)
+ scanner_test.go frontmatter corpus: char-for-char preservation + per-element colors
+internal/picker/
+ picker.go mtime walk, today-float, fuzzy+mtime sort, preview pane, Ctrl+N, theme colors
+ picker_test.go walkMarkdown returns+sorts by mtime; today-float ordering; fuzzy+mtime tiebreak; new-note path building (pure)
+internal/preview/
+ preview.go unchanged API; app passes the theme-derived glamour style
+internal/app/
+ app.go hold active theme; Ctrl+T cycle; pass theme to editor/picker/status; Ctrl+N routing in picker; theme→glamour sync
+ app_test.go Ctrl+T cycles theme + re-renders; Ctrl+N creates+opens; picker still opens/dismisses
+internal/config/
+ config.go Theme field (toml `theme`, default "auto"); GlamourStyle default → "" (theme drives)
+ config_test.go theme default + overlay
+main.go unchanged wiring (Start path already covers picker/daily/path)
+```
+
+- **Detection is tested without depending on the host OS**: `Detect()` delegates to an internal helper that parses a provided string (the raw `defaults` output) so the parse logic is unit-tested deterministically; the OS call is a thin wrapper around it.
+- **Theme correctness test**: every registered theme has all color fields non-empty (the explicit-foreground invariant, enforced for light too).
+- TDD throughout, executed with the same subagent-driven-development flow as v1 (fresh implementer + two-stage review per task, whole-branch review at the end).
+- `go vet ./...`, `go build ./...`, `go test ./...` clean; manual smoke in both a light and a dark terminal, plus `Ctrl+T` cycling and the picker preview.
+
+## Keybinds (additions)
+
+| Key | Action |
+|---|---|
+| `Ctrl+T` | cycle theme (flexoki-light → flexoki-dark → charm → …) |
+| `Ctrl+N` | (in picker) new note from query |
+
+All v1 keybinds unchanged: `Ctrl+S` save, `Ctrl+P` picker, `Ctrl+D` daily, `Ctrl+R` preview, `Esc` back, `Ctrl+Q` quit (confirm if dirty), and the v1.0 confirm-then-discard on `Ctrl+P`/`Ctrl+D` while dirty.