diff --git a/HANDOFF.md b/HANDOFF.md index ef3c55b..68bf2e2 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -49,6 +49,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능. - **2026-05-03 P9 도그푸딩 후속 (p9-fb-20)** — `kebab ask` 의 CLI citation block. 답변 출력 후 `근거:` 절 — `[N] # (score=)` 한 줄씩. `--show-citations` (default ON) / `--hide-citations` (pipe 시 답변 본문만) flag. `--json` 모드는 무영향 (citations 가 항상 wire payload 에 포함). spec p9-fb-20 의 \"TUI citation pane + jump\" 부분은 P9-3 의 기존 `render_citations_or_explain` 가 일부 cover — 추가 기능 (turn 별 fold + Enter/o jump + i inspect) 은 후속 task 로 미룸 (사용자 도그푸딩 priority 5위 의 핵심 = full path 가독성 = CLI block 으로 충족). spec: `tasks/p9/p9-fb-20-citation-surface.md`. - **2026-05-03 P9 도그푸딩 후속 (p9-fb-07)** — Markdown title fallback chain. `kebab-normalize::derive_title(frontmatter_title, &[Block], file_stem)` — 1) frontmatter title → 2) 첫 H1 → 3) 첫 H2 → 4) 첫 paragraph 80 chars → 5) 파일 stem (모든 단계 NFC 정규화, 빈 문자열 절대 반환 안 함, 마지막 sentinel `"untitled"`). `build_canonical_document` 가 lift 후 helper 호출. parser_version 상수 `pulldown-cmark-0.x` → `md-frontmatter-v2` bump — 기존 doc 은 `doc_id` 가 갱신되므로 다음 ingest 가 자동 재처리 (idempotent upsert, design §9 cascade). spec: `tasks/p9/p9-fb-07-md-title-fallback.md`. - **2026-05-03 P9 도그푸딩 후속 (p9-fb-09)** — TUI external editor return restore. Search `g` 키 (citation jump) 후 TUI 화면이 깨지는 버그 수정. `kebab-tui::editor::with_external_program(&mut TuiTerminal, Command)` helper 가 suspend (LeaveAlternateScreen + Show cursor + disable_raw_mode) → spawn → restore (enable_raw_mode + EnterAlternateScreen + Hide cursor + `terminal.clear()`) 시퀀스를 RAII guard 로 atomic 하게 묶음. `App.pending_editor: Option` + `App.force_redraw: bool` 추가 — 키 핸들러는 EditorRequest enqueue 만, 실제 spawn 은 run loop 가 `TuiTerminal` 핸들 들고 처리. 후속 task (p9-fb-20 의 citation jump 등) 가 같은 helper 위에 build. spec: `tasks/p9/p9-fb-09-tui-editor-restore.md`. +- **2026-05-03 P9 도그푸딩 후속 (p9-fb-14)** — TUI color theme module. `kebab-tui::theme::{Theme, Role, Palette}` 신규 — 16 개 Role (BorderActive/Title/Path/ModeLexical/ModeVector/ModeHybrid/Selected/Hint/Heading/Warning/Error/Success/CitationMarker/Bullet/Body/BorderInactive) 을 dark + light 두 팔레트가 exhaustive match 로 매핑. 모든 Pane (library/search/ask/inspect/run/error_popup) 의 inline `Style::default().fg(Color::*)` 호출이 `theme.style(Role::X)` 로 격리됨. `Config.ui.theme: String` (default `"dark"`) 신규. `App.theme: Theme` 가 `App::new` 에서 `Theme::from_name(&config.ui.theme)` 로 build — 알 수 없는 값은 dark fallback (config 가 typo 로 죽지 않음). `T` 키 runtime toggle 은 mode machine (p9-fb-12) 미진행이라 skip — config 만으로 결정. p9-fb-11 (ask markdown render) 의 Theme 의존성 unblock. spec: `tasks/p9/p9-fb-14-tui-color-theme.md`. ## 다음 task 후보 diff --git a/README.md b/README.md index 3e1476f..a6f3ab4 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ flowchart TB ## Configuration -- `~/.config/kebab/config.toml` — `kebab init` 가 XDG 경로에 생성. `[workspace] include`, `[storage]`, `[chunking]`, `[models.embedding]`, `[models.llm]`, `[image.ocr]`, `[image.caption]`, `[search]`, `[rag]` 절. +- `~/.config/kebab/config.toml` — `kebab init` 가 XDG 경로에 생성. `[workspace] include`, `[storage]`, `[chunking]`, `[models.embedding]`, `[models.llm]`, `[image.ocr]`, `[image.caption]`, `[search]`, `[rag]`, `[ui]` 절. `[ui] theme = "dark" | "light"` 로 TUI 팔레트 선택 (default `"dark"`, 알 수 없는 값은 dark fallback). - `--config ` flag — 임시 워크스페이스 / 격리 테스트 시 사용. CLI / TUI 모두 honor. - `KEBAB_*` env — 일부 키 override (`KEBAB_RAG_SCORE_GATE`, `KEBAB_EVAL_GOLDEN`, `KEBAB_COMMIT_HASH` 등). - XDG layout: `~/.config/kebab/`, `~/.local/share/kebab/`, `~/.cache/kebab/`, `~/.local/state/kebab/`. diff --git a/crates/kebab-config/src/lib.rs b/crates/kebab-config/src/lib.rs index e70b953..56121a6 100644 --- a/crates/kebab-config/src/lib.rs +++ b/crates/kebab-config/src/lib.rs @@ -27,6 +27,11 @@ pub struct Config { /// (they cost a model call per asset). #[serde(default = "ImageCfg::defaults")] pub image: ImageCfg, + /// p9-fb-14: TUI palette + role-style mapping. `#[serde(default)]` + /// so configs that predate this section still load (defaults to + /// `dark`). + #[serde(default = "UiCfg::defaults")] + pub ui: UiCfg, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -194,6 +199,27 @@ impl CaptionCfg { } } +/// p9-fb-14: TUI-only configuration. Currently a single `theme` +/// selector (`"dark"` / `"light"`); future fields (custom role +/// overrides, mode-machine cursor shapes, …) extend the same +/// section so the CLI doesn't grow a per-feature `[ui.*]` table. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct UiCfg { + /// Palette name. Recognized: `"dark"` (default), `"light"`. + /// Unknown values fall back to `"dark"` at construction time + /// — config never errors on a typo, the TUI just keeps the + /// default theme so the user has a working shell. + pub theme: String, +} + +impl UiCfg { + pub fn defaults() -> Self { + Self { + theme: "dark".to_string(), + } + } +} + impl Config { /// Defaults per design §6.4. pub fn defaults() -> Self { @@ -263,6 +289,7 @@ impl Config { max_context_tokens: 8000, }, image: ImageCfg::defaults(), + ui: UiCfg::defaults(), } } diff --git a/crates/kebab-tui/src/app.rs b/crates/kebab-tui/src/app.rs index 43497fe..48c7eee 100644 --- a/crates/kebab-tui/src/app.rs +++ b/crates/kebab-tui/src/app.rs @@ -240,6 +240,12 @@ pub const TERMINAL_LINE_HOLD_SECS: u64 = 3; /// add panes by populating their `Option<*State>` slot. pub struct App { pub config: Config, + /// p9-fb-14: resolved palette + role-style mapping. Built once + /// in `App::new` from `config.ui.theme` (`"dark"` / `"light"`, + /// fallback dark on unknown). Every pane reads its styles via + /// `app.theme.style(Role::X)` instead of inlining + /// `Style::default().fg(Color::*)`. + pub theme: crate::theme::Theme, pub focus: Pane, pub library: LibraryState, /// Populated by p9-2 (None until that crate links in). @@ -295,8 +301,10 @@ impl App { /// run loop calls `library.refresh` on first frame so a slow /// `kebab-app::list_docs_with_config` does not block startup. pub fn new(config: Config) -> anyhow::Result { + let theme = crate::theme::Theme::from_name(&config.ui.theme); Ok(Self { config, + theme, focus: Pane::Library, library: LibraryState::new(), search: None, diff --git a/crates/kebab-tui/src/ask.rs b/crates/kebab-tui/src/ask.rs index 60220bb..a365e30 100644 --- a/crates/kebab-tui/src/ask.rs +++ b/crates/kebab-tui/src/ask.rs @@ -17,7 +17,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use kebab_core::{RefusalReason, SearchMode}; use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Color, Modifier, Style}; +use ratatui::style::Modifier; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; use std::sync::mpsc; @@ -44,12 +44,12 @@ pub fn render_ask(f: &mut Frame, area: Rect, state: &App) { ]) .split(area); - render_input(f, layout[0], s); - render_answer(f, layout[1], s); - render_bottom(f, layout[2], s); + render_input(f, layout[0], s, &state.theme); + render_answer(f, layout[1], s, &state.theme); + render_bottom(f, layout[2], s, &state.theme); } -fn render_input(f: &mut Frame, area: Rect, s: &AskState) { +fn render_input(f: &mut Frame, area: Rect, s: &AskState, theme: &crate::theme::Theme) { let mode_badge = if s.explain { " explain" } else { "" }; // Distinguish three async states for the operator: // - currently streaming (worker still emitting tokens) @@ -64,10 +64,10 @@ fn render_input(f: &mut Frame, area: Rect, s: &AskState) { "" }; let line = Line::from(vec![ - Span::styled("? ", Style::default().fg(Color::Cyan)), + Span::styled("? ", theme.style(crate::theme::Role::Heading)), Span::raw(s.input.as_str()), - Span::styled(mode_badge, Style::default().fg(Color::Yellow)), - Span::styled(busy, Style::default().add_modifier(Modifier::DIM)), + Span::styled(mode_badge, theme.style(crate::theme::Role::Warning)), + Span::styled(busy, theme.style(crate::theme::Role::Hint)), ]); let block = Block::default() .title("ask (Enter=submit e=explain Ctrl-L=new conversation Esc=back)") @@ -75,7 +75,7 @@ fn render_input(f: &mut Frame, area: Rect, s: &AskState) { f.render_widget(Paragraph::new(line).block(block), area); } -fn render_answer(f: &mut Frame, area: Rect, s: &AskState) { +fn render_answer(f: &mut Frame, area: Rect, s: &AskState, theme: &crate::theme::Theme) { let title = if s.turns.is_empty() && !s.streaming { "transcript".to_string() } else { @@ -88,14 +88,20 @@ fn render_answer(f: &mut Frame, area: Rect, s: &AskState) { // Completed turns first (chronological), then the in-flight // turn (if any) at the bottom. The most-recent completed // turn's grounded flag (from `last_answer`) styles its A line - // — yellow on refusal so the user keeps the P9-3 visual - // distinction even inside the transcript. + // via the theme's Warning role on refusal so the user keeps + // the P9-3 visual distinction even inside the transcript. let last_turn_grounded = s.last_answer.as_ref().map(|a| a.grounded); let last_turn_idx = s.turns.len().saturating_sub(1); let mut lines: Vec = Vec::new(); for (idx, turn) in s.turns.iter().enumerate() { - let style_override = if idx == last_turn_idx { - last_turn_grounded.and_then(|g| if g { None } else { Some(Color::Yellow) }) + let role_override = if idx == last_turn_idx { + last_turn_grounded.and_then(|g| { + if g { + None + } else { + Some(crate::theme::Role::Warning) + } + }) } else { None }; @@ -105,7 +111,8 @@ fn render_answer(f: &mut Frame, area: Rect, s: &AskState) { &turn.question, &turn.answer, false, - style_override, + role_override, + theme, ); lines.push(Line::raw("")); } @@ -115,13 +122,13 @@ fn render_answer(f: &mut Frame, area: Rect, s: &AskState) { let mut a = s.partial.clone(); a.push('▍'); let idx = s.turns.len(); - push_turn_lines(&mut lines, idx, q, &a, true, None); + push_turn_lines(&mut lines, idx, q, &a, true, None, theme); } if lines.is_empty() { let hint = Paragraph::new(Span::styled( "(type a question and press Enter. follow-ups inherit history. Ctrl-L clears the conversation.)", - Style::default().add_modifier(Modifier::DIM), + theme.style(crate::theme::Role::Hint), )) .wrap(Wrap { trim: false }); f.render_widget(hint.block(block), area); @@ -140,35 +147,34 @@ fn push_turn_lines( question: &str, answer: &str, streaming: bool, - answer_color_override: Option, + answer_role_override: Option, + theme: &crate::theme::Theme, ) { let q_label = format!("Q{}", idx + 1); let a_label = format!("A{}", idx + 1); out.push(Line::from(vec![ - Span::styled( - q_label, - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ), + // `Role::Heading` already includes BOLD in both palettes, so + // no need to `add_modifier(BOLD)` here — the redundancy would + // imply Heading lacks BOLD elsewhere. + Span::styled(q_label, theme.style(crate::theme::Role::Heading)), Span::raw(": "), Span::raw(question.to_string()), ])); - // p9-fb-16: refusal turn (caller passed Yellow) keeps the P9-3 - // visual distinction inside the transcript. Streaming turn fades - // to dim gray. Default is plain. - let answer_style = if let Some(c) = answer_color_override { - Style::default().fg(c) + // p9-fb-16: refusal turn (caller passed Role::Warning) keeps the + // P9-3 visual distinction inside the transcript. Streaming turn + // fades to dim hint. Default is plain Body. + let answer_style = if let Some(role) = answer_role_override { + theme.style(role) } else if streaming { - Style::default().fg(Color::Gray) + theme.style(crate::theme::Role::Hint) } else { - Style::default() + theme.style(crate::theme::Role::Body) }; out.push(Line::from(vec![ Span::styled( a_label, - Style::default() - .fg(Color::Green) + theme + .style(crate::theme::Role::Success) .add_modifier(Modifier::BOLD), ), Span::raw(": "), @@ -176,21 +182,21 @@ fn push_turn_lines( ])); } -fn render_bottom(f: &mut Frame, area: Rect, s: &AskState) { +fn render_bottom(f: &mut Frame, area: Rect, s: &AskState, theme: &crate::theme::Theme) { let split = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(40), Constraint::Percentage(60)]) .split(area); - render_status(f, split[0], s); - render_citations_or_explain(f, split[1], s); + render_status(f, split[0], s, theme); + render_citations_or_explain(f, split[1], s, theme); } -fn render_status(f: &mut Frame, area: Rect, s: &AskState) { +fn render_status(f: &mut Frame, area: Rect, s: &AskState, theme: &crate::theme::Theme) { let block = Block::default().title("status").borders(Borders::ALL); let lines: Vec = match &s.last_answer { None => vec![Line::from(Span::styled( "(no answer yet)", - Style::default().add_modifier(Modifier::DIM), + theme.style(crate::theme::Role::Hint), ))], Some(a) => { let grounded = if a.grounded { "✓" } else { "✗" }; @@ -220,17 +226,17 @@ fn render_status(f: &mut Frame, area: Rect, s: &AskState) { f.render_widget(Paragraph::new(lines).block(block), area); } -fn render_citations_or_explain(f: &mut Frame, area: Rect, s: &AskState) { +fn render_citations_or_explain(f: &mut Frame, area: Rect, s: &AskState, theme: &crate::theme::Theme) { let title = if s.explain { "explain (per-claim)" } else { "citations" }; let block = Block::default().title(title).borders(Borders::ALL); let lines: Vec = match &s.last_answer { None => vec![Line::from(Span::styled( "(submit a question to see citations)", - Style::default().add_modifier(Modifier::DIM), + theme.style(crate::theme::Role::Hint), ))], Some(a) if a.citations.is_empty() => vec![Line::from(Span::styled( if a.grounded { "(no citations)" } else { "(가까운 후보 없음)" }, - Style::default().add_modifier(Modifier::DIM), + theme.style(crate::theme::Role::Hint), ))], Some(a) => a .citations @@ -240,7 +246,7 @@ fn render_citations_or_explain(f: &mut Frame, area: Rect, s: &AskState) { Line::from(vec![ Span::styled( format!("[{marker}] "), - Style::default().fg(Color::Cyan), + theme.style(crate::theme::Role::CitationMarker), ), Span::raw(c.citation.to_uri()), ]) diff --git a/crates/kebab-tui/src/error_popup.rs b/crates/kebab-tui/src/error_popup.rs index 9a89d36..67c9125 100644 --- a/crates/kebab-tui/src/error_popup.rs +++ b/crates/kebab-tui/src/error_popup.rs @@ -4,10 +4,12 @@ use ratatui::Frame; use ratatui::layout::Rect; -use ratatui::style::{Color, Modifier, Style}; +use ratatui::style::Modifier; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; +use crate::theme::{Role, Theme}; + /// Captured snapshot of an `anyhow::Error` for rendering. We do NOT /// store the `anyhow::Error` itself (it is `!Sync` in pre-1.0.99 /// versions on some toolchains and would force lifetime gymnastics @@ -38,16 +40,16 @@ impl ErrorOverlay { /// Render the popup centred in `area`. Caller is responsible for /// clearing the underlying region (`Clear` widget); we do that here. -pub fn render_error_overlay(f: &mut Frame, area: Rect, overlay: &ErrorOverlay) { +/// `theme` is threaded so the overlay's red borders / dim hint use +/// the same role-style mapping as every other pane (p9-fb-14). +pub fn render_error_overlay(f: &mut Frame, area: Rect, overlay: &ErrorOverlay, theme: &Theme) { let popup_area = centered_rect(area, 60, 50); f.render_widget(Clear, popup_area); let mut lines: Vec = Vec::with_capacity(overlay.chain.len() + 2); lines.push(Line::from(Span::styled( format!("{}: {}", overlay.title, overlay.chain.first().map_or("(unknown)", String::as_str)), - Style::default() - .fg(Color::Red) - .add_modifier(Modifier::BOLD), + theme.style(Role::Error).add_modifier(Modifier::BOLD), ))); for cause in overlay.chain.iter().skip(1) { lines.push(Line::from(format!(" caused by: {cause}"))); @@ -55,13 +57,13 @@ pub fn render_error_overlay(f: &mut Frame, area: Rect, overlay: &ErrorOverlay) { lines.push(Line::from("")); lines.push(Line::from(Span::styled( "press any key to dismiss", - Style::default().add_modifier(Modifier::DIM), + theme.style(Role::Hint), ))); let block = Block::default() .title("error") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Red)); + .border_style(theme.style(Role::Error)); let para = Paragraph::new(lines).block(block).wrap(Wrap { trim: false }); f.render_widget(para, popup_area); } diff --git a/crates/kebab-tui/src/inspect.rs b/crates/kebab-tui/src/inspect.rs index 67376d6..6fe3b36 100644 --- a/crates/kebab-tui/src/inspect.rs +++ b/crates/kebab-tui/src/inspect.rs @@ -19,7 +19,7 @@ use crossterm::event::{KeyCode, KeyEvent}; use kebab_core::{Block, CanonicalDocument, Chunk}; use ratatui::Frame; use ratatui::layout::Rect; -use ratatui::style::{Color, Modifier, Style}; +use ratatui::style::Modifier; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block as RBlock, Borders, Paragraph, Wrap}; @@ -48,9 +48,9 @@ pub fn render_inspect(f: &mut Frame, area: Rect, state: &App) { return; } match (&s.target, &s.doc, &s.chunk) { - (Some(InspectTarget::Doc(_)), Some(doc), _) => render_doc(f, area, s, doc), + (Some(InspectTarget::Doc(_)), Some(doc), _) => render_doc(f, area, s, doc, &state.theme), (Some(InspectTarget::Chunk(_)), _, Some(chunk)) => { - render_chunk(f, area, s, chunk) + render_chunk(f, area, s, chunk, &state.theme) } _ => { let block = RBlock::default() @@ -59,7 +59,7 @@ pub fn render_inspect(f: &mut Frame, area: Rect, state: &App) { let hint = Paragraph::new(Span::styled( "(no target — return to Library and press Enter on a doc, \ or to Search and press `i` on a hit)", - Style::default().add_modifier(Modifier::DIM), + state.theme.style(crate::theme::Role::Hint), )) .wrap(Wrap { trim: false }); f.render_widget(hint.block(block), area); @@ -67,8 +67,8 @@ pub fn render_inspect(f: &mut Frame, area: Rect, state: &App) { } } -fn render_doc(f: &mut Frame, area: Rect, s: &InspectState, doc: &CanonicalDocument) { - let lines = build_doc_lines(s, doc); +fn render_doc(f: &mut Frame, area: Rect, s: &InspectState, doc: &CanonicalDocument, theme: &crate::theme::Theme) { + let lines = build_doc_lines(s, doc, theme); let block = RBlock::default() .title(format!( "Inspect Doc — {}", @@ -81,8 +81,8 @@ fn render_doc(f: &mut Frame, area: Rect, s: &InspectState, doc: &CanonicalDocume f.render_widget(para.block(block), area); } -fn render_chunk(f: &mut Frame, area: Rect, s: &InspectState, chunk: &Chunk) { - let lines = build_chunk_lines(s, chunk); +fn render_chunk(f: &mut Frame, area: Rect, s: &InspectState, chunk: &Chunk, theme: &crate::theme::Theme) { + let lines = build_chunk_lines(s, chunk, theme); let block = RBlock::default() .title(format!( "Inspect Chunk — {}", @@ -100,31 +100,34 @@ fn render_chunk(f: &mut Frame, area: Rect, s: &InspectState, chunk: &Chunk) { pub(crate) fn build_doc_lines<'a>( s: &InspectState, doc: &'a CanonicalDocument, + theme: &crate::theme::Theme, ) -> Vec> { let mut lines: Vec = Vec::new(); // Header - lines.push(header_kv("title", &doc.title)); - lines.push(header_kv("doc_path", &doc.workspace_path.0)); - lines.push(header_kv("doc_id", &doc.doc_id.0)); - lines.push(header_kv("lang", &doc.lang.0)); + lines.push(header_kv("title", &doc.title, theme)); + lines.push(header_kv("doc_path", &doc.workspace_path.0, theme)); + lines.push(header_kv("doc_id", &doc.doc_id.0, theme)); + lines.push(header_kv("lang", &doc.lang.0, theme)); lines.push(header_kv( "source_type", &format!("{:?}", doc.metadata.source_type).to_lowercase(), + theme, )); lines.push(header_kv( "trust_level", &format!("{:?}", doc.metadata.trust_level).to_lowercase(), + theme, )); - lines.push(header_kv("parser_version", &doc.parser_version.0)); + lines.push(header_kv("parser_version", &doc.parser_version.0, theme)); lines.push(blank()); // metadata - push_section_header(&mut lines, SECTION_METADATA, s); + push_section_header(&mut lines, SECTION_METADATA, s, theme); if !s.collapsed.contains(SECTION_METADATA) { - lines.push(kv("aliases", &format!("{:?}", doc.metadata.aliases))); - lines.push(kv("tags", &format!("{:?}", doc.metadata.tags))); - lines.push(kv("created_at", &fmt_dt(&doc.metadata.created_at))); - lines.push(kv("updated_at", &fmt_dt(&doc.metadata.updated_at))); + lines.push(kv("aliases", &format!("{:?}", doc.metadata.aliases), theme)); + lines.push(kv("tags", &format!("{:?}", doc.metadata.tags), theme)); + lines.push(kv("created_at", &fmt_dt(&doc.metadata.created_at), theme)); + lines.push(kv("updated_at", &fmt_dt(&doc.metadata.updated_at), theme)); // user metadata pretty-printed JSON if let Ok(pretty) = serde_json::to_string_pretty(&serde_json::Value::Object( @@ -139,12 +142,12 @@ pub(crate) fn build_doc_lines<'a>( } // provenance - push_section_header(&mut lines, SECTION_PROVENANCE, s); + push_section_header(&mut lines, SECTION_PROVENANCE, s, theme); if !s.collapsed.contains(SECTION_PROVENANCE) { if doc.provenance.events.is_empty() { lines.push(Line::from(Span::styled( " (no events)", - Style::default().add_modifier(Modifier::DIM), + theme.style(crate::theme::Role::Hint), ))); } else { for ev in &doc.provenance.events { @@ -171,6 +174,7 @@ pub(crate) fn build_doc_lines<'a>( SECTION_BLOCKS, s, Some(doc.blocks.len()), + theme, ); if !s.collapsed.contains(SECTION_BLOCKS) { let preview_n = 16.min(doc.blocks.len()); @@ -183,7 +187,7 @@ pub(crate) fn build_doc_lines<'a>( if doc.blocks.len() > preview_n { lines.push(Line::from(Span::styled( format!(" … +{} more", doc.blocks.len() - preview_n), - Style::default().add_modifier(Modifier::DIM), + theme.style(crate::theme::Role::Hint), ))); } } @@ -193,11 +197,12 @@ pub(crate) fn build_doc_lines<'a>( pub(crate) fn build_chunk_lines<'a>( s: &InspectState, chunk: &'a Chunk, + theme: &crate::theme::Theme, ) -> Vec> { let mut lines: Vec = Vec::new(); // Header - lines.push(header_kv("chunk_id", &chunk.chunk_id.0)); - lines.push(header_kv("doc_id", &chunk.doc_id.0)); + lines.push(header_kv("chunk_id", &chunk.chunk_id.0, theme)); + lines.push(header_kv("doc_id", &chunk.doc_id.0, theme)); lines.push(header_kv( "heading_path", &if chunk.heading_path.is_empty() { @@ -205,22 +210,24 @@ pub(crate) fn build_chunk_lines<'a>( } else { chunk.heading_path.join(" / ") }, + theme, )); - lines.push(header_kv("chunker_version", &chunk.chunker_version.0)); - lines.push(header_kv("policy_hash", &chunk.policy_hash)); + lines.push(header_kv("chunker_version", &chunk.chunker_version.0, theme)); + lines.push(header_kv("policy_hash", &chunk.policy_hash, theme)); lines.push(header_kv( "token_estimate", &chunk.token_estimate.to_string(), + theme, )); lines.push(blank()); // source spans - push_section_header(&mut lines, SECTION_SPANS, s); + push_section_header(&mut lines, SECTION_SPANS, s, theme); if !s.collapsed.contains(SECTION_SPANS) { if chunk.source_spans.is_empty() { lines.push(Line::from(Span::styled( " (no spans)", - Style::default().add_modifier(Modifier::DIM), + theme.style(crate::theme::Role::Hint), ))); } else { for span in &chunk.source_spans { @@ -231,7 +238,7 @@ pub(crate) fn build_chunk_lines<'a>( } // text - push_section_header(&mut lines, SECTION_TEXT, s); + push_section_header(&mut lines, SECTION_TEXT, s, theme); if !s.collapsed.contains(SECTION_TEXT) { for line in chunk.text.lines() { lines.push(Line::from(format!(" {line}"))); @@ -239,7 +246,7 @@ pub(crate) fn build_chunk_lines<'a>( if chunk.text.is_empty() { lines.push(Line::from(Span::styled( " (empty)", - Style::default().add_modifier(Modifier::DIM), + theme.style(crate::theme::Role::Hint), ))); } lines.push(blank()); @@ -252,11 +259,12 @@ pub(crate) fn build_chunk_lines<'a>( SECTION_EMBEDDINGS, s, Some(chunk.block_ids.len()), + theme, ); if !s.collapsed.contains(SECTION_EMBEDDINGS) { lines.push(Line::from(Span::styled( " (embedding records not loaded — out of v1 scope)", - Style::default().add_modifier(Modifier::DIM), + theme.style(crate::theme::Role::Hint), ))); for bid in &chunk.block_ids { lines.push(Line::from(format!(" {}", bid.0))); @@ -265,21 +273,21 @@ pub(crate) fn build_chunk_lines<'a>( lines } -fn header_kv(k: &str, v: &str) -> Line<'static> { +fn header_kv(k: &str, v: &str, theme: &crate::theme::Theme) -> Line<'static> { Line::from(vec![ Span::styled( format!("{k:>16}: "), - Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan), + theme.style(crate::theme::Role::Heading), ), Span::raw(v.to_string()), ]) } -fn kv(k: &str, v: &str) -> Line<'static> { +fn kv(k: &str, v: &str, theme: &crate::theme::Theme) -> Line<'static> { Line::from(vec![ Span::styled( format!(" {k}: "), - Style::default().add_modifier(Modifier::DIM), + theme.style(crate::theme::Role::Hint), ), Span::raw(v.to_string()), ]) @@ -289,8 +297,13 @@ fn blank() -> Line<'static> { Line::from("") } -fn push_section_header(lines: &mut Vec>, name: &'static str, s: &InspectState) { - push_section_header_with_count(lines, name, s, None); +fn push_section_header( + lines: &mut Vec>, + name: &'static str, + s: &InspectState, + theme: &crate::theme::Theme, +) { + push_section_header_with_count(lines, name, s, None, theme); } /// Section header + optional inline count. Inline-count form is used @@ -301,6 +314,7 @@ fn push_section_header_with_count( name: &'static str, s: &InspectState, count: Option, + theme: &crate::theme::Theme, ) { let collapsed = s.collapsed.contains(name); let marker = if collapsed { "▸" } else { "▾" }; @@ -310,9 +324,9 @@ fn push_section_header_with_count( }; lines.push(Line::from(Span::styled( title, - Style::default() - .add_modifier(Modifier::BOLD) - .fg(Color::Yellow), + theme + .style(crate::theme::Role::Warning) + .add_modifier(Modifier::BOLD), ))); } diff --git a/crates/kebab-tui/src/lib.rs b/crates/kebab-tui/src/lib.rs index f7c36bf..f49cea2 100644 --- a/crates/kebab-tui/src/lib.rs +++ b/crates/kebab-tui/src/lib.rs @@ -22,7 +22,9 @@ mod library; mod run; mod search; mod terminal; +mod theme; +pub use theme::{Palette, Role, Theme}; pub use app::{ App, AskState, IngestState, InspectState, InspectTarget, KeyOutcome, LibraryState, Pane, SearchState, TERMINAL_LINE_HOLD_SECS, diff --git a/crates/kebab-tui/src/library.rs b/crates/kebab-tui/src/library.rs index ce08141..39b746a 100644 --- a/crates/kebab-tui/src/library.rs +++ b/crates/kebab-tui/src/library.rs @@ -11,7 +11,6 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use kebab_core::{DocFilter, DocSummary, Lang}; use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; use unicode_width::UnicodeWidthStr; @@ -111,7 +110,7 @@ pub fn render_library(f: &mut Frame, area: Rect, state: &App) { .split(area); if let Some(edit) = &state.library.inner.filter_edit { - render_filter_overlay(f, layout[0], edit); + render_filter_overlay(f, layout[0], edit, &state.theme); } render_doc_list(f, layout[1], state); } @@ -124,7 +123,7 @@ fn filter_overlay_height(state: &App) -> u16 { } } -fn render_filter_overlay(f: &mut Frame, area: Rect, edit: &FilterEdit) { +fn render_filter_overlay(f: &mut Frame, area: Rect, edit: &FilterEdit, theme: &crate::theme::Theme) { let block = Block::default() .title("Filter (Tab=cycle field, Enter=apply, Esc=cancel)") .borders(Borders::ALL); @@ -132,18 +131,23 @@ fn render_filter_overlay(f: &mut Frame, area: Rect, edit: &FilterEdit) { f.render_widget(block, area); let lines = vec![ - line_with_focus("tags_any (csv): ", &edit.tags_buf, edit.field == FilterField::Tags), - line_with_focus("lang: ", &edit.lang_buf, edit.field == FilterField::Lang), + line_with_focus("tags_any (csv): ", &edit.tags_buf, edit.field == FilterField::Tags, theme), + line_with_focus("lang: ", &edit.lang_buf, edit.field == FilterField::Lang, theme), ]; let para = Paragraph::new(lines); f.render_widget(para, inner); } -fn line_with_focus<'a>(label: &'a str, value: &'a str, focused: bool) -> Line<'a> { +fn line_with_focus<'a>( + label: &'a str, + value: &'a str, + focused: bool, + theme: &crate::theme::Theme, +) -> Line<'a> { let style = if focused { - Style::default().add_modifier(Modifier::REVERSED) + theme.style(crate::theme::Role::Selected) } else { - Style::default() + theme.style(crate::theme::Role::Body) }; Line::from(vec![Span::raw(label), Span::styled(value, style)]) } @@ -173,7 +177,7 @@ fn render_doc_list(f: &mut Frame, area: Rect, state: &App) { let list = List::new(items) .block(block) - .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) + .highlight_style(state.theme.style(crate::theme::Role::Selected)) .highlight_symbol("> "); let mut list_state = inner.list_state.clone(); diff --git a/crates/kebab-tui/src/run.rs b/crates/kebab-tui/src/run.rs index ab98466..1d1665f 100644 --- a/crates/kebab-tui/src/run.rs +++ b/crates/kebab-tui/src/run.rs @@ -5,7 +5,6 @@ use anyhow::Result; use crossterm::event::{self, Event, KeyEventKind}; use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph}; use std::time::Duration; @@ -239,7 +238,7 @@ fn render_root(f: &mut Frame, app: &App) { render_footer(f, outer[2], app); } if let Some(err) = &app.error_overlay { - render_error_overlay(f, f.area(), err); + render_error_overlay(f, f.area(), err, &app.theme); } } @@ -248,10 +247,16 @@ fn render_ingest_status(f: &mut Frame, area: Rect, app: &App) { return; }; let line = crate::ingest_progress::status_line(state); + // p9-fb-14: `aborted` is a non-fatal-but-noteworthy state (Ctrl-C + // partial commit) — `Role::Warning` (yellow) is the right semantic + // signal, plus an explicit BOLD so the abort line still stands + // out from the live progress lines around it. let style = if state.aborted { - Style::default().add_modifier(Modifier::BOLD) + app.theme + .style(crate::theme::Role::Warning) + .add_modifier(ratatui::style::Modifier::BOLD) } else { - Style::default() + app.theme.style(crate::theme::Role::Body) }; f.render_widget( Paragraph::new(Line::from(Span::styled(line, style))), @@ -268,10 +273,7 @@ fn render_header(f: &mut Frame, area: Rect, app: &App) { Pane::Jobs => "Jobs", }; let line = Line::from(vec![ - Span::styled( - "kebab", - Style::default().add_modifier(Modifier::BOLD), - ), + Span::styled("kebab", app.theme.style(crate::theme::Role::Title)), Span::raw(" / "), Span::raw(pane_label), ]); @@ -294,7 +296,7 @@ fn render_footer(f: &mut Frame, area: Rect, app: &App) { }; let line = Line::from(Span::styled( hints, - Style::default().add_modifier(Modifier::DIM), + app.theme.style(crate::theme::Role::Hint), )); f.render_widget( Paragraph::new(line).block(Block::default().borders(Borders::TOP)), diff --git a/crates/kebab-tui/src/search.rs b/crates/kebab-tui/src/search.rs index 9eb8869..71092a4 100644 --- a/crates/kebab-tui/src/search.rs +++ b/crates/kebab-tui/src/search.rs @@ -19,7 +19,6 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use kebab_core::{Citation, SearchHit, SearchMode, SearchQuery}; use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}; use std::path::Path; @@ -60,18 +59,23 @@ pub fn render_search(f: &mut Frame, area: Rect, state: &App) { ]) .split(area); - render_input_bar(f, layout[0], s); - render_result_list(f, layout[1], s); - render_preview(f, layout[2], s); + render_input_bar(f, layout[0], s, &state.theme); + render_result_list(f, layout[1], s, &state.theme); + render_preview(f, layout[2], s, &state.theme); } -fn render_input_bar(f: &mut Frame, area: Rect, s: &SearchState) { +fn render_input_bar(f: &mut Frame, area: Rect, s: &SearchState, theme: &crate::theme::Theme) { let mode_label = mode_label(s.mode); + let mode_role = match s.mode { + SearchMode::Lexical => crate::theme::Role::ModeLexical, + SearchMode::Vector => crate::theme::Role::ModeVector, + SearchMode::Hybrid => crate::theme::Role::ModeHybrid, + }; let searching_hint = if s.searching { " searching…" } else { "" }; let line = Line::from(vec![ - Span::styled(format!("[{mode_label}] "), Style::default().fg(Color::Cyan)), + Span::styled(format!("[{mode_label}] "), theme.style(mode_role)), Span::raw(s.input.as_str()), - Span::styled(searching_hint, Style::default().add_modifier(Modifier::DIM)), + Span::styled(searching_hint, theme.style(crate::theme::Role::Hint)), ]); let block = Block::default() .title("query (Tab=mode Enter=search Esc=back)") @@ -87,7 +91,7 @@ fn mode_label(m: SearchMode) -> &'static str { } } -fn render_result_list(f: &mut Frame, area: Rect, s: &SearchState) { +fn render_result_list(f: &mut Frame, area: Rect, s: &SearchState, theme: &crate::theme::Theme) { let block = Block::default() .title(format!("results ({})", s.hits.len())) .borders(Borders::ALL); @@ -100,11 +104,11 @@ fn render_result_list(f: &mut Frame, area: Rect, s: &SearchState) { let items: Vec = s .hits .iter() - .map(|h| ListItem::new(format_hit_lines(h))) + .map(|h| ListItem::new(format_hit_lines(h, theme))) .collect(); let list = List::new(items) .block(block) - .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) + .highlight_style(theme.style(crate::theme::Role::Selected)) .highlight_symbol("> "); let mut list_state = ListState::default(); list_state.select(Some(s.selected_hit.min(s.hits.len().saturating_sub(1)))); @@ -116,7 +120,7 @@ fn render_result_list(f: &mut Frame, area: Rect, s: &SearchState) { /// 2. ` | section_label?` /// 3. snippet line 1 /// 4. snippet line 2 (or trailing blank for layout symmetry) -fn format_hit_lines(h: &SearchHit) -> Vec> { +fn format_hit_lines(h: &SearchHit, theme: &crate::theme::Theme) -> Vec> { let header = format!( "{}. {:.4} {}", h.rank, @@ -138,17 +142,14 @@ fn format_hit_lines(h: &SearchHit) -> Vec> { let s1 = snippet_lines.next().unwrap_or("").to_string(); let s2 = snippet_lines.next().unwrap_or("").to_string(); vec![ - Line::from(Span::styled( - header, - Style::default().add_modifier(Modifier::BOLD), - )), - Line::from(Span::styled(path_line, Style::default().fg(Color::DarkGray))), + Line::from(Span::styled(header, theme.style(crate::theme::Role::Title))), + Line::from(Span::styled(path_line, theme.style(crate::theme::Role::Path))), Line::from(format!(" {s1}")), Line::from(format!(" {s2}")), ] } -fn render_preview(f: &mut Frame, area: Rect, s: &SearchState) { +fn render_preview(f: &mut Frame, area: Rect, s: &SearchState, theme: &crate::theme::Theme) { let block = Block::default() .title("preview (g=open in $EDITOR)") .borders(Borders::ALL); @@ -157,7 +158,7 @@ fn render_preview(f: &mut Frame, area: Rect, s: &SearchState) { (Some(text), _) => Paragraph::new(text.as_str()).wrap(Wrap { trim: false }), (None, _) => Paragraph::new(Span::styled( "(loading preview… select a hit to fetch its chunk text)", - Style::default().add_modifier(Modifier::DIM), + theme.style(crate::theme::Role::Hint), )), }; f.render_widget(body.block(block), area); diff --git a/crates/kebab-tui/src/theme.rs b/crates/kebab-tui/src/theme.rs new file mode 100644 index 0000000..ec7cd74 --- /dev/null +++ b/crates/kebab-tui/src/theme.rs @@ -0,0 +1,280 @@ +//! p9-fb-14: TUI palette + role-style mapping. +//! +//! Every pane (`library`, `search`, `ask`, `inspect`, `error_popup`, +//! the `run::render_root` shell) routes its `ratatui::style::Style` +//! through `Theme::style(role)` instead of inlining +//! `Style::default().fg(...)`. Adding a new role here is the only +//! place a color decision needs to land — accidental drift between +//! panes (`Cyan` for one badge, `LightCyan` for another) becomes a +//! single-file diff. +//! +//! ## Why role-based, not "style table" +//! +//! Earlier sketches keyed a hashmap by role at runtime. A `match` +//! against an enum is faster (no allocation, no hashing), exhaustive +//! at compile time (forgetting a role for `Theme::light` is a +//! compile error if you `match` exhaustively on `Role` in the +//! palette body — and we do), and lets `Theme::style` return +//! `Style` by value without lifetimes. +//! +//! ## Accessibility +//! +//! Color is never the *only* signal — the score badge ships +//! `[score=0.92]` text alongside its color, the mode badge ships +//! `[Hybrid]` text, the refusal renders the literal `(refused)` +//! prefix. The theme just amplifies signals that the text already +//! carries. + +use ratatui::style::{Color, Modifier, Style}; + +/// Role-style enumeration. Adding a variant requires updating both +/// `dark_style` and `light_style` (the compiler enforces it via the +/// exhaustive `match` in each palette). +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum Role { + /// Active pane border (the focused one). + BorderActive, + /// Inactive pane border. + BorderInactive, + /// Document/section title (bold, accent color). + Title, + /// Secondary path / subpath (dim). + Path, + /// Lexical search-mode badge. + ModeLexical, + /// Vector search-mode badge. + ModeVector, + /// Hybrid search-mode badge. + ModeHybrid, + /// Selected row in any list (search hits, library docs, …). + Selected, + /// Dim hint / placeholder text (mode line subtext, "loading…"). + Hint, + /// Section heading (bold + accent — Inspect uses this). + Heading, + /// Warning yellow — refusals, malformed-frontmatter notices. + Warning, + /// Error red — error overlays, "spawn failed" lines. + Error, + /// Success green — completed ingest, grounded answer. + Success, + /// Citation marker (`[1]`, `[2]`) and citation link text. + CitationMarker, + /// Bullet glyph in list rendering. + Bullet, + /// Default body text (no decoration). Returned as + /// `Style::default()` in both palettes — kept as a Role so + /// callers don't sprinkle `Style::default()` directly. + Body, +} + +/// Palette identity. `Theme` carries this so panes can branch on +/// "is dark" if they need a different glyph (rarely needed since +/// roles already abstract the color), but in practice the +/// `Theme::style` dispatcher is the only consumer. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Palette { + Dark, + Light, +} + +#[derive(Clone, Debug)] +pub struct Theme { + palette: Palette, +} + +impl Theme { + /// Default dark palette — intended for the typical terminal + /// (white-on-black scheme). Distinct from `Theme::light`. + pub fn dark() -> Self { + Self { + palette: Palette::Dark, + } + } + + /// Light palette — intended for users running a light-background + /// terminal scheme. Hues stay the same; brightness shifts so the + /// foreground stays readable on white. + pub fn light() -> Self { + Self { + palette: Palette::Light, + } + } + + /// Resolve a config string ("dark" / "light", case-insensitive) + /// to a `Theme`. Unknown values fall back to dark — never errors. + /// p9-fb-14 spec: "config never errors on a typo, the TUI just + /// keeps the default theme so the user has a working shell." + pub fn from_name(s: &str) -> Self { + match s.trim().to_ascii_lowercase().as_str() { + "light" => Self::light(), + _ => Self::dark(), + } + } + + /// The underlying palette identity. Mostly a debugging aid. + pub fn palette(&self) -> Palette { + self.palette + } + + /// Resolve a `Role` to a `Style`. Both palettes implement every + /// role exhaustively (compile error if a variant is added but + /// the palette body forgets it). + pub fn style(&self, role: Role) -> Style { + match self.palette { + Palette::Dark => dark_style(role), + Palette::Light => light_style(role), + } + } +} + +/// `Theme::default() == Theme::dark()` — pinned by +/// `default_palette_is_dark` test. If the default ever flips, both +/// the test and downstream callers (e.g. integration smokes that +/// rely on dark contrast) need a coordinated update. +impl Default for Theme { + fn default() -> Self { + Self::dark() + } +} + +/// Dark palette — high-contrast on black. The exhaustive match +/// guarantees adding a `Role` variant here forces the same in +/// `light_style`. +fn dark_style(role: Role) -> Style { + match role { + Role::BorderActive => Style::default().fg(Color::Cyan), + Role::BorderInactive => Style::default().fg(Color::DarkGray), + Role::Title => Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + Role::Path => Style::default().fg(Color::DarkGray), + Role::ModeLexical => Style::default().fg(Color::Yellow), + Role::ModeVector => Style::default().fg(Color::Magenta), + Role::ModeHybrid => Style::default().fg(Color::Cyan), + Role::Selected => Style::default().add_modifier(Modifier::REVERSED), + Role::Hint => Style::default().add_modifier(Modifier::DIM), + Role::Heading => Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + Role::Warning => Style::default().fg(Color::Yellow), + Role::Error => Style::default().fg(Color::Red), + Role::Success => Style::default().fg(Color::Green), + Role::CitationMarker => Style::default().fg(Color::Cyan), + Role::Bullet => Style::default().fg(Color::DarkGray), + Role::Body => Style::default(), + } +} + +/// Light palette — high-contrast on white. Same hues as dark +/// (so user mental-models transfer) but with darker variants where +/// `Color::*` differs in 16-color terminals (e.g., `LightYellow` +/// would wash out on white, so `Yellow` stays). +fn light_style(role: Role) -> Style { + match role { + Role::BorderActive => Style::default().fg(Color::Blue), + Role::BorderInactive => Style::default().fg(Color::Gray), + Role::Title => Style::default() + .fg(Color::Black) + .add_modifier(Modifier::BOLD), + Role::Path => Style::default().fg(Color::Gray), + Role::ModeLexical => Style::default().fg(Color::Yellow), + Role::ModeVector => Style::default().fg(Color::Magenta), + Role::ModeHybrid => Style::default().fg(Color::Blue), + Role::Selected => Style::default().add_modifier(Modifier::REVERSED), + Role::Hint => Style::default().add_modifier(Modifier::DIM), + Role::Heading => Style::default() + .fg(Color::Blue) + .add_modifier(Modifier::BOLD), + Role::Warning => Style::default().fg(Color::Yellow), + Role::Error => Style::default().fg(Color::Red), + Role::Success => Style::default().fg(Color::Green), + Role::CitationMarker => Style::default().fg(Color::Blue), + Role::Bullet => Style::default().fg(Color::Gray), + Role::Body => Style::default(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Both palettes resolve every `Role` to a `Style` (no panic / + /// no `unreachable!()` branch). The exhaustive match in + /// `dark_style` / `light_style` makes this true at compile + /// time, but we exercise it at runtime so a regression to + /// `match _ => unreachable!()` would surface in test instead + /// of in production. + #[test] + fn every_role_resolves_in_dark_and_light() { + let roles = [ + Role::BorderActive, + Role::BorderInactive, + Role::Title, + Role::Path, + Role::ModeLexical, + Role::ModeVector, + Role::ModeHybrid, + Role::Selected, + Role::Hint, + Role::Heading, + Role::Warning, + Role::Error, + Role::Success, + Role::CitationMarker, + Role::Bullet, + Role::Body, + ]; + for r in roles { + let _ = Theme::dark().style(r); + let _ = Theme::light().style(r); + } + } + + /// `Theme::from_name` recognizes exactly two palette names; any + /// other input falls back to dark. Pinned per spec: "config + /// never errors on a typo". + #[test] + fn from_name_recognizes_dark_light_and_falls_back() { + assert_eq!(Theme::from_name("dark").palette(), Palette::Dark); + assert_eq!(Theme::from_name("DARK").palette(), Palette::Dark); + assert_eq!(Theme::from_name(" dark ").palette(), Palette::Dark); + assert_eq!(Theme::from_name("light").palette(), Palette::Light); + assert_eq!(Theme::from_name("LIGHT").palette(), Palette::Light); + assert_eq!(Theme::from_name("solarized").palette(), Palette::Dark); + assert_eq!(Theme::from_name("").palette(), Palette::Dark); + } + + /// `Theme::default()` is dark — pinned so the default doesn't + /// silently flip in a future refactor. + #[test] + fn default_palette_is_dark() { + assert_eq!(Theme::default().palette(), Palette::Dark); + } + + /// Critical roles emit `Style` with at least one decoration — + /// catches regressions where someone replaces a styled palette + /// branch with a bare `Style::default()`. `Body` is excluded + /// (it intentionally returns the default). + #[test] + fn primary_roles_carry_decoration_in_dark() { + let theme = Theme::dark(); + for r in [ + Role::Title, + Role::Selected, + Role::Heading, + Role::Error, + Role::Warning, + Role::Success, + ] { + let style = theme.style(r); + let has_color = style.fg.is_some() || style.bg.is_some(); + let has_modifier = !style.add_modifier.is_empty(); + assert!( + has_color || has_modifier, + "role {:?} resolves to bare Style::default() in dark palette", + r + ); + } + } +} diff --git a/docs/SMOKE.md b/docs/SMOKE.md index 53c7c71..2f9c177 100644 --- a/docs/SMOKE.md +++ b/docs/SMOKE.md @@ -108,6 +108,9 @@ prompt_template_version = "rag-v1" score_gate = 0.05 # RRF 정규화 후 [0, 1] 범위라 default 그대로 OK explain_default = false max_context_tokens = 6000 + +[ui] +theme = "dark" # p9-fb-14 — TUI palette ("dark" / "light", default "dark") ``` `KEBAB_*` 환경변수로 override 가능 (`KEBAB_MODELS_LLM_MODEL=gemma4:26b kebab …` 등). 자세한 키 목록은 `crates/kebab-config/src/lib.rs` 의 `apply_env` 매치 암. diff --git a/tasks/p9/p9-fb-14-tui-color-theme.md b/tasks/p9/p9-fb-14-tui-color-theme.md index d4f4237..2ef1064 100644 --- a/tasks/p9/p9-fb-14-tui-color-theme.md +++ b/tasks/p9/p9-fb-14-tui-color-theme.md @@ -3,7 +3,7 @@ phase: P9 component: kebab-tui task_id: p9-fb-14 title: "TUI color theme module (role-based + dark/light toggle)" -status: planned +status: in_progress depends_on: [] unblocks: [p9-fb-11] contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md