docs(02): create phase plan

This commit is contained in:
2026-02-28 22:02:14 +01:00
parent 24f11f39be
commit ab71c42c6b
4 changed files with 758 additions and 2 deletions
+6 -2
View File
@@ -45,7 +45,11 @@ Plans:
3. User lands on index.md when the app starts; when index.md is missing, a readable error page is shown instead of a crash 3. User lands on index.md when the app starts; when index.md is missing, a readable error page is shown instead of a crash
4. User can scroll content longer than the terminal with j/k, arrow keys, and PgUp/PgDn 4. User can scroll content longer than the terminal with j/k, arrow keys, and PgUp/PgDn
5. User sees keyboard hints in the status bar, box-drawing borders on panels, and a CGA-era retro color theme; the layout reflows correctly when the terminal is resized 5. User sees keyboard hints in the status bar, box-drawing borders on panels, and a CGA-era retro color theme; the layout reflows correctly when the terminal is resized
**Plans**: TBD **Plans:** 3 plans
Plans:
- [ ] 02-01-PLAN.md — Dependencies, vault file loading, and syntax highlighter
- [ ] 02-02-PLAN.md — Markdown-to-styled-lines renderer (all constructs)
- [ ] 02-03-PLAN.md — App integration: scrolling, status bar, error screen, startup wiring
### Phase 3: Navigation and Links ### Phase 3: Navigation and Links
**Goal**: Users can browse the vault by following links and navigating back and forward through their history **Goal**: Users can browse the vault by following links and navigating back and forward through their history
@@ -78,6 +82,6 @@ Phases execute in numeric order: 1 → 2 → 3 → 4
| Phase | Plans Complete | Status | Completed | | Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------| |-------|----------------|--------|-----------|
| 1. Safety Foundation | 3/3 | Complete | 2026-02-28 | | 1. Safety Foundation | 3/3 | Complete | 2026-02-28 |
| 2. Vault Core and Rendering | 0/TBD | Not started | - | | 2. Vault Core and Rendering | 0/3 | Not started | - |
| 3. Navigation and Links | 0/TBD | Not started | - | | 3. Navigation and Links | 0/TBD | Not started | - |
| 4. BBS Polish and Live Content | 0/TBD | Not started | - | | 4. BBS Polish and Live Content | 0/TBD | Not started | - |
@@ -0,0 +1,156 @@
---
phase: 02-vault-core-and-rendering
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- Cargo.toml
- src/vault.rs
- src/highlighter.rs
autonomous: true
requirements:
- REND-03
- REND-10
- NAV-06
- NAV-07
must_haves:
truths:
- "pulldown-cmark, syntect, and syntect-tui compile without errors alongside existing ratatui 0.30"
- "vault.rs can load a markdown file from a vault path and return structured success/missing/error states"
- "highlighter.rs initializes syntect once and can highlight a code string into Vec<Line<'static>> with CGA 16-color palette"
artifacts:
- path: "Cargo.toml"
provides: "pulldown-cmark, syntect, syntect-tui dependencies"
contains: "pulldown-cmark"
- path: "src/vault.rs"
provides: "VaultDocument enum and load_document function"
contains: "VaultDocument"
- path: "src/highlighter.rs"
provides: "syntect initialization, CGA color mapping, highlight_code function"
contains: "syntect_color_to_cga"
key_links:
- from: "src/highlighter.rs"
to: "syntect"
via: "OnceLock<SyntaxSet> and OnceLock<ThemeSet> initialized once"
pattern: "OnceLock.*SyntaxSet"
- from: "src/vault.rs"
to: "std::fs"
via: "read_to_string for markdown file loading"
pattern: "read_to_string"
---
<objective>
Add Phase 2 dependencies (pulldown-cmark, syntect, syntect-tui) to Cargo.toml, create the vault file-loading module, and create the syntax highlighter module with CGA 16-color mapping.
Purpose: Establish the foundation libraries and I/O layer that the markdown renderer (Plan 02) and app integration (Plan 03) build upon.
Output: Three files modified/created; `cargo build` succeeds with all new dependencies.
</objective>
<execution_context>
@/Users/ruohki/.claude/get-shit-done/workflows/execute-plan.md
@/Users/ruohki/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02-vault-core-and-rendering/02-RESEARCH.md
@.planning/phases/02-vault-core-and-rendering/02-CONTEXT.md
@src/config.rs
@Cargo.toml
</context>
<tasks>
<task type="auto">
<name>Task 1: Add dependencies and create vault file-loading module</name>
<files>Cargo.toml, src/vault.rs</files>
<action>
**Cargo.toml:** Add three new dependencies alongside the existing ones:
```toml
pulldown-cmark = "0.13.1"
syntect = { version = "5.3", default-features = false, features = ["default-fancy"] }
syntect-tui = "3.0"
```
Note: `default-features = false, features = ["default-fancy"]` avoids the Oniguruma C library — pure Rust regex engine for clean SSH server builds.
**src/vault.rs:** Create a new module with:
1. `VaultDocument` enum with three variants:
- `Loaded { path: PathBuf, content: String }` — file read successfully
- `Missing { path: PathBuf }` — file does not exist (io::ErrorKind::NotFound)
- `ReadError { path: PathBuf, reason: String }` — other I/O error
2. `load_document(vault_path: &Path, relative: &str) -> VaultDocument`:
- Join `vault_path` with `relative` to get the full path
- Use `std::fs::read_to_string()` with proper match on the Result
- Map `ErrorKind::NotFound` to `VaultDocument::Missing`
- Map other errors to `VaultDocument::ReadError`
- Return `VaultDocument::Loaded` on success
The function takes `vault_path` (from Config) and a relative filename (e.g., "index.md").
Do NOT register the module in main.rs yet — Plan 03 handles wiring.
</action>
<verify>`cargo check` passes with no errors (new deps resolve, vault.rs compiles). Run `cargo check 2>&1` and confirm exit code 0.</verify>
<done>Cargo.toml has pulldown-cmark 0.13.1, syntect 5.3 with default-fancy, and syntect-tui 3.0. vault.rs exists with VaultDocument enum and load_document function. `cargo check` succeeds.</done>
</task>
<task type="auto">
<name>Task 2: Create syntax highlighter module with CGA color mapping</name>
<files>src/highlighter.rs</files>
<action>
Create `src/highlighter.rs` with these components:
1. **Module-level OnceLock statics** for one-time initialization:
```rust
static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();
static THEME_SET: OnceLock<ThemeSet> = OnceLock::new();
```
2. **`init_highlighter()`** — call once from main.rs before App::new():
- `SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines);`
- `THEME_SET.get_or_init(ThemeSet::load_defaults);`
3. **`syntax_set() -> &'static SyntaxSet`** and **`theme_set() -> &'static ThemeSet`** accessor functions that call `.get().expect(...)`.
4. **`syntect_color_to_cga(c: syntect::highlighting::Color) -> ratatui::style::Color`** — RGB-to-CGA Euclidean distance mapper using the 16 CGA palette entries exactly as documented in the research (Pattern 8). The CGA palette entries:
- Black(0,0,0), Red(170,0,0), Green(0,170,0), Yellow(170,170,0), Blue(0,0,170), Magenta(170,0,170), Cyan(0,170,170), Gray(170,170,170)
- DarkGray(85,85,85), LightRed(255,85,85), LightGreen(85,255,85), LightYellow(255,255,85), LightBlue(85,85,255), LightMagenta(255,85,255), LightCyan(85,255,255), White(255,255,255)
5. **`highlight_code(code: &str, lang: &str) -> Vec<Line<'static>>`** — the public API for code block highlighting:
- Look up syntax by token first, then by name, fall back to plain text
- Use `HighlightLines::new(syntax, &theme_set().themes["base16-ocean.dark"])`
- For each line in `code.lines()`: call `highlight_line()`, map each `(Style, &str)` tuple to a `Span::styled(text.to_string(), Style::default().fg(syntect_color_to_cga(style.foreground)))`, collect into `Line::from(spans)`
- Return `Vec<Line<'static>>` (all owned strings, no lifetime leakage)
Do NOT register the module in main.rs yet — Plan 03 handles wiring. But do ensure the file compiles in isolation (no unresolved imports that depend on other new modules).
</action>
<verify>`cargo check` passes. Verify highlighter.rs compiles by temporarily adding `mod highlighter;` to main.rs, running `cargo check`, then removing it. Or simply verify the file has no syntax errors by checking `cargo check` output.</verify>
<done>highlighter.rs exists with init_highlighter(), syntax_set(), theme_set(), syntect_color_to_cga(), and highlight_code() functions. All use OnceLock for one-time init. Output is Vec<Line<'static>> with CGA 16-color palette.</done>
</task>
</tasks>
<verification>
1. `cargo build` succeeds with all new dependencies
2. `src/vault.rs` contains `VaultDocument` enum with `Loaded`, `Missing`, `ReadError` variants
3. `src/vault.rs` contains `load_document()` function
4. `src/highlighter.rs` contains `init_highlighter()`, `highlight_code()`, `syntect_color_to_cga()`
5. No changes to existing Phase 1 files (app.rs, main.rs, config.rs, signals.rs, terminal.rs) other than Cargo.toml
</verification>
<success_criteria>
- `cargo build` compiles cleanly with pulldown-cmark 0.13.1, syntect 5.3, and syntect-tui 3.0
- vault.rs provides file loading with structured error states (Loaded/Missing/ReadError)
- highlighter.rs provides one-shot syntect initialization and CGA-mapped syntax highlighting
- Both new modules produce `'static` lifetime outputs (no borrowed data leaking)
</success_criteria>
<output>
After completion, create `.planning/phases/02-vault-core-and-rendering/02-01-SUMMARY.md`
</output>
@@ -0,0 +1,278 @@
---
phase: 02-vault-core-and-rendering
plan: 02
type: execute
wave: 2
depends_on:
- "02-01"
files_modified:
- src/renderer.rs
autonomous: true
requirements:
- REND-01
- REND-02
- REND-03
- REND-04
- REND-05
- REND-06
- REND-07
- REND-08
- REND-09
- REND-10
must_haves:
truths:
- "Headings H1-H6 render with distinct CGA colors and decorators (H1=LightCyan+══, H2=LightYellow+──, H3-H6 color-only)"
- "Bold text uses Modifier::BOLD, italic uses Modifier::ITALIC, inline code uses LightCyan styling"
- "Fenced code blocks render with rounded box-drawing borders (╭─╮│╰─╯) and syntax-highlighted content via highlighter.rs"
- "Ordered and unordered lists render with bullets/numbers and proper indentation per nesting depth"
- "Blockquotes render with a yellow │ left border and gray content"
- "Horizontal rules render as full-width ─── lines in DarkGray"
- "Images render as [IMAGE: alt text] placeholders in DarkGray+DIM"
- "GFM tables render with full box-drawing grid (┌┬┐├┼┤└┴┘) and bold LightCyan header row"
artifacts:
- path: "src/renderer.rs"
provides: "Complete markdown-to-styled-lines conversion pipeline"
exports: ["render_markdown"]
min_lines: 200
key_links:
- from: "src/renderer.rs"
to: "pulldown-cmark"
via: "Parser::new_ext with TextMergeStream consuming all Event variants"
pattern: "TextMergeStream"
- from: "src/renderer.rs"
to: "src/highlighter.rs"
via: "highlight_code() called for fenced code blocks"
pattern: "highlight_code"
- from: "src/renderer.rs"
to: "ratatui::text"
via: "Produces Vec<Line<'static>> with Span styling"
pattern: "Vec<Line<'static>>"
---
<objective>
Build the complete markdown-to-styled-lines renderer that converts a markdown string into `Vec<Line<'static>>` using pulldown-cmark event processing, CGA-themed styling, syntax-highlighted code blocks, and box-drawing table grids.
Purpose: This is the core content pipeline — every markdown construct the user sees flows through this module.
Output: `src/renderer.rs` with a public `render_markdown(input: &str, width: u16) -> Vec<Line<'static>>` function.
</objective>
<execution_context>
@/Users/ruohki/.claude/get-shit-done/workflows/execute-plan.md
@/Users/ruohki/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02-vault-core-and-rendering/02-RESEARCH.md
@.planning/phases/02-vault-core-and-rendering/02-CONTEXT.md
@.planning/phases/02-vault-core-and-rendering/02-01-SUMMARY.md
@src/highlighter.rs
</context>
<tasks>
<task type="auto">
<name>Task 1: Build core markdown renderer with inline and block elements</name>
<files>src/renderer.rs</files>
<action>
Create `src/renderer.rs` with the public function `render_markdown(input: &str, width: u16) -> Vec<Line<'static>>`.
The `width` parameter is needed for horizontal rules (full-width `─` repeats) and could be used for code block border sizing.
**Internal state struct (private):**
```rust
struct RenderState {
lines: Vec<Line<'static>>,
current_spans: Vec<Span<'static>>,
style_stack: Vec<Style>, // push on Start, pop on End
in_code_block: bool,
code_lang: String,
code_buf: String,
list_counters: Vec<Option<u64>>, // None=unordered, Some(n)=ordered from n
in_blockquote: bool,
blockquote_depth: u32,
in_image: bool,
image_alt: String,
// Table state
in_table: bool,
table_alignments: Vec<pulldown_cmark::Alignment>,
table_rows: Vec<Vec<String>>,
current_cell: String,
in_table_head: bool,
// Width for rules
width: u16,
}
```
**Parser setup:**
- Use `Options::empty()` then insert `ENABLE_TABLES`, `ENABLE_STRIKETHROUGH`, `ENABLE_TASKLISTS`
- Wrap parser in `TextMergeStream::new(Parser::new_ext(input, opts))`
- Iterate events, dispatch to handler methods on RenderState
**Element handling (implement ALL of these):**
**Headings (REND-01, REND-10):**
- On `Start(Tag::Heading { level, .. })`: push heading style onto style_stack based on level:
- H1: `Color::LightCyan + Modifier::BOLD`
- H2: `Color::LightYellow + Modifier::BOLD`
- H3: `Color::LightGreen + Modifier::BOLD`
- H4: `Color::Green`
- H5: `Color::Cyan`
- H6: `Color::DarkGray`
- On `End(TagEnd::Heading(level))`: flush current_spans as a Line, then emit decorator:
- H1: Line of `═` repeated to width, in `Color::LightCyan`
- H2: Line of `─` repeated to width, in `Color::LightYellow`
- H3-H6: no decorator line
- Always add an empty Line after for spacing
**Paragraphs:**
- On `Start(Tag::Paragraph)`: nothing (spans accumulate)
- On `End(TagEnd::Paragraph)`: flush current_spans to a Line, push empty Line for spacing
- Special handling: if inside blockquote, prepend blockquote decorators to each flushed line
**Inline formatting (REND-02):**
- `Start(Tag::Strong)`: push current style with `Modifier::BOLD` added onto style_stack
- `End(TagEnd::Strong)`: pop style_stack
- `Start(Tag::Emphasis)`: push current style with `Modifier::ITALIC` added
- `End(TagEnd::Emphasis)`: pop style_stack
- `Event::Code(text)`: push Span with `Style::default().fg(Color::LightCyan)` — inline code
**Text:**
- `Event::Text(text)`: if `in_code_block`, append to `code_buf`; if `in_image`, append to `image_alt`; if `in_table` and inside a cell, append to `current_cell`; otherwise push `Span::styled(text.to_string(), current_style())` to current_spans
- `current_style()` returns the top of `style_stack`, or `Style::default()` if empty
**Soft/Hard breaks:**
- `Event::SoftBreak`: push `Span::raw(" ")`
- `Event::HardBreak`: flush current_spans as a Line
**Lists (REND-04):**
- `Start(Tag::List(start))`: push `start` onto `list_counters`
- `End(TagEnd::List(_))`: pop `list_counters`, push empty Line for spacing after top-level list
- `Start(Tag::Item)`: compute indent = `" ".repeat(list_counters.len())`. Compute bullet:
- If `list_counters.last() == Some(None)`: use `"• "` (depth 1), `"◦ "` (depth 2), `"▪ "` (depth 3+)
- If `list_counters.last() == Some(Some(n))`: use `format!("{}. ", n)`, then increment n in the counter
- Push indent + bullet as a Span in `Color::Cyan` to current_spans
- `End(TagEnd::Item)`: flush current_spans as a Line
**Blockquotes (REND-05):**
- `Start(Tag::BlockQuote(_))`: increment blockquote_depth, set in_blockquote = true, push empty line before
- `End(TagEnd::BlockQuote(_))`: decrement blockquote_depth, set in_blockquote = false if depth == 0, push empty line after
- When flushing lines inside blockquote: prepend `Span::styled("│ ".repeat(depth), Style::default().fg(Color::Yellow))` to each Line, and apply `Color::Gray` to the content spans
**Horizontal rules (REND-06):**
- `Event::Rule`: push `Line::from(Span::styled("─".repeat(width as usize), Style::default().fg(Color::DarkGray)))`, then push empty Line
**Images (REND-07):**
- `Start(Tag::Image { .. })`: set `in_image = true`, clear `image_alt`
- Collect text inside image into `image_alt`
- `End(TagEnd::Image)`: push `Span::styled(format!("[IMAGE: {}]", image_alt), Style::default().fg(Color::DarkGray).add_modifier(Modifier::DIM))` to current_spans, set `in_image = false`
**Links:**
- `Start(Tag::Link { .. })`: for Phase 2, just pass through — link text renders as normal styled text. Links become interactive in Phase 3.
- `End(TagEnd::Link)`: nothing special
**All other events:** ignore (`_ => {}`)
Do NOT add table or code block rendering yet — Task 2 handles those. For now, stub them:
- `Start(Tag::CodeBlock(_))`: set `in_code_block = true`, clear `code_buf`, capture lang
- `End(TagEnd::CodeBlock)`: call a placeholder `emit_code_block()` that pushes `code_buf` lines as plain styled text
- Table events: set `in_table = true`, accumulate cells, call placeholder `emit_table()` on End
**Helper methods on RenderState:**
- `flush_line()`: take current_spans, make a Line, apply blockquote prefix if needed, push to lines, clear current_spans
- `current_style()`: return top of style_stack or Style::default()
- `finish() -> Vec<Line<'static>>`: return the accumulated lines
Ensure ALL Span content uses `.to_string()` for owned strings — every output must be `Line<'static>`.
</action>
<verify>`cargo check` passes after temporarily adding `mod renderer;` to main.rs. Test with a simple markdown string mentally: `# Hello\n\nSome **bold** text.` should produce heading line + decorator + empty + paragraph line + empty.</verify>
<done>renderer.rs exists with render_markdown() function that handles headings (H1-H6 with colors + decorators), paragraphs, bold/italic/inline code, lists (ordered/unordered/nested), blockquotes with │ prefix, horizontal rules, images as placeholders, soft/hard breaks. Code blocks and tables have working stubs that render content without fancy borders.</done>
</task>
<task type="auto">
<name>Task 2: Add code block borders with syntax highlighting and GFM table grid rendering</name>
<files>src/renderer.rs</files>
<action>
Replace the placeholder `emit_code_block()` and `emit_table()` with full implementations.
**Code blocks (REND-03, REND-09):**
Replace the code block stub with `emit_code_block(code_buf: &str, lang: &str, width: u16, lines: &mut Vec<Line<'static>>)`:
1. Get syntax-highlighted lines by calling `crate::highlighter::highlight_code(code_buf, lang)`
2. Determine the box width: `max(highlighted lines' max display width + 4, lang.len() + 6)`, capped at `width`
3. Build the top border line:
- `╭─ {lang} ` + `─` repeated to fill + `╮`
- Style: `Color::DarkGray` for border chars, `Color::Yellow` for lang label
- If lang is empty, just `╭` + `─` fill + `╮`
4. For each highlighted line: `│ ` + highlighted spans + padding to box width + ` │`
- Border chars (`│`) in `Color::DarkGray`
- Content spans keep their syntax highlighting colors from highlight_code()
5. Build the bottom border: `╰` + `─` fill + `╯` in `Color::DarkGray`
6. Push empty Line before and after the code block for spacing
If `highlight_code()` returns empty (no syntax found), fall back to plain `Color::Gray` text for the code content.
**Tables (REND-08, REND-09):**
Replace the table stub with `emit_table(alignments: &[Alignment], rows: &[Vec<String>], lines: &mut Vec<Line<'static>>)`:
1. Compute column widths: for each column index, find `max(cell.len() + 2)` across all rows (header + data), minimum 5 chars
2. Build helper `build_border_line(left: char, mid: char, right: char, fill: char, widths: &[usize]) -> String`:
- e.g., `build_border_line('┌', '┬', '┐', '─', &widths)``┌──────┬──────┐`
3. Emit top border: `build_border_line('┌', '┬', '┐', '─', ...)` styled `Color::Cyan`
4. Emit header row (rows[0]):
- For each cell: `│` + padded cell text (aligned per alignments[i]) + space
- Cell text styled `Color::LightCyan + Modifier::BOLD`
- `│` border chars styled `Color::Cyan`
- Trailing `│`
5. Emit header separator: `build_border_line('├', '┼', '┤', '─', ...)` styled `Color::Cyan`
6. Emit data rows (rows[1..]):
- Same format as header but with `Style::default()` for cell text
7. Emit bottom border: `build_border_line('└', '┴', '┘', '─', ...)` styled `Color::Cyan`
8. Push empty Line after table for spacing
**Alignment handling in table cells:**
- `Alignment::Left` or `Alignment::None`: left-pad with 1 space, right-pad to width
- `Alignment::Right`: left-pad to width, right-pad with 1 space
- `Alignment::Center`: center the text within width
Ensure the table state machine in the event handler correctly:
- Captures `alignments` from `Start(Tag::Table(alignments))`
- Pushes new `Vec<String>` on `Start(Tag::TableRow)` AND `Start(Tag::TableHead)`
- Accumulates cell text on `Event::Text` when `in_table` is true
- Pushes cell to current row on `End(TagEnd::TableCell)`
- Calls `emit_table()` on `End(TagEnd::Table)`
</action>
<verify>`cargo check` passes. Mentally verify: a markdown code block ````rust\nlet x = 1;\n```` should produce 4 lines (top border, content, bottom border) plus spacing. A 2-column 3-row table should produce 6 lines (top border, header, separator, 2 data rows, bottom border) plus spacing.</verify>
<done>Code blocks render with ╭─╮│╰─╯ rounded borders, language label in yellow, syntax-highlighted content from highlighter.rs. GFM tables render with ┌┬┐├┼┤└┴┘ full box-drawing grid, bold LightCyan header row, aligned cell content. Both emit proper spacing.</done>
</task>
</tasks>
<verification>
1. `cargo check` passes with renderer.rs compiling
2. `render_markdown("# Test\n\nHello **world**\n", 80)` would produce: heading line (LightCyan bold), ══ decorator, empty, paragraph with bold span, empty
3. All pulldown-cmark Event variants for Phase 2 requirements are handled (Heading, Paragraph, Strong, Emphasis, Code, CodeBlock, List, Item, BlockQuote, Rule, Image, Table, TableHead, TableRow, TableCell, Text, SoftBreak, HardBreak)
4. Code block output includes ╭╮│╰╯ border characters with syntax-highlighted inner content
5. Table output includes ┌┬┐├┼┤└┴┘ border characters with bold header row
6. Every Span uses owned String content (.to_string()) — no borrowed lifetimes
</verification>
<success_criteria>
- renderer.rs contains a single public function `render_markdown()` that processes all markdown constructs
- Headings H1-H6 have distinct CGA colors and H1/H2 have decorators
- Bold, italic, inline code have correct terminal modifiers/colors
- Lists support ordered/unordered with nesting
- Code blocks have syntax highlighting with CGA colors and rounded box-drawing borders
- Tables have full box-drawing grid with aligned columns
- All output is Vec<Line<'static>> with no lifetime dependencies
</success_criteria>
<output>
After completion, create `.planning/phases/02-vault-core-and-rendering/02-02-SUMMARY.md`
</output>
@@ -0,0 +1,318 @@
---
phase: 02-vault-core-and-rendering
plan: 03
type: execute
wave: 3
depends_on:
- "02-01"
- "02-02"
files_modified:
- src/app.rs
- src/main.rs
autonomous: true
requirements:
- NAV-05
- NAV-06
- NAV-07
- NAV-08
- NAV-09
must_haves:
truths:
- "App starts and displays index.md content from the configured vault_path"
- "When index.md is missing, user sees a BBS-style error screen with box-drawing border and helpful message"
- "User can scroll content with j/k (one line), arrow keys (one line), PgUp/PgDn (full page)"
- "Status bar at the bottom shows filename on the left and keyboard hints on the right in reverse video"
- "Terminal resize reflows the layout without crashing or corrupting display"
- "Quit behavior from Phase 1 (q key, double Ctrl+C) is preserved"
artifacts:
- path: "src/app.rs"
provides: "Document display, scrolling, status bar, error screen"
contains: "DocumentState"
- path: "src/main.rs"
provides: "Wiring: mod declarations, highlighter init, vault loading"
contains: "mod renderer"
key_links:
- from: "src/main.rs"
to: "src/highlighter.rs"
via: "init_highlighter() called before App::new()"
pattern: "init_highlighter"
- from: "src/app.rs"
to: "src/vault.rs"
via: "load_document() called to get markdown content"
pattern: "load_document"
- from: "src/app.rs"
to: "src/renderer.rs"
via: "render_markdown() converts content to styled lines for display"
pattern: "render_markdown"
- from: "src/app.rs"
to: "ratatui::widgets::Paragraph"
via: "Paragraph::new(lines).scroll((offset, 0)) for scrollable content"
pattern: "scroll"
---
<objective>
Wire everything together: rework app.rs to display rendered markdown content with scrolling, a status bar, and error screens; update main.rs to register all new modules, initialize the highlighter, and load index.md on startup.
Purpose: This is the integration plan that turns the separate modules (vault, renderer, highlighter) into a working content viewer.
Output: Running `cargo run` displays index.md from the vault with full markdown styling, scrolling, and a status bar.
</objective>
<execution_context>
@/Users/ruohki/.claude/get-shit-done/workflows/execute-plan.md
@/Users/ruohki/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02-vault-core-and-rendering/02-RESEARCH.md
@.planning/phases/02-vault-core-and-rendering/02-CONTEXT.md
@.planning/phases/02-vault-core-and-rendering/02-01-SUMMARY.md
@.planning/phases/02-vault-core-and-rendering/02-02-SUMMARY.md
@src/app.rs
@src/main.rs
</context>
<tasks>
<task type="auto">
<name>Task 1: Rework app.rs with document state, scrolling, status bar, and error screen</name>
<files>src/app.rs</files>
<action>
**Extend the App struct** — add new fields while preserving ALL Phase 1 fields and behavior:
```rust
pub struct App {
// Phase 1 fields (keep exactly as-is)
is_login_shell: bool,
ctrl_c_pressed_at: Option<Instant>,
show_quit_prompt: bool,
should_quit: bool,
config: Config,
// Phase 2 additions
document: DocumentState,
scroll_offset: u16,
}
```
**Add `DocumentState` enum:**
```rust
pub enum DocumentState {
Loaded {
filename: String,
lines: Vec<Line<'static>>,
},
Missing {
path: PathBuf,
},
Error {
path: PathBuf,
reason: String,
},
}
```
**Update `App::new()`** to accept the initial document state:
Change signature to `pub fn new(is_login_shell: bool, config: Config, document: DocumentState) -> Self` with `scroll_offset: 0`.
**Rework `draw()`** — replace the Phase 1 placeholder UI entirely:
1. Split frame into two areas using Layout:
```rust
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(1)])
.split(frame.area());
let content_area = chunks[0];
let status_area = chunks[1];
```
2. **Content area rendering** — match on `self.document`:
- `DocumentState::Loaded { filename, lines }`:
- Create `Paragraph::new(lines.clone()).scroll((self.scroll_offset, 0))`
- Do NOT enable Wrap — lines are pre-rendered
- Render into `content_area`
- `DocumentState::Missing { path }`:
- Call `draw_error_screen(frame, content_area, path)` (see below)
- `DocumentState::Error { path, reason }`:
- Call `draw_error_screen_with_reason(frame, content_area, path, reason)`
3. **Status bar rendering (NAV-08):**
- Determine filename text: from DocumentState::Loaded filename, or "ERROR" for missing/error states
- Determine hints: `"q:Quit j/k:Scroll PgUp/PgDn:Page"` (if not login shell), or `"Ctrl+C×2:Quit j/k:Scroll PgUp/PgDn:Page"` (if login shell)
- Build the status bar as a single Line with the filename left-aligned and hints right-aligned:
- Calculate padding to fill the full status_area.width between filename and hints
- Style: `Style::default().add_modifier(Modifier::REVERSED)` on the entire Paragraph
- Render `Paragraph::new(status_line).style(Style::default().add_modifier(Modifier::REVERSED))` into `status_area`
4. **Quit prompt overlay** — if `self.show_quit_prompt` is true, render the "Press Ctrl+C again..." message. Options:
- Render it as part of the status bar (replace hints with the warning), OR
- Overlay it on the bottom of content_area
- Recommended: replace the status bar content with the quit prompt in yellow bold reverse video
**Error screen widget (NAV-07):**
Create a private method `draw_error_screen(frame: &mut Frame, area: Rect, path: &Path)`:
Use ratatui's `Block` widget with `BorderType::Plain` (┌─┐│└─┘) borders:
```
┌─────────────────────────────────────────┐
│ *** SYSTEM ERROR *** │
│ │
│ No index.md found in vault: │
│ /path/to/vault │
│ │
│ Create index.md to begin. │
└─────────────────────────────────────────┘
```
- Block border: `Color::Red`
- "SYSTEM ERROR" text: `Color::LightRed + Modifier::BOLD`
- Path: `Color::Yellow`
- Hint: `Color::DarkGray`
- Center the block in the content area (calculate a centered Rect)
Also create `draw_error_screen_with_reason()` for ReadError states — same layout but shows the error reason instead of "No index.md found".
**Extend `handle_key()` for scrolling (NAV-05):**
Add new key bindings BEFORE the existing `_` catch-all:
```rust
KeyCode::Char('j') | KeyCode::Down => {
self.scroll_down(1);
}
KeyCode::Char('k') | KeyCode::Up => {
self.scroll_up(1);
}
KeyCode::PageDown => {
self.scroll_down(self.page_height());
}
KeyCode::PageUp => {
self.scroll_up(self.page_height());
}
```
Helper methods:
- `scroll_down(n: u16)`: `self.scroll_offset = self.scroll_offset.saturating_add(n).min(self.max_scroll())`
- `scroll_up(n: u16)`: `self.scroll_offset = self.scroll_offset.saturating_sub(n)`
- `max_scroll() -> u16`: if Loaded, `lines.len() as u16 - page_height`, else 0. Use `saturating_sub`.
- `page_height() -> u16`: store the content area height from the last draw, or default to 24. Add a `last_content_height: u16` field to App, update it in draw().
**IMPORTANT:** The scroll keys (j/k/arrows/PgUp/PgDn) must NOT trigger the quit prompt dismissal. Currently the `_ =>` branch dismisses the prompt. The scroll keys should be handled before the `_` catch-all, and only dismiss the prompt for truly unrelated keys.
**Resize handling (NAV-09):**
- In `run_event_loop()`, add `Event::Resize(_, _) => {}` handling — ratatui handles the buffer resize automatically for `Viewport::Fullscreen`; we just need to ensure the event is consumed so it doesn't fall through
- `max_scroll()` recomputes on every draw based on `last_content_height`, so resize is handled naturally
**Preserve ALL Phase 1 behavior:**
- Double Ctrl+C quit mechanism
- Login shell mode suppressing 'q' key
- `show_goodbye()` function unchanged
- `ShutdownReason` enum unchanged
- `DOUBLE_PRESS_WINDOW` unchanged
</action>
<verify>`cargo check` passes. Mentally trace: App starts with DocumentState::Loaded, draw() shows content with status bar, j/k adjust scroll_offset, q still quits, Ctrl+C double-press still works.</verify>
<done>app.rs has DocumentState enum, scroll_offset field, status bar with filename+hints in reverse video, BBS error screen for missing files, j/k/arrow/PgUp/PgDn scroll keys, resize handling. All Phase 1 quit behavior preserved.</done>
</task>
<task type="auto">
<name>Task 2: Wire main.rs with module declarations, highlighter init, and index.md loading</name>
<files>src/main.rs</files>
<action>
**Add module declarations** at the top of main.rs (add to the existing mod block):
```rust
mod app;
mod config;
mod highlighter;
mod renderer;
mod signals;
mod terminal;
mod vault;
```
**Update the startup sequence** in `fn main()`:
After step 3 (config loading) and before step 4 (panic hook), add:
```rust
// 3a. Initialize syntax highlighting (one-time, ~23ms)
highlighter::init_highlighter();
// 3b. Load initial document (index.md from vault)
let initial_doc = match vault::load_document(&app_config.vault_path, "index.md") {
vault::VaultDocument::Loaded { path, content } => {
// Get terminal width for rendering — use a reasonable default before terminal init
// We'll re-render if needed, but 80 is safe for initial parse
let width = ratatui::crossterm::terminal::size()
.map(|(w, _)| w)
.unwrap_or(80);
let filename = path.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "index.md".to_string());
let lines = renderer::render_markdown(&content, width);
app::DocumentState::Loaded { filename, lines }
}
vault::VaultDocument::Missing { path } => {
app::DocumentState::Missing { path }
}
vault::VaultDocument::ReadError { path, reason } => {
app::DocumentState::Error { path, reason }
}
};
```
**Update App::new() call** in step 7:
```rust
let mut app_state = app::App::new(is_login_shell, app_config, initial_doc);
```
**Event loop resize re-rendering consideration:**
The initial render uses terminal width at startup. On resize, the content would ideally re-render. For Phase 2, accept the initial render width — re-rendering on resize can be added later if needed. The content still displays correctly; only horizontal rules and code block borders may not be pixel-perfect after resize.
**Remove the `#[allow(dead_code)]` on config field** in app.rs if it's now used (vault_path is accessed). Actually, config is passed at construction but vault loading happens in main.rs, so config may still appear unused inside App. Keep the allow if needed, or add a method to access vault_path for future use.
</action>
<verify>
1. `cargo build` succeeds
2. Create a test vault: `mkdir -p /tmp/bbs-test-vault && echo '# Welcome\n\nHello **world**!\n\n- Item one\n- Item two\n\n> A blockquote\n\n---\n\n```rust\nlet x = 42;\n```' > /tmp/bbs-test-vault/index.md`
3. Run: `cargo run -- --config /dev/null` with a bbs.toml pointing to the test vault (or modify the default path for testing)
4. Verify: content displays with colored headings, styled text, and a status bar at the bottom
5. Verify: j/k scroll content, q exits cleanly
6. Verify: without index.md, the BBS error screen appears
</verify>
<done>main.rs declares all 7 modules (app, config, highlighter, renderer, signals, terminal, vault). Startup sequence initializes highlighter, loads index.md via vault, renders via renderer, passes DocumentState to App. Running `cargo run` displays styled markdown content with scrolling and status bar. Missing index.md shows error screen.</done>
</task>
</tasks>
<verification>
1. `cargo build` succeeds with zero warnings on new code
2. With a vault containing index.md: app displays rendered markdown content
3. Without index.md in vault: app displays BBS error screen with box-drawing border
4. j/k keys scroll one line, PgUp/PgDn scroll one page
5. Status bar shows filename on left, keyboard hints on right, in reverse video
6. q key exits (non-login-shell), double Ctrl+C exits (always)
7. Terminal resize does not crash — content area adjusts
8. All Phase 1 safety features (panic hook, signal handling, terminal restore) still work
</verification>
<success_criteria>
- Running `cargo run` displays index.md from the configured vault with full markdown styling
- Missing index.md shows BBS error screen instead of crashing
- Content scrolls smoothly with j/k/arrows/PgUp/PgDn
- Status bar visible at bottom with filename and keyboard hints
- All Phase 1 quit/safety behavior preserved
- Terminal resize handled gracefully
</success_criteria>
<output>
After completion, create `.planning/phases/02-vault-core-and-rendering/02-03-SUMMARY.md`
</output>