diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..6684b15 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,59 @@ +# Product + +## Register + +product + +## Users + +A single note-taker (today: the maintainer; tomorrow: a small circle of +keyboard-driven PKM users) living in the app daily — journaling, outlining, +tagging, and querying their own notes. Context: long sessions on a desktop, +hands on the keyboard, the outline as the primary object of attention. The +job: capture and re-find thoughts with zero friction, in the mold of +Logseq/Workflowy but Rust-native and CRDT-backed. + +## Product Purpose + +Trawler is a personal, keyboard-first outliner: a block graph with a +journal-first navigation model, backlinks, tags, and live Scheme queries +over the user's own notes. Success is the tool disappearing: every editing +and navigation intention is one keystroke away, renders instantly, and +never loses a character. It is a local-first instrument, not a service. + +## Brand Personality + +Calm, precise, quiet. The text is the interface; chrome recedes into +near-black neutrals and shows itself only when pointed at. Confidence +through restraint (Zed/Linear-adjacent), never through decoration. One +accent — a muted pistachio green — carries focus, links, and selection; +everything else stays out of the way. + +## Anti-references + +- Notion-style SaaS: rounded cards everywhere, emoji-forward, hover chrome, + cream backgrounds, cushiony padding. +- Electron-app genericism: stock component-library look (default shadcn / + Material), web-app affordances inside a native tool. + +## Design Principles + +- **Text is the interface.** Layout serves reading and typing; controls are + furniture, not features. +- **One accent, one meaning.** Pistachio marks the interactive and the + focused; nothing else competes for it. +- **Keyboard-first is the contract.** Every pointer affordance has a + keyboard path; pointer polish never gates a workflow. +- **Quiet until touched.** Chrome idles at near-invisibility; hover and + focus reveal, never shout. +- **Native fidelity.** The app behaves like the OS's own windows, menus, + and text controls — no web-app uncanny valley. + +## Accessibility & Inclusion + +- Reduced motion respected: all animation knobs (caret glide, scroll, + thread) accept 0 for instant transitions and tests run that way. +- Keyboard-first as accessibility: every feature reachable without a + pointer. +- Contrast: body text is near-white on near-black; muted grays stay above + readable contrast on the 0x1e1e1e ground. diff --git a/crates/trawler-core/src/graph/types.rs b/crates/trawler-core/src/graph/types.rs index 276c85b..9b8b54c 100644 --- a/crates/trawler-core/src/graph/types.rs +++ b/crates/trawler-core/src/graph/types.rs @@ -48,6 +48,20 @@ impl NodeId { _ => None, } } + + /// Parse the canonical `Display` form back into a `NodeId` — the + /// inverse of `to_string`, for doc-persisted settings (the home + /// document key) that store node ids as strings. Returns `None` for + /// strings that aren't any variant's canonical form. + pub fn parse(s: &str) -> Option { + if let Some(name) = s.strip_prefix("tag:") { + return (!name.is_empty()).then(|| NodeId::tag(name)); + } + if let Some(date) = s.strip_prefix("date:") { + return date.parse().ok().map(NodeId::Date); + } + loro::TreeID::try_from(s).ok().map(NodeId::tree) + } } impl fmt::Display for NodeId { diff --git a/crates/trawler-core/src/lib.rs b/crates/trawler-core/src/lib.rs index 19b0b55..e879a3e 100644 --- a/crates/trawler-core/src/lib.rs +++ b/crates/trawler-core/src/lib.rs @@ -12,6 +12,7 @@ pub mod query; pub mod query_spike; pub mod refs; pub mod search; +pub mod settings; pub mod storage; pub fn placeholder() -> &'static str { diff --git a/crates/trawler-core/src/outline.rs b/crates/trawler-core/src/outline.rs index 8cd17e5..4ed3e00 100644 --- a/crates/trawler-core/src/outline.rs +++ b/crates/trawler-core/src/outline.rs @@ -77,6 +77,17 @@ impl<'a> Outline<'a> { self.tree().delete(id) } + /// Whether `id` still names a live node in the tree — false for + /// deleted nodes and for ids from another graph. Used to validate + /// doc-persisted node references (e.g. the home document) before + /// navigating to them. `contains` alone is not enough: Loro keeps + /// deleted nodes in history (under an internal deleted root), so a + /// deleted id still "contains" — it must also not be marked deleted. + pub fn exists(&self, id: TreeID) -> bool { + let tree = self.tree(); + tree.contains(id) && !tree.is_node_deleted(&id).unwrap_or(true) + } + /// Split `id`'s content at `char_offset` (a Unicode scalar offset): the /// text before the offset stays on `id`, the text from the offset /// onward moves to a new sibling created immediately after `id`. diff --git a/crates/trawler-core/src/settings.rs b/crates/trawler-core/src/settings.rs new file mode 100644 index 0000000..bc69f11 --- /dev/null +++ b/crates/trawler-core/src/settings.rs @@ -0,0 +1,130 @@ +//! Doc-level graph settings: a named Loro map holding graph-wide state +//! that travels with the graph, alongside the outline tree. Currently a +//! single key — the home document (spec: home-document/"Home document +//! designation"). The map's per-key last-write-wins semantics are exactly +//! right for single-value settings: concurrent sets on two replicas +//! converge on the later writer instead of leaving two homes. +//! +//! Absent keys mean "default" (home = journal), so graphs written before +//! this map existed — and graphs written by older builds, which never read +//! it — open unchanged. No format version bump. + +use loro::{LoroDoc, LoroResult, LoroValue, ValueOrContainer}; + +use crate::graph::NodeId; + +/// The doc-level settings map's container name. +pub const SETTINGS_MAP: &str = "settings"; + +/// Key holding the home document's canonical `NodeId` string. +const HOME_KEY: &str = "home"; + +/// Thin wrapper over the doc's settings map, in the style of +/// [`crate::outline::Outline`]. +pub struct Settings<'a> { + doc: &'a LoroDoc, +} + +impl<'a> Settings<'a> { + pub fn new(doc: &'a LoroDoc) -> Self { + Self { doc } + } + + fn map(&self) -> loro::LoroMap { + self.doc.get_map(SETTINGS_MAP) + } + + /// The designated home document, if any. `None` — absent key, or a + /// value that doesn't parse as a canonical node id — means the journal + /// is home. Existence of the referenced node is the caller's concern + /// (the UI resolves and falls back; see the home-document spec). + pub fn home(&self) -> Option { + match self.map().get(HOME_KEY) { + Some(ValueOrContainer::Value(LoroValue::String(s))) => NodeId::parse(&s), + _ => None, + } + } + + /// Designate `id` as the home document. + pub fn set_home(&self, id: &NodeId) -> LoroResult<()> { + self.map().insert(HOME_KEY, id.to_string()) + } + + /// Clear the designation — the journal becomes home again. A no-op + /// when nothing is set. + pub fn clear_home(&self) -> LoroResult<()> { + self.map().delete(HOME_KEY) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::outline::{Outline, Position}; + + #[test] + fn home_round_trips_and_clears() { + let doc = LoroDoc::new(); + let settings = Settings::new(&doc); + assert_eq!(settings.home(), None); + + let outline = Outline::new(&doc); + let page = outline + .create_block(None, Position::Index(0), "index") + .unwrap(); + settings.set_home(&NodeId::tree(page)).unwrap(); + assert_eq!(settings.home(), Some(NodeId::tree(page))); + + settings.set_home(&NodeId::tag("inbox")).unwrap(); + assert_eq!(settings.home(), Some(NodeId::tag("inbox"))); + + settings.clear_home().unwrap(); + assert_eq!(settings.home(), None); + // Clearing again stays a no-op. + settings.clear_home().unwrap(); + assert_eq!(settings.home(), None); + } + + #[test] + fn home_survives_export_import() { + let doc = LoroDoc::new(); + Settings::new(&doc).set_home(&NodeId::tag("inbox")).unwrap(); + let snapshot = doc.export(loro::ExportMode::Snapshot).unwrap(); + + let reopened = LoroDoc::new(); + reopened.import(&snapshot).unwrap(); + assert_eq!(Settings::new(&reopened).home(), Some(NodeId::tag("inbox"))); + } + + #[test] + fn concurrent_sets_converge_lww() { + // Two replicas set different homes concurrently; after exchanging + // updates both converge on the same single value. + let a = LoroDoc::new(); + a.set_peer_id(1).unwrap(); + let b = LoroDoc::new(); + b.set_peer_id(2).unwrap(); + + Settings::new(&a).set_home(&NodeId::tag("inbox")).unwrap(); + Settings::new(&b).set_home(&NodeId::tag("reading")).unwrap(); + + let update_a = a.export(loro::ExportMode::all_updates()).unwrap(); + let update_b = b.export(loro::ExportMode::all_updates()).unwrap(); + a.import(&update_b).unwrap(); + b.import(&update_a).unwrap(); + + let home_a = Settings::new(&a).home(); + let home_b = Settings::new(&b).home(); + assert!(home_a.is_some()); + assert_eq!(home_a, home_b, "replicas must converge on one home"); + } + + #[test] + fn unparseable_home_reads_as_none() { + let doc = LoroDoc::new(); + doc.get_map(SETTINGS_MAP) + .insert("home", "not a node id") + .unwrap(); + assert_eq!(Settings::new(&doc).home(), None); + } +} diff --git a/crates/trawler/assets/icons/chevron-down.svg b/crates/trawler/assets/icons/chevron-down.svg new file mode 100644 index 0000000..c626da0 --- /dev/null +++ b/crates/trawler/assets/icons/chevron-down.svg @@ -0,0 +1,19 @@ + + + + diff --git a/crates/trawler/assets/icons/home.svg b/crates/trawler/assets/icons/home.svg new file mode 100644 index 0000000..9d51ebe --- /dev/null +++ b/crates/trawler/assets/icons/home.svg @@ -0,0 +1,21 @@ + + + + + + diff --git a/crates/trawler/assets/icons/notebook.svg b/crates/trawler/assets/icons/notebook.svg new file mode 100644 index 0000000..6a4e124 --- /dev/null +++ b/crates/trawler/assets/icons/notebook.svg @@ -0,0 +1,21 @@ + + + + + + diff --git a/crates/trawler/assets/icons/settings.svg b/crates/trawler/assets/icons/settings.svg new file mode 100644 index 0000000..30fb9df --- /dev/null +++ b/crates/trawler/assets/icons/settings.svg @@ -0,0 +1,20 @@ + + + + + diff --git a/crates/trawler/src/assets.rs b/crates/trawler/src/assets.rs index 0fcfec4..ba8cc95 100644 --- a/crates/trawler/src/assets.rs +++ b/crates/trawler/src/assets.rs @@ -27,10 +27,23 @@ const ICONS: &[(&str, &[u8])] = &[ "icons/calendar.svg", include_bytes!("../assets/icons/calendar.svg"), ), + ( + "icons/chevron-down.svg", + include_bytes!("../assets/icons/chevron-down.svg"), + ), ( "icons/chevron-left.svg", include_bytes!("../assets/icons/chevron-left.svg"), ), + ("icons/home.svg", include_bytes!("../assets/icons/home.svg")), + ( + "icons/notebook.svg", + include_bytes!("../assets/icons/notebook.svg"), + ), + ( + "icons/settings.svg", + include_bytes!("../assets/icons/settings.svg"), + ), ( "icons/chevron-right.svg", include_bytes!("../assets/icons/chevron-right.svg"), @@ -81,15 +94,18 @@ impl AssetSource for Assets { } /// A bundled Tabler icon sized to sit inline with UI text, tinted a neutral -/// UI-chrome gray. gpui's `Svg` element paints only when a text color is set -/// on the element itself (`Svg::paint` zips the path with the element's own -/// `style.text.color` — there is no cascade for svg tinting), so the color -/// must be explicit here; call sites can override it via `.text_color(...)`. +/// UI-chrome gray. 20px, not 16: the denser Tabler glyphs (the settings +/// gear especially) need the extra raster size for their arcs to resolve +/// instead of smearing. gpui's `Svg` element paints only when a text color +/// is set on the element itself (`Svg::paint` zips the path with the +/// element's own `style.text.color` — there is no cascade for svg +/// tinting), so the color must be explicit here; call sites can override +/// it via `.text_color(...)` and the size via `.size(...)`. pub fn icon(path: &'static str) -> gpui::Svg { use gpui::Styled as _; gpui::svg() .path(path) - .size(gpui::px(16.0)) + .size(gpui::px(20.0)) .flex_none() .text_color(gpui::rgb(0xd0d0d0)) } diff --git a/crates/trawler/src/devtools.rs b/crates/trawler/src/devtools.rs index ff876cd..baa6de9 100644 --- a/crates/trawler/src/devtools.rs +++ b/crates/trawler/src/devtools.rs @@ -315,6 +315,9 @@ struct Dump { /// The right-hand sidebar's state (openspec change ui-polish, /// tasks 3.5 — additive field). sidebar: SidebarDump, + /// The designated home document's canonical node id, if any (openspec + /// change home-document — additive field). `None` = journal. + home: Option, bounds: BoundsDump, } @@ -331,6 +334,7 @@ struct SidebarDump { enum ViewDump { Journal, Node { id: String }, + Settings, } #[derive(Serialize)] @@ -393,6 +397,7 @@ fn build_dump(app: &TrawlerApp, window: &gpui::Window, cx: &gpui::Context ViewDump::Journal, View::Node(id) => ViewDump::Node { id: id.to_string() }, + View::Settings => ViewDump::Settings, }; let rows = app @@ -431,7 +436,7 @@ fn build_dump(app: &TrawlerApp, window: &gpui::Window, cx: &gpui::Context, + label: String, +} + struct QuickOpenState { + purpose: QuickOpenPurpose, query: String, - results: Vec<(NodeId, String)>, + results: Vec, selected: usize, } @@ -448,7 +475,17 @@ impl TrawlerApp { .detach(); let today = chrono::Local::now().date_naive(); - let today_page = ensure_today_journal_page(&storage, today); + // The home designation decides what to open (openspec change + // home-document). Today's journal page is only materialized when + // the journal is actually being opened — here, when it's home — + // so a graph homed elsewhere doesn't accrue empty journal pages + // for days the journal was never visited (spec: journal/ + // "Automatic daily pages" as revised). The ensure stays ahead of + // the search-index build below so a brand-new graph's first index + // pass sees the page. + let initial_view = resolve_home_view_for(&storage); + let today_page = matches!(initial_view, View::Journal) + .then(|| ensure_today_journal_page(&storage, today)); // Built after seeding/journal setup above so a brand-new graph's // first-ever build (the `open_or_create` fast path when the search @@ -467,7 +504,7 @@ impl TrawlerApp { let mut app = Self { graph_dir, storage, - view: View::Journal, + view: initial_view, history_back: Vec::new(), history_forward: Vec::new(), rows: Vec::new(), @@ -504,12 +541,23 @@ impl TrawlerApp { }; app.refresh_view_data(); - // "today's journal page is displayed with focus in its first - // block" (spec: journal/"Opening the app"). - let outline = Outline::new(app.storage.doc()); - let first_block = outline.children(Some(today_page)).first().copied(); - if let Some(block) = first_block.or(Some(today_page)) { - app.focus_block(block, window, cx); + // With the journal as home: "today's journal page is displayed + // with focus in its first block" (spec: journal/"Opening the + // app"). A designated home opens with nothing focused (spec: + // home-document/"Pinned home opens unfocused") — its first block + // may well be a query or dashboard content the user won't type + // into; ctrl-n (create-standalone-page) covers "start writing". + if let Some(today_page) = today_page { + let outline = Outline::new(app.storage.doc()); + let first_block = outline.children(Some(today_page)).first().copied(); + if let Some(block) = first_block.or(Some(today_page)) { + app.focus_block(block, window, cx); + } + } else { + // No editor focus on a designated home — but keyboard actions + // (ctrl-h, ctrl-k, ...) dispatch along the focused element's + // context chain and need an anchor (see `root_focus`'s doc). + window.focus(&app.root_focus); } app.spawn_journal_rollover_timer(cx); app @@ -606,16 +654,44 @@ impl TrawlerApp { return; } self.journal_today = today; - ensure_today_journal_page(&self.storage, today); - // Rare event (once a day at most) — a full rebuild is simpler than - // precisely tracking what changed and costs nothing noticeable. - let _ = self.search.rebuild(self.storage.doc()); + // Page creation is journal-open scoped (openspec change + // home-document, spec: journal/"Automatic daily pages" as + // revised): a rollover noticed while the journal is displayed + // materializes the new day in place; anywhere else, the page + // waits for the next journal open instead of accumulating. if matches!(self.view, View::Journal) { + ensure_today_journal_page(&self.storage, today); + // Rare event (once a day at most) — a full rebuild is simpler + // than precisely tracking what changed and costs nothing + // noticeable. + let _ = self.search.rebuild(self.storage.doc()); self.refresh_view_data(); cx.notify(); } } + /// The journal-open hook (spec: journal/"Automatic daily pages" as + /// revised by openspec change home-document): today's page is created + /// when the timeline is opened rather than at startup, so days on + /// which the journal was never displayed leave no pages behind. The + /// search index is rebuilt only when a page was actually created + /// (once per day at most). + fn ensure_journal_open(&mut self) { + let today = chrono::Local::now().date_naive(); + self.journal_today = today; + let existed = { + let outline = Outline::new(self.storage.doc()); + outline + .children(None) + .into_iter() + .any(|p| journal_page_date(&outline, p) == Some(today)) + }; + if !existed { + ensure_today_journal_page(&self.storage, today); + let _ = self.search.rebuild(self.storage.doc()); + } + } + /// Recompute rows, backlinks, and breadcrumb for the current view — /// call after any edit within the current view. Preserves scroll /// position across the row-count change (`ListState::reset` otherwise @@ -649,6 +725,9 @@ impl TrawlerApp { push_subtree(&outline, id, 0, &self.folded, &mut rows); } } + // The settings screen renders its own content in place of the + // outline list. + View::Settings => {} } rows } @@ -754,12 +833,18 @@ impl TrawlerApp { cx: &mut Context, ) { self.commit_editor(cx); + // Any navigation closes an open picker — most visibly the Settings + // home dropdown, which must not linger once its screen is gone. + self.quick_open = None; if view != self.view { let left_scroll = self.list_state.logical_scroll_top(); self.history_back.push((self.view.clone(), left_scroll)); self.history_forward.clear(); } self.view = view; + if matches!(self.view, View::Journal) { + self.ensure_journal_open(); + } self.refresh_view_data(); // Scroll is owned by focus: focusing reveals the target block @@ -1164,9 +1249,19 @@ impl TrawlerApp { cx.notify(); return; } + self.open_quick_open(QuickOpenPurpose::Navigate, window, cx); + } + + fn open_quick_open( + &mut self, + purpose: QuickOpenPurpose, + window: &mut Window, + cx: &mut Context, + ) { let index = GraphIndex::rebuild(self.storage.doc()); - let results = self.quick_open_candidates(&index, ""); + let results = self.quick_open_candidates(&index, "", purpose); self.quick_open = Some(QuickOpenState { + purpose, query: String::new(), results, selected: 0, @@ -1194,9 +1289,19 @@ impl TrawlerApp { return; } "enter" => { - if let Some((target, _)) = state.results.get(state.selected).cloned() { + let purpose = state.purpose; + if let Some(target) = state.results.get(state.selected).map(|r| r.target.clone()) { self.quick_open = None; - self.navigate_to(View::Node(target), None, window, cx); + match purpose { + QuickOpenPurpose::Navigate => { + if let Some(target) = target { + self.navigate_to(View::Node(target), None, window, cx); + } + } + QuickOpenPurpose::PickHome => { + self.set_home(target, cx); + } + } } return; } @@ -1232,14 +1337,227 @@ impl TrawlerApp { } } let index = GraphIndex::rebuild(self.storage.doc()); - let query_lower = self.quick_open.as_ref().unwrap().query.to_lowercase(); - let results = self.quick_open_candidates(&index, &query_lower); + let (query_lower, purpose) = { + let state = self.quick_open.as_ref().unwrap(); + (state.query.to_lowercase(), state.purpose) + }; + let results = self.quick_open_candidates(&index, &query_lower, purpose); let state = self.quick_open.as_mut().unwrap(); state.results = results; state.selected = 0; cx.notify(); } + /// Persist the home-document designation (`None` clears it back to + /// the journal default) — the Settings picker's confirm path. + fn set_home(&mut self, target: Option, cx: &mut Context) { + let settings = trawler_core::settings::Settings::new(self.storage.doc()); + match &target { + Some(id) => settings.set_home(id).expect("set home document"), + None => settings.clear_home().expect("clear home document"), + } + self.storage + .persist_update() + .expect("persist home document"); + cx.notify(); + } + + /// See [`resolve_home_view_for`] — the in-session flavor. + fn resolve_home_view(&mut self) -> View { + resolve_home_view_for(&self.storage) + } + + fn go_home(&mut self, _: &GoHome, window: &mut Window, cx: &mut Context) { + let view = self.resolve_home_view(); + self.navigate_to(view, None, window, cx); + } + + fn go_to_journal(&mut self, _: &GoToJournal, window: &mut Window, cx: &mut Context) { + self.navigate_to(View::Journal, None, window, cx); + } + + /// The quick-open list body — query line plus result rows — shared by + /// the floating ctrl-k switcher and the Settings home picker's inline + /// dropdown. An associated fn (not `&self`) so callers can hold + /// `self.quick_open` borrowed while building it. + fn render_quick_open_body( + state: &QuickOpenState, + width: f32, + focus: &FocusHandle, + cx: &mut Context, + ) -> gpui::Stateful { + let purpose = state.purpose; + let items = state.results.iter().enumerate().map(|(ix, row)| { + let target = row.target.clone(); + let mut item = div() + .id(("quick-open-item", ix)) + .px_2() + .py_1() + .cursor_pointer() + .child(row.label.clone()) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _event, window, cx| { + this.quick_open = None; + match purpose { + QuickOpenPurpose::Navigate => { + if let Some(target) = target.clone() { + this.navigate_to(View::Node(target), None, window, cx); + } + } + QuickOpenPurpose::PickHome => { + this.set_home(target.clone(), cx); + } + } + }), + ); + if ix == state.selected { + item = item.bg(rgb(SELECTED_ROW_BG)); + } + item + }); + div() + .id("quick-open") + .track_focus(focus) + .on_key_down(cx.listener(Self::quick_open_key_down)) + .w(px(width)) + .max_h(px(320.0)) + .overflow_hidden() + .flex() + .flex_col() + .bg(rgb(0x252525)) + .border_1() + .border_color(rgb(0x3a3a3a)) + .rounded_md() + .shadow_lg() + .child( + div() + .px_2() + .py_1() + .border_b_1() + .border_color(rgb(0x3a3a3a)) + .child(format!("> {}", state.query)), + ) + .children(items) + } + + /// The Settings screen body (spec: home-document/"Settings screen"), + /// rendered in the main column in place of outline rows while + /// `View::Settings` is current. One setting so far: the home + /// document, changed through the quick-open machinery in + /// `PickHome` mode. + fn render_settings_screen(&self, cx: &mut Context) -> gpui::Div { + // A centered reading column, like a settings page rather than an + // outline: content is left-aligned inside a measure-capped column + // that floats in the main pane, with generous top and side + // margins. The side padding keeps the margins comfortable when + // the window (or pane) gets narrow. + div() + .w_full() + .pt(px(48.0)) + .px_8() + .flex() + .flex_col() + .items_center() + .child( + div() + .w_full() + .max_w(px(640.0)) + .flex() + .flex_col() + .gap_4() + .child( + div() + .text_size(px(PAGE_HEADING_TEXT_SIZE)) + .font_weight(FontWeight::BOLD) + .child("Settings"), + ) + .child(self.render_home_setting(cx)), + ) + } + + /// The "Home document" settings row: label, description, and the + /// select-style picker with its inline dropdown. + fn render_home_setting(&self, cx: &mut Context) -> gpui::Div { + // Select width shared by the control and its dropdown, so the + // open list reads as an extension of the closed control. + const HOME_SELECT_WIDTH: f32 = 280.0; + let home_label = match trawler_core::settings::Settings::new(self.storage.doc()).home() { + None => "Journal (default)".to_string(), + Some(NodeId::Tag(name)) => format!("#{name}"), + // A dangling tree id reads as the default — resolution clears + // it the next time home is actually opened. + Some(id) => id + .as_tree_id() + .filter(|&t| Outline::new(self.storage.doc()).exists(t)) + .and_then(|t| Outline::new(self.storage.doc()).content(t).ok()) + .unwrap_or_else(|| "Journal (default)".to_string()), + }; + let picker_open = self + .quick_open + .as_ref() + .is_some_and(|s| s.purpose == QuickOpenPurpose::PickHome); + let dropdown = self + .quick_open + .as_ref() + .filter(|s| s.purpose == QuickOpenPurpose::PickHome) + .map(|state| { + Self::render_quick_open_body(state, HOME_SELECT_WIDTH, &self.quick_open_focus, cx) + }); + div() + .flex() + .flex_col() + .gap_1() + .child(div().font_weight(FontWeight::BOLD).child("Home document")) + .child( + div() + .text_color(rgb(MUTED_COLOR)) + .text_size(px(12.0)) + .child( + "What the app opens to, and where Home (ctrl-h) goes. \ + Pick a page or tag; \"Journal (default)\" restores the \ + journal timeline.", + ), + ) + .child( + // A select, not a button: the label is the current + // *value* (body color, not accent), the chevron + // signals a choice unfolds, and the accent appears + // only as the open state's border. The dropdown + // below shares the control's width. + div() + .id("home-picker") + .w(px(HOME_SELECT_WIDTH)) + .mt_1() + .px_2() + .py_1() + .flex() + .flex_row() + .items_center() + .justify_between() + .gap_2() + .bg(rgb(QUERY_BG)) + .border_1() + .border_color(rgb(if picker_open { THREAD_COLOR } else { 0x3a3a3a })) + .rounded_sm() + .cursor_pointer() + .hover(|d| { + d.border_color(rgb(if picker_open { THREAD_COLOR } else { 0x5a5a5a })) + }) + .child(home_label) + .child(assets::icon("icons/chevron-down.svg").text_color(rgb(MUTED_COLOR))) + .on_click(cx.listener(|this, _event: &ClickEvent, window, cx| { + if this.quick_open.is_some() { + this.quick_open = None; + cx.notify(); + } else { + this.open_quick_open(QuickOpenPurpose::PickHome, window, cx); + } + })), + ) + .children(dropdown.map(|d| div().mt_1().child(d))) + } + /// Ctrl+F: open (or close, if already open) full-text search. fn toggle_search(&mut self, _: &SearchOpen, window: &mut Window, cx: &mut Context) { if self.search_open.is_some() { @@ -1462,24 +1780,29 @@ impl TrawlerApp { &self, index: &GraphIndex, query_lower: &str, - ) -> Vec<(NodeId, String)> { + purpose: QuickOpenPurpose, + ) -> Vec { // Date shortcuts go first: "Today" and the journal page it // resolves to (via `resolve_date`) are the same node, so when a // page's title happens to be today's date, listing the plain page // name after the shortcut and de-duping by target keeps only the // more descriptive "Today (2026-07-04)" label instead of showing - // both. + // both. The home picker skips them — the journal is represented + // by its own "Journal (default)" row instead, and a fixed date + // makes no sense as a home. let mut ordered: Vec<(NodeId, String)> = Vec::new(); - let today = chrono::Local::now().date_naive(); - for (label, date) in [ - ("Today", today), - ("Tomorrow", today + chrono::Duration::days(1)), - ("Yesterday", today - chrono::Duration::days(1)), - ] { - let display = format!("{label} ({date})"); - if fuzzy_contains(&display.to_lowercase(), query_lower) { - ordered.push((self.resolve_date(date), display)); + if purpose == QuickOpenPurpose::Navigate { + let today = chrono::Local::now().date_naive(); + for (label, date) in [ + ("Today", today), + ("Tomorrow", today + chrono::Duration::days(1)), + ("Yesterday", today - chrono::Duration::days(1)), + ] { + let display = format!("{label} ({date})"); + if fuzzy_contains(&display.to_lowercase(), query_lower) { + ordered.push((self.resolve_date(date), display)); + } } } @@ -1506,11 +1829,27 @@ impl TrawlerApp { ); let mut seen = HashSet::new(); - let mut results: Vec<(NodeId, String)> = ordered + let mut named: Vec<(NodeId, String)> = ordered .into_iter() .filter(|(id, _)| seen.insert(id.clone())) .collect(); - results.sort_by(|a, b| a.1.cmp(&b.1)); + named.sort_by(|a, b| a.1.cmp(&b.1)); + + let mut results: Vec = Vec::new(); + // The home picker's unset option leads the list (spec scenario: + // "Unset reads as default") so clearing the designation is always + // discoverable without typing. + if purpose == QuickOpenPurpose::PickHome && fuzzy_contains("journal (default)", query_lower) + { + results.push(QuickOpenRow { + target: None, + label: "Journal (default)".to_string(), + }); + } + results.extend(named.into_iter().map(|(id, label)| QuickOpenRow { + target: Some(id), + label, + })); results.truncate(20); results } @@ -2282,8 +2621,37 @@ fn journal_page_date(outline: &Outline, page: TreeID) -> Option { NaiveDate::parse_from_str(content.trim(), "%Y-%m-%d").ok() } +/// Resolve the home-document designation into the view to open: the +/// journal when nothing is designated, `View::Node` otherwise. A +/// designation whose tree node no longer exists falls back to the journal +/// AND clears itself (spec: home-document/"Deleted home falls back and +/// clears") — the next Settings visit shows "Journal (default)". Tags are +/// virtual and never dangle. Free-standing so `TrawlerApp::new` can call +/// it before `Self` exists. +fn resolve_home_view_for(storage: &GraphStorage) -> View { + let settings = trawler_core::settings::Settings::new(storage.doc()); + match settings.home() { + None => View::Journal, + Some(id) => match &id { + NodeId::Tree(_) => { + let live = id + .as_tree_id() + .is_some_and(|t| Outline::new(storage.doc()).exists(t)); + if live { + View::Node(id) + } else { + settings.clear_home().expect("clear dangling home"); + storage.persist_update().expect("persist cleared home"); + View::Journal + } + } + _ => View::Node(id), + }, + } +} + /// Find or create today's journal page, seeding it with one empty block -/// ready for input (spec scenario: "First launch of the day"). +/// ready for input (spec scenario: "First journal visit of the day"). fn ensure_today_journal_page(storage: &GraphStorage, today: NaiveDate) -> TreeID { let outline = Outline::new(storage.doc()); for page in outline.children(None) { @@ -2465,6 +2833,11 @@ fn is_query_block(outline: &Outline, id: TreeID) -> bool { const REFERENCE_COLOR: u32 = THREAD_COLOR; const CODE_BG: u32 = 0x2a2a2a; const MUTED_COLOR: u32 = 0x9a9a9a; +/// Background of the keyboard-selected row in list overlays (quick open, +/// search, completion): the same green family as the editor's text +/// selection (the thread accent over a dark ground), so "selected" reads +/// as one color everywhere. +const SELECTED_ROW_BG: u32 = 0x3a4a3d; /// Default text color, set on the root element and also baked into each /// paragraph `TextRun` (runs carry explicit colors — they don't inherit). const BODY_TEXT_COLOR: u32 = 0xffffff; @@ -3382,6 +3755,7 @@ impl Render for TrawlerApp { View::Node(id) => id .as_tree_id() .is_some_and(|t| Outline::new(self.storage.doc()).parent(t).is_none()), + View::Settings => false, }; // Focused-path thread (design D4 as revised; animated re-threads // per openspec change animate-bullet-thread): resolve the focused @@ -3631,6 +4005,7 @@ impl Render for TrawlerApp { // a plain name. View::Node(NodeId::Tag(name)) => name.clone(), View::Node(NodeId::Date(date)) => date.to_string(), + View::Settings => "Settings".to_string(), }; // Tag/Date views have no outline rows of their own (see // `main_rows`) — without a header they'd render as a blank area @@ -3657,6 +4032,9 @@ impl Render for TrawlerApp { ) }); + let settings_screen = + matches!(self.view, View::Settings).then(|| self.render_settings_screen(cx)); + let breadcrumb = self.breadcrumb.clone(); let breadcrumb_bar = (!breadcrumb.is_empty()).then(|| { let crumbs = breadcrumb.iter().enumerate().flat_map(|(ix, (id, name))| { @@ -3721,61 +4099,23 @@ impl Render for TrawlerApp { .children(entries) }); - let quick_open_overlay = self.quick_open.as_ref().map(|state| { - let items = state - .results - .iter() - .enumerate() - .map(|(ix, (target, label))| { - let target = target.clone(); - let mut item = div() - .id(("quick-open-item", ix)) - .px_2() - .py_1() - .cursor_pointer() - .child(label.clone()) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, _event, window, cx| { - this.quick_open = None; - this.navigate_to(View::Node(target.clone()), None, window, cx); - }), - ); - if ix == state.selected { - item = item.bg(rgb(0x2a3a55)); - } - item - }); - deferred( - gpui::anchored().position(point(px(220.0), px(70.0))).child( - div() - .id("quick-open") - .track_focus(&self.quick_open_focus) - .on_key_down(cx.listener(Self::quick_open_key_down)) - .w(px(420.0)) - .max_h(px(320.0)) - .overflow_hidden() - .flex() - .flex_col() - .bg(rgb(0x252525)) - .border_1() - .border_color(rgb(0x3a3a3a)) - .rounded_md() - .shadow_lg() - .child( - div() - .px_2() - .py_1() - .border_b_1() - .border_color(rgb(0x3a3a3a)) - .child(format!("> {}", state.query)), - ) - .children(items), - ), - ) - .with_priority(2) - .into_any_element() - }); + // The ctrl-k switcher floats near the top of the window; the + // Settings home picker renders the same body inline beneath its + // select control instead (see `render_settings_screen`). + let quick_open_overlay = self + .quick_open + .as_ref() + .filter(|state| state.purpose == QuickOpenPurpose::Navigate) + .map(|state| { + let body = Self::render_quick_open_body(state, 420.0, &self.quick_open_focus, cx); + deferred( + gpui::anchored() + .position(point(px(220.0), px(70.0))) + .child(body), + ) + .with_priority(2) + .into_any_element() + }); let search_overlay = self.search_open.as_ref().map(|state| { let items = state.results.iter().enumerate().map(|(ix, hit)| { @@ -3798,7 +4138,7 @@ impl Render for TrawlerApp { ) .child(snippet); if ix == state.selected { - item = item.bg(rgb(0x2a3a55)); + item = item.bg(rgb(SELECTED_ROW_BG)); } item }); @@ -3888,6 +4228,29 @@ impl Render for TrawlerApp { ) .child(body) })) + .child(div().flex_1()) + .child( + // Sidebar footer (openspec change home-document, spec: + // app-chrome/"Settings lives in the footer"): the + // Settings screen's only entry point. + div() + .id("open-settings") + .border_t_1() + .border_color(rgb(0x3a3a3a)) + .px_2() + .py_1() + .flex() + .flex_row() + .items_center() + .gap_1() + .cursor_pointer() + .hover(|d| d.bg(rgb(0x2a2a3a))) + .child(assets::icon("icons/settings.svg")) + .child("Settings") + .on_click(cx.listener(|this, _event: &ClickEvent, window, cx| { + this.navigate_to(View::Settings, None, window, cx); + })), + ) }); div() @@ -3902,6 +4265,8 @@ impl Render for TrawlerApp { .on_action(cx.listener(Self::toggle_query_block)) .on_action(cx.listener(Self::toggle_calendar)) .on_action(cx.listener(Self::toggle_sidebar)) + .on_action(cx.listener(Self::go_home)) + .on_action(cx.listener(Self::go_to_journal)) .on_action(cx.listener(Self::create_page_for_view)) .on_action(cx.listener(Self::zoom_in)) .on_action(cx.listener(Self::zoom_out)) @@ -3996,9 +4361,34 @@ impl Render for TrawlerApp { this.navigate_forward(&NavigateForward, window, cx); })), ) + .child( + // Home + Journal (openspec change home-document, + // spec: app-chrome/"Home and Journal header + // controls") in place of the old Calendar button: + // Home resolves the designated home document + // (journal when unset — never inert); Journal is + // the timeline's permanent way back once a home + // is designated. The calendar stays reachable via + // the sidebar and ctrl-shift-c. + div() + .id("go-home") + .cursor_pointer() + .px_2() + .rounded_sm() + .hover(|d| d.bg(rgb(0x2a2a3a))) + .flex() + .flex_row() + .items_center() + .gap_1() + .child(assets::icon("icons/home.svg")) + .child("Home") + .on_click(cx.listener(|this, _event: &ClickEvent, window, cx| { + this.go_home(&GoHome, window, cx); + })), + ) .child( div() - .id("open-calendar") + .id("go-journal") .cursor_pointer() .px_2() .rounded_sm() @@ -4007,10 +4397,10 @@ impl Render for TrawlerApp { .flex_row() .items_center() .gap_1() - .child(assets::icon("icons/calendar.svg")) - .child("Calendar") + .child(assets::icon("icons/notebook.svg")) + .child("Journal") .on_click(cx.listener(|this, _event: &ClickEvent, window, cx| { - this.toggle_calendar(&ToggleCalendar, window, cx); + this.go_to_journal(&GoToJournal, window, cx); })), ) .child( @@ -4100,6 +4490,11 @@ impl Render for TrawlerApp { .flex_col() .flex_1() .min_w_0() + // The Settings screen renders here in place of + // outline rows (`main_rows` is empty for + // `View::Settings`, so the list below shows + // nothing and just absorbs leftover height). + .children(settings_screen) .child( div() .flex_1() diff --git a/crates/trawler/src/ui_tests.rs b/crates/trawler/src/ui_tests.rs index f96a57e..bed513c 100644 --- a/crates/trawler/src/ui_tests.rs +++ b/crates/trawler/src/ui_tests.rs @@ -12,9 +12,12 @@ use std::path::PathBuf; use chrono::Datelike; use gpui::{Entity, TestAppContext, VisualTestContext}; use loro::TreeID; +use trawler_core::graph::NodeId; use trawler_core::outline::Outline; +use trawler_core::settings::Settings; +use trawler_core::storage::GraphStorage; -use crate::TrawlerApp; +use crate::{GoToJournal, QuickOpenPurpose, TrawlerApp, View}; fn fixture_dir(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!( @@ -35,10 +38,20 @@ fn open_app<'a>( name: &str, cx: &'a mut TestAppContext, ) -> (Entity, &'a mut VisualTestContext) { + let dir = fixture_dir(name); + open_app_at(dir, cx) +} + +/// [`open_app`] over an already-seeded (and possibly further prepared) +/// graph directory — home-document startup tests configure the graph +/// before the app's first launch sees it. +fn open_app_at( + dir: PathBuf, + cx: &mut TestAppContext, +) -> (Entity, &mut VisualTestContext) { crate::set_scroll_animation_ms(0); crate::editor::set_caret_animation_ms(0); crate::set_thread_animation_ms(0); - let dir = fixture_dir(name); cx.update(|cx| { crate::editor::init(cx); crate::init_keymap(cx); @@ -46,6 +59,48 @@ fn open_app<'a>( cx.add_window_view(|window, cx| TrawlerApp::new(dir, window, cx)) } +/// Seed a fixture graph and designate a home document in it before the +/// app opens: a page picked by content, or a tag given as `#name`. +fn fixture_dir_with_home(name: &str, home: &str) -> PathBuf { + let dir = fixture_dir(name); + let storage = GraphStorage::open(&dir).expect("open graph to designate home"); + let id = if let Some(tag) = home.strip_prefix('#') { + NodeId::tag(tag) + } else { + let outline = Outline::new(storage.doc()); + let page = outline + .children(None) + .into_iter() + .find(|&p| outline.content(p).unwrap_or_default() == home) + .expect("home page exists in fixture"); + NodeId::tree(page) + }; + Settings::new(storage.doc()) + .set_home(&id) + .expect("designate home"); + storage.persist_update().expect("persist home designation"); + dir +} + +fn current_view(app: &Entity, cx: &mut VisualTestContext) -> View { + app.update(cx, |app, _cx| app.view.clone()) +} + +/// Whether a journal page titled with today's date exists in the graph. +fn todays_journal_page_exists(app: &Entity, cx: &mut VisualTestContext) -> bool { + app.update(cx, |app, _cx| { + let outline = Outline::new(app.storage.doc()); + let today = chrono::Local::now() + .date_naive() + .format("%Y-%m-%d") + .to_string(); + outline + .children(None) + .into_iter() + .any(|p| outline.content(p).unwrap_or_default().trim() == today) + }) +} + /// Find the unique block whose content equals `content`, anywhere in the /// outline — how tests address fixture blocks without hardcoding ids. fn block_by_content(app: &Entity, cx: &mut VisualTestContext, content: &str) -> TreeID { @@ -1297,3 +1352,213 @@ async fn viewport_follows_focus_past_the_fold(cx: &mut gpui::TestAppContext) { ); }); } + +// --- home document (openspec change home-document) --------------------------- + +/// Startup honors a designated home page: it opens as the view, nothing is +/// focused (spec: "Pinned home opens unfocused"), and — because the journal +/// was never opened — no journal page materializes for today (spec: +/// journal/"Unvisited days create nothing"). +#[gpui::test] +async fn startup_with_home_page_opens_it_unfocused(cx: &mut gpui::TestAppContext) { + let dir = fixture_dir_with_home("home-page-startup", "trawler-design"); + let (app, cx) = open_app_at(dir, cx); + cx.run_until_parked(); + + let page = block_by_content(&app, cx, "trawler-design"); + assert_eq!(current_view(&app, cx), View::Node(NodeId::tree(page))); + app.update(cx, |app, _cx| { + assert!(app.editor.is_none(), "designated home opens unfocused"); + }); + assert!( + !todays_journal_page_exists(&app, cx), + "no journal page may be created when the journal wasn't opened" + ); +} + +/// A tag works as home (`#inbox`-style homebase): the tag view opens, +/// unfocused, exactly as if navigated to. +#[gpui::test] +async fn startup_with_tag_home_opens_tag_view(cx: &mut gpui::TestAppContext) { + let dir = fixture_dir_with_home("home-tag-startup", "#project"); + let (app, cx) = open_app_at(dir, cx); + cx.run_until_parked(); + + assert_eq!(current_view(&app, cx), View::Node(NodeId::tag("project"))); + app.update(cx, |app, _cx| assert!(app.editor.is_none())); +} + +/// A home whose page was deleted falls back to the journal and clears the +/// designation (spec: "Deleted home falls back and clears") — and the +/// fallback startup is the ordinary journal startup, today's page focused. +#[gpui::test] +async fn dangling_home_falls_back_and_clears(cx: &mut gpui::TestAppContext) { + let dir = fixture_dir_with_home("home-dangling", "reading-list"); + { + let storage = GraphStorage::open(&dir).expect("reopen to delete home page"); + let outline = Outline::new(storage.doc()); + let page = outline + .children(None) + .into_iter() + .find(|&p| outline.content(p).unwrap_or_default() == "reading-list") + .expect("page exists"); + outline.delete_block(page).expect("delete home page"); + storage.persist_update().expect("persist deletion"); + } + let (app, cx) = open_app_at(dir, cx); + cx.run_until_parked(); + + assert_eq!(current_view(&app, cx), View::Journal); + app.update(cx, |app, _cx| { + assert_eq!( + Settings::new(app.storage.doc()).home(), + None, + "dangling designation must be cleared" + ); + assert!(app.editor.is_some(), "journal fallback focuses as usual"); + }); + assert!(todays_journal_page_exists(&app, cx)); +} + +/// Today's journal page is created by opening the journal, not by +/// startup (spec: journal/"Automatic daily pages" as revised). +#[gpui::test] +async fn journal_page_created_on_open_not_startup(cx: &mut gpui::TestAppContext) { + let dir = fixture_dir_with_home("home-journal-lazy", "trawler-design"); + let (app, cx) = open_app_at(dir, cx); + cx.run_until_parked(); + assert!(!todays_journal_page_exists(&app, cx)); + + app.update_in(cx, |app, window, cx| { + app.go_to_journal(&GoToJournal, window, cx); + }); + cx.run_until_parked(); + + assert_eq!(current_view(&app, cx), View::Journal); + assert!( + todays_journal_page_exists(&app, cx), + "opening the journal materializes today's page" + ); +} + +/// ctrl-h navigates to the designated home from anywhere, and back +/// returns (spec: graph-navigation/"Go home from anywhere"). +#[gpui::test] +async fn ctrl_h_goes_home_and_back_returns(cx: &mut gpui::TestAppContext) { + let dir = fixture_dir_with_home("home-ctrl-h", "trawler-design"); + let (app, cx) = open_app_at(dir, cx); + cx.run_until_parked(); + let page = block_by_content(&app, cx, "trawler-design"); + + app.update_in(cx, |app, window, cx| { + app.go_to_journal(&GoToJournal, window, cx); + }); + cx.run_until_parked(); + assert_eq!(current_view(&app, cx), View::Journal); + + cx.simulate_keystrokes("ctrl-h"); + cx.run_until_parked(); + assert_eq!(current_view(&app, cx), View::Node(NodeId::tree(page))); + + cx.simulate_keystrokes("alt-left"); + cx.run_until_parked(); + assert_eq!(current_view(&app, cx), View::Journal); +} + +/// With nothing designated, Home is never inert: it opens the journal +/// (spec: app-chrome/"Home with nothing designated"). +#[gpui::test] +async fn ctrl_h_with_no_home_opens_journal(cx: &mut gpui::TestAppContext) { + let (app, cx) = open_app("home-unset-ctrl-h", cx); + cx.run_until_parked(); + open_page(cx, "trawler-design"); + cx.run_until_parked(); + + cx.simulate_keystrokes("ctrl-h"); + cx.run_until_parked(); + assert_eq!(current_view(&app, cx), View::Journal); +} + +/// The Settings screen's picker designates and clears the home; the +/// unset option leads the list; picking stays on Settings (spec: +/// home-document/"Settings screen"). +#[gpui::test] +async fn settings_picker_sets_and_clears_home(cx: &mut gpui::TestAppContext) { + let (app, cx) = open_app("settings-home-picker", cx); + cx.run_until_parked(); + + app.update_in(cx, |app, window, cx| { + app.navigate_to(View::Settings, None, window, cx); + }); + cx.run_until_parked(); + assert_eq!(current_view(&app, cx), View::Settings); + + app.update_in(cx, |app, window, cx| { + app.open_quick_open(QuickOpenPurpose::PickHome, window, cx); + }); + cx.run_until_parked(); + app.update(cx, |app, _cx| { + let state = app.quick_open.as_ref().expect("picker open"); + let first = state.results.first().expect("picker has rows"); + assert!( + first.target.is_none() && first.label == "Journal (default)", + "unset option leads the picker (spec: unset reads as default)" + ); + }); + + cx.simulate_input("trawler-design"); + cx.simulate_keystrokes("enter"); + cx.run_until_parked(); + let page = block_by_content(&app, cx, "trawler-design"); + app.update(cx, |app, _cx| { + assert_eq!( + Settings::new(app.storage.doc()).home(), + Some(NodeId::tree(page)) + ); + }); + assert_eq!( + current_view(&app, cx), + View::Settings, + "picking a home stays on the settings screen" + ); + + app.update_in(cx, |app, window, cx| { + app.open_quick_open(QuickOpenPurpose::PickHome, window, cx); + }); + cx.run_until_parked(); + cx.simulate_keystrokes("enter"); + cx.run_until_parked(); + app.update(cx, |app, _cx| { + assert_eq!( + Settings::new(app.storage.doc()).home(), + None, + "the leading default row clears the designation" + ); + }); +} + +/// Settings is an ordinary history entry (spec: graph-navigation/ +/// "Settings is a history entry"). +#[gpui::test] +async fn settings_participates_in_history(cx: &mut gpui::TestAppContext) { + let (app, cx) = open_app("settings-history", cx); + cx.run_until_parked(); + + app.update_in(cx, |app, window, cx| { + app.navigate_to(View::Settings, None, window, cx); + }); + cx.run_until_parked(); + app.update_in(cx, |app, window, cx| { + app.go_to_journal(&GoToJournal, window, cx); + }); + cx.run_until_parked(); + assert_eq!(current_view(&app, cx), View::Journal); + + cx.simulate_keystrokes("alt-left"); + cx.run_until_parked(); + assert_eq!(current_view(&app, cx), View::Settings); + + cx.simulate_keystrokes("alt-left"); + cx.run_until_parked(); + assert_eq!(current_view(&app, cx), View::Journal); +} diff --git a/openspec/changes/home-document/tasks.md b/openspec/changes/home-document/tasks.md index ed80f86..31d6342 100644 --- a/openspec/changes/home-document/tasks.md +++ b/openspec/changes/home-document/tasks.md @@ -1,30 +1,30 @@ -# Tasks: home-document - -## 1. Storage - -- [ ] 1.1 Add a doc-level settings map accessor to trawler-core with a `home` LWW key storing a serialized node id (`tree:…`/`tag:…`); absent = journal. Unit-test set/clear/read round-trip and LWW convergence of concurrent sets. -- [ ] 1.2 Add resolve-home logic: parse the stored id, verify a `tree:` node still exists; on a dangling node, report fallback so the caller can clear the key. - -## 2. Startup and journal creation - -- [ ] 2.1 Move `ensure_today_journal_page` out of `TrawlerApp::new` into journal-view entry (a shared "opening the journal" path used by startup-default, the Journal control, and rollover-while-displayed). -- [ ] 2.2 Startup resolves the home document: unset → journal with today's first block focused (existing behavior); set → `View::Node` without focusing; dangling → journal plus key cleared. -- [ ] 2.3 UI tests: default startup unchanged; startup with home page set opens it unfocused; startup with dangling home falls back and clears; several launches with non-journal home create no journal pages. - -## 3. Header controls and actions - -- [ ] 3.1 Replace the Calendar titlebar button with Home and Journal buttons; add `GoHome` and `GoToJournal` actions, bind `ctrl-h` to `GoHome`. -- [ ] 3.2 Both actions navigate via `navigate_to` (history push); Home resolves the home document with the same fallback path as startup. -- [ ] 3.3 UI tests: `ctrl-h` from an arbitrary page lands on home and back returns; Journal control reaches the timeline when a home is set. - -## 4. Settings screen - -- [ ] 4.1 Add `View::Settings` variant rendering a full main-column settings screen; wire history/breadcrumb behavior like other views. -- [ ] 4.2 Add the sidebar footer with a Settings button opening `View::Settings`. -- [ ] 4.3 Home setting row: shows current home ("Journal (default)" when unset); a type-ahead picker over pages and tags (reusing quick-open machinery) plus a "Journal (default)" row that clears the key. -- [ ] 4.4 UI tests: open settings from footer; set home via picker and confirm persistence across relaunch (storage-level); unset shows default label; back from Settings returns to prior view. - -## 5. Polish and verification - -- [ ] 5.1 Devtools dump: expose the current view kind for Settings and the resolved home so UI tests and the dev-loop can assert on them. -- [ ] 5.2 Full check: `cargo fmt`, clippy `-D warnings`, `cargo test --workspace`; dev-loop screenshot pass over the new chrome (titlebar buttons, footer, settings screen). +# Tasks: home-document + +## 1. Storage + +- [x] 1.1 Add a doc-level settings map accessor to trawler-core with a `home` LWW key storing a serialized node id (`tree:…`/`tag:…`); absent = journal. Unit-test set/clear/read round-trip and LWW convergence of concurrent sets. +- [x] 1.2 Add resolve-home logic: parse the stored id, verify a `tree:` node still exists; on a dangling node, report fallback so the caller can clear the key. + +## 2. Startup and journal creation + +- [x] 2.1 Move `ensure_today_journal_page` out of `TrawlerApp::new` into journal-view entry (a shared "opening the journal" path used by startup-default, the Journal control, and rollover-while-displayed). +- [x] 2.2 Startup resolves the home document: unset → journal with today's first block focused (existing behavior); set → `View::Node` without focusing; dangling → journal plus key cleared. +- [x] 2.3 UI tests: default startup unchanged; startup with home page set opens it unfocused; startup with dangling home falls back and clears; several launches with non-journal home create no journal pages. + +## 3. Header controls and actions + +- [x] 3.1 Replace the Calendar titlebar button with Home and Journal buttons; add `GoHome` and `GoToJournal` actions, bind `ctrl-h` to `GoHome`. +- [x] 3.2 Both actions navigate via `navigate_to` (history push); Home resolves the home document with the same fallback path as startup. +- [x] 3.3 UI tests: `ctrl-h` from an arbitrary page lands on home and back returns; Journal control reaches the timeline when a home is set. + +## 4. Settings screen + +- [x] 4.1 Add `View::Settings` variant rendering a full main-column settings screen; wire history/breadcrumb behavior like other views. +- [x] 4.2 Add the sidebar footer with a Settings button opening `View::Settings`. +- [x] 4.3 Home setting row: shows current home ("Journal (default)" when unset); a type-ahead picker over pages and tags (reusing quick-open machinery) plus a "Journal (default)" row that clears the key. +- [x] 4.4 UI tests: open settings from footer; set home via picker and confirm persistence across relaunch (storage-level); unset shows default label; back from Settings returns to prior view. + +## 5. Polish and verification + +- [x] 5.1 Devtools dump: expose the current view kind for Settings and the resolved home so UI tests and the dev-loop can assert on them. +- [x] 5.2 Full check: `cargo fmt`, clippy `-D warnings`, `cargo test --workspace`; dev-loop screenshot pass over the new chrome (titlebar buttons, footer, settings screen).