git.lucas.co / cce-graph
node-based graph editor
git clone https://git.lucas.co/cce-graph.git

commit80296c348b179e7ce4576fa06731a27faa04495a
parent3a4ee54c5e
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 13:24
feat: cce-graph --vault, the notes vault as a link graph (Obsidian-on-cce M4)

A separate Application that main runs for `--vault [DIR] [--local
[NOTE]]`; the project editor is untouched.

- linkgraph: a node per note plus a ghost per unresolved link target, one
  edge per linked pair, built from cce-vault's index and rebuilt on each
  watcher batch keeping positions and pins. Force layout with repulsion
  through a spatial grid, springs and a centering pull, stepped while it
  cools; settled, the app goes idle
- global and local views; the local graph is N hops (1-4) around the note
  open in cce-notes, polled as `current` on cce-notes' socket each second
- a click opens the note in cce-notes (its socket, or a launch); drag
  pins, right-click unpins; hover lights a node's neighbourhood
- filter: names, #tag / tag:, path:; labels fade in with zoom
- single instance on cce-graph-vault-<display>.sock; Vault Graph desktop
  entry

Verified in a scale-1 shadow with cce-notes, against a copy of the vault
plus 80 generated linked notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md               |  32 ++
 Cargo.toml              |   4 +
 cce-graph-vault.desktop |  10 +
 src/linkgraph.rs        | 460 ++++++++++++++++++++++++
 src/main.rs             |   9 +
 src/vault.rs            | 923 ++++++++++++++++++++++++++++++++++++++++++++++++
 6 files changed, 1438 insertions(+)

diff --git a/CLAUDE.md b/CLAUDE.md
index 7fb640b..850d5aa 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -22,6 +22,38 @@ here — this crate is orchestration: menu bar + File/Edit/View dropdowns, the `
 widget, an image overlay, and a floating "control panel" showing the selected
 node/image.
 
+## Vault mode (`cce-graph --vault`)
+
+A second, separate `Application` (`src/vault.rs`, model in
+`src/linkgraph.rs`): the notes vault as a link graph, milestone 4 of the
+Obsidian-on-cce plan. `main` runs it when the arguments carry `--vault`
+(`cce-graph --vault [DIR] [--local [NOTE]]`; DIR defaults to the vault in
+config.kdl, as cce-notes reads it). Nothing it does touches the project
+editor.
+
+- **Model:** a node per note plus a grey ghost per link target that does
+  not exist; one edge per linked pair. Built from `cce_vault::Index`, and
+  rebuilt on every watcher batch keeping positions and pins.
+- **Layout:** force-directed (repulsion through a spatial grid of
+  `REPULSE_RANGE` cells, springs, a centering pull), stepped in `tick`
+  while `alpha` cools; once settled the app goes idle. The camera fits
+  once the layout has spread and again when it settles, unless the user
+  has panned or zoomed.
+- **Local graph:** N hops (Depth chip, Ctrl+=/−) around the note open in
+  cce-notes, polled once a second as `current` on cce-notes' instance
+  socket (`idle_poll_interval` runs only in local mode). A click on a node
+  sends `open <abs path>` over that socket, or launches cce-notes.
+- **Single instance** on `/tmp/cce-graph-vault-<display>.sock`; a second
+  launch forwards `local [note]` / `global` and exits — cce-notes' Ctrl+G
+  relies on that.
+- **Filter box:** words match names, `#tag` / `tag:` tags (nested too),
+  `path:` paths. Drag pins a node, right-click unpins.
+- Labels fade in from zoom 1.1; the hovered node's neighbourhood is lit
+  and labelled whatever the zoom.
+
+`cargo test -p cce-graph` covers the model (build, ghosts, hops, the layout
+settling with links short, filters, hit-testing) and the editor's wiring.
+
 ## Build and run
 
 ```sh
diff --git a/Cargo.toml b/Cargo.toml
index f98810a..24e03c6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,4 +15,8 @@ serde = { version = "1", features = ["derive"] }
 serde_json = "1"
 image = { version = "0.25.10", features = ["png", "jpeg"] }
 kdl = "4.6"
+cce-vault = { git = "https://github.com/lsgalante/cce-vault.git", rev = "11a26a5f8e1ff1ac55a951d88afc751ab68a5aba" }
+
+[dev-dependencies]
+tempfile = "3"
 
diff --git a/cce-graph-vault.desktop b/cce-graph-vault.desktop
new file mode 100644
index 0000000..61068da
--- /dev/null
+++ b/cce-graph-vault.desktop
@@ -0,0 +1,10 @@
+[Desktop Entry]
+Type=Application
+Name=Vault Graph
+Comment=The notes vault as a graph of links
+Exec=cce-graph --vault
+Icon=cce-graph
+Categories=Office;Utility;
+Terminal=false
+StartupNotify=true
+StartupWMClass=cce-graph-vault
diff --git a/src/linkgraph.rs b/src/linkgraph.rs
new file mode 100644
index 0000000..238bce7
--- /dev/null
+++ b/src/linkgraph.rs
@@ -0,0 +1,460 @@
+//! The vault as a link graph: one node per note (plus a ghost per link
+//! target that does not exist yet, as Obsidian shows them), an edge per
+//! pair of notes linked either way, and a force layout that cools and
+//! stops so the app goes idle once the picture settles.
+//!
+//! Pure model, no drawing: positions are world units, the app owns the
+//! camera. Kept apart from cce-ui's `Graph` widget on purpose — ports,
+//! grid snapping and name-keyed wires are node-editor semantics, and a
+//! vault of thousands of notes needs id-keyed edges and a spatial grid.
+
+use std::collections::{HashMap, HashSet, VecDeque};
+
+use cce_vault::index::stem;
+use cce_vault::{FileKind, Index};
+
+/// The rest length of a link's spring.
+const LINK_LEN: f32 = 90.0;
+const LINK_K: f32 = 0.08;
+const REPULSE: f32 = 9000.0;
+/// Repulsion only reaches this far; past it the centering pull dominates
+/// anyway. It is also the spatial grid's cell size.
+const REPULSE_RANGE: f32 = 300.0;
+const CENTER_K: f32 = 0.006;
+const DAMPING: f32 = 0.55;
+const COOLING: f32 = 0.985;
+/// Below this temperature the layout is settled and stops stepping.
+const SETTLED: f32 = 0.004;
+
+#[derive(Clone, Debug)]
+pub struct Node {
+    /// The note's vault path; for a ghost, the link text that resolves
+    /// nowhere.
+    pub path: String,
+    pub name: String,
+    pub ghost: bool,
+    pub tags: Vec<String>,
+    pub pos: (f32, f32),
+    vel: (f32, f32),
+    /// Held where the user dropped it.
+    pub pinned: bool,
+}
+
+#[derive(Default)]
+pub struct LinkGraph {
+    pub nodes: Vec<Node>,
+    pub edges: Vec<(usize, usize)>,
+    adj: Vec<Vec<usize>>,
+    by_path: HashMap<String, usize>,
+    /// The layout's temperature: forces scale with it, and it decays.
+    pub alpha: f32,
+}
+
+impl LinkGraph {
+    /// Build from the index. Nodes that were in `prev` keep their place;
+    /// new ones start next to a neighbour already placed, or on a spiral.
+    pub fn build(ix: &Index, prev: Option<&LinkGraph>) -> LinkGraph {
+        let mut g = LinkGraph::default();
+        let mut notes: Vec<&str> = ix.files().iter().filter(|(_, e)| e.kind == FileKind::Note).map(|(p, _)| p.as_str()).collect();
+        notes.sort();
+        for p in notes {
+            let tags = ix.note(p).map(|n| n.tags.iter().map(|t| t.name.to_lowercase()).collect()).unwrap_or_default();
+            g.add(Node { path: p.to_string(), name: stem(p).to_string(), ghost: false, tags, pos: (0.0, 0.0), vel: (0.0, 0.0), pinned: false });
+        }
+        let mut seen: HashSet<(usize, usize)> = HashSet::new();
+        let count = g.nodes.len();
+        for i in 0..count {
+            let from = g.nodes[i].path.clone();
+            for (link, target) in ix.outgoing(&from) {
+                let j = match target {
+                    Some(t) => match g.by_path.get(t) {
+                        Some(&j) => j,
+                        // A link to an attachment or canvas: not drawn.
+                        None => continue,
+                    },
+                    None => {
+                        let key = format!("?{}", link.target.to_lowercase());
+                        match g.by_path.get(&key) {
+                            Some(&j) => j,
+                            None => g.add(Node {
+                                path: key,
+                                name: link.target.clone(),
+                                ghost: true,
+                                tags: Vec::new(),
+                                pos: (0.0, 0.0),
+                                vel: (0.0, 0.0),
+                                pinned: false,
+                            }),
+                        }
+                    }
+                };
+                if i != j && seen.insert((i.min(j), i.max(j))) {
+                    g.edges.push((i.min(j), i.max(j)));
+                }
+            }
+        }
+        g.adj = vec![Vec::new(); g.nodes.len()];
+        for &(a, b) in &g.edges {
+            g.adj[a].push(b);
+            g.adj[b].push(a);
+        }
+        g.place(prev);
+        g.alpha = 1.0;
+        g
+    }
+
+    fn add(&mut self, node: Node) -> usize {
+        let i = self.nodes.len();
+        self.by_path.insert(node.path.clone(), i);
+        self.nodes.push(node);
+        i
+    }
+
+    fn place(&mut self, prev: Option<&LinkGraph>) {
+        let mut placed = vec![false; self.nodes.len()];
+        if let Some(prev) = prev {
+            for (i, n) in self.nodes.iter_mut().enumerate() {
+                if let Some(&j) = prev.by_path.get(&n.path) {
+                    n.pos = prev.nodes[j].pos;
+                    n.pinned = prev.nodes[j].pinned;
+                    placed[i] = true;
+                }
+            }
+        }
+        // A golden-angle spiral spreads the rest without overlaps; a new
+        // note with a placed neighbour starts beside it instead.
+        for i in 0..self.nodes.len() {
+            if placed[i] {
+                continue;
+            }
+            let near = self.adj[i].iter().find(|&&j| placed[j]).map(|&j| self.nodes[j].pos);
+            let t = i as f32 * 2.399_963;
+            self.nodes[i].pos = match near {
+                Some((x, y)) => (x + 20.0 * t.cos(), y + 20.0 * t.sin()),
+                None => {
+                    let r = 18.0 * (i as f32 + 1.0).sqrt();
+                    (r * t.cos(), r * t.sin())
+                }
+            };
+            placed[i] = true;
+        }
+    }
+
+    pub fn len(&self) -> usize {
+        self.nodes.len()
+    }
+
+    pub fn index_of(&self, path: &str) -> Option<usize> {
+        self.by_path.get(path).copied()
+    }
+
+    pub fn neighbors(&self, i: usize) -> &[usize] {
+        &self.adj[i]
+    }
+
+    pub fn degree(&self, i: usize) -> usize {
+        self.adj[i].len()
+    }
+
+    /// Draw radius: grows with the number of links, as Obsidian's does.
+    pub fn radius(&self, i: usize) -> f32 {
+        3.5 + (self.degree(i) as f32).sqrt() * 2.2
+    }
+
+    /// Every node within `hops` links of `center`.
+    pub fn within(&self, center: usize, hops: usize) -> Vec<bool> {
+        let mut seen = vec![false; self.nodes.len()];
+        let mut queue = VecDeque::from([(center, 0usize)]);
+        seen[center] = true;
+        while let Some((i, d)) = queue.pop_front() {
+            if d == hops {
+                continue;
+            }
+            for &j in &self.adj[i] {
+                if !seen[j] {
+                    seen[j] = true;
+                    queue.push_back((j, d + 1));
+                }
+            }
+        }
+        seen
+    }
+
+    pub fn reheat(&mut self, to: f32) {
+        self.alpha = self.alpha.max(to);
+    }
+
+    pub fn settled(&self) -> bool {
+        self.alpha < SETTLED
+    }
+
+    /// One step of the layout over the `visible` nodes (the others are
+    /// frozen and exert nothing). False once settled.
+    pub fn step(&mut self, visible: &[bool]) -> bool {
+        if self.settled() {
+            return false;
+        }
+        let n = self.nodes.len();
+        let mut force = vec![(0.0f32, 0.0f32); n];
+
+        // Repulsion between nodes sharing or neighbouring a grid cell.
+        let cell = |p: (f32, f32)| ((p.0 / REPULSE_RANGE).floor() as i32, (p.1 / REPULSE_RANGE).floor() as i32);
+        let mut grid: HashMap<(i32, i32), Vec<usize>> = HashMap::new();
+        for i in (0..n).filter(|&i| visible[i]) {
+            grid.entry(cell(self.nodes[i].pos)).or_default().push(i);
+        }
+        let range2 = REPULSE_RANGE * REPULSE_RANGE;
+        for (&(cx, cy), members) in &grid {
+            for dx in -1..=1 {
+                for dy in -1..=1 {
+                    let Some(others) = grid.get(&(cx + dx, cy + dy)) else { continue };
+                    for &i in members {
+                        for &j in others {
+                            if j <= i {
+                                continue;
+                            }
+                            let (pi, pj) = (self.nodes[i].pos, self.nodes[j].pos);
+                            let (mut ddx, mut ddy) = (pi.0 - pj.0, pi.1 - pj.1);
+                            let mut d2 = ddx * ddx + ddy * ddy;
+                            if d2 > range2 {
+                                continue;
+                            }
+                            if d2 < 0.01 {
+                                // Coincident: nudge apart deterministically.
+                                ddx = 0.1 * ((i as f32).sin() + 0.5);
+                                ddy = 0.1 * ((j as f32).cos() + 0.5);
+                                d2 = ddx * ddx + ddy * ddy;
+                            }
+                            let f = REPULSE / d2.max(25.0);
+                            let d = d2.sqrt();
+                            let (fx, fy) = (ddx / d * f, ddy / d * f);
+                            force[i].0 += fx;
+                            force[i].1 += fy;
+                            force[j].0 -= fx;
+                            force[j].1 -= fy;
+                        }
+                    }
+                }
+            }
+        }
+        // Springs along the links.
+        for &(a, b) in &self.edges {
+            if !(visible[a] && visible[b]) {
+                continue;
+            }
+            let (pa, pb) = (self.nodes[a].pos, self.nodes[b].pos);
+            let (dx, dy) = (pb.0 - pa.0, pb.1 - pa.1);
+            let d = (dx * dx + dy * dy).sqrt().max(0.01);
+            let f = (d - LINK_LEN) * LINK_K;
+            let (fx, fy) = (dx / d * f, dy / d * f);
+            force[a].0 += fx;
+            force[a].1 += fy;
+            force[b].0 -= fx;
+            force[b].1 -= fy;
+        }
+        // A gentle pull to the middle keeps islands and orphans in view.
+        let mut moving = false;
+        for i in (0..n).filter(|&i| visible[i]) {
+            let node = &mut self.nodes[i];
+            if node.pinned {
+                node.vel = (0.0, 0.0);
+                continue;
+            }
+            let (fx, fy) = (force[i].0 - node.pos.0 * CENTER_K, force[i].1 - node.pos.1 * CENTER_K);
+            node.vel.0 = (node.vel.0 + fx * self.alpha) * DAMPING;
+            node.vel.1 = (node.vel.1 + fy * self.alpha) * DAMPING;
+            // Cap a step, so a cold start cannot fling a node away.
+            let speed = (node.vel.0 * node.vel.0 + node.vel.1 * node.vel.1).sqrt();
+            if speed > 40.0 {
+                node.vel.0 *= 40.0 / speed;
+                node.vel.1 *= 40.0 / speed;
+            }
+            node.pos.0 += node.vel.0;
+            node.pos.1 += node.vel.1;
+            moving |= speed > 0.05;
+        }
+        self.alpha *= COOLING;
+        if !moving {
+            self.alpha = 0.0;
+        }
+        !self.settled()
+    }
+
+    /// The topmost visible node within its radius (plus `slop`) of a world
+    /// point.
+    pub fn hit(&self, visible: &[bool], x: f32, y: f32, slop: f32) -> Option<usize> {
+        let mut best: Option<(usize, f32)> = None;
+        for i in (0..self.nodes.len()).filter(|&i| visible[i]) {
+            let (px, py) = self.nodes[i].pos;
+            let d2 = (px - x).powi(2) + (py - y).powi(2);
+            let r = self.radius(i) + slop;
+            if d2 <= r * r && best.is_none_or(|(_, b)| d2 < b) {
+                best = Some((i, d2));
+            }
+        }
+        best.map(|(i, _)| i)
+    }
+
+    /// The bounds of the visible nodes, as (min, max).
+    pub fn bounds(&self, visible: &[bool]) -> Option<((f32, f32), (f32, f32))> {
+        let mut it = (0..self.nodes.len()).filter(|&i| visible[i]).map(|i| self.nodes[i].pos);
+        let first = it.next()?;
+        Some(it.fold((first, first), |(lo, hi), p| ((lo.0.min(p.0), lo.1.min(p.1)), (hi.0.max(p.0), hi.1.max(p.1)))))
+    }
+}
+
+/// What the filter box asks for: every term must match. `#tag` matches a
+/// note carrying the tag (or one nested under it), `path:x` a path
+/// containing `x`, anything else a name containing it.
+#[derive(Default, Debug, PartialEq)]
+pub struct Filter {
+    tags: Vec<String>,
+    paths: Vec<String>,
+    words: Vec<String>,
+}
+
+impl Filter {
+    pub fn parse(q: &str) -> Filter {
+        let mut f = Filter::default();
+        for term in q.split_whitespace() {
+            let t = term.to_lowercase();
+            if let Some(tag) = t.strip_prefix('#').or_else(|| t.strip_prefix("tag:")) {
+                if !tag.is_empty() {
+                    f.tags.push(tag.to_string());
+                }
+            } else if let Some(p) = t.strip_prefix("path:") {
+                if !p.is_empty() {
+                    f.paths.push(p.to_string());
+                }
+            } else {
+                f.words.push(t);
+            }
+        }
+        f
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.tags.is_empty() && self.paths.is_empty() && self.words.is_empty()
+    }
+
+    pub fn matches(&self, n: &Node) -> bool {
+        let name = n.name.to_lowercase();
+        let path = n.path.to_lowercase();
+        self.words.iter().all(|w| name.contains(w.as_str()))
+            && self.paths.iter().all(|p| !n.ghost && path.contains(p.as_str()))
+            && self.tags.iter().all(|t| n.tags.iter().any(|nt| nt == t || nt.starts_with(&format!("{t}/"))))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn vault(files: &[(&str, &str)]) -> (tempfile::TempDir, Index) {
+        let dir = tempfile::tempdir().unwrap();
+        for (p, t) in files {
+            let abs = dir.path().join(p);
+            std::fs::create_dir_all(abs.parent().unwrap()).unwrap();
+            std::fs::write(abs, t).unwrap();
+        }
+        let ix = Index::open(dir.path(), false).unwrap();
+        (dir, ix)
+    }
+
+    fn sample() -> (tempfile::TempDir, Index) {
+        vault(&[
+            ("A.md", "[[B]] [[C]] [[Nowhere]] #proj\n"),
+            ("B.md", "[[A]] back again\n"),
+            ("dir/C.md", "[[D]]\n"),
+            ("D.md", "end #proj/sub\n"),
+            ("Lone.md", "no links\n"),
+            ("pic.png", "x"),
+        ])
+    }
+
+    #[test]
+    fn nodes_edges_and_ghosts() {
+        let (_d, ix) = sample();
+        let g = LinkGraph::build(&ix, None);
+        let names: Vec<&str> = g.nodes.iter().map(|n| n.name.as_str()).collect();
+        assert_eq!(names, ["A", "B", "D", "Lone", "C", "Nowhere"]);
+        assert!(g.nodes[5].ghost);
+        // A–B once although linked both ways; A–C, A–Nowhere, C–D.
+        assert_eq!(g.edges.len(), 4);
+        let a = g.index_of("A.md").unwrap();
+        assert_eq!(g.degree(a), 3);
+        assert!(g.radius(a) > g.radius(g.index_of("Lone.md").unwrap()));
+    }
+
+    #[test]
+    fn hops_reach_the_neighbourhood() {
+        let (_d, ix) = sample();
+        let g = LinkGraph::build(&ix, None);
+        let d = g.index_of("D.md").unwrap();
+        let names = |v: Vec<bool>| -> Vec<String> {
+            v.iter().enumerate().filter(|(_, &s)| s).map(|(i, _)| g.nodes[i].name.clone()).collect()
+        };
+        assert_eq!(names(g.within(d, 1)), ["D", "C"]);
+        assert_eq!(names(g.within(d, 2)), ["A", "D", "C"]);
+    }
+
+    #[test]
+    fn layout_settles_and_keeps_links_short() {
+        let (_d, ix) = sample();
+        let mut g = LinkGraph::build(&ix, None);
+        let all = vec![true; g.len()];
+        let mut steps = 0;
+        while g.step(&all) {
+            steps += 1;
+            assert!(steps < 2000, "never settled");
+        }
+        let dist = |a: &str, b: &str| {
+            let (p, q) = (g.nodes[g.index_of(a).unwrap()].pos, g.nodes[g.index_of(b).unwrap()].pos);
+            ((p.0 - q.0).powi(2) + (p.1 - q.1).powi(2)).sqrt()
+        };
+        // Linked notes sit closer than an unlinked pair.
+        assert!(dist("A.md", "B.md") < dist("B.md", "D.md"), "{} vs {}", dist("A.md", "B.md"), dist("B.md", "D.md"));
+        assert!(g.nodes.iter().all(|n| n.pos.0.is_finite() && n.pos.1.is_finite()));
+    }
+
+    #[test]
+    fn rebuild_keeps_positions_and_pins() {
+        let (_d, ix) = sample();
+        let mut g = LinkGraph::build(&ix, None);
+        let a = g.index_of("A.md").unwrap();
+        g.nodes[a].pos = (500.0, -40.0);
+        g.nodes[a].pinned = true;
+        let g2 = LinkGraph::build(&ix, Some(&g));
+        let a2 = g2.index_of("A.md").unwrap();
+        assert_eq!(g2.nodes[a2].pos, (500.0, -40.0));
+        assert!(g2.nodes[a2].pinned);
+    }
+
+    #[test]
+    fn filters_by_name_path_and_tag() {
+        let (_d, ix) = sample();
+        let g = LinkGraph::build(&ix, None);
+        let hits = |q: &str| -> Vec<String> {
+            let f = Filter::parse(q);
+            g.nodes.iter().filter(|n| f.matches(n)).map(|n| n.name.clone()).collect()
+        };
+        assert_eq!(hits("a"), ["A"]);
+        assert_eq!(hits("path:dir/"), ["C"]);
+        assert_eq!(hits("#proj"), ["A", "D"]);
+        assert_eq!(hits("tag:proj/sub"), ["D"]);
+        assert!(Filter::parse("  ").is_empty());
+    }
+
+    #[test]
+    fn hit_finds_the_nearest_visible_node() {
+        let (_d, ix) = sample();
+        let mut g = LinkGraph::build(&ix, None);
+        for (i, n) in g.nodes.iter_mut().enumerate() {
+            n.pos = (i as f32 * 100.0, 0.0);
+        }
+        let mut vis = vec![true; g.len()];
+        assert_eq!(g.hit(&vis, 101.0, 2.0, 0.0), Some(1));
+        vis[1] = false;
+        assert_eq!(g.hit(&vis, 101.0, 2.0, 0.0), None);
+    }
+}
diff --git a/src/main.rs b/src/main.rs
index 1bb57fd..60dc634 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,5 +1,7 @@
 use wayland_client::QueueHandle;
 use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
+mod linkgraph;
+mod vault;
 mod wiring;
 
 use cce_ui::widget::{Adapted, MouseButton, ElementState, MouseScrollDelta, KeyEvent, WidgetHost, Event, Graph, GraphNode, MenuBar, GraphController, Dropdown, Label};
@@ -1679,5 +1681,12 @@ fn main() {
     let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
     let _guard = rt.enter();
 
+    // `--vault` is the notes vault's link graph, a separate app; anything
+    // else is the project editor.
+    let args: Vec<String> = std::env::args().skip(1).collect();
+    if let Some(v) = vault::Args::parse(&args) {
+        vault::run(v);
+        return;
+    }
     cce_ui::engine::run::<GraphApp>();
 }
diff --git a/src/vault.rs b/src/vault.rs
new file mode 100644
index 0000000..1d5f3a7
--- /dev/null
+++ b/src/vault.rs
@@ -0,0 +1,923 @@
+//! `cce-graph --vault`: the notes vault as a link graph (Obsidian-on-cce
+//! milestone 4). A separate `Application` from the project editor —
+//! `main` picks one by its arguments.
+//!
+//! Global mode shows every note; local mode shows the notes within N links
+//! of the one open in cce-notes, which it learns by polling `current` on
+//! cce-notes' instance socket once a second. A click opens a note in
+//! cce-notes (over the same socket, or by launching it); dragging a note
+//! pins it, a right click unpins it. The filter box takes words (names),
+//! `#tag` and `path:folder`.
+//!
+//! The layout steps in `tick` until it cools, then the app goes idle: no
+//! frames while nothing moves.
+
+use std::io::{BufRead, BufReader, Write};
+use std::os::unix::net::{UnixListener, UnixStream};
+use std::path::PathBuf;
+use std::sync::{Mutex, OnceLock};
+use std::time::{Duration, Instant};
+
+use cce_ui::engine::{Application, CursorIcon, EngineState, LogicalPosition, LogicalSize, WindowSettings};
+use cce_ui::scene::layout::Rect;
+use cce_ui::scene::paint::{Cap, DisplayList, PaintCtx};
+use cce_ui::widget::{
+    Adapted, ElementState, Event, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey, TextBox, WidgetHost,
+};
+use cce_vault::{Index, VaultWatcher};
+use wayland_client::QueueHandle;
+
+use crate::linkgraph::{Filter, LinkGraph};
+
+const BAND_H: f32 = 40.0;
+const STATUS_H: f32 = 26.0;
+const CHIP_H: f32 = 24.0;
+const FILTER_W: f32 = 240.0;
+const LABEL_SIZE: f32 = 11.0;
+const MIN_ZOOM: f32 = 0.05;
+const MAX_ZOOM: f32 = 6.0;
+/// Layout steps per frame while it is warm.
+const STEPS_PER_FRAME: usize = 2;
+const POLL_EVERY: Duration = Duration::from_secs(1);
+/// A press that moves less than this is a click, not a drag.
+const CLICK_SLOP: f32 = 4.0;
+
+const FG: [f32; 4] = cce_ui::colors::TEXT_FG;
+const DIM: [f32; 4] = cce_ui::colors::TEXT_DIM;
+
+fn accent(alpha: f32) -> [f32; 4] {
+    let mut c = cce_ui::colors::to_linear([0.66, 0.55, 0.98, 1.0]);
+    c[3] = alpha;
+    c
+}
+
+fn srgb_u8(linear: [f32; 4]) -> [u8; 3] {
+    let s = cce_ui::colors::to_srgb(linear);
+    [(s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8]
+}
+
+/// `cce-graph --vault [DIR] [--local [NOTE]]`, parsed in `main`.
+pub struct Args {
+    pub vault: Option<PathBuf>,
+    pub local: bool,
+    pub note: Option<String>,
+}
+
+impl Args {
+    /// `None` when the arguments do not ask for vault mode.
+    pub fn parse(args: &[String]) -> Option<Args> {
+        let at = args.iter().position(|a| a == "--vault")?;
+        let operand = |i: usize| args.get(i).filter(|a| !a.starts_with("--")).cloned();
+        let vault = operand(at + 1).map(PathBuf::from);
+        let local_at = args.iter().position(|a| a == "--local");
+        Some(Args { vault, local: local_at.is_some(), note: local_at.and_then(|i| operand(i + 1)) })
+    }
+}
+
+static ARGS: OnceLock<Args> = OnceLock::new();
+
+// ---- single instance -------------------------------------------------------
+
+const SOCKET: &str = "cce-graph-vault";
+static CLAIMED: Mutex<Option<UnixListener>> = Mutex::new(None);
+static OWNED: Mutex<Option<String>> = Mutex::new(None);
+
+/// The line a launch hands a running instance.
+fn launch_line(args: &Args) -> String {
+    match (args.local, &args.note) {
+        (true, Some(n)) => format!("local {n}"),
+        (true, None) => "local".into(),
+        (false, _) => "global".into(),
+    }
+}
+
+/// True when a running instance took this launch.
+fn forward_or_claim(args: &Args) -> bool {
+    let path = cce_ui::ipc::socket_path(SOCKET);
+    let forward = |path: &str| -> bool {
+        let Ok(mut s) = UnixStream::connect(path) else { return false };
+        if s.write_all(format!("{}\n", launch_line(args)).as_bytes()).is_err() {
+            return false;
+        }
+        let mut reply = String::new();
+        BufReader::new(s).read_line(&mut reply).is_ok()
+    };
+    if forward(&path) {
+        return true;
+    }
+    let _ = std::fs::remove_file(&path);
+    match UnixListener::bind(&path) {
+        Ok(l) => {
+            *CLAIMED.lock().unwrap() = Some(l);
+            *OWNED.lock().unwrap() = Some(path);
+            false
+        }
+        Err(_) => forward(&path),
+    }
+}
+
+fn spawn_listener(sender: calloop::channel::Sender<Message>) {
+    let Some(listener) = CLAIMED.lock().unwrap().take() else { return };
+    std::thread::spawn(move || {
+        for conn in listener.incoming().flatten() {
+            let mut reader = BufReader::new(conn);
+            let mut line = String::new();
+            if reader.read_line(&mut line).is_err() {
+                continue;
+            }
+            let line = line.trim();
+            let msg = match line.split_once(' ') {
+                Some(("local", note)) => Message::Mode { local: true, note: Some(note.to_string()) },
+                _ if line == "local" => Message::Mode { local: true, note: None },
+                _ if line == "global" => Message::Mode { local: false, note: None },
+                _ => continue,
+            };
+            if sender.send(msg).is_err() {
+                return;
+            }
+            let _ = reader.get_mut().write_all(b"ok\n");
+        }
+    });
+}
+
+// ---- cce-notes over its socket ---------------------------------------------
+
+fn notes_socket() -> String {
+    cce_ui::ipc::socket_path("cce-notes")
+}
+
+/// The note open in cce-notes, by vault path; `None` when it is not
+/// running or has no note open.
+fn notes_current() -> Option<String> {
+    let mut s = UnixStream::connect(notes_socket()).ok()?;
+    s.set_read_timeout(Some(Duration::from_millis(300))).ok()?;
+    s.write_all(b"current\n").ok()?;
+    let mut reply = String::new();
+    BufReader::new(s).read_line(&mut reply).ok()?;
+    reply.trim().strip_prefix("ok ").map(|p| p.trim().to_string()).filter(|p| !p.is_empty())
+}
+
+/// Show a note in cce-notes: hand it to the running instance, or start one.
+fn open_in_notes(vault: &std::path::Path, abs: &std::path::Path) -> Result<(), String> {
+    let target = abs.to_string_lossy();
+    if let Ok(mut s) = UnixStream::connect(notes_socket()) {
+        s.write_all(format!("open {target}\n").as_bytes()).map_err(|e| e.to_string())?;
+        let mut reply = String::new();
+        let _ = BufReader::new(s).read_line(&mut reply);
+        return Ok(());
+    }
+    std::process::Command::new("cce-notes")
+        .arg("--vault")
+        .arg(vault)
+        .arg("open")
+        .arg(abs)
+        .spawn()
+        .map(|_| ())
+        .map_err(|e| format!("could not start cce-notes: {e}"))
+}
+
+// ---- the app ---------------------------------------------------------------
+
+#[derive(Debug, Clone)]
+pub enum Message {
+    VaultChanged(Vec<PathBuf>),
+    Mode { local: bool, note: Option<String> },
+    Exit,
+}
+
+enum Drag {
+    None,
+    Pan { last: (f32, f32) },
+    Node { i: usize, from: (f32, f32), moved: bool },
+}
+
+pub struct VaultApp {
+    index: Option<Index>,
+    error: Option<String>,
+    _watcher: Option<VaultWatcher>,
+    graph: LinkGraph,
+    visible: Vec<bool>,
+
+    local: bool,
+    depth: usize,
+    /// The local graph's centre, by vault path.
+    center: Option<String>,
+    next_poll: Instant,
+
+    filter_input: Adapted<TextBox>,
+    filter_seen: String,
+
+    /// World point at the middle of the graph area, and px per world unit.
+    cam: (f32, f32),
+    zoom: f32,
+    /// Fit the camera to the visible nodes once the layout has spread,
+    /// and again when it settles unless the user has moved the camera.
+    fit_pending: bool,
+    refit_on_settle: bool,
+    hover: Option<usize>,
+    drag: Drag,
+    pointer: (f32, f32),
+    status: Option<String>,
+
+    width: u32,
+    height: u32,
+    needs_rebuild: bool,
+    ui_context: cce_ui::context::UiContext,
+    widgets_registered: bool,
+}
+
+/// Where the band's controls sit.
+struct Metrics {
+    area: Rect,
+    global_chip: Rect,
+    local_chip: Rect,
+    depth_chip: Rect,
+    filter: Rect,
+}
+
+impl VaultApp {
+    fn metrics(&self) -> Metrics {
+        let (w, h) = (self.width as f32, self.height as f32);
+        let inset = cce_ui::layout::root_plate_inset();
+        let y = (BAND_H - CHIP_H) / 2.0;
+        let filter_w = FILTER_W.min(w * 0.35);
+        let filter = Rect { x: w - inset - filter_w, y: y - 2.0, width: filter_w, height: CHIP_H + 4.0 };
+        let depth_chip = Rect { x: filter.x - 12.0 - 76.0, y, width: 76.0, height: CHIP_H };
+        let local_chip = Rect { x: depth_chip.x - 8.0 - 64.0, y, width: 64.0, height: CHIP_H };
+        let global_chip = Rect { x: local_chip.x - 64.0, y, width: 64.0, height: CHIP_H };
+        Metrics {
+            area: Rect { x: 0.0, y: BAND_H, width: w, height: (h - BAND_H - STATUS_H).max(0.0) },
+            global_chip,
+            local_chip,
+            depth_chip,
+            filter,
+        }
+    }
+
+    fn filter_query(&self) -> String {
+        if self.filter_input.editing {
+            self.filter_input.edit_buffer.clone()
+        } else {
+            self.filter_input.text.clone()
+        }
+    }
+
+    /// Which nodes show: the local neighbourhood (if local), narrowed by
+    /// the filter.
+    fn recompute_visible(&mut self) {
+        let n = self.graph.len();
+        let mut vis = vec![true; n];
+        if self.local {
+            match self.center.as_deref().and_then(|c| self.graph.index_of(c)) {
+                Some(c) => vis = self.graph.within(c, self.depth),
+                None => vis = vec![false; n],
+            }
+        }
+        let f = Filter::parse(&self.filter_query());
+        if !f.is_empty() {
+            for (i, v) in vis.iter_mut().enumerate() {
+                *v = *v && f.matches(&self.graph.nodes[i]);
+            }
+        }
+        self.visible = vis;
+        self.hover = None;
+        self.graph.reheat(0.4);
+        self.needs_rebuild = true;
+    }
+
+    fn set_mode(&mut self, local: bool, note: Option<String>) {
+        if let Some(n) = note {
+            self.center = self.index.as_ref().and_then(|ix| ix.lookup(&n)).or(Some(n));
+        }
+        if local && self.center.is_none() {
+            self.center = notes_current();
+        }
+        self.local = local;
+        self.recompute_visible();
+        self.fit_pending = true;
+        self.refit_on_settle = true;
+    }
+
+    fn poll_center(&mut self) {
+        if !self.local {
+            return;
+        }
+        let now = Instant::now();
+        if now < self.next_poll {
+            return;
+        }
+        self.next_poll = now + POLL_EVERY;
+        if let Some(cur) = notes_current() {
+            if self.center.as_deref() != Some(cur.as_str()) {
+                self.center = Some(cur);
+                self.recompute_visible();
+                self.fit_pending = true;
+            }
+        }
+    }
+
+    fn vault_changed(&mut self, paths: Vec<PathBuf>) {
+        let Some(ix) = self.index.as_mut() else { return };
+        ix.apply_changes(&paths);
+        let g = LinkGraph::build(ix, Some(&self.graph));
+        self.graph = g;
+        // A rebuild keeps positions; it only needs a nudge, not a restart.
+        self.graph.alpha = 0.3;
+        self.recompute_visible();
+    }
+
+    fn to_screen(&self, area: Rect, p: (f32, f32)) -> (f32, f32) {
+        let (mx, my) = (area.x + area.width / 2.0, area.y + area.height / 2.0);
+        (mx + (p.0 - self.cam.0) * self.zoom, my + (p.1 - self.cam.1) * self.zoom)
+    }
+
+    fn to_world(&self, area: Rect, s: (f32, f32)) -> (f32, f32) {
+        let (mx, my) = (area.x + area.width / 2.0, area.y + area.height / 2.0);
+        (self.cam.0 + (s.0 - mx) / self.zoom, self.cam.1 + (s.1 - my) / self.zoom)
+    }
+
+    fn fit(&mut self) {
+        let area = self.metrics().area;
+        let Some((lo, hi)) = self.graph.bounds(&self.visible) else { return };
+        let (bw, bh) = ((hi.0 - lo.0).max(1.0), (hi.1 - lo.1).max(1.0));
+        let z = ((area.width - 80.0) / bw).min((area.height - 80.0) / bh);
+        self.zoom = z.clamp(MIN_ZOOM, 2.0);
+        self.cam = ((lo.0 + hi.0) / 2.0, (lo.1 + hi.1) / 2.0);
+        self.needs_rebuild = true;
+    }
+
+    fn zoom_at(&mut self, s: (f32, f32), factor: f32) {
+        let area = self.metrics().area;
+        let before = self.to_world(area, s);
+        self.zoom = (self.zoom * factor).clamp(MIN_ZOOM, MAX_ZOOM);
+        let after = self.to_world(area, s);
+        self.cam.0 += before.0 - after.0;
+        self.cam.1 += before.1 - after.1;
+        self.refit_on_settle = false;
+        self.needs_rebuild = true;
+    }
+
+    fn node_at(&self, s: (f32, f32)) -> Option<usize> {
+        let area = self.metrics().area;
+        if !contains(area, s.0, s.1) {
+            return None;
+        }
+        let w = self.to_world(area, s);
+        self.graph.hit(&self.visible, w.0, w.1, 4.0 / self.zoom)
+    }
+
+    fn open(&mut self, i: usize) {
+        let node = &self.graph.nodes[i];
+        if node.ghost {
+            self.status = Some(format!("“{}” does not exist yet — follow a link to it in cce-notes to create it", node.name));
+            return;
+        }
+        let Some(ix) = &self.index else { return };
+        if let Err(e) = open_in_notes(ix.root(), &ix.abs(&node.path)) {
+            self.status = Some(e);
+        }
+    }
+
+    fn paint_band(&self, pc: &mut PaintCtx, m: &Metrics) {
+        let w = self.width as f32;
+        pc.recess_edges(
+            Rect { x: 0.0, y: 0.0, width: w, height: BAND_H },
+            (0.0, 0.0, 0.0, 0.0),
+            cce_ui::layout::bar_wall_width(),
+            (false, false, true, false),
+        );
+        let (family, size) = cce_ui::layout::menubar_font_parsed();
+        let inset = cce_ui::layout::root_plate_inset();
+        let title = match (&self.index, self.local, &self.center) {
+            (Some(_), true, Some(c)) => format!("Local graph · {}", c.trim_end_matches(".md")),
+            (Some(_), true, None) => "Local graph".to_string(),
+            (Some(ix), false, _) => format!("Graph · {}", vault_name(ix.root())),
+            (None, _, _) => "Graph".to_string(),
+        };
+        let ty = cce_ui::layout::align_text_y(0.0, BAND_H, size, 0.0);
+        pc.text_with(title, inset, ty, size, srgb_u8(FG), Some(family.clone()), Some([inset, 0.0, m.global_chip.x - 12.0, BAND_H]));
+        let chip = |pc: &mut PaintCtx, r: Rect, label: &str, on: bool| {
+            pc.rounded_rect(r, 6.0, (true, true, true, true), if on { accent(0.25) } else { [1.0, 1.0, 1.0, 0.06] });
+            let lw = label.chars().count() as f32 * size * 0.6;
+            pc.text_with(
+                label.to_string(),
+                r.x + ((r.width - lw) / 2.0).max(2.0),
+                cce_ui::layout::align_text_y(r.y, r.height, size, 0.0),
+                size,
+                srgb_u8(if on { FG } else { DIM }),
+                Some(family.clone()),
+                Some([r.x, r.y, r.x + r.width, r.y + r.height]),
+            );
+        };
+        chip(pc, m.global_chip, "Global", !self.local);
+        chip(pc, m.local_chip, "Local", self.local);
+        if self.local {
+            chip(pc, m.depth_chip, &format!("Depth {}", self.depth), false);
+        }
+        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.filter_input, pc);
+    }
+
+    fn paint_status(&self, pc: &mut PaintCtx) {
+        let (w, h) = (self.width as f32, self.height as f32);
+        let y = h - STATUS_H;
+        pc.recess_edges(
+            Rect { x: 0.0, y, width: w, height: STATUS_H },
+            (0.0, 0.0, 0.0, 0.0),
+            cce_ui::layout::bar_wall_width(),
+            (true, false, false, false),
+        );
+        let (family, size) = cce_ui::layout::statusbar_font_parsed();
+        let inset = cce_ui::layout::root_plate_inset();
+        let ty = cce_ui::layout::align_text_y(y, STATUS_H, size, 0.0);
+        let msg = if let Some(s) = &self.status {
+            s.clone()
+        } else if self.index.is_none() {
+            String::new()
+        } else if self.local && self.center.is_none() {
+            "Open a note in cce-notes; the local graph follows it".to_string()
+        } else {
+            let notes = self.visible.iter().filter(|v| **v).count();
+            let links = self.graph.edges.iter().filter(|(a, b)| self.visible[*a] && self.visible[*b]).count();
+            format!("{notes} notes · {links} links · drag to pin, right-click to unpin, Ctrl+0 to fit")
+        };
+        pc.text_with(msg, inset, ty, size, srgb_u8(DIM), Some(family), Some([inset, y, w - inset, h]));
+    }
+
+    fn paint_graph(&self, pc: &mut PaintCtx, area: Rect) {
+        if let Some(e) = &self.error {
+            let (family, size) = cce_ui::layout::list_font_parsed();
+            for (i, line) in e.lines().enumerate() {
+                pc.text_with(line.to_string(), area.x + 28.0, area.y + 28.0 + i as f32 * size * 1.6, size, srgb_u8(if i == 0 { FG } else { DIM }), Some(family.clone()), None);
+            }
+            return;
+        }
+        let g = &self.graph;
+        let vis = &self.visible;
+        let center = if self.local { self.center.as_deref().and_then(|c| g.index_of(c)) } else { None };
+        // While a node is hovered, it and its neighbours stand out and
+        // everything else steps back.
+        let mut lit = vec![false; g.len()];
+        if let Some(h) = self.hover {
+            lit[h] = true;
+            for &j in g.neighbors(h) {
+                lit[j] = true;
+            }
+        }
+        let focus = self.hover.is_some();
+        let margin = 40.0;
+        let on_screen = |p: (f32, f32)| {
+            p.0 > area.x - margin && p.0 < area.x + area.width + margin && p.1 > area.y - margin && p.1 < area.y + area.height + margin
+        };
+        let line_w = (self.zoom * 1.2).clamp(0.6, 2.0);
+        pc.clip(area, |pc| {
+            for &(a, b) in &g.edges {
+                if !(vis[a] && vis[b]) {
+                    continue;
+                }
+                let (pa, pb) = (self.to_screen(area, g.nodes[a].pos), self.to_screen(area, g.nodes[b].pos));
+                if !on_screen(pa) && !on_screen(pb) {
+                    continue;
+                }
+                let touches = self.hover.is_some_and(|h| h == a || h == b);
+                let color = if touches {
+                    accent(0.9)
+                } else if focus {
+                    [1.0, 1.0, 1.0, 0.04]
+                } else {
+                    [1.0, 1.0, 1.0, 0.16]
+                };
+                pc.vector(pa.0, pa.1, pb.0, pb.1, if touches { line_w * 1.5 } else { line_w }, color, Cap::Round);
+            }
+            // Labels fade in from this zoom, so a whole-vault view is
+            // dots and lines and names appear as you zoom in.
+            let labels_from = 1.1;
+            for i in (0..g.len()).filter(|&i| vis[i]) {
+                let p = self.to_screen(area, g.nodes[i].pos);
+                if !on_screen(p) {
+                    continue;
+                }
+                let r = (g.radius(i) * self.zoom).max(1.5);
+                let node = &g.nodes[i];
+                let color = if Some(i) == self.hover || Some(i) == center {
+                    accent(1.0)
+                } else if node.ghost {
+                    [0.25, 0.25, 0.28, if focus && !lit[i] { 0.3 } else { 1.0 }]
+                } else if focus && !lit[i] {
+                    [0.45, 0.45, 0.5, 0.25]
+                } else {
+                    [0.62, 0.62, 0.68, 1.0]
+                };
+                pc.circle(p.0, p.1, r, color);
+                if node.pinned {
+                    pc.circle(p.0, p.1, (r * 0.35).max(1.0), [0.1, 0.1, 0.12, 1.0]);
+                }
+                // Labels fade in with zoom; the hovered node and its
+                // neighbours always show theirs.
+                let alpha = if lit[i] || Some(i) == center {
+                    1.0
+                } else if focus {
+                    0.0
+                } else {
+                    ((self.zoom - labels_from) / 0.5).clamp(0.0, 1.0)
+                };
+                if alpha <= 0.02 {
+                    continue;
+                }
+                let lw = node.name.chars().count() as f32 * LABEL_SIZE * 0.55;
+                let color = if node.ghost { DIM } else { FG };
+                pc.text_faded(
+                    node.name.clone(),
+                    p.0 - lw / 2.0,
+                    p.1 + r + 3.0,
+                    LABEL_SIZE,
+                    srgb_u8(color),
+                    alpha,
+                    Some("sans-serif".to_string()),
+                    Some([area.x, area.y, area.x + area.width, area.y + area.height]),
+                );
+            }
+        });
+    }
+}
+
+fn contains(r: Rect, x: f32, y: f32) -> bool {
+    x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height
+}
+
+fn vault_name(root: &std::path::Path) -> String {
+    root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
+}
+
+impl Application for VaultApp {
+    type Message = Message;
+
+    fn ui_context(&self) -> Option<&cce_ui::context::UiContext> {
+        Some(&self.ui_context)
+    }
+
+    fn ui_context_mut(&mut self) -> Option<&mut cce_ui::context::UiContext> {
+        Some(&mut self.ui_context)
+    }
+
+    fn new(_qh: &QueueHandle<EngineState<Self>>, sender: calloop::channel::Sender<Message>) -> Self {
+        let args = ARGS.get().expect("args set in run");
+        let root = cce_vault::config::vault_root(args.vault.as_deref()).map_err(|e| {
+            format!("No vault: {e}\nSet `vault {{ path \"~/Notes\" }}` in ~/.config/cce/config.kdl, or pass --vault <dir>.")
+        });
+        let (index, error, watcher) = match root {
+            Ok(root) => match Index::open(&root, true) {
+                Ok(ix) => {
+                    let tx = sender.clone();
+                    let w = VaultWatcher::spawn(&root, move |paths| {
+                        let _ = tx.send(Message::VaultChanged(paths));
+                    })
+                    .ok();
+                    (Some(ix), None, w)
+                }
+                Err(e) => (None, Some(format!("Could not read the vault at {}\n{e}", root.display())), None),
+            },
+            Err(e) => (None, Some(e), None),
+        };
+        spawn_listener(sender.clone());
+        let graph = index.as_ref().map(|ix| LinkGraph::build(ix, None)).unwrap_or_default();
+        let n = graph.len();
+        let mut app = VaultApp {
+            index,
+            error,
+            _watcher: watcher,
+            graph,
+            visible: vec![true; n],
+            local: false,
+            depth: 1,
+            center: None,
+            next_poll: Instant::now(),
+            filter_input: TextBox::new(String::new()).with_placeholder("Filter: words, #tag, path:"),
+            filter_seen: String::new(),
+            cam: (0.0, 0.0),
+            zoom: 1.0,
+            fit_pending: true,
+            refit_on_settle: true,
+            hover: None,
+            drag: Drag::None,
+            pointer: (0.0, 0.0),
+            status: None,
+            width: 1000,
+            height: 700,
+            needs_rebuild: true,
+            ui_context: cce_ui::context::UiContext::new(),
+            widgets_registered: false,
+        };
+        app.set_mode(args.local, args.note.clone());
+        app
+    }
+
+    fn settings(&self) -> WindowSettings {
+        WindowSettings {
+            title: "Graph".to_string(),
+            app_id: "cce-graph-vault".to_string(),
+            width: 1000,
+            height: 700,
+            fullscreen: false,
+            min_size: Some((420, 300)),
+        }
+    }
+
+    fn update(&mut self, msg: Message, needs_rebuild: &mut bool, _exit: &mut bool) {
+        match msg {
+            Message::VaultChanged(paths) => self.vault_changed(paths),
+            Message::Mode { local, note } => self.set_mode(local, note),
+            Message::Exit => *_exit = true,
+        }
+        *needs_rebuild = true;
+    }
+
+    fn idle_poll_interval(&self) -> Option<Duration> {
+        // The local graph follows cce-notes; nothing else needs waking.
+        self.local.then_some(POLL_EVERY)
+    }
+
+    fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
+        if self.ui_context.tick(dt) {
+            *needs_rebuild = true;
+        }
+        self.poll_center();
+        if !self.graph.settled() {
+            for _ in 0..STEPS_PER_FRAME {
+                self.graph.step(&self.visible);
+            }
+            if self.fit_pending && self.graph.alpha < 0.35 {
+                self.fit_pending = false;
+                self.fit();
+            }
+            *needs_rebuild = true;
+        } else if self.fit_pending || self.refit_on_settle {
+            self.fit_pending = false;
+            self.refit_on_settle = false;
+            self.fit();
+            *needs_rebuild = true;
+        }
+        if self.needs_rebuild {
+            *needs_rebuild = true;
+        }
+    }
+
+    fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<DisplayList> {
+        if !self.widgets_registered {
+            self.widgets_registered = true;
+            let (id, ptr) = (self.filter_input.id(), self.filter_input.as_ptr_mut());
+            self.ui_context.register_widget(id, ptr);
+        }
+        let resized = self.width != size.width as u32 || self.height != size.height as u32;
+        self.width = size.width as u32;
+        self.height = size.height as u32;
+        cce_ui::scale::set_scale_factor(scale as f32);
+        let m = self.metrics();
+        if self.needs_rebuild || resized {
+            let f = m.filter;
+            self.filter_input.set_rect(f.x, f.y, f.width, f.height);
+            self.ui_context.rebuild_spatial_grid();
+            self.needs_rebuild = false;
+        }
+        let mut pc = PaintCtx::new();
+        pc.root_plate(size.width as f32, size.height as f32);
+        self.paint_graph(&mut pc, m.area);
+        self.paint_band(&mut pc, &m);
+        self.paint_status(&mut pc);
+        Some(pc.finish())
+    }
+
+    fn display_list_text(&self) -> bool {
+        true
+    }
+
+    fn cursor_icon(&self, x: f32, y: f32) -> Option<CursorIcon> {
+        let m = self.metrics();
+        if contains(m.filter, x, y) {
+            return Some(CursorIcon::Text);
+        }
+        match self.drag {
+            Drag::Pan { .. } => Some(CursorIcon::Grabbing),
+            Drag::Node { .. } => Some(CursorIcon::Grabbing),
+            Drag::None if self.hover.is_some() => Some(CursorIcon::Pointer),
+            Drag::None => None,
+        }
+    }
+
+    fn on_exit(&mut self) {
+        if let Some(ix) = self.index.as_mut() {
+            let _ = ix.save_cache();
+        }
+    }
+
+    fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
+        let s = (pos.x as f32, pos.y as f32);
+        self.pointer = s;
+        let area = self.metrics().area;
+        match &mut self.drag {
+            Drag::Pan { last } => {
+                let (dx, dy) = (s.0 - last.0, s.1 - last.1);
+                *last = s;
+                self.cam.0 -= dx / self.zoom;
+                self.cam.1 -= dy / self.zoom;
+                self.refit_on_settle = false;
+                *needs_rebuild = true;
+                return;
+            }
+            Drag::Node { i, from, moved } => {
+                let i = *i;
+                if !*moved && ((s.0 - from.0).abs() > CLICK_SLOP || (s.1 - from.1).abs() > CLICK_SLOP) {
+                    *moved = true;
+                }
+                if *moved {
+                    let w = self.to_world(area, s);
+                    let node = &mut self.graph.nodes[i];
+                    node.pos = w;
+                    node.pinned = true;
+                    self.graph.reheat(0.25);
+                    *needs_rebuild = true;
+                }
+                return;
+            }
+            Drag::None => {}
+        }
+        let h = self.node_at(s);
+        if h != self.hover {
+            self.hover = h;
+            *needs_rebuild = true;
+        }
+        let ev = Event::PointerMove { x: s.0, y: s.1, local_x: s.0, local_y: s.1 };
+        if self.ui_context.propagate_event(&ev, self.filter_input.id()) {
+            *needs_rebuild = true;
+        }
+    }
+
+    fn handle_mouse_input(
+        &mut self,
+        button: MouseButton,
+        state: ElementState,
+        pos: LogicalPosition,
+        needs_rebuild: &mut bool,
+    ) -> Option<Message> {
+        let s = (pos.x as f32, pos.y as f32);
+        *needs_rebuild = true;
+        let m = self.metrics();
+        let ev = Event::MouseButton { button, state, x: s.0, y: s.1, local_x: s.0, local_y: s.1 };
+        let pressed = state == ElementState::Pressed;
+
+        if contains(m.filter, s.0, s.1) {
+            if pressed && !self.filter_input.editing {
+                self.ui_context.set_focused(&mut self.filter_input);
+                WidgetHost::focus(&mut self.filter_input);
+            }
+            self.ui_context.propagate_event(&ev, self.filter_input.id());
+            return None;
+        }
+        if pressed && self.filter_input.editing {
+            self.filter_input.unfocus();
+        }
+        if pressed && button == MouseButton::Left {
+            if contains(m.global_chip, s.0, s.1) {
+                self.set_mode(false, None);
+                return None;
+            }
+            if contains(m.local_chip, s.0, s.1) {
+                self.set_mode(true, None);
+                return None;
+            }
+            if self.local && contains(m.depth_chip, s.0, s.1) {
+                self.depth = self.depth % 4 + 1;
+                self.recompute_visible();
+                self.fit_pending = true;
+                return None;
+            }
+        }
+        if !contains(m.area, s.0, s.1) && pressed {
+            return None;
+        }
+        match (button, state) {
+            (MouseButton::Left, ElementState::Pressed) => {
+                self.drag = match self.node_at(s) {
+                    Some(i) => Drag::Node { i, from: s, moved: false },
+                    None => Drag::Pan { last: s },
+                };
+                self.status = None;
+            }
+            (MouseButton::Left, ElementState::Released) => {
+                if let Drag::Node { i, moved: false, .. } = self.drag {
+                    self.open(i);
+                }
+                self.drag = Drag::None;
+                self.hover = self.node_at(s);
+            }
+            (MouseButton::Right, ElementState::Pressed) => {
+                if let Some(i) = self.node_at(s) {
+                    self.graph.nodes[i].pinned = false;
+                    self.graph.reheat(0.3);
+                }
+            }
+            _ => {}
+        }
+        None
+    }
+
+    fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
+        let s = (pos.x as f32, pos.y as f32);
+        if !contains(self.metrics().area, s.0, s.1) {
+            return;
+        }
+        match delta {
+            // A wheel notch zooms about the pointer, as Obsidian's does.
+            MouseScrollDelta::LineDelta(_, y) => self.zoom_at(s, 1.15f32.powf(*y)),
+            // A trackpad pans; with Ctrl (a pinch on most stacks) it zooms.
+            MouseScrollDelta::PixelDelta(p) => {
+                if self.ui_context.ctrl_pressed {
+                    self.zoom_at(s, 1.01f32.powf(p.y as f32));
+                } else {
+                    self.cam.0 -= p.x as f32 / self.zoom;
+                    self.cam.1 -= p.y as f32 / self.zoom;
+                    self.refit_on_settle = false;
+                }
+            }
+        }
+        *needs_rebuild = true;
+    }
+
+    fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Message> {
+        *needs_rebuild = true;
+        let pressed = event.state == ElementState::Pressed;
+        if self.filter_input.editing {
+            if pressed && matches!(event.logical_key, Key::Named(NamedKey::Escape)) {
+                self.filter_input.unfocus();
+                return None;
+            }
+            let ev = Event::KeyInput(event.clone());
+            self.ui_context.propagate_event(&ev, self.filter_input.id());
+            let q = self.filter_query();
+            if q != self.filter_seen {
+                self.filter_seen = q;
+                self.recompute_visible();
+            }
+            return None;
+        }
+        if !pressed {
+            return None;
+        }
+        let k = |name: &str, default: &str| cce_ui::widget::match_key_shortcut(event, &cce_ui::input::app_chord(name, default));
+        if k("toggle_local", "ctrl+l") {
+            let local = !self.local;
+            self.set_mode(local, None);
+        } else if k("fit", "ctrl+0") {
+            self.fit();
+        } else if k("filter", "ctrl+f") {
+            self.ui_context.set_focused(&mut self.filter_input);
+            WidgetHost::focus(&mut self.filter_input);
+        } else if k("depth_more", "ctrl+=") || k("depth_more_plus", "ctrl+shift+=") {
+            self.depth = (self.depth + 1).min(4);
+            self.recompute_visible();
+            self.fit_pending = true;
+        } else if k("depth_less", "ctrl+-") {
+            self.depth = self.depth.saturating_sub(1).max(1);
+            self.recompute_visible();
+            self.fit_pending = true;
+        } else if k("quit", "ctrl+q") {
+            return Some(Message::Exit);
+        }
+        None
+    }
+}
+
+/// Run the vault graph, or hand the launch to the one already running.
+pub fn run(args: Args) {
+    if forward_or_claim(&args) {
+        return;
+    }
+    let _ = ARGS.set(args);
+    cce_ui::engine::run::<VaultApp>();
+    if let Some(p) = OWNED.lock().unwrap().take() {
+        let _ = std::fs::remove_file(p);
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn args(a: &[&str]) -> Vec<String> {
+        a.iter().map(|s| s.to_string()).collect()
+    }
+
+    #[test]
+    fn vault_arguments() {
+        assert!(Args::parse(&args(&[])).is_none());
+        assert!(Args::parse(&args(&["project/dir"])).is_none());
+        let a = Args::parse(&args(&["--vault"])).unwrap();
+        assert!(a.vault.is_none() && !a.local);
+        let a = Args::parse(&args(&["--vault", "/v", "--local", "Note"])).unwrap();
+        assert_eq!(a.vault.as_deref(), Some(std::path::Path::new("/v")));
+        assert!(a.local);
+        assert_eq!(a.note.as_deref(), Some("Note"));
+        let a = Args::parse(&args(&["--vault", "--local"])).unwrap();
+        assert!(a.vault.is_none() && a.local && a.note.is_none());
+        assert_eq!(launch_line(&a), "local");
+    }
+}