From f8b9f51d94b6a75c3d3b52cab40cf70024aa5443 Mon Sep 17 00:00:00 2001 From: kb Date: Mon, 27 Apr 2026 12:14:16 +0000 Subject: [PATCH] tasks: add P9 component specs (tui x4, desktop) --- tasks/p9/p9-1-tui-library.md | 124 +++++++++++++++++++++++++++++ tasks/p9/p9-2-tui-search.md | 117 +++++++++++++++++++++++++++ tasks/p9/p9-3-tui-ask.md | 114 +++++++++++++++++++++++++++ tasks/p9/p9-4-tui-inspect.md | 118 +++++++++++++++++++++++++++ tasks/p9/p9-5-desktop-tauri.md | 140 +++++++++++++++++++++++++++++++++ 5 files changed, 613 insertions(+) create mode 100644 tasks/p9/p9-1-tui-library.md create mode 100644 tasks/p9/p9-2-tui-search.md create mode 100644 tasks/p9/p9-3-tui-ask.md create mode 100644 tasks/p9/p9-4-tui-inspect.md create mode 100644 tasks/p9/p9-5-desktop-tauri.md diff --git a/tasks/p9/p9-1-tui-library.md b/tasks/p9/p9-1-tui-library.md new file mode 100644 index 0000000..6fd2a90 --- /dev/null +++ b/tasks/p9/p9-1-tui-library.md @@ -0,0 +1,124 @@ +--- +phase: P9 +component: kb-tui (library view) +task_id: p9-1 +title: "Ratatui library list view + tag filter" +status: planned +depends_on: [p1-6] +unblocks: [p9-2, p9-3, p9-4] +contract_source: ../../docs/superpowers/specs/2026-04-27-kb-final-form-design.md +contract_sections: [§16.2 TUI epic (tasks/phase-9-ui.md), §3.7 SearchHit, §1 UX scenes for shared key bindings] +--- + +# p9-1 — TUI library view + +## Goal + +Stand up a Ratatui app skeleton with a "Library" pane: list documents, filter by tag/lang, navigate. Establishes the global app loop, key dispatch, and `kb-app` integration point that the search/ask/inspect panes (p9-2..p9-4) extend. + +## Why now / why this size + +Library is the cheapest screen and the natural anchor for the TUI shell. Subsequent panes plug into the same dispatch / shared state. + +## Allowed dependencies + +- `kb-core` +- `kb-config` +- `kb-app` (facade — the only crate this binary touches besides `kb-core`/`kb-config`) +- `ratatui = "0.28"` +- `crossterm` +- `tracing` +- `thiserror` + +## Forbidden dependencies + +- `kb-source-fs`, `kb-parse-*`, `kb-normalize`, `kb-chunk`, `kb-store-*`, `kb-embed*`, `kb-search`, `kb-llm*`, `kb-rag` (UI must go through `kb-app` only — this is the design §8 boundary) + +## Inputs + +| input | type | source | +|-------|------|--------| +| `kb-app::list_docs(filter)` | facade call | runtime | +| keyboard events | `crossterm` | terminal | +| `kb-config::Config` | runtime | env / file | + +## Outputs + +| output | type | downstream | +|--------|------|------------| +| Ratatui frame | terminal render | user | +| App state (selected doc, filter, focus) | in-memory | next-pane handoff | + +## Public surface (signatures only — no new types) + +```rust +pub struct App { /* state: docs, filter, selection, focus pane */ } + +impl App { + pub fn new(config: kb_config::Config) -> anyhow::Result; + pub fn run(&mut self) -> anyhow::Result<()>; // blocking loop until quit +} + +pub enum Pane { Library, Search, Ask, Inspect, Jobs } + +pub fn render_library(f: &mut ratatui::Frame, area: ratatui::layout::Rect, state: &App); + +pub fn handle_key_library(state: &mut App, key: crossterm::event::KeyEvent) -> KeyOutcome; + +pub enum KeyOutcome { Continue, Quit, SwitchPane(Pane), Refresh } +``` + +## Behavior contract + +- Layout: header (1 line, breadcrumb / pane label) + body (full) + footer (key hints). +- Library body: scrollable list of `DocSummary` with columns `[title] [tag list] [updated_at] [chunk_count]`. +- Filter bar (toggled by `f`): edit `tags_any` and `lang` fields; pressing `Enter` re-runs `list_docs`. +- Key bindings (Library pane only): + - `j` / `k` or arrow keys → move selection down/up + - `g g` → top, `G` → bottom + - `f` → toggle filter + - `/` → switch to Search pane (p9-2) + - `?` → switch to Ask pane (p9-3) + - `Enter` → switch to Inspect pane (p9-4) on selected doc + - `q` or `Esc` → quit +- All facade calls run on the main thread (no async). For long calls, render a "loading…" state and call from a worker thread; bridge via `mpsc::channel` (this task may keep things synchronous and accept brief UI hangs for v1). +- Logging: `tracing` initialized to a file under `~/.local/state/kb/logs/`; never to stdout/stderr (so the TUI is not corrupted). +- Error rendering: a popup overlay shows `error: {msg}\nhint: {hint}` from `anyhow::Error` chain; press any key to dismiss. + +## Storage / wire effects + +- Reads: `kb-app::list_docs` only. +- Writes: none. + +## Test plan + +| kind | description | fixture / data | +|------|-------------|----------------| +| unit | `handle_key_library` arrow-down increments selection within bounds | inline state | +| unit | filter `f` opens edit overlay; `Enter` triggers refresh | inline | +| snapshot | rendered library with 3 docs + filter open produces stable frame buffer (use `ratatui::backend::TestBackend`) | inline | +| unit | error popup renders without panic on injected `anyhow::Error` | inline | +| integration | mocked `kb-app::list_docs` returning N docs renders all rows | inline | + +All tests under `cargo test -p kb-tui library`. + +## Definition of Done + +- [ ] `cargo check -p kb-tui` passes +- [ ] `cargo test -p kb-tui library` passes +- [ ] No imports outside `kb-core`, `kb-config`, `kb-app` +- [ ] `kb tui` (or `kb` if TUI is the default) launches and shows Library on a real terminal (manual smoke) +- [ ] PR links design §8 module boundary, §16.2 epic + +## Out of scope + +- Search pane (p9-2), Ask pane (p9-3), Inspect pane (p9-4), Jobs pane. +- Mouse support (P+). +- Theme / color customization (P+). +- Cross-platform installation packaging (separate concern). + +## Risks / notes + +- Ratatui re-renders on every event; large doc lists can be slow. Use `ListState` and only render visible rows. +- crossterm raw-mode cleanup must run on panic (`color_eyre` or manual `disable_raw_mode` in `Drop`); a corrupted terminal after a crash is a UX disaster. +- Korean text rendering width: use `unicode-width` and account for wide characters when computing column widths. diff --git a/tasks/p9/p9-2-tui-search.md b/tasks/p9/p9-2-tui-search.md new file mode 100644 index 0000000..c464150 --- /dev/null +++ b/tasks/p9/p9-2-tui-search.md @@ -0,0 +1,117 @@ +--- +phase: P9 +component: kb-tui (search pane) +task_id: p9-2 +title: "TUI Search pane: input + result list + preview + editor jump" +status: planned +depends_on: [p2-2, p3-4, p9-1] +unblocks: [] +contract_source: ../../docs/superpowers/specs/2026-04-27-kb-final-form-design.md +contract_sections: [§1.5/1.6 search output, §3.7 SearchHit, §0 Q3 citation] +--- + +# p9-2 — TUI Search pane + +## Goal + +Add a Search pane to the TUI that drives `kb-app::search`, renders dense results (rank+score / path#frag / heading / snippet), and supports `g` (editor jump to citation) for the selected hit. + +## Why now / why this size + +Search is the most-used surface. Confining it to one pane leverages the App skeleton from p9-1 without rebuilding key dispatch. + +## Allowed dependencies + +- `kb-core` +- `kb-config` +- `kb-app` +- `kb-tui` (extends p9-1) +- `ratatui`, `crossterm` +- `tracing` +- `thiserror` + +## Forbidden dependencies + +- `kb-source-fs`, `kb-parse-*`, `kb-normalize`, `kb-chunk`, `kb-store-*`, `kb-embed*`, `kb-search`, `kb-llm*`, `kb-rag`, `kb-desktop` + +## Inputs + +| input | type | source | +|-------|------|--------| +| `kb-app::search(query)` | facade | runtime | +| keyboard events | `crossterm` | terminal | +| selected hit's citation | `kb_core::Citation` | App state | + +## Outputs + +| output | type | downstream | +|--------|------|------------| +| Ratatui frame for Search pane | render | user | +| External editor process spawn | `std::process::Command` | OS | + +## Public surface (signatures only — no new types) + +```rust +pub fn render_search(f: &mut ratatui::Frame, area: ratatui::layout::Rect, state: &App); +pub fn handle_key_search(state: &mut App, key: crossterm::event::KeyEvent) -> KeyOutcome; +pub fn jump_to_citation(citation: &kb_core::Citation, editor_env: &str /* $EDITOR */) -> anyhow::Result<()>; +``` + +`App` (from p9-1) is extended with: `search_input: String`, `search_mode: SearchMode`, `hits: Vec`, `selected_hit: usize`. + +## Behavior contract + +- Layout: top input bar (search query + mode badge `[hybrid|lexical|vector]`), middle result list (one hit per 4 lines per design §1.5 dense format), bottom preview pane (full chunk text fetched lazily via `kb-app::inspect_chunk`). +- Key bindings (Search pane): + - typing → updates `search_input`; debounced (200 ms) re-search + - `Tab` → cycles `search_mode` Lexical → Vector → Hybrid → Lexical + - `Enter` → forces re-search immediately + - `j` / `k` or arrow keys → move selected hit + - `g` → call `jump_to_citation(&hits[selected].citation, &env::var("EDITOR").unwrap_or_else(|_| "vi".into()))` + - `Esc` → switch back to Library pane +- `jump_to_citation`: + - For `Citation::Line { path, start, .. }`: spawn `editor + /`. Common editors `vim`/`nvim`/`vi`/`emacs`/`hx` accept `+N`. Fallback: `code -g :` if `$EDITOR` contains "code". + - For other citation kinds: open the file in `$EDITOR` without line jump (best effort). + - Use `std::process::Command::status()` blocking; suspend the TUI (`disable_raw_mode`) before launch and restore on return. +- The search call runs synchronously; for hybrid mode that may take seconds, render a centered "searching…" overlay until complete. +- All search results rendered must conform to design §1.5 dense format (4 lines: `. ` / `` / `` / ``). +- Errors → popup overlay (consistent with p9-1). +- Stable terminal restoration on panic and process exit. + +## Storage / wire effects + +- Reads only. No DB writes. +- Spawns external editor process; that process can mutate user files. The TUI does not interfere. + +## Test plan + +| kind | description | fixture / data | +|------|-------------|----------------| +| unit | typing into search_input triggers re-search after debounce | inline timer mock | +| unit | `Tab` cycles mode through 3 values back to Lexical | inline | +| unit | `j` / `k` move selection within bounds | inline | +| unit | `jump_to_citation` for `Line` builds `+ ` command (assert via mocked Command runner) | inline | +| snapshot | rendered Search pane with 3 hits + preview stable | TestBackend | +| integration | mocked `kb-app::search` returning fixture hits drives render | inline | + +All tests under `cargo test -p kb-tui search`. + +## Definition of Done + +- [ ] `cargo check -p kb-tui` passes +- [ ] `cargo test -p kb-tui search` passes +- [ ] `g` keybinding launches `$EDITOR` with correct `+` argument (manual smoke against vim) +- [ ] No imports outside Allowed dependencies +- [ ] PR links design §1.5/1.6, §3.7 + +## Out of scope + +- Inline citation render of LLM answers (Ask pane = p9-3). +- Full `--explain` retrieval trace (mention but defer to a future toggle). +- Mouse selection. + +## Risks / notes + +- Suspending and restoring crossterm raw mode around the editor spawn is finicky; code defensively (RAII guard). +- Different editors take different jump syntaxes. Provide an env override `KB_EDITOR_JUMP_FORMAT="vim"` for users on exotic editors. +- Long snippet text wrap: clamp to viewport width and ellipsize per design §1.5 (`…` already in dense template). diff --git a/tasks/p9/p9-3-tui-ask.md b/tasks/p9/p9-3-tui-ask.md new file mode 100644 index 0000000..327b1ec --- /dev/null +++ b/tasks/p9/p9-3-tui-ask.md @@ -0,0 +1,114 @@ +--- +phase: P9 +component: kb-tui (ask pane) +task_id: p9-3 +title: "TUI Ask pane: streaming answer + citation links + --explain toggle" +status: planned +depends_on: [p4-3, p9-1] +unblocks: [] +contract_source: ../../docs/superpowers/specs/2026-04-27-kb-final-form-design.md +contract_sections: [§1.1–1.4 ask scenes, §2.3 Answer wire, §3.8 Answer] +--- + +# p9-3 — TUI Ask pane + +## Goal + +Add an Ask pane that calls `kb-app::ask`, streams tokens into the answer area in real time, renders citation footnotes (default mode A), and toggles to `--explain` (mode B + retrieval trace) with a key. + +## Why now / why this size + +Streaming UI is the only TUI piece that meaningfully differs from search/inspect. Confining it here keeps the change set focused. + +## Allowed dependencies + +- `kb-core` +- `kb-config` +- `kb-app` +- `kb-tui` (extends p9-1) +- `ratatui`, `crossterm` +- `tracing` +- `thiserror` + +## Forbidden dependencies + +- `kb-source-fs`, `kb-parse-*`, `kb-normalize`, `kb-chunk`, `kb-store-*`, `kb-embed*`, `kb-search`, `kb-llm*`, `kb-rag` (only via `kb-app`), `kb-desktop` + +## Inputs + +| input | type | source | +|-------|------|--------| +| `kb-app::ask(query, AskOpts)` | facade | runtime | +| keyboard events | `crossterm` | terminal | + +## Outputs + +| output | type | downstream | +|--------|------|------------| +| Ratatui Ask pane render | terminal | user | +| `kb-app::ask` invocation with streaming closure | facade | RAG pipeline | + +## Public surface (signatures only — no new types) + +```rust +pub fn render_ask(f: &mut ratatui::Frame, area: ratatui::layout::Rect, state: &App); +pub fn handle_key_ask(state: &mut App, key: crossterm::event::KeyEvent) -> KeyOutcome; +``` + +`App` extended with: `ask_input: String`, `ask_explain: bool`, `ask_streaming: bool`, `ask_partial: String`, `ask_answer: Option`, `ask_thread: Option>>`, `ask_rx: Option>`. + +## Behavior contract + +- Layout: top input bar (`?` prompt, query text), middle answer area (rendered Markdown-light: paragraphs + inline `[N]` markers), bottom-right citations panel (numbered list of citations with `path#fragment` and section label), bottom-left status (`grounded ✓/✗ model prompt_v k chunks`). +- Submission: `Enter` triggers a worker thread that calls `kb-app::ask`. The thread receives a `mpsc::Sender` it forwards each token through (closure plugged into `AskOpts.print_stream`). The TUI reads from the receiver and appends to `ask_partial`. +- Streaming: while `ask_streaming = true`, the Answer area shows `ask_partial` and a small "▍" cursor. When the worker finishes, `ask_answer` is populated and the citations panel switches to the final list. +- Refusal rendering: + - `grounded = false` and `refusal_reason = ScoreGate` → render the answer (which is the human-friendly "근거 부족…" message), citations show "가까운 후보". + - `grounded = false` and `refusal_reason = LlmSelfJudge` → same layout but status shows `grounded ✗ … 3 chunks searched, 0 grounded`. +- Key bindings (Ask pane): + - typing → updates `ask_input` + - `Enter` → submit (only when not currently streaming) + - `e` → toggle `ask_explain`; resubmit on next `Enter`. While explain ON, citations panel is replaced by the per-claim breakdown (mode B in design §1.2) and a footer shows the retrieval trace summary. + - `Esc` → switch back to Library pane (cancellation of an in-flight ask is best-effort: the worker thread continues but its final answer is dropped). + - `j` / `k` → scroll the answer area when oversized. +- All facade calls stay within `kb-app::ask` — never reach into `kb-rag` directly. +- Errors render as a popup overlay; do not crash the pane. + +## Storage / wire effects + +- Reads/writes via `kb-app::ask` which itself writes the `answers` row in `kb.sqlite`. The pane has no direct DB access. + +## Test plan + +| kind | description | fixture / data | +|------|-------------|----------------| +| unit | submission spawns worker exactly once per `Enter` | inline mock | +| unit | streaming receiver accumulates tokens into `ask_partial` | inline mock with 5 tokens | +| unit | toggle `e` flips `ask_explain` and re-submits on `Enter` | inline | +| unit | refusal answer renders without citations panel index errors | inline | +| snapshot | rendered Ask pane mid-stream is stable | TestBackend | +| snapshot | rendered Ask pane after finished grounded answer is stable | TestBackend | +| integration | mocked `kb-app::ask` returning a canned `Answer` populates final state correctly | inline | + +All tests under `cargo test -p kb-tui ask`. + +## Definition of Done + +- [ ] `cargo check -p kb-tui` passes +- [ ] `cargo test -p kb-tui ask` passes +- [ ] No imports outside Allowed dependencies +- [ ] Manual smoke: stream tokens visible character-by-character against a real Ollama (or `MockLanguageModel`) +- [ ] PR links design §1.1–1.4, §2.3 + +## Out of scope + +- Persistent multi-turn chat memory. +- Conversational follow-ups. +- Voice input. +- Token-by-token highlighting per claim (the per-claim mode renders after completion). + +## Risks / notes + +- `mpsc::Receiver::try_recv` polled in the render loop; missing polls = stuttery streaming. Throttle the render at 30 fps and drain the channel each frame. +- Worker thread join on quit must not block forever; use `join_timeout` or detach if quit signaled. +- Cancellation: real cancellation of the LLM stream is provider-specific and out of scope. We accept "fire and forget" with discarded result on `Esc`. diff --git a/tasks/p9/p9-4-tui-inspect.md b/tasks/p9/p9-4-tui-inspect.md new file mode 100644 index 0000000..0aaeda5 --- /dev/null +++ b/tasks/p9/p9-4-tui-inspect.md @@ -0,0 +1,118 @@ +--- +phase: P9 +component: kb-tui (inspect pane) +task_id: p9-4 +title: "TUI Inspect pane: document & chunk detail render" +status: planned +depends_on: [p1-6, p9-1] +unblocks: [] +contract_source: ../../docs/superpowers/specs/2026-04-27-kb-final-form-design.md +contract_sections: [§1 inspect output, §3.5 Chunk, §2.5 DocSummary, §2.6 ChunkInspection] +--- + +# p9-4 — TUI Inspect pane + +## Goal + +Render document and chunk inspection views (matching the wire schemas `doc_summary.v1` and `chunk_inspection.v1`) with collapsible sections for `metadata`, `provenance`, `blocks` (doc) and `embeddings` (chunk). + +## Why now / why this size + +Inspect is read-only and has no external interactions; smallest possible pane. Useful for debugging chunker output and citation provenance during P5+ tuning. + +## Allowed dependencies + +- `kb-core` +- `kb-config` +- `kb-app` +- `kb-tui` (extends p9-1) +- `ratatui`, `crossterm` +- `tracing` +- `thiserror` + +## Forbidden dependencies + +- `kb-source-fs`, `kb-parse-*`, `kb-normalize`, `kb-chunk`, `kb-store-*`, `kb-embed*`, `kb-search`, `kb-llm*`, `kb-rag` (only via `kb-app`), `kb-desktop` + +## Inputs + +| input | type | source | +|-------|------|--------| +| `kb-app::inspect_doc(id)` | facade | runtime | +| `kb-app::inspect_chunk(id)` | facade | runtime | +| keyboard events | `crossterm` | terminal | + +## Outputs + +| output | type | downstream | +|--------|------|------------| +| Ratatui Inspect pane render | terminal | user | + +## Public surface (signatures only — no new types) + +```rust +pub enum InspectTarget { Doc(kb_core::DocumentId), Chunk(kb_core::ChunkId) } + +pub fn render_inspect(f: &mut ratatui::Frame, area: ratatui::layout::Rect, state: &App); +pub fn handle_key_inspect(state: &mut App, key: crossterm::event::KeyEvent) -> KeyOutcome; +``` + +`App` extended with: `inspect_target: Option`, `inspect_doc: Option`, `inspect_chunk: Option`, `inspect_collapsed: HashSet<&'static str>` (sections collapsed), `inspect_scroll: u16`. + +## Behavior contract + +- Switching to Inspect from Library passes `Doc(selected.doc_id)`. From Search pressing `i` (new key on Search pane) passes `Chunk(selected_hit.chunk_id)`. +- Doc view layout (top to bottom): + 1. Header (title, doc_path, doc_id, lang, source_type, trust_level) + 2. Metadata (aliases / tags / timestamps / `metadata.user` JSON pretty-printed) + 3. Provenance (events list) + 4. Blocks (count + first-N preview; on `b` toggle to full list paginated) +- Chunk view layout: + 1. Header (chunk_id, doc_id, doc_path, heading_path, chunker_version) + 2. Source spans (rendered as W3C fragment URIs per design §0 Q3) + 3. Text (chunk full text in a scrollable area) + 4. Embeddings (model_id, dims, embedding_id list — empty if none yet) +- Key bindings: + - `j` / `k` → scroll + - `c` → collapse / expand currently focused section (focus is implicit by current scroll position; v1 may simplify by toggling all sections) + - `Esc` → return to previous pane (Library or Search) + - `Enter` → no-op (Inspect is terminal — no editor jump here; users use Search pane for jump) +- Loading: while `kb-app::inspect_doc` or `inspect_chunk` runs, show "loading…". On error, popup with hint. +- Renders must conform to wire schemas `doc_summary.v1` (subset for header) and `chunk_inspection.v1`. + +## Storage / wire effects + +- Reads only. + +## Test plan + +| kind | description | fixture / data | +|------|-------------|----------------| +| unit | switching to InspectTarget::Doc triggers `kb-app::inspect_doc` once | inline mock | +| unit | scroll bounded by content height | inline | +| unit | collapse toggle via `c` flips state | inline | +| snapshot | doc-view rendered for fixture stable | TestBackend + fixture | +| snapshot | chunk-view rendered for fixture stable | TestBackend + fixture | + +All tests under `cargo test -p kb-tui inspect`. + +## Definition of Done + +- [ ] `cargo check -p kb-tui` passes +- [ ] `cargo test -p kb-tui inspect` passes +- [ ] No imports outside Allowed dependencies +- [ ] Manual smoke: inspect a doc with multiple chunks, scroll, return to library +- [ ] PR links design §3.5, §2.5, §2.6 + +## Out of scope + +- Editing documents. +- Re-ingestion buttons. +- Embedding inspection beyond listing model identity. +- Side-by-side diff with previous doc version. + +## Risks / notes + +- Long chunk text (~10 KB) rendering can be slow if re-rendered every frame; cache wrapped lines and re-wrap only on resize. +- Pretty-printing `metadata.user` as JSON: prefer `serde_json::to_string_pretty`. Indentation = 2 spaces. +- Korean text in metadata: ensure `unicode-width`-aware wrapping. diff --git a/tasks/p9/p9-5-desktop-tauri.md b/tasks/p9/p9-5-desktop-tauri.md new file mode 100644 index 0000000..51de7e7 --- /dev/null +++ b/tasks/p9/p9-5-desktop-tauri.md @@ -0,0 +1,140 @@ +--- +phase: P9 +component: kb-desktop (Tauri) +task_id: p9-5 +title: "Tauri desktop app: backend commands wrapping kb-app + multimodal source viewer" +status: planned +depends_on: [p9-1, p9-2, p9-3, p9-4] +unblocks: [] +contract_source: ../../docs/superpowers/specs/2026-04-27-kb-final-form-design.md +contract_sections: [§16.3 desktop epic (tasks/phase-9-ui.md), §1 ask/search scenes, §2 wire schemas v1, §8 module boundaries] +--- + +# p9-5 — Tauri desktop app + +## Goal + +Stand up a Tauri 2.x app (`kb-desktop` crate as backend, `kb-desktop-frontend/` as web assets) whose Tauri commands wrap `kb-app` 1:1. The frontend renders multimodal source viewers (Markdown render, PDF page viewer, image viewer with region overlay, audio player with seek). Citation clicks route to the appropriate viewer. + +## Why now / why this size + +Last task. Combines all backend phases into a single user-facing surface. Strict policy: backend commands are thin wrappers over `kb-app`; no new business logic. + +## Allowed dependencies + +- backend (`kb-desktop`): + - `kb-core` + - `kb-config` + - `kb-app` + - `tauri = "2"` + `tauri-build` + - `serde`, `serde_json` + - `tracing` + - `thiserror` +- frontend (`kb-desktop-frontend/`): vanilla TypeScript + Vite (default; user may swap to Svelte/Solid in a follow-up). + - PDF rendering: `pdfjs-dist` + - Markdown rendering: `marked` + `dompurify` + - Audio: HTML `