瀏覽代碼

openspec: add app-chatview, app-gesture, app-pydrk-cli, app-theme

darkfi 3 周之前
父節點
當前提交
93fd612ca4

+ 2 - 0
openspec/changes/app-chatview/.openspec.yaml

@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-08-30

+ 1164 - 0
openspec/changes/app-chatview/design.md

@@ -0,0 +1,1164 @@
+## Context
+
+The spec delta in `specs/chatview/spec.md` is the behavioral contract,
+and this design document is the complete requirements record from the
+redesign exploration — every agreed requirement lives in this change
+(spec or design); nothing outside it is normative. The current
+implementation (`src/ui/chatview/`) is the functional spec:
+`MessageBuffer` holds a `Vec<Message>` newest-first with
+cached parley layouts and mesh caches; `adjust_scroll`→
+`calc_total_height` walks the entire buffer per scroll tick; `gen_meshes`
+clones instructions for every message from newest to the viewport top
+every frame; mesh caches are only cleared on rect/scale/epoch change
+(never on scroll); `chat::make()` builds a full screen per channel and
+switching toggles `is_visible`. Wheel/flick share one `speed: AtomicF32`
+decayed by a 10ms loop.
+
+Scene/property system facts the design relies on: `Property*::wrap`
+returns a live handle to a property object on a node (cross-node wrapping
+already exists — `window_scale` from `/window`); nodes track parents;
+nodes carry signals (`register`/`trigger`), method-call subscriptions,
+`OnModify`, and task lists; `Pimpl` UIObjects implement `draw` and
+`handle_*` input.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Ground-up `src/ui/chatview2/` with one module per concern, replacing
+  `src/ui/chatview/` at parity.
+- Geometry operations independent of buffer size; bounded render
+  resources; non-blocking loading.
+- Scene API: chatview2 node + one stable sub-node per message type, ids
+  in signal/method payloads, property inheritance with a single `regen`
+  verb for styling and content changes.
+- Keep kvdb storage with a type-tagged value format; persist unconfirmed
+  messages.
+
+**Non-Goals:**
+
+- Rich span/block privmsg body (quotes, code, math) — v1 is plain text +
+  nicks + URLs + cap/expand; APIs shaped for later.
+- New message types beyond current parity set (stickers, forms, latex,
+  all-view, first-contact notices) — the registry makes them additive.
+- Layout sidecar (persisted height cache) — deferred until re-entry on
+  big histories measures poorly.
+- Hard buffer cap, strict window-bound eviction, full anchor-based scroll
+  core — simplest-thing-first; upgrade paths noted below.
+- Any change outside `bin/app`.
+
+**Future message content** (out of v1, but the registry, property
+inheritance, payload format, and height-change machinery are shaped so
+each lands as a new message type without reworking buffer or scroll):
+animated stickers, forms, emojis, code blocks, latex, status messages,
+images and rich media, green-text quotes, multi-line text, clickable
+nicks/media, network messages, all-view (merged-channel view showing
+channel labels), nick-highlight on mention, first-time-message and
+rename notices, show/hide timestamps, click-to-notify items.
+
+## Decisions
+
+### Module layout
+
+```
+src/util/fenwick.rs        generic Fenwick tree (see Fenwick section)
+src/ui/chatview2/
+├── mod.rs        ChatView2 node: properties, methods, signals, input
+│                 dispatch, draw assembly. Orchestrates; owns nothing heavy.
+├── buffer.rs     MsgBuffer: ordering, dedup, geometry. Pure data.
+├── scroll.rs     ScrollController: gestures, animation, clamping,
+│                 compensation, save/restore.
+├── loader.rs     Loader: kvdb → filter → buffer, coverage maintenance.
+└── msg/
+    ├── mod.rs    MessageType contract + registry
+    ├── privmsg.rs  privmsg type node + instances
+    ├── filemsg.rs  fud file type node + instances
+    └── datemsg.rs  date separator type node (derived, never stored)
+```
+
+Main structures and basic APIs per file (sketches, not final signatures):
+
+`mod.rs` — the scene node and its public surface:
+
+```rust
+pub struct ChatView2 { /* node, renderer, redraw, executor, buffer,
+                          scroll controller, loader handles */ }
+
+impl ChatView2 {
+    pub async fn new(node, kv_db, window_scale, renderer, redraw, sg_root,
+        i18n_fish, ex) -> Pimpl
+}
+
+// Properties (rect, shared styling, is_at_bottom: bool — arrow visibility;
+// scroll position itself is internal to the scroll controller).
+// Methods (called via call_method) — view-wide concerns only:
+//   set_channel(channel), set_filter, copy_select, unselect,
+//   scroll_to_bottom
+// Testing methods (live testing, e.g. driven from the python gui api):
+//   get_line_ids() -> [(ts, id), …] of loaded messages in
+//                    display order
+//   delete_line(id) -> drop from buffer + channel kv tree
+// Signals — view-wide concerns only:
+//   select_changed(bool)
+// Message lifecycle methods and message-derived signals live on the
+// type nodes, since each type defines its own semantics: privmsg
+// exposes insert_line/insert_unconf_line/confirm plus url/nick
+// interaction signals; filemsg exposes set_file_status plus its file
+// signals.
+// UIObject: draw() assembles only the visible window; handle_mouse_*,
+// handle_touch, handle_key_down dispatch to materialized instances
+// through the type registry.
+```
+
+`buffer.rs` — ordering + geometry, no rendering:
+
+```rust
+pub struct MsgBuffer {
+    /// Loaded message records
+    records: SlotMap<SlotKey, MsgRecord>,
+    /// Arena slots in display order (newest first, by (ts, msg_id))
+    order: Vec<SlotKey>,
+    /// Cumulative heights over `order`
+    fenwick: Fenwick,
+    /// msg_id -> arena slot
+    index: HashMap<MessageId, SlotKey>,
+}
+
+pub struct MsgRecord {
+    pub ts: Timestamp,
+    pub id: MessageId,
+    /// Message type; its repr(u8) discriminant is the wire tag
+    pub msg_type: MsgType,
+    /// Type-owned payload; per-type state like `confirmed` lives here,
+    /// not on the record
+    pub payload: Vec<u8>,
+    /// Last height reported by the owning type node
+    pub height: f32,
+}
+
+impl MsgBuffer {
+    /// Insert at any (ts, id) position; false if already loaded (dedup)
+    pub fn insert(&mut self, rec: MsgRecord) -> bool
+    /// Remove a loaded record; false if unknown (debug deletion,
+    /// structural edit — batched Fenwick rebuild)
+    pub fn remove(&mut self, id: &MessageId) -> bool
+    /// Update a height; returns the delta for scroll compensation
+    pub fn set_height(&mut self, id: &MessageId, h: f32) -> Option<f32>
+    /// Total px of loaded content
+    pub fn total_height(&self) -> f32
+    /// Display-order range intersecting [scroll, scroll + view_h)
+    pub fn visible_range(&self, scroll: f32, view_h: f32) -> Range<usize>
+    /// Px from content bottom up to the top of msg `id`
+    pub fn pos_of(&self, id: &MessageId) -> Option<f32>
+    /// Oldest loaded ts (loader resume point)
+    pub fn oldest_ts(&self) -> Option<Timestamp>
+    pub fn clear(&mut self)
+}
+```
+
+`scroll.rs` — gestures, animation, anchoring policy:
+
+```rust
+pub enum ScrollState {
+    Idle,
+    Drag { start_y: f32, scroll0: f32 },
+    Glide { velocity: f32 },
+    Anim { from: f32, to: f32, started: Instant },
+}
+
+pub struct ScrollController {
+    state: ScrollState,
+    /// Internal position: px from content bottom; 0 = live bottom.
+    /// NOT a scene property — see the scroll decision section.
+    scroll: f32,
+    /// total_height − view_h, maintained by the chatview
+    max_scroll: f32,
+}
+
+/// Serialized "what the user is looking at" (save/restore boundaries
+/// only): viewport-top message + dy below its top edge
+pub struct Anchor { pub msg: Option<MessageId>, pub dy: f32 }
+
+impl ScrollController {
+    /// Drag: 1:1, cancels Glide/Anim
+    pub fn drag_start(&mut self, y: f32)
+    pub fn drag_move(&mut self, y: f32) -> f32
+    pub fn drag_end(&mut self, velocity: f32)
+    /// Wheel/PageUp/PageDown: set or extend Anim target by half a page
+    pub fn page_tick(&mut self, dir: f32, page: f32)
+    /// The down-arrow: teleport to bottom, cancel all motion
+    pub fn scroll_to_bottom(&mut self)
+    /// Whether scroll == 0 (drives the is_at_bottom scene property)
+    pub fn is_at_bottom(&self) -> bool
+    /// Animator advance; applies the frame's scroll internally
+    pub fn tick(&mut self, now: Instant) -> Option<f32>
+    pub fn clamp(&self, scroll: f32) -> f32
+    /// Height-change compensation (see the scroll decision section)
+    pub fn compensate(&mut self, delta: f32, msg_below_viewport: bool)
+    /// Snapshot of the current view position; no persistence here
+    pub fn anchor(&self) -> Anchor
+    /// Resolve an anchor against pos_of(id); clamped fallback
+    pub fn restore(&mut self, anchor: &Anchor,
+        pos_of: impl Fn(&MessageId) -> Option<f32>) -> f32
+}
+```
+
+`loader.rs` — the single background pipeline:
+
+```rust
+/// Why the loader was woken. Reasons are advisory bookkeeping: the
+/// pump always just restores the coverage invariant, whatever the
+/// trigger — except ChannelSwitch, which clears the buffer first.
+/// The reason is recorded for trace logs and to let the pump skip
+/// work it knows is pointless (e.g. NearTop when already covered).
+pub enum Wakeup { ChannelSwitch, NearTop, Insert, FilterChange, RectChange }
+
+pub type FilterFn = Arc<SyncMutex<Box<dyn Fn(&MsgRecord) -> bool + Send>>>;
+
+pub struct Loader {
+    /// Sole kvdb accessor for this chatview
+    tree: Tree,
+    buffer: Arc<AsyncMutex<MsgBuffer>>,
+    filter: FilterFn,
+    /// Wakers call wake(reason); reasons accumulate in a bitset so
+    /// coalesced wakes are not lost while the pump is running
+    cv: CondVar,
+    pending: AtomicU8,
+}
+
+impl Loader {
+    /// Wake the pump, recording the reason (bits coalesce)
+    pub fn wake(&self, reason: Wakeup)
+    /// Bind a channel tree; refill newest→older until coverage met
+    pub async fn bind(&mut self, tree: Tree)
+    /// Coverage pump: take pending reasons, ChannelSwitch clears the
+    /// buffer, then load newest→older until viewport + margin is
+    /// covered; apply filter, batch structural edits into one Fenwick
+    /// rebuild per batch
+    async fn pump(&mut self, viewport: Range<f32>, margin: f32)
+    /// Decode kvdb entry -> record; unknown type id panics (corrupt)
+    fn decode_entry(key: &[u8], val: &[u8]) -> MsgRecord
+}
+```
+
+Wakers: the chatview calls `wake(ChannelSwitch)` from `set_channel`;
+the draw/scroll path calls `wake(NearTop)` when the viewport approaches
+the top of loaded coverage; the type nodes' insert methods call
+`wake(Insert)`; `set_filter` and rect changes call their own reasons.
+The loop itself is the plain condvar wait → pump → reset shown in the
+loader decision section.
+
+`msg/mod.rs` — the type contract and registry:
+
+```rust
+/// All message types, hardcoded — a fixed enum, no factories.
+/// The wire tag IS the discriminant (repr(u8)): encode with
+/// `msg_type as u8`.
+#[repr(u8)]
+pub enum MsgType {
+    PrivMsg = 0,
+    FileMsg = 1,
+    DateMsg = 2,
+}
+
+/// Decode a wire tag. Unknown tags are corrupt data and panic —
+/// errors are always explicit, never a silent skip.
+pub fn msg_type_from_u8(t: u8) -> MsgType {
+    match t {
+        0 => MsgType::PrivMsg,
+        1 => MsgType::FileMsg,
+        2 => MsgType::DateMsg,
+        _ => panic!("unknown msg type tag {t}"),
+    }
+}
+
+pub trait MessageType {
+    type Instance;
+    fn msg_type(&self) -> MsgType;
+    /// new() + regen(): build an instance from a record + live props
+    fn materialize(&mut self, rec: &MsgRecord) -> &mut Self::Instance
+    /// Drop render state, cancel render-scoped tasks (LRU budget)
+    fn release(&mut self, id: &MessageId)
+    /// Rebuild rendered state from live props + current data
+    fn regen(&mut self, id: &MessageId)
+    fn height(&self, id: &MessageId) -> Option<f32>
+    fn draw(&mut self, id: &MessageId, rect: &Rectangle, renderer: &Renderer)
+        -> Vec<DrawInstruction>
+    /// Hit dispatch (urls, nicks, buttons) in msg-local coordinates
+    fn hit_test(&mut self, id: &MessageId, pos: Point) -> Option<Hit>
+    /// Clipboard contribution when selected (None = nothing copied)
+    fn copy_text(&mut self, id: &MessageId) -> Option<String>
+}
+```
+
+Each type file pairs one scene node (properties/signals/methods, stable
+across channels) with its per-id instances. All three implement the
+`MessageType` trait from `msg/mod.rs`; the sketches below show only the
+type-specific surface.
+
+`msg/privmsg.rs` — the type with insertion semantics:
+
+```rust
+pub struct PrivMsgNode {
+    /// Scene node handle; properties: nick_colors, url_text_color,
+    /// url_bg_color, url_bg_border_*, action_text_color, cap_max_height
+    /* node, renderer, executor handles */
+    instances: HashMap<MessageId, PrivMsg>,
+}
+
+pub struct PrivMsg {
+    /// nick, text, is_action, is_notice, confirmed, expanded
+    pub data: PrivData,
+    /// Live handles resolved at new() (inherited or type-local)
+    pub props: PrivProps,
+    /// Txt layout, url click rects, cached draw instrs, height
+    pub rendered: PrivRendered,
+}
+
+// Methods: insert_line, insert_unconf_line (persist via the loader,
+//          buffer insert, materialize if visible), confirm(id) —
+//          mark an unconfirmed message confirmed (rewrite payload in
+//          the kv tree, update data, regen for styling)
+// Signals: nick_clicked(id, nick), url_clicked(id, url)
+```
+
+`msg/filemsg.rs` — status lifecycle + eviction-surviving tasks:
+
+```rust
+pub struct FileMsgNode {
+    /// Properties: max_height, margins, box styling, glow
+    /* node, renderer, executor handles */
+    instances: HashMap<MessageId, FileMsg>,
+    /// Content-scoped tasks keyed by file url; survive release,
+    /// dedup re-materialization, drain on stop()
+    tasks: HashMap<Url, Task<()>>,
+}
+
+pub struct FileMsg {
+    /// file_url, status, imgbuf (Arc<SyncMutex<Option<…>>>)
+    pub data: FileData,
+    pub props: FileProps,
+    /// Status box / image meshes, active_rect, height
+    pub rendered: FileRendered,
+}
+
+// Method:  set_file_status(url, status)
+// Signals: fileurl_detected(url), download_request(id, url),
+//          status_changed(id)
+```
+
+`msg/datemsg.rs` — derived day separators (see next section):
+
+```rust
+pub struct DateMsgNode {
+    /// Properties: color (font/size inherited from the chatview)
+    /* node, renderer, executor handles */
+    instances: HashMap<MessageId, DateMsg>,
+}
+
+pub struct DateMsg {
+    /// Local-midnight ts of the labeled day (from the record payload)
+    pub data: DateData,
+    pub props: DateProps,
+    /// Rendered label line, height = line_height
+    pub rendered: DateRendered,
+}
+
+// No methods, no signals. Never persisted. Copy text: the date label.
+```
+
+A question about the system maps to exactly one file (buffer↔ordering/
+geometry, scroll↔gestures/animation, loader↔persistence/coverage,
+msg/↔rendering/interaction).
+
+### Derived records: how datemsg interleaves
+
+Date separators occupy vertical space, so they must participate in
+geometry exactly like any other message — but they have no storage and
+no network identity. The model: separators are **derived records owned
+by the buffer**, materialized through the ordinary `datemsg` type. A
+separator for day D gets a synthetic composite key
+`(ts = local_midnight(D), id = [0; 32])`, and its payload is the
+midnight timestamp. Because every message of day D has
+`ts >= midnight(D)`, and every message of any older day has
+`ts < midnight(D)`, the ordinary `(ts, msg_id)` ordering places the
+separator **exactly at the boundary of D's day-run** — no special
+positioning logic exists anywhere; the zero id only breaks the
+exact-midnight tie, sorting the separator older than a message stamped
+at precisely midnight.
+
+```
+display order (bottom of screen = newest)
+───────────────────────────────────────────
+ privmsg  10:02   Aug 30   ┐
+ privmsg  09:58   Aug 30   ┘ day-run D₂
+ ◆ "Sun 30 Aug 2026"       key (midnight Aug 30, 0)
+ filemsg  23:41   Aug 29   ┐
+ privmsg  23:12   Aug 29   │ day-run D₁
+ privmsg  01:03   Aug 29   ┘
+ ◆ "Sat 29 Aug 2026"       key (midnight Aug 29, 0)
+ …older / still loading…
+```
+
+Buffer invariant, maintained on every structural change: **for every
+maximal same-day run in the loaded order, exactly one separator record
+keyed to that day exists.**
+
+```rust
+// Inside every structural batch (insert / remove / reload):
+fn sync_separators(&mut self, batch: &[MsgRecord]) {
+    // A record starting a new day-run gets its separator next to it
+    for rec in day_run_heads(batch) {
+        if !self.has_separator(rec.day()) {
+            self.insert_derived(separator_for(rec.day()));
+        }
+    }
+    // A day-run that became empty leaves an orphan separator behind
+    for day in orphaned_days(batch) {
+        self.remove_derived(separator_of(day));
+    }
+}
+
+fn separator_for(day: NaiveDate) -> MsgRecord {
+    let midnight = local_midnight_ts(day);
+    MsgRecord {
+        ts: midnight,
+        id: MessageId([0; 32]),       // zero id sorts older at the
+                                      // exact-midnight tie
+        msg_type: MsgType::DateMsg,
+        payload: encode(&midnight),
+        ..
+    }
+}
+```
+
+The maintenance paths:
+
+- **Load (loader)**: `derive_separators` runs per record as the batch
+  is collected (see the loader sketch); records and separators enter
+  the buffer together, one Fenwick rebuild covering both. The topmost
+  separator exists because loading stops mid-history; when an older
+  day's messages load, its separator keys naturally to its own
+  midnight — the previous separator needs no move.
+- **Live insert (privmsg insert_line)**: `sync_separators` over the
+  single-record batch; a record starting a new day-run inserts its
+  separator next to it.
+- **Removal (delete_line)**: if the last message of a day-run is
+  removed, the run disappears and the orphan separator is removed in
+  the same batch. This is the path the live-testing deletion exercises.
+- **Filter/reload**: separators re-derive from whatever records the
+  filter admits; they are never filtered themselves.
+
+Separators count toward `total_height`/`visible_range` like any record,
+are hit-tested/materialized through the registry like any type, are
+selectable like any line (contributing their date label to copy), and
+are never written to the kv tree.
+
+### Buffer: arena + order index + Fenwick tree
+
+Records live in a slotmap arena (`{ts, msg_id, msg_type, payload,
+height}`); a `Vec<u32>` of arena slots ordered by `(ts, msg_id)`
+is the display order; a Fenwick tree over heights in that order answers
+`total_height`, `visible_range(scroll)`, and `pos_of(id)`. The per-frame
+paths use only those queries:
+
+```rust
+// Draw path — cost is O(log n) + O(visible), never O(buffer):
+let total = buffer.total_height();
+let max_scroll = (total - view_h).max(0.);
+let scroll = controller.clamp(requested, max_scroll);
+
+for idx in buffer.visible_range(scroll, view_h) {
+    let rec = buffer.record_at(idx);
+    let node = type_node(rec.msg_type);
+    node.materialize_if_needed(rec);
+    instrs.extend(node.draw(&rec.id, &rect, &renderer));
+}
+```
+
+Height changes and newest-position inserts are O(log n) point updates;
+structural edits that move display positions (mid-array backfill
+inserts, removals) are applied by the loader in batches with a single
+O(n) rebuild per batch (see the Fenwick section):
+
+```rust
+// Live arrival (hot path): newest end, no positions move
+buffer.insert(rec);              // order.push + fenwick.push
+
+// Backfill batch (loader): positions shift — one rebuild covers it
+buffer.insert_batch(batch);      // merge into order, then
+                                 // fenwick.rebuild(heights)
+```
+
+Rejected alternatives: per-message scene nodes (rejected by requirement
+— one node per type); plain `Vec<Message>` with linear scans (the
+current design and the O(n) hot paths this change exists to remove);
+uniform row quantization (heights are inherently variable — images,
+expansion — so a fixed row unit is a lie while the expensive parts
+remain parley layout and draw submission).
+
+Heights come from the owning type node when a message is materialized
+(wrapped at the current width) — the buffer never lays out text:
+
+```rust
+// materialize() measures; the height flows back into geometry
+let h = node.materialize(rec)?.height;
+let delta = buffer.set_height(&rec.id, h);   // fenwick.add(idx, h − old)
+```
+
+First layout of a message is the only remaining
+linear-in-loaded-messages cost; it happens inside the loader,
+incrementally. Full re-layout on resize is O(loaded) parley runs —
+accepted; resize is rare and buffers are bounded by eviction pressure.
+
+### Fenwick tree: what it offers and why
+
+Every geometric question the chatview asks is a **prefix-sum question
+over mutable heights**: total content height, which messages fall inside
+`[scroll, scroll + view_h)`, and where message M sits. The summands
+change constantly — inserts at any timestamp, async height changes
+(image loads, expansion), filter rebuilds. A Fenwick tree (binary
+indexed tree) is the minimal structure that answers both directions at
+O(log n): a flat `Vec<f32>` laid parallel to the order index where entry
+`i` holds the partial sum of a bit-aligned block of heights ending at
+`i`. Walking bit patterns instead of scanning messages gives:
+
+- `prefix(i)` — cumulative height of messages `0..i` (from the live
+  bottom upward): O(log n). `total_height` is one call.
+- `lower_bound(px)` — descend the tree to find the message containing
+  pixel distance `px` from the bottom, never touching per-message
+  heights: O(log n). The visible range at a scroll position is two
+  calls (`scroll`, `scroll + view_h`); `pos_of(id)` is its inverse.
+- `add(idx, δ)` — height change `h → h'` (`δ = h' − h`) and end-appends:
+  O(log n), touching ~log n floats. Structural edits that *move*
+  display positions (mid-array backfill inserts, removals) are not
+  point-updates — the Fenwick is rebuilt from the order index in one
+  O(n) pass per loader batch, keeping the structural cost off the
+  frame path (µs at 10k records, under the loader's lock, amortized
+  across the whole batch).
+
+This is what makes the buffer-size-independence requirement structural
+rather than hopeful: the per-frame costs of scrolling (clamp, visible
+range, draw window) are O(log n) + O(visible), independent of how much
+history is loaded, and height changes from async content updates are
+cheap point-adds.
+
+The tree lives in `src/util/fenwick.rs` as a plain reusable structure
+(it knows nothing about messages); `buffer.rs` owns the invariant that
+the Fenwick mirrors the order index. Basic design and API:
+
+```rust
+pub struct Fenwick {
+    /// Partial sums, 1-indexed; node i covers the (i & -i) items
+    /// ending at i
+    tree: Vec<f32>,
+    /// Number of items
+    len: usize,
+}
+
+impl Fenwick {
+    /// Build from display-order values (O(n))
+    pub fn new(vals: &[f32]) -> Self
+    /// Append an item (O(log n)) — live-arrival hot path
+    pub fn push(&mut self, val: f32)
+    /// Current value at position idx (O(log n))
+    pub fn get(&self, idx: usize) -> f32
+    /// val += delta at idx (O(log n)) — height changes
+    pub fn add(&mut self, idx: usize, delta: f32)
+    /// Overwrite the value at idx (O(log n))
+    pub fn set(&mut self, idx: usize, val: f32)
+    /// Sum of [0, idx) (O(log n)) — total_height, pos_of
+    pub fn prefix(&self, idx: usize) -> f32
+    /// Sum of [from, to) (O(log n))
+    pub fn range(&self, from: usize, to: usize) -> f32
+    /// First idx whose cumulative sum exceeds `target` (O(log n)) —
+    /// px-from-bottom position → message lookup
+    pub fn lower_bound(&self, target: f32) -> usize
+    /// Full rebuild from new values (O(n)) — structural batches
+    pub fn rebuild(&mut self, vals: &[f32])
+    pub fn len(&self) -> usize
+    pub fn is_empty(&self) -> bool
+}
+```
+
+Internals — the classic implicit layout: no node objects, no pointers,
+one flat `Vec<f32>` where `tree[i]` sums the `i & -i` items ending at
+`i`:
+
+```rust
+// Sum of items [0, i): walk downward, stripping the low bit
+fn prefix(&self, mut i: usize) -> f32 {
+    let mut sum = 0.;
+    while i > 0 {
+        sum += self.tree[i];
+        i -= i & i.wrapping_neg();
+    }
+    sum
+}
+
+// Apply a delta at item i: walk upward through every covering node
+fn add(&mut self, mut i: usize, delta: f32) {
+    i += 1;
+    while i <= self.len {
+        self.tree[i] += delta;
+        i += i & i.wrapping_neg();
+    }
+}
+
+// lower_bound(target): descend top-down through the implicit tree,
+// accumulating node sums, landing on the item containing `target`
+// without ever touching per-message heights.
+// get(idx) = prefix(idx + 1) - prefix(idx)
+```
+
+`src/util/fenwick.rs` carries randomized unit tests comparing
+every operation against brute-force sums over a reference `Vec<f32>`.
+
+Alternatives considered:
+
+- **Precomputed flat prefix-sum array** — O(1) queries but O(n)
+  recompute on every insert/height change; per-frame recompute is
+  exactly the current `calc_total_height` failure mode this change
+  removes.
+- **Segment tree** — same asymptotics as Fenwick, but a recursive node
+  structure with more code and worse cache behavior. It only pays off
+  for range assignments or min/max queries (e.g. "first visible
+  message with property X"), which this design doesn't need; if such a
+  query appears later, Fenwick swaps out behind `buffer.rs`'s API.
+- **Skiplist over absolute px positions** (an idea sketched in the old code
+  comments) — pointer-chasing per level, worse constants, and it
+  indexes absolute positions that every below-insertion invalidates;
+  sums compose better than absolute positions under mutation.
+- **Quantized uniform rows** — rejected in the buffer section:
+  variable heights are the point (images, expansion), so a fixed row
+  unit is a lie that still needs this machinery for accuracy.
+
+Costs/limits accepted: float summation means `prefix` can drift by
+rounding as deltas accumulate. Heights are bounded (~1e5–1e6 px total)
+and update counts per session are far below f32 precision limits
+(~7 significant digits), so drift stays sub-pixel; the buffer's
+randomized unit tests compare Fenwick results against exact brute-force
+sums, and a cheap full rebuild (O(n) adds) is available as a
+periodic/manual re-anchoring tool if drift is ever observed.
+
+### Scroll: internal pixels-from-bottom, exposed minimally
+
+Scroll position is **internal controller state**, not a scene property:
+a plain `f32`, pixels from content bottom (0 = live bottom), backed by
+O(log n) geometry. Rationale: compensation and animations mutate it
+constantly (per height change, per animation frame); as a scene
+property every mutation would fire property events, atomic guards, and
+subscriber notifications — overhead plus ordering hazards, for no
+consumer that needs it. The only external consumers are satisfied by:
+
+```rust
+// Scene surface for scrolling (everything else is internal):
+//   method:  scroll_to_bottom()     // the down-arrow button
+//   property: is_at_bottom: bool    // arrow visibility; the chatview
+//                                    // sets it when scroll hits 0
+```
+
+The controller state machine replaces the old shared `speed` scalar:
+
+```
+State ::= Idle
+        | Drag  { grab_y, scroll0 }         1:1, no animation
+        | Glide { velocity }                exponential decay
+        | Anim  { from, to, t0, ease }      wheel/PageUp/PageDown
+```
+
+Inputs are intents; each intent names the state it produces, so
+gestures never smear into a shared scalar:
+
+```rust
+// Grabbing: 1:1 tracking, cancels any in-flight motion
+pub fn drag_start(&mut self, y: f32) {
+    self.state = ScrollState::Drag { start_y: y, scroll0: self.scroll };
+}
+
+// Wheel/PageUp/PageDown: retarget the animation — never add velocity.
+// Repeated ticks extend `to` from the current target (coalescing).
+pub fn page_tick(&mut self, dir: f32, page: f32) {
+    let base = match self.state {
+        ScrollState::Anim { to, .. } => to,
+        _ => self.scroll,
+    };
+    let to = self.clamp(base + dir * page);
+    self.state =
+        ScrollState::Anim { from: self.scroll, to, started: Instant::now() };
+}
+
+// Flick: hand the sampled velocity to a decaying glide
+pub fn drag_end(&mut self, velocity: f32) {
+    self.state = ScrollState::Glide { velocity };
+}
+
+// The down-arrow: teleport to bottom, cancel all motion
+pub fn scroll_to_bottom(&mut self) {
+    self.state = ScrollState::Idle;
+    self.scroll = 0.;
+}
+```
+
+One animator task drives Glide/Anim on a deadline cadence (computed
+from the easing curve) instead of the fixed 10ms tick, writing
+`self.scroll` directly and triggering redraw. Bottom clamps hard at 0;
+top clamps at loaded content (loader extends coverage as the viewport
+nears the top of the loaded region).
+
+`compensate()` is the single entry point for the height-change rule —
+the chatview calls it after `buffer.set_height` reports a delta:
+
+```rust
+/// Height-change compensation. When a message entirely below the
+/// viewport bottom changes height by `delta`, the content the user is
+/// looking at must not move: since scroll measures from the content
+/// bottom, keeping the same content in view means adding `delta`.
+/// At scroll == 0 (bottom pinned) there is nothing to hold — the
+/// content grows upward and the view auto-follows, so no adjustment.
+/// Changes overlapping or above the viewport are visible growth by
+/// design (image expanding in place) and are also left alone.
+impl ScrollController {
+    pub fn compensate(&mut self, delta: f32, msg_below_viewport: bool) {
+        if msg_below_viewport && self.scroll > 0. {
+            self.scroll += delta;
+        }
+    }
+}
+
+// Caller side (after a regen reported a new height):
+let top = buffer.pos_of(&id);               // measured before update
+if let Some(delta) = buffer.set_height(&id, new_h) {
+    controller.compensate(delta, top <= controller.scroll());
+}
+```
+
+This one rule covers live arrival while reading history (stable),
+pinned bottom (auto-follow), and in-viewport expansion (grows in
+place). Deferred alternative: anchor-based core (`(msg_id, dy)` as
+ground truth) — more robust to mass reflow but more machinery; upgrade
+path is to keep the controller API and swap the internal representation.
+
+#### Anchors: what they are and how inserts affect them
+
+An `Anchor` is the serialization of "what the user is looking at",
+used only at save/restore boundaries (channel exit/entry, reflow) —
+runtime stability is `compensate()`'s job, never the anchor's:
+
+```rust
+pub struct Anchor {
+    /// Message whose top edge is at or above the viewport top edge —
+    /// the oldest visible message. None = bottom (scroll == 0).
+    pub msg: Option<MessageId>,
+    /// dy = (scroll + view_h) − pos_of(msg): how far the viewport top
+    /// sits below the anchor message's top, in px. 0 = the message's
+    /// top is exactly at the viewport top; larger = it is further
+    /// down (partially scrolled past).
+    pub dy: f32,
+}
+```
+
+Anchoring at the **viewport top** message makes the anchor immune to
+inserts by construction:
+
+- **Inserts below (newer messages arriving, live bottom growing)**:
+  `pos_of(anchor)` shifts, but the anchor is expressed relative to the
+  message itself — restore recomputes from `pos_of`, so the same
+  content reappears at the same place.
+- **Inserts above (older messages backfilled during sync)**: same —
+  the anchor names content, not a position relative to the bottom; whatever
+  shifted above it does not move it.
+- **The anchor message disappearing** (deleted): restore clamps to a
+  valid scroll (nearest valid position) — an explicit, logged
+  fallback, never a silent jump.
+
+```rust
+// Snapshot — cheap, no persistence (the chatview's per-channel state
+// map persists it on channel exit):
+pub fn anchor(&self) -> Anchor { /* per the definition above */ }
+
+// Resolve — after the loader's coverage reaches the anchor:
+//   scroll = pos_of(msg) + dy − view_h, clamped
+```
+
+The method is `anchor()` (a snapshot), not `save_anchor()` — nothing
+is persisted by the call itself; persistence is the chatview storing
+the snapshot in its per-channel state map on exit.
+
+### Reflow: width, scale, and styling changes
+
+Wrapping depends on viewport width, so any width change (window
+resize/rotation), window_scale change, or styling change (font size,
+timestamp width, cap height) invalidates the rendered state of every
+loaded message of the affected types. Height-only viewport changes
+(e.g. the chat editor growing) do not re-wrap — they only recompute
+coverage and max scroll, and re-clamp.
+
+Reflow reuses the anchor machinery from channel restore:
+
+```rust
+// Width/scale/styling change → anchor protocol, NOT compensation
+fn reflow(&mut self, atom: &mut PropertyAtomicGuard) {
+    let anchor = self.controller.anchor();     // 1. before invalidating
+    for node in self.affected_types() {         // 2. drop rendered state
+        node.drop_rendered_all();
+    }
+    let heights = self.remeasure_all();         // 3. re-wrap, visible
+    self.buffer.rebuild_heights(&heights);      //    first; one O(n)
+                                                //    Fenwick rebuild
+    self.controller.restore(&anchor, &pos_of);  // 4. pos_of recomputed
+    self.redraw.trigger();
+}
+```
+
+Step 3 in v1 is synchronous for all loaded messages — the O(loaded)
+parley-run hitch the buffer section accepts; laying out the visible
+window first keeps the frame correct. The same content stays under the
+viewport across the reflow; a bottom-pinned view stays bottom-pinned.
+
+The height-change compensation rule is deliberately NOT used for
+reflow: it covers incremental async changes (one image loading, one
+message expanding) where a single below-viewport delta keeps the view
+stable. Mass rewrapping changes heights both above and below the
+viewport, where only anchor restore is correct. If the synchronous
+pass ever measures poorly on large buffers, the staged variant
+(visible-first, loader corrects the remainder with stale heights in
+the Fenwick until re-measured) is the upgrade path — same anchor
+protocol, incremental invalidation.
+
+### Loader: single background pipeline
+
+One async task owns all kvdb access per chatview. Coverage invariant:
+the loaded region always includes the live bottom and extends far
+enough above the viewport to cover `viewport + preload margin`:
+
+```rust
+// The only kvdb reader/writer for this chatview
+async fn run(mut self) {
+    loop {
+        self.cv.wait().await;
+        self.pump().await;      // restores the coverage invariant
+        self.cv.reset();
+    }
+}
+
+async fn pump(&mut self) {
+    let covered = self.buffer.total_height();
+    if covered >= self.scroll + self.view_h + self.margin {
+        return
+    }
+
+    // Iterate newest→older from the oldest loaded ts, decode,
+    // apply the filter, derive separators, stop once covered:
+    let mut batch = vec![];
+    for entry in self.tree.range(..oldest_key).rev() {
+        let rec = decode_entry(entry);          // panics on corrupt
+        if !(self.filter)(&rec) {
+            continue
+        }
+        batch.extend(derive_separators(&rec));  // day-run boundary
+        batch.push(rec);
+        if batch_height(&batch) >= shortfall {
+            break
+        }
+    }
+    self.buffer.insert_batch(batch);            // one Fenwick rebuild
+    if batch_touches_viewport {
+        self.redraw.trigger()
+    }
+}
+```
+
+Wake-ups: `set_channel`, scroll change nearing the top of coverage,
+message insert, filter change, rect change. Insert of a live message
+writes to kvdb and hands the record to the buffer directly (still
+under the loader's lock ordering). Filter application happens here —
+kvdb → filter → buffer — so filter swap is just a coverage rebuild
+through the same path; filtered messages remain stored.
+
+### Type nodes: data / props / rendered, rebuilt by regen
+
+One sub-node per message type, created once with the chatview, stable
+across channel switches. Per-id message instances owned by the type node:
+
+```
+msg instance = data      record payload + self-owned mutable state
+                          (file status, image buffer, …) behind interior
+                          mutability (Arc<SyncMutex<…>>)
+              props     live property handles received in new()
+              rendered  layouts, meshes, textures, hit rects, measured
+                          height — a pure cache of (data, props)
+```
+
+`regen()` re-reads live props + current data and rebuilds rendered
+state and height. Three triggers, one path:
+
+```rust
+// Trigger 1: a styling property changed → regen all of the type
+fn on_styling_change(&self) {
+    for id in self.instance_ids() {
+        self.regen(&id);
+    }
+}
+
+// Trigger 2: an async task updated data (image decoded, status) → one
+fn on_data_update(&self, id: &MessageId) {
+    self.regen(id);
+}
+
+// Trigger 3 (materialize after eviction) calls the same verb via new()
+fn regen(&mut self, id: &MessageId) {
+    let data = self.instance(id).data();       // current data
+    let rendered = self.render(&data);         // live prop handles,
+                                               // re-layout, re-measure
+    self.instance_mut(id).rendered = rendered;
+    self.report_height(id, rendered.height);   // → buffer + compensation
+}
+```
+
+Property inheritance is resolved at `new()` time: the type node's own
+property if it defines one, else the chatview's — the same
+`PropertyPtr`, so change notification arrives via existing
+subscriptions and the handler is just "regen".
+
+Task classes: render-scoped (spawn on materialize, cancel on release —
+animations, progressive decode) and content-scoped (hosted on the type
+node, surviving eviction and optionally channel switches for
+content-addressed work like fud downloads):
+
+```rust
+// Content-scoped hosting: keyed by content address, dedups
+// re-materialization, survives release, drains on stop()
+fn ensure_download(&mut self, url: &Url) {
+    self.tasks.entry(url.clone()).or_insert_with(|| {
+        self.ex.spawn(async move { download(url).await })
+    });
+}
+
+// materialize(id) → ensure_download(&url): attach, never duplicate
+// release(id)    → render-scoped tasks cancelled; downloads continue
+// stop()         → all tasks dropped
+```
+
+For fud the plugin remains the true task host — the node-hosted task
+may simply be a status subscription feeding the data layer.
+
+### Testability: unit coverage of major codepaths
+
+Most major codepaths MUST be unit-testable without a GPU or a window.
+The structural rule that makes this true: **renderer-dependent work
+(mesh/texture allocation) stays at the draw edge; everything else is
+CPU-only.** Concretely:
+
+```rust
+// parley layout needs only fonts (data/font), not a GPU context:
+let layout = text::make_layout2(text, color, font_size, lineheight,
+    window_scale, Some(width), &[], &fg_ranges, align, wrap);
+let height = layout.height();          // measurable in a unit test
+
+// so the msg instance's `rendered` splits into:
+//   testable:  txt layout, measured height, url hit rects, copy text
+//   renderer-  mesh/texture caches (verified visually + via trace
+//   bound:     logs, not by unit test)
+```
+
+Cache *behavior* is bookkeeping, not GPU state, and is unit-tested:
+layout reused across draws until width/styling/data changes, regen
+re-measures and reports, materialize-after-release rebuilds state, LRU
+eviction order under budget pressure. The loader reads the kv tree
+through its tree handle, so unit tests build fixture trees in-test.
+The eviction policy is a pure
+budget component tested standalone. Unit-test surface by phase:
+fenwick ops (1), ordering/dedup/removal (2), geometry + compensation
+(3), codec round-trip + corrupt-entry panic (4), scroll transitions/
+anchors (5), type
+framework cache lifecycle (8), privmsg layout cache + invalidation
+(9), mixed-type copy ordering (11), separator sync/orphans (12), LRU
+policy (14), cap/expand measurement (15). Trace logs and netdebug
+verify the live integration of exactly these paths; they never
+substitute for the unit tests.
+
+### Selection: view-wide state, per-type copy text
+
+Every line is selectable regardless of type (the old chatview exempted
+date separators — dropped). Selection state and rendering live on the
+chatview; what a selected line *contributes to the clipboard* is
+defined by its type.
+
+```rust
+// ChatView2 (mod.rs) owns the state:
+//   selected: HashSet<MessageId>
+// Highlight is chatview-drawn: a filled rect of the message's extent
+// (geometry comes from the buffer) in hi_bg_color, drawn behind the
+// type's instructions — so selecting never invalidates a type's
+// rendered cache and types stay ignorant of selection.
+
+// Toggling: hit-test the y position against the buffer window
+async fn select_line(&self, y: f32) {
+    if let Some(rec) = self.buffer.hit(y, self.scroll, self.rect) {
+        self.selected.insert(rec.id.clone());
+        self.redraw.trigger();
+        self.notify_select_changed().await;
+    }
+}
+
+// Copying: display order, per-type contribution, join by newlines
+fn copy_text(&self) -> String {
+    self.buffer
+        .iter_display_order()                  // newest→older
+        .filter(|rec| self.selected.contains(&rec.id))
+        .filter_map(|rec| self.type_node(rec.msg_type).copy_text(&rec.id))
+        .collect::<Vec<_>>()
+        .join("\n")
+}
+```
+
+Per-type copy text: privmsg contributes its rendered line
+(`<nick> text`, action/notice variants as displayed); filemsg
+contributes its file URL; datemsg contributes its date label; future
+types decide for themselves (a type MAY contribute nothing). Selection
+gestures (click toggle, drag sweep, selection-mode taps), `unselect`,
+and the `select_changed` transition signal are unchanged from the
+current chatview and operate uniformly over all types.
+
+### Wire format
+
+kv key: 8-byte BE timestamp + 32-byte msg id (composite key is
+required — same-millisecond collisions are real and derived filemsgs
+intentionally share their source privmsg's timestamp). Value is
+`[u8 tag][type-owned bytes]` (the tag is the `MsgType` discriminant);
+rest. The privmsg payload after the tag is `nick, text` plus a
+`confirmed` flag (confirmed is privmsg-owned state, living in the
+payload — not record state).
+
+**No backward compatibility.** The old chatview's untagged values are
+not readable by chatview2 and no legacy decode path exists. Corrupt or
+unknown data is an explicit, loud failure — decoding an unknown type
+id panics; errors are never silently swallowed:
+
+```rust
+// key:   [u64 BE ts][32-byte msg id]
+// value: [u8 tag][type-owned bytes]  (tag = MsgType discriminant)
+//
+// privmsg (MsgType::PrivMsg):
+//   [nick: String][text: String][confirmed: bool]
+
+fn decode_value(val: &[u8], ts: Timestamp, id: MessageId) -> MsgRecord {
+    let (&tag, rest) = val.split_first().expect("empty value");
+    let msg_type = msg_type_from_u8(tag);        // panics on unknown
+    let payload = msg_type.decode_payload(rest).expect("bad payload");
+    MsgRecord { ts, id, msg_type, payload, height: 0. }
+}
+```
+
+Old channel trees therefore cannot be opened by chatview2; the
+migration is a clean break (see Migration Plan), not a decode concern.
+Storage engine decision: **kvdb stays.** The access pattern is ordered
+range scans over `(ts, msg_id)` — a B-tree — and storage I/O is a
+negligible fraction of load cost (layout/wrapping dominates by orders
+of magnitude), so switching engines cannot meaningfully improve
+performance. Turso/libsql would add a heavy dependency and a SQL
+mapping for zero gain on this pattern (its strengths — SQL queries,
+replication, remote access — are unused here), and adding a dependency
+is a supply-chain decision requiring human review regardless.
+
+### Channel switching and app integration
+
+`set_channel(name)` looks up the channel's tree from an internal
+registry (kvdb handle given at construction), saves the outgoing
+channel's scroll anchor, releases the buffer, and lets the loader
+refill:
+
+```rust
+// Single method; the caller already knows, so no signal
+async fn set_channel(&mut self, channel: String) {
+    let anchor = self.controller.anchor();
+    self.channel_state.insert(self.current_channel.clone(), anchor);
+    self.buffer.lock().await.clear();          // release in-memory state
+    let tree = self.tree_registry.tree(&channel);
+    self.loader.bind(tree).await;              // refill in the bg
+    // Scroll restore resolves when coverage reaches the saved anchor
+}
+```
+
+`src/app/schema/chat.rs` is reworked to build the screen once; the
+per-channel `chat::make()` loop in `schema/mod.rs` disappears; the
+channel label and relay paths in `main.rs` retarget the single chatview
+via `set_channel`. Unread highlighting in the menu keys off
+message-received events carrying the channel instead of per-screen
+layers.
+
+## Risks / Trade-offs
+
+- [Fenwick/bookkeeping bugs corrupt geometry] → buffer unit tests over
+  insert/remove/height-change sequences with random operations and
+  invariant checks (prefix sums vs brute force).
+- [Panic on corrupt db data takes down the app] → intentional:
+  corrupt/unknown entries are explicit failures, never silently
+  skipped; the panic message names the channel tree, key, and type id
+  so the offending entry can be found and removed.
+- [Compensation rule degrades under mass reflow (resize/regen)] → mass
+  reflow never uses the compensation rule; it follows the anchor
+  snapshot/restore protocol (see Reflow section). If synchronous
+  rewrap hitches on large buffers, stage it via the loader
+  (visible-first, stale heights corrected incrementally).
+- [Deep scroll restore loads+measures everything newest→anchor] →
+  background-only, content streams in; if it measures poorly, build the
+  deferred layout sidecar.
+- [Per-type node `HashMap<id, …>` grows without pressure] → same LRU
+  budget question as eviction; instrument before tuning (non-strict by
+  decision).
+- [Two chatviews during migration window (old screens + chatview2)] →
+  switchover is a single cutover task in the sequence; old module deleted
+  in the same change once parity tests pass.
+
+## Migration Plan
+
+1. Land chatview2 alongside the old module (unused) — no behavior change.
+2. Cutover `schema/chat.rs` + relays to the single screen; delete
+   `src/ui/chatview/`.
+3. Storage is a clean break, deliberately: chatview2 uses new per-channel
+   trees (versioned names, e.g. `{channel}__chat_tree_v2`), so old trees
+   are ignored rather than misread — history is rebuilt by a darkirc
+   rescan of the DAG. Rollback before cutover is trivial (old path and
+   old trees untouched); after cutover, rollback = revert the cutover
+   commit (new trees are simply ignored by the old code).
+
+## Development Protocol
+
+tasks.md is sequenced as 19 phases, each ending at a **logically atomic
+commit point**. A phase closes only through its gate: the phase's
+verification runs and its evidence is checked, the owner performs a
+thorough code review (amendments and clarifications are applied to the
+code before proceeding), and only then is the phase committed. Work on
+the next phase does not begin before the gate passes. Verification
+tooling available at every phase:
+
+- **Unit tests** (`cargo test` in `bin/app`) for pure components —
+  fenwick, buffer, codec, scroll controller.
+- **netdebug** (zeromq scene backend, enabled by default in `make dev`
+  features): drive and inspect the live scene from a CLI — `CallMethod`
+  (`insert_line`, `set_channel`, `get_line_ids`, `delete_line`),
+  `GetPropertyValue` (e.g. `is_at_bottom`), node/property introspection, and
+  signal subscriptions over the pub socket.
+- **Trace logs** (`ui::chatview2*` log targets with filelog): assert
+  internal behavior — loader batches, Fenwick rebuilds, layout cache
+  hit/miss, materialize/release lifecycle, scroll state transitions.
+- **Visual inspection** on the schema-chatview dev screen, compared
+  against the old chatview where parity applies.
+
+The dev screen — `src/app/schema/test_chatview.rs`, selected by the
+`schema-test-chatview` cargo feature (following the existing
+`schema-test-*` convention and its one-schema-at-a-time compile guard)
+— lands in phase 6 and carries all interactive verification until the
+cutover phases rework the real chat screen.
+
+## Open Questions
+
+- Eviction budget shape (bytes vs entry count, per-type vs global) —
+  tuning detail, decided during implementation.
+- `set_channel` argument is the channel name with an internal tree
+  registry (assumed in design); if the schema layer prefers passing tree
+  handles, it is a constructor-signature change only.
+- Exact signal payload encodings (field order in the encoded data vec) —
+  pinned down per signal while writing the msg nodes.

+ 83 - 0
openspec/changes/app-chatview/proposal.md

@@ -0,0 +1,83 @@
+## Why
+
+The app's chat UI duplicates a full screen (layer tree, ChatView, kv tree,
+mesh caches) for every joined channel, scrolls with costs that grow linearly
+with buffer size, keeps render resources (meshes, glyphs, textures, layouts)
+forever once messages scroll out of view, and conflates every scroll gesture
+into a single velocity scalar — making animated wheel paging, stable
+anchoring, and richer content (images, expandable rich messages, stickers)
+progressively harder to bolt on. The full requirements list agreed during
+exploration is captured in this change's `design.md` and spec; this change
+implements it as a ground-up `chatview2` with the current implementation
+serving as the functional spec (feature parity, no regressions).
+
+## What Changes
+
+- New `src/ui/chatview2/` module in `bin/app`: modular chatview — buffer
+  (ordered arena + Fenwick height index), scroll controller, background
+  loader, virtualizing view, and per-type message nodes.
+- **BREAKING** (internal scene API): the old `src/ui/chatview/` is removed
+  once parity is reached; `src/app/schema/chat.rs` moves from one duplicated
+  screen per channel to a single chat screen with the chatview retargeted
+  via `set_channel`.
+- Message system: one scene sub-node per message **type** (not per
+  message), carrying type-specific styling properties, signals, and
+  methods with msg ids in payloads; messages are records in the buffer
+  plus per-id render state owned by the type node.
+- Property inheritance: shared styling (font_size, line_height, timestamp
+  styling, selection color) defined once on the chatview2 node; type nodes
+  define only type-specific properties; property handles passed into the
+  msg type's `new()`; `.regen()` rebuilds rendered state (used for both
+  styling changes and async content updates).
+- Storage: keep kvdb (no custom format); kv key stays the
+  `(timestamp, msg_id)` composite; value becomes a type ID followed by
+  type-owned bytes; unconfirmed messages are persisted with a confirmed
+  flag.
+- Scrolling: pixel scroll from bottom (scroll=0 is always the bottom),
+  1:1 finger drag, animated half-page mouse-wheel jumps, flick inertia as
+  distinct states of a scroll controller; compensation rule for height
+  changes below the viewport; per-channel scroll restore (anchor msg id +
+  offset) on re-entry.
+- Performance: visible-range lookup, total height, and position queries
+  are O(log n) in buffer size; only visible messages (soft window + LRU
+  budget) hold render resources; loading is a single async background
+  pipeline; no hard buffer cap in v1.
+- Runtime-settable message filter callback applied in the load pipeline.
+- Privmsg v1 ships plain text + nicks + URLs plus capped-height with
+  expand; the rich span/block body model (quotes, styling, code, math) is
+  deferred but the APIs are shaped for it.
+- Message types take the i18n_fish for translation support.
+
+## Capabilities
+
+### New Capabilities
+
+- `chatview`: the chatview2 widget — scene API (properties, methods,
+  signals, per-type message sub-nodes), buffer and geometry semantics
+  (ordering, insertion, dedup, heights), scrolling behavior (gestures,
+  animation, clamping, anchoring, restore), storage format, background
+  loading, filter, resource eviction, and feature parity with the current
+  chatview (URL clicks, selection/copy, file messages, notices/actions,
+  unconfirmed messages, date separators, keyboard scrolling).
+
+### Modified Capabilities
+
+(none — no existing specs in this repo; the chat screen rework in
+`src/app/schema/` is part of this change's tasks and is exercised through
+the `chatview` capability.)
+
+## Impact
+
+- `bin/app/src/ui/chatview2/` (new module), `bin/app/src/ui/chatview/`
+  (removed at switchover), `bin/app/src/ui/mod.rs`.
+- `bin/app/src/app/schema/chat.rs` (single screen, channel switching),
+  `bin/app/src/app/schema/mod.rs` (per-channel screen creation loop),
+  `bin/app/src/app/node.rs` (node factories), `bin/app/src/main.rs`
+  (message relay paths), `bin/app/src/plugin/darkirc.rs`,
+  `bin/app/src/plugin/fud.rs` (insert/status paths, unconfirmed
+  persistence).
+- Per-channel kv trees: value format gains a type ID tag; keys unchanged.
+  Existing history remains readable (v1 privmsg payload is the current
+  `nick, text` encoding plus a confirmed flag).
+- Build/test via `bin/app` Makefile targets (`make compile-dev`,
+  `compile-apk`); no changes outside `bin/app`, no new dependencies.

+ 499 - 0
openspec/changes/app-chatview/specs/chatview/spec.md

@@ -0,0 +1,499 @@
+## Purpose
+
+The chatview2 widget renders a channel's message history as a virtualized,
+scrollable chat log with typed interactive messages, replacing the current
+`bin/app` chatview. This spec defines its observable behavior: scene API,
+buffer semantics, scrolling, storage, loading, filtering, resource
+management, and feature parity with the current implementation.
+
+## ADDED Requirements
+
+### Requirement: Single chat screen with channel retargeting
+
+The chatview SHALL support rebinding to a different channel's message
+store at runtime via a `set_channel` method. On exit from a channel it
+SHALL release that channel's in-memory buffer; on entry it SHALL reload
+the target channel's messages through the background loading pipeline.
+Message-type sub-nodes and their signal wirings SHALL remain attached and
+functional across channel switches.
+
+#### Scenario: Switching channels clears and reloads
+
+- **WHEN** `set_channel` is called with a channel that has stored history
+- **THEN** the previously displayed messages are no longer rendered and
+  the target channel's newest messages load in the background
+
+#### Scenario: Signal wirings survive channel switches
+
+- **WHEN** the UI has subscribed to a message-type sub-node signal and the
+  channel is switched
+- **THEN** the subscription remains active and receives signals from
+  messages of the newly bound channel
+
+#### Scenario: Entering a channel with no history
+
+- **WHEN** `set_channel` targets a channel with an empty store
+- **THEN** the view renders empty and remains interactive
+
+### Requirement: Scroll position restore on re-entry
+
+The chatview SHALL remember, per channel, where the user left off and
+restore that position on re-entry. The remembered state SHALL identify the
+message being viewed (anchor id plus pixel offset) so restoration is stable
+when messages arrive while the channel is not open. A user who left the
+channel at the bottom SHALL return to the bottom.
+
+#### Scenario: Re-entry restores the same content
+
+- **WHEN** the user exits a channel while scrolled into history and new
+  messages arrive before re-entry
+- **THEN** the restored view shows the same anchor message at the same
+  offset within the viewport
+
+#### Scenario: Re-entry at the bottom
+
+- **WHEN** the user exits a channel while at the live bottom
+- **THEN** re-entry restores the bottom position and newly arrived
+  messages are visible
+
+#### Scenario: Anchor no longer available
+
+- **WHEN** the anchored message cannot be found on re-entry
+- **THEN** the scroll position is clamped to a valid position without
+  crashing or blocking
+
+### Requirement: Render resources released when out of view
+
+The chatview SHALL release render resources (meshes, glyphs, textures,
+layouts) for messages that leave the visible region, using a soft window
+plus LRU budget rather than strict window-bound eviction. Scrolling
+through a large history SHALL NOT cause unbounded growth of memory or GPU
+resource usage. Render-scoped async tasks SHALL be cancelled when their
+message is released.
+
+#### Scenario: Long scroll does not accumulate resources
+
+- **WHEN** the user scrolls through many screens of history
+- **THEN** resources held for messages far outside the viewport are
+  released, and total resource usage stays bounded
+
+### Requirement: Buffer-size independent interaction
+
+Geometry queries (total content height, the set of messages visible at a
+scroll position, the position of a given message) and per-frame scrolling
+work SHALL NOT scale with the number of buffered messages. UI interaction
+responsiveness SHALL NOT degrade as the buffer grows.
+
+#### Scenario: Scrolling a large buffer costs like a small one
+
+- **WHEN** the same viewport is scrolled by the same delta with a small
+  and then a very large loaded buffer
+- **THEN** per-frame cost and responsiveness are comparable
+
+### Requirement: Scroll semantics in pixels from the bottom
+
+The scroll position SHALL be measured in pixels from the live bottom of
+the content (`0` = bottom, increasing = further up in history) and
+SHALL be clamped to the valid range. The position SHALL be internal
+view state, not a settable scene property: externally, the view SHALL
+expose a `scroll_to_bottom` method and an at-bottom indication that
+distinguishes "at the live bottom" from "scrolled into history".
+
+#### Scenario: Scroll zero pins to live bottom
+
+- **WHEN** the view is at the bottom and a new message arrives
+- **THEN** the view stays at the bottom and the new message is visible
+
+#### Scenario: Clamping
+
+- **WHEN** a gesture or animation requests a scroll position beyond the
+  valid range
+- **THEN** the resulting position is clamped and no error occurs
+
+#### Scenario: Scroll to bottom
+
+- **WHEN** `scroll_to_bottom` is invoked (e.g. the down-arrow button)
+- **THEN** any in-flight motion stops and the view returns to the live
+  bottom; the at-bottom indication reflects the position
+
+### Requirement: Direct-drag scrolling
+
+Touch or mouse-drag scrolling SHALL move content 1:1 with the pointer in
+pixels, without animation or smoothing on top. Starting a drag SHALL
+cancel any in-flight glide or scroll animation.
+
+#### Scenario: Finger tracking is pixel-exact
+
+- **WHEN** the finger moves up by N pixels during a drag
+- **THEN** the content scrolls exactly N pixels in the same frame cadence
+  as the input
+
+#### Scenario: Grabbing stops motion
+
+- **WHEN** a drag starts while an animated scroll or glide is in progress
+- **THEN** the animation/glide stops immediately and the drag takes over
+
+### Requirement: Animated page scrolling for wheel and keys
+
+Mouse wheel ticks and PageUp/PageDown SHALL scroll half a page, animated
+with easing. Repeated ticks while an animation is in flight SHALL
+retarget/coalesce the animation rather than accumulate velocity.
+
+#### Scenario: Single wheel tick
+
+- **WHEN** the mouse wheel is scrolled one tick
+- **THEN** the view animates half a page in the wheel direction
+
+#### Scenario: Repeated ticks coalesce
+
+- **WHEN** several wheel ticks occur in quick succession
+- **THEN** the animation target extends by half a page per tick and the
+  motion remains smooth, without a velocity runaway
+
+### Requirement: Flick inertia
+
+Releasing a drag with sufficient velocity SHALL produce an inertial glide
+that decays over time and stops within the clamped range. A stationary
+hold during a glide-capable touch SHALL stop the glide.
+
+#### Scenario: Flick decays and stops
+
+- **WHEN** the finger is released with upward velocity
+- **THEN** the content glides in the same direction, decaying, and comes
+  to rest at or within the valid scroll range
+
+### Requirement: Scroll compensation for height changes
+
+When a message below the viewport bottom changes height, the scroll
+position SHALL be adjusted by the height delta so the viewed content stays
+stable, unless scroll is `0` (bottom pinned). Height changes inside the
+viewport SHALL grow or shrink the content around the current view without
+jumping. Total height and the maximum scroll SHALL reflect height changes
+immediately.
+
+#### Scenario: Image loads below the reading position
+
+- **WHEN** the user is scrolled into history and a message below the
+  viewport grows as its image loads
+- **THEN** the viewed content does not move
+
+#### Scenario: Expansion inside the viewport
+
+- **WHEN** the user expands a collapsed message that is on screen
+- **THEN** the message expands in place without the surrounding content
+  jumping out of view
+
+### Requirement: Message type sub-nodes
+
+Each message type SHALL be represented by exactly one sub-node of the
+chatview, exposing type-specific styling properties, signals whose
+payloads identify the message (msg id) plus type-specific data, and
+methods. Message lifecycle operations defined by each type's own
+semantics (e.g. inserting messages of that type, file status updates)
+SHALL be methods and signals of the type's sub-node; the chatview node
+SHALL expose only view-wide methods and signals (channel switching,
+filtering, selection). Sub-nodes SHALL NOT be created or destroyed when
+the buffer changes (channel switch, load, eviction). Registering a new
+message type SHALL NOT require modifying existing types.
+
+#### Scenario: Nick click emits an identified signal
+
+- **WHEN** the user clicks a nick inside a privmsg
+- **THEN** the privmsg type sub-node emits a signal carrying the msg id
+  and nick, which the UI can use (e.g. inserting the nick into the chat
+  editor)
+
+#### Scenario: Lifecycle operations go through type nodes
+
+- **WHEN** a privmsg is inserted, confirmed, or a file status changes
+- **THEN** the operation is invoked as a method on the corresponding
+  type sub-node (privmsg insert/confirm, filemsg status), not on the
+  chatview node
+
+#### Scenario: New message type is additive
+
+- **WHEN** a new message type is registered with its sub-node and payload
+  decoder
+- **THEN** existing types and stored messages continue to work unchanged
+
+### Requirement: Debug introspection and deletion
+
+The chatview SHALL provide methods to support live testing:
+enumerating the ids (with timestamps) of currently loaded messages in
+display order, and deleting a loaded message by id. Deletion SHALL
+remove the message from the buffer (updating ordering, geometry, and
+rendered state correctly) and from the channel's storage. These methods
+are testing affordances, not user-facing features.
+
+#### Scenario: Enumerate loaded messages
+
+- **WHEN** the id-enumeration method is called
+- **THEN** it returns the ids and timestamps of all currently loaded
+  messages in display order
+
+#### Scenario: Delete by id updates everything
+
+- **WHEN** a loaded message is deleted by id via the deletion method
+- **THEN** it disappears from the view, geometry (total height, scroll
+  range) updates correctly, and it is absent after the channel is
+  re-entered
+
+### Requirement: Styling inheritance and regen
+
+Styling properties shared across message types (e.g. font size, line
+height, timestamp styling, selection color) SHALL be defined once on the
+chatview node. A type sub-node SHALL only define properties specific to
+it and MAY override an inherited property by defining its own. Message
+types SHALL receive live property handles when created; changing a styling
+property SHALL cause affected messages' rendered state to be rebuilt
+(regen) with re-measured heights.
+
+#### Scenario: Font size change re-renders everything
+
+- **WHEN** the chatview font size property changes
+- **THEN** all rendered messages are re-laid-out at the new size, heights
+  are re-measured, and the scroll position remains valid (compensated)
+
+#### Scenario: Type-specific override
+
+- **WHEN** a type sub-node defines its own value for an otherwise
+  inherited property
+- **THEN** messages of that type render using the override while other
+  types use the inherited value
+
+### Requirement: Async message content updates
+
+Message types MAY run async tasks that update their own persistent data
+(e.g. download progress, decoded image buffers) and then rebuild their
+rendered state, including height. Tasks tied to rendering SHALL be
+cancelled on release. Tasks that must outlive eviction (e.g. background
+downloads) SHALL be hosted on the type sub-node, keyed by msg id or
+content address so duplicates are not spawned, and surviving tasks SHALL
+keep updating state; re-materializing a message SHALL attach to running
+tasks or current state rather than restart from scratch.
+
+#### Scenario: Download continues while evicted
+
+- **WHEN** a file message's download task is running and the message
+  scrolls out of view
+- **THEN** the download continues, and scrolling back shows current
+  progress without a duplicate task
+
+#### Scenario: Content update changes height
+
+- **WHEN** an image finishes loading and replaces a progress placeholder
+- **THEN** the message re-renders at its new height and scroll
+  compensation keeps the view stable per the height-change rules
+
+### Requirement: Random insertion at any timestamp
+
+Messages SHALL be insertable at any timestamp position (including
+backfill of older messages during sync) with the view updating correctly.
+Buffer ordering SHALL use the (timestamp, msg_id) composite key.
+Inserting a message whose (timestamp, msg_id) already exists SHALL be
+ignored (deduplication).
+
+#### Scenario: Backfilled message appears in order
+
+- **WHEN** an older message arrives while the user views history that
+  includes its timestamp position
+- **THEN** it appears at the correct chronological position without
+  duplicating or displacing other messages
+
+#### Scenario: Same-millisecond messages coexist
+
+- **WHEN** two messages share a timestamp but have different msg ids
+- **THEN** both are stored, ordered, and rendered
+
+#### Scenario: Duplicate insert is ignored
+
+- **WHEN** the same (timestamp, msg_id) is inserted twice
+- **THEN** the second insert has no visible effect
+
+### Requirement: Persisted message storage format
+
+Messages SHALL be persisted in the per-channel kvdb tree with key
+`(timestamp big-endian, msg_id)` and value `[type_id][type-owned
+bytes]`; the type decides how to interpret its bytes. Unconfirmed
+messages SHALL be persisted with a confirmed flag (type-owned payload
+state), and their later confirmation SHALL update the existing entry in
+place rather than create a duplicate. The format is a clean break from
+the previous chatview: values it cannot decode are corrupt data and
+SHALL fail explicitly (panic) rather than be skipped or misread.
+
+#### Scenario: Unconfirmed then confirmed
+
+- **WHEN** a message is sent unconfirmed and later confirmed via the
+  privmsg type node's confirm method
+- **THEN** exactly one stored entry exists, rendered with confirmed
+  styling, and it survives app restart
+
+#### Scenario: Corrupt entry fails explicitly
+
+- **WHEN** a stored value carries an unknown type id or undecodable
+  payload
+- **THEN** loading fails loudly with an explicit error identifying the
+  entry, never a silent skip
+
+### Requirement: Background loading pipeline
+
+All message loading — channel entry, live receive, scrolling toward
+history, and filter changes — SHALL happen in an async background pipeline
+that never blocks UI interaction. The loader SHALL maintain coverage of
+the visible region plus a preload margin, waking on demand.
+
+#### Scenario: Entering a large channel is non-blocking
+
+- **WHEN** `set_channel` targets a channel with a large history
+- **THEN** the UI is immediately interactive while messages stream in
+
+### Requirement: Runtime message filter
+
+A filter callback SHALL be settable and replaceable at runtime. The filter
+SHALL decide which stored messages enter the buffer during loading;
+filtered-out messages SHALL still be persisted. Changing the filter SHALL
+rebuild the visible set through the background pipeline.
+
+#### Scenario: Filter narrows the view
+
+- **WHEN** a filter that excludes some messages is set and the buffer
+  reloads
+- **THEN** excluded messages are not rendered but remain in storage
+
+#### Scenario: Filter replaced at runtime
+
+- **WHEN** the filter callback is swapped
+- **THEN** the view rebuilds in the background using the new filter
+
+### Requirement: Privmsg rendering parity
+
+Privmsgs SHALL render with the current chatview's feature set: nick
+coloring (stable per nick), CTCP ACTION rendering, NOTICE styling with
+reduced font size, unconfirmed gray styling, timestamps, URL detection
+with colored/backgrounded spans, URL click/tap opening, and URL
+right-click or long-press copying with the "copied link" toast overlay.
+
+#### Scenario: URL interaction parity
+
+- **WHEN** a message containing a URL is clicked, or long-pressed on
+  touch
+- **THEN** the URL opens (click) or is copied with the toast overlay
+  (long-press/right-click), matching current behavior
+
+### Requirement: Selection across message types
+
+Any displayed message, regardless of type, SHALL be selectable (click
+toggle, drag sweep, tap toggling in selection mode). `copy_select`
+SHALL copy the selected messages' text in display order joined by
+newlines, where each selected message contributes copy text defined by
+its type; a type MAY contribute nothing. `unselect` SHALL clear all
+selection. `select_changed` SHALL fire on transitions between having
+and not having any selection.
+
+#### Scenario: Mixed-type selection copies per-type text
+
+- **WHEN** a privmsg, a file message, and a date separator are all
+  selected and `copy_select` is invoked
+- **THEN** the clipboard contains each message's type-defined copy
+  text (privmsg its rendered line, file message its file URL, date
+  separator its date label) joined by newlines in display order, and
+  selection is cleared
+
+#### Scenario: Every type toggles
+
+- **WHEN** a date separator line is clicked
+- **THEN** it becomes selected and shows the selection highlight,
+  unlike the current chatview where separators are unselectable
+
+#### Scenario: Selection transitions signal
+
+- **WHEN** the first line becomes selected, or the last selection is
+  cleared
+- **THEN** `select_changed` fires with true and false respectively
+
+### Requirement: Expandable message height
+
+Long messages SHALL be capped to a default height with an affordance to
+expand to full height; expansion changes the message height and follows
+the height-change scroll rules. In v1 the privmsg body is plain text with
+nicks and URLs; the payload format and APIs SHALL accommodate a richer
+span/block body (quotes, styling, code, math) added later without
+breaking stored messages or the buffer/scroll machinery.
+
+#### Scenario: Long message collapses and expands
+
+- **WHEN** a message taller than the cap is rendered and the user toggles
+  expansion
+- **THEN** the message renders capped, then expands in place following
+  the height-change rules, and collapsing restores the capped height
+
+### Requirement: File message parity
+
+fud file messages SHALL be derived from privmsg text containing fud URLs,
+render the file status lifecycle (initializing, idle, downloading with
+progress, downloaded, error), request downloads on click/tap via a signal,
+update rendering when status changes, and display downloaded images
+scaled to fit width and height bounds.
+
+#### Scenario: File download lifecycle
+
+- **WHEN** a file message is tapped while idle and the download progresses
+- **THEN** a download request signal fires, status renders with progress,
+  and on completion the image (or completed state) is displayed
+
+### Requirement: Reflow on viewport changes
+
+When the viewport width or window scale changes, the chatview SHALL
+re-wrap affected messages, re-measure heights, and keep the user's
+reading position stable: the anchored content remains in view after
+reflow, and a bottom-pinned view stays bottom-pinned. Height-only
+viewport changes (e.g. the input editor growing) SHALL NOT trigger
+re-wrapping. Reflow SHALL complete without losing messages.
+
+#### Scenario: Width change keeps reading position
+
+- **WHEN** the window is resized while the user is reading history
+- **THEN** messages re-wrap at the new width and the message under the
+  viewport anchor remains in view at the same offset
+
+#### Scenario: Bottom stays bottom across reflow
+
+- **WHEN** a reflow occurs while the view is pinned at the live bottom
+- **THEN** the view remains pinned at the bottom
+
+#### Scenario: Height-only change does not rewrap
+
+- **WHEN** the chat editor grows and only the viewport height changes
+- **THEN** no re-wrapping occurs and the scroll position remains valid
+
+### Requirement: Date separators and derived messages
+
+Date separator messages SHALL be derived from the stored messages' dates,
+never persisted, and inserted into the display order at day boundaries.
+
+#### Scenario: Day boundary renders a separator
+
+- **WHEN** consecutive messages span midnight
+- **THEN** a date separator renders between them showing the new date
+
+### Requirement: i18n support
+
+Message types SHALL accept the i18n translation fish and use it for
+translatable user-facing strings (e.g. file status labels).
+
+#### Scenario: File status string is translated
+
+- **WHEN** the active language differs and a file message shows "tap to
+  download"
+- **THEN** the string is rendered translated
+
+### Requirement: Keyboard scrolling
+
+PageUp/PageDown (and wheel equivalents) SHALL scroll via the animated page
+scrolling behavior, and keyboard input SHALL be consumable so it does not
+leak to other UI elements while interacting with the chatview.
+
+#### Scenario: PageUp animates half a page
+
+- **WHEN** PageUp is pressed
+- **THEN** the view animates half a page up and the key event is consumed

+ 240 - 0
openspec/changes/app-chatview/tasks.md

@@ -0,0 +1,240 @@
+Every phase below ends at a **logically atomic commit point** and closes
+with a review gate: run the phase verification (unit tests and/or
+netdebug + trace logs + visual inspection), then a thorough code review
+by the owner with possible amendments or clarifications, then commit.
+The next phase does not start before the gate passes. Tooling: unit
+tests (`cargo test` in bin/app), netdebug zeromq CLI (CallMethod,
+GetPropertyValue, signal subscriptions), trace logs (`ui::chatview2*`),
+and visual inspection on the schema-chatview dev screen. See
+design.md → Development Protocol.
+
+## 1. Fenwick tree util
+
+- [ ] 1.1 Implement `src/util/fenwick.rs` (new, get, add, set, push,
+  prefix, range, lower_bound, rebuild) with randomized unit tests
+  comparing every operation against brute-force sums over a reference
+  `Vec<f32>`; verify `cargo test` in bin/app passes
+- [ ] 1.2 Gate: review tests + implementation with the owner, apply
+  amendments, commit as one atomic unit
+
+## 2. Buffer: records and ordering
+
+- [ ] 2.1 Implement `src/ui/chatview2/buffer.rs` record store: slotmap
+  arena, order index sorted by `(timestamp, msg_id)`, dedup set, removal
+  by id; verify unit tests pass: insert at any position, duplicate
+  insert ignored, same-millisecond coexistence, removal, ordered
+  iteration
+- [ ] 2.2 Gate: review + amendments + atomic commit
+
+## 3. Buffer: geometry
+
+- [ ] 3.1 Wire the Fenwick tree into the buffer: `total_height`,
+  `visible_range(scroll, view_h)`, `pos_of(msg_id)`, `set_height`
+  point-updates, `insert_batch` single-rebuild, plus the
+  below-viewport compensation math helper; verify randomized unit tests
+  pass comparing geometry against a linear scan and compensation
+  below/inside/above viewport and at scroll==0
+- [ ] 3.2 Gate: review + amendments + atomic commit
+
+## 4. Wire codec and legacy decode
+
+- [ ] 4.1 Implement the tagged value codec (`[u8 tag][type bytes]`,
+  privmsg payload = `nick, text` encoding + confirmed flag): a fixed
+  `#[repr(u8)]` `MsgType` enum whose discriminants are the wire tags
+  (encode via `as u8`, decode via a `from_u8` match, no factories);
+  verify unit tests pass for encode/decode round-trip and that an
+  unknown tag or undecodable payload panics with an identifying
+  message (corrupt data is never silently skipped)
+- [ ] 4.2 Gate: review + amendments + atomic commit
+
+## 5. Scroll controller
+
+- [ ] 5.1 Implement `src/ui/chatview2/scroll.rs`: internal
+  pixels-from-bottom scroll (no scene property), Idle/Drag/Glide/Anim
+  state machine with intents (drag start/move/end, flick, page tick,
+  scroll_to_bottom), clamping, is_at_bottom indication,
+  height-change compensation application, anchor snapshot/resolve with
+  bottom shortcut and clamped fallback; verify unit tests pass for
+  state transitions (grab cancels motion, wheel coalescing extends the
+  target, flick decays to stop, clamps at 0 and top, scroll_to_bottom,
+  anchor round-trip with inserts above and below)
+- [ ] 5.2 Gate: review + amendments + atomic commit
+
+## 6. Chatview2 skeleton + dev schema screen
+
+- [ ] 6.1 Create the `src/ui/chatview2/` module skeleton and the
+  `ChatView2` UIObject: properties (rect, shared styling, is_at_bottom
+  bool), view-wide method stubs (`set_channel`, `set_filter`,
+  `copy_select`, `unselect`, `scroll_to_bottom`, `get_line_ids`,
+  `delete_line`), empty-buffer draw running the visible-window loop,
+  and the node factory; verify `make compile-dev` succeeds and the dev
+  screen renders an empty view
+- [ ] 6.2 Create the `src/app/schema/test_chatview.rs` dev schema
+  (modeled on `schema/test.rs`) hosting the chatview2 node for
+  development, gated behind a new `schema-test-chatview` cargo feature
+  following the existing `schema-test-*` convention (mutually exclusive
+  with `schema-app` via the one-schema compile guard); verify by
+  running the app with the feature enabled: screen shows, and a
+  netdebug scene dump lists the chatview2 node with its properties and
+  methods
+- [ ] 6.3 Gate: review + amendments + atomic commit
+
+## 7. Loader pipeline + live-testing methods
+
+- [ ] 7.1 Implement `src/ui/chatview2/loader.rs`: single kvdb-owning
+  bg task, coverage invariant (live bottom + viewport + preload
+  margin), wake-ups (set_channel, near-top scroll, insert, filter, rect
+  change), filter application at load, plus working `get_line_ids` and
+  `delete_line` methods on the chatview; verify via netdebug on a
+  fixture tree: `set_channel` then `get_line_ids` returns the expected
+  (ts, id) list in display order, `delete_line` removes a record and it
+  stays gone after re-`set_channel`; trace logs show load batches and
+  Fenwick rebuilds as designed
+- [ ] 7.2 Gate: review + amendments + atomic commit
+
+## 8. Message type framework
+
+- [ ] 8.1 Implement `src/ui/chatview2/msg/mod.rs`: `MessageType`
+  trait (materialize/release/regen/height/draw/hit_test/copy_text) and
+  the hardcoded `MsgType` enum dispatch (no factories, no placeholder —
+  unknown type ids panic at decode), height reporting into the buffer;
+  verify unit tests pass for materialize→release→materialize state
+  rebuild, height report, and cache lifecycle (state cached across
+  draws, invalidated by width/styling/data changes); visually confirm
+  records render on the dev screen and trace logs show
+  materialize/release as the window moves
+- [ ] 8.2 Gate: review + amendments + atomic commit
+
+## 9. Privmsg I: insertion and basic rendering
+
+- [ ] 9.1 Implement `msg/privmsg.rs` part 1: the type's
+  `insert_line`/`insert_unconf_line`/`confirm` methods (persist via the
+  loader, dedup, buffer insert, confirm rewrites the payload in place
+  and regens, materialize if visible) and basic rendering (nick colors,
+  timestamp, plain text layout at the wrapped width); verify unit tests
+  pass for layout cache behavior (layout reused across repeated
+  measures, re-wrapped on width change, invalidated on styling/data
+  change) and the unconfirmed→confirm in-place update; verify via
+  netdebug `CallMethod insert_line` feeding batches of lines and
+  `confirm` on one: trace logs show dedup, fenwick pushes, layout cache
+  hit/miss behavior; visual inspection against the old chatview for
+  basic lines and unconfirmed→confirmed restyling
+- [ ] 9.2 Gate: review + amendments + atomic commit
+
+## 10. Privmsg II: URLs, signals, variants
+
+- [ ] 10.1 Implement privmsg part 2: URL spans with backgrounds,
+  click/tap open, right-click/long-press copy with the toast overlay,
+  `nick_clicked` signal carrying msg id + nick, CTCP ACTION rendering,
+  NOTICE styling, unconfirmed gray; verify netdebug-driven inserts of
+  urls/actions/notices render correctly (visual), the `nick_clicked`
+  signal is observable on the netdebug pub socket, and toast behavior
+  matches the old chatview
+- [ ] 10.2 Gate: review + amendments + atomic commit
+
+## 11. Selection across types
+
+- [ ] 11.1 Implement selection: chatview-owned selected set,
+  chatview-drawn highlight (no per-type cache invalidation), click
+  toggle, drag sweep, selection-mode taps, per-type `copy_text` joined
+  in display order, `unselect`, `select_changed` transitions; verify
+  unit test for mixed-type copy ordering passes, netdebug
+  `copy_select`/`unselect` behave per spec, and manual drag/toggle
+  selection works visually on the dev screen
+- [ ] 11.2 Gate: review + amendments + atomic commit
+
+## 12. Date separators
+
+- [ ] 12.1 Implement `msg/datemsg.rs` and derived records: synthetic
+  `(midnight, [0;32])` keys, `sync_separators` on insert/load/delete,
+  orphan cleanup, selectable with date-label copy text; verify unit
+  tests pass for separator sync and orphan removal, netdebug
+  `delete_line` of a day's only message removes its separator, and
+  visual day-boundary rendering matches the old chatview
+- [ ] 12.2 Gate: review + amendments + atomic commit
+
+## 13. Scroll input integration
+
+- [ ] 13.1 Wire the scroll controller to input on the dev screen:
+  1:1 touch/mouse drag, flick inertia, animated half-page wheel with
+  coalescing, PageUp/PageDown keys, clamps, a visible scroll-to-bottom
+  arrow driven by `is_at_bottom` calling `scroll_to_bottom`, and the
+  animator deadline-cadence task; verify trace logs show the intended
+  state transitions per gesture, netdebug `GetPropertyValue is_at_bottom`
+  flips as expected, and visual inspection confirms pixel-exact drag,
+  smooth wheel animation, correct stops at both clamps, and the arrow
+  toggling with position
+- [ ] 13.2 Gate: review + amendments + atomic commit
+
+## 14. Materialization lifecycle and eviction
+
+- [ ] 14.1 Implement the virtualization window with soft margin and
+  LRU eviction budget: materialize on window enter, release on exit
+  (render-scoped tasks cancelled), bounded render resources; verify a
+  unit test for the LRU/budget policy passes (eviction order under
+  pressure, window membership), trace logs show materialize/release
+  while scrolling a large fixture history, renderer debug stats show
+  bounded memory/GPU resources after long scrolls, and visual
+  inspection shows no ghost lines or missing lines around the window
+  edges
+- [ ] 14.2 Gate: review + amendments + atomic commit
+
+## 15. Cap/expand for long messages
+
+- [ ] 15.1 Implement the collapsed default height with expand
+  affordance and toggle: height change flows through regen +
+  compensation; verify a unit test for capped measurement and expand
+  height reporting passes, and visual toggle on very long messages
+  keeps surrounding content stable per the compensation rules
+- [ ] 15.2 Gate: review + amendments + atomic commit
+
+## 16. Reflow
+
+- [ ] 16.1 Implement the reflow protocol (anchor snapshot → invalidate
+  rendered state → re-wrap visible-first → single Fenwick rebuild →
+  anchor restore; height-only rect changes just re-clamp); verify via
+  netdebug `SetPropertyValue` on font_size and by resizing the window:
+  trace logs show visible-first regen order and one rebuild, visual
+  inspection confirms the anchored message stays put and bottom stays
+  pinned
+- [ ] 16.2 Gate: review + amendments + atomic commit
+
+## 17. Filemsg, content-scoped tasks, i18n
+
+- [ ] 17.1 Implement `msg/filemsg.rs`: `set_file_status` method, status
+  lifecycle rendering, fud URL derivation, `download_request`/
+  `fileurl_detected`/`status_changed` signals, downloaded image display
+  with fit bounds, and the content-scoped `key → Task` map surviving
+  eviction; wire `i18n_fish` into type constructors and translate file
+  status strings; verify a fud round-trip with the plugin enabled
+  (visual image + statuses), trace logs show task dedup/attach on
+  re-materialization, eviction mid-download continues the task, and a
+  language switch translates the status text
+- [ ] 17.2 Gate: review + amendments + atomic commit
+
+## 18. Single-screen cutover
+
+- [ ] 18.1 Rework `src/app/schema/chat.rs` to a single chat screen
+  with one chatview2: `set_channel` on channel selection, channel
+  label binding, scroll-to-bottom arrow driven by `is_at_bottom` +
+  `scroll_to_bottom`; remove the per-channel screen loop in
+  `schema/mod.rs`; retarget relay paths in `main.rs` and plugins
+  (darkirc insert/confirm to the privmsg node, fud status fan-out to
+  the filemsg node); verify on desktop with darkirc: receiving messages
+  updates the active channel, switching channels clears/reloads and
+  restores each channel's position, and unread indication works via
+  signals
+- [ ] 18.2 Gate: review + amendments + atomic commit
+
+## 19. Old module removal + full validation
+
+- [ ] 19.1 Delete `src/ui/chatview/` and all old-chatview references
+  (`ui/mod.rs` re-exports, test schemas using `create_chatview`); verify
+  `make compile-dev` and `make compile-apk` both succeed
+- [ ] 19.2 Full validation: parity checklist from `specs/chatview/spec.md`
+  (URLs, selection/copy incl. separators, file messages, actions/notices,
+  unconfirmed, date separators, keys, touch, wheel, restore, reflow) on
+  desktop; performance pass scrolling a large history (no slowdown,
+  bounded memory via renderer debug stats); android device smoke test
+  (drag, flick, long-press copy, channel switching)
+- [ ] 19.3 Gate: final review + atomic commit closing the change

+ 2 - 0
openspec/changes/app-gesture/.openspec.yaml

@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-08-30

+ 517 - 0
openspec/changes/app-gesture/design.md

@@ -0,0 +1,517 @@
+## Context
+
+Touch input currently enters the app on the miniquad Stage thread and splits into
+two dispatch paths with different ordering and different visibility:
+
+```
+                       miniquad Stage thread
+                              │ touch_event()
+                              ▼
+                ┌───────────────────────────────┐
+                │  handle_touch_sync()  (sync)  │   BaseEdit: Started+Moved
+                │  returns true ⇒ event         │   Menu:     Started+Moved
+                │  is swallowed entirely        │   Button:  Started
+                └──────────────┬────────────────┘
+                               │ if unclaimed
+                               ▼
+                 event_pub.notify_touch() → executor hop
+                               │
+                               ▼
+                ┌───────────────────────────────┐
+                │  Window::handle_touch (async) │
+                │  1. GestureProcessor::process │   inert: nothing consumes
+                │  2. raw handle_touch fallback │   full tree, coord-translated
+                └───────────────────────────────┘
+```
+
+Five overlapping recognizers exist (see proposal.md - Why): the window-level
+`GestureProcessor` (landed from the earlier attempt, commits `9f16bed6f` /
+`56f1f8c60`, currently dead dispatch), and hand-rolled state machines in
+ChatView, Menu, BaseEdit, and EmojiPicker. Recognition constants diverge by two
+orders of magnitude (tap strictness 0.05px in ChatView/Menu vs 10px in the
+processor). `handle_gesture` cannot propagate past the first `Layer` and is not
+hit-tested. The `EMULATE_TOUCH` desktop path bypasses the processor entirely.
+`ui/gesture.rs` (two-finger pinch node) is dead code.
+
+Relevant prior decisions: `BaseEdit`'s Stage-thread handling exists for
+selection-handle drag latency (its design.md D6 accepted one hop to the
+serialized redraw pass); ChatView's scroll already runs fully async with a 20ms
+throttle, so the async hop is proven tolerable for scrolling.
+
+## Goals / Non-Goals
+
+Goals:
+
+- Recognition mechanics (distance/time/velocity state machines) exist exactly
+  once; gesture constants exist exactly once.
+- Widgets shrink to a declarative contract: which gestures they accept, where
+  they are hit, and what the gestures mean.
+- One dispatch path with one ordering and one coordinate translation.
+- Long-press that fires during hold (timer-driven), fixing the still-finger gap
+  in the processor and in BaseEdit's move-triggered check.
+
+Non-Goals:
+
+- Pinch/multi-finger gestures in v1. The stream is shaped so a `Pinch`
+  recognizer can be added without API break. Secondary touches keep today's
+  "ignored, cannot disturb the primary" semantics.
+- Mouse, wheel, and keyboard handling. Desktop keeps `handle_mouse_*`;
+  `EMULATE_TOUCH` only gets routed through the session so emulated touches
+  produce gestures.
+- Scroll physics. Inertia, decay, grab-to-stop, anchoring stay widget-side
+  (and become chatview2's scroll controller under `app-chatview`).
+- Rewriting ChatView itself (`app-chatview` owns that; this change supplies the
+  recognizer seam it is specified to consume).
+
+## Decisions
+
+### D1: Window-owned session + per-node configured recognizers
+
+The window owns a `GestureSession`: the touch stream, target resolution,
+timers, throttling, and arbitration. Recognition is a small library of pure
+per-gesture state machines instantiated per accepting node with that node's
+`GestureCfg`.
+
+Alternatives rejected:
+
+- Window god-object with one hardcoded behavior (the landed attempt's shape):
+  legitimately different per-widget semantics (axis lock, drag direction,
+  zero-threshold precision drags) cannot be expressed.
+- Per-widget self-contained recognizers with no orchestration (Android
+  `GestureDetector` style): keeps the targeting/timer/arbitration duplication
+  this change exists to remove, and inherits the sync-path blindness.
+
+This mirrors the convergent design of iOS `UIGestureRecognizer`/Flutter's
+gesture arena: distributed recognizers, one central orchestrator.
+
+### D2: The session is fed from the Stage thread
+
+The feed point is `gfx` `touch_event`, before any sync claiming. A touch that
+begins on a sync-claiming widget (BaseEdit) still drives recognition for its
+target chain. The recognizer math runs inline (pure, cheap); timers are
+executor tasks using the version-guard pattern ChatView/Menu already use.
+
+### D3: One event stream, lifecycle included; flick is derived
+
+```
+Down ──▶ (recognition) ──▶ Tap | LongPress | DragStart ─ DragMove* ─ DragEnd
+```
+
+`Down`/`Up` are delivered to the hit-tested target immediately, without
+recognition, replacing the raw handlers' remaining legitimate uses (press
+visuals, precision-grab arming, cleanup). `DragEnd` carries end velocity;
+flick is the consumer's threshold on that velocity (ChatView's
+`scroll_start_accel · dist/time` formula reproduces exactly), so no separate
+`Flick` event is minted.
+
+### D4: Sticky ownership via hit-test chain resolved at touch start
+
+At `Down`, the session walks the tree (existing priority ordering) and resolves
+the chain of `gesture_hit_test` passers; all events for that touch id go to that chain
+until `Up`/cancel. A touch that wanders into a sibling mid-gesture does not
+hand off — the iOS behavior, strictly more predictable than today's
+per-phase re-propagation.
+
+### D5: Arbitration is first-resolved-wins
+
+All recognizers in the target chain observe the stream; the first to resolve
+claims (events delivered to its node), the rest cancel. Tap-vs-drag is
+mechanical via slop/timeout; child-vs-parent (row tap inside a scrollable
+menu) resolves as "tap wins within slop, drag wins beyond it" — the standard
+mobile contract. No Flutter-scale arena politics are needed at this app's
+complexity.
+
+### D6: Unified constants, Android-flavored
+
+One source of truth, `long_press_timeout()` already pulled from Android
+`ViewConfiguration`:
+
+| Constant | Replaces (today) | Value |
+|---|---|---|
+| `touch_slop` | 10/15/10/10/5/0.5/0.05px scatter | 10px |
+| `tap_max_duration` | 300ms / unbounded ×3 | 300ms |
+| `long_press_timeout` | 500ms hardcoded / sys ×3, move-triggered | sys, timer-fired |
+| `flick` sampling | 40ms window ×2 | 40ms, velocity on `DragEnd` |
+| `move_delivery_period` | 20ms ×2, none ×2 | 20ms (raw samples still collected) |
+
+Per-node config survives only where semantic: `axes` (y-lock for scrollers),
+`direction` (BaseEdit's vertical/horizontal split), `min_travel: 0.` for
+precision drags.
+
+### D7: Async-only delivery; the sync path dies
+
+All gesture delivery is async (executor), like today's `handle_touch` path.
+Rationale: every visual feedback already gates on the serialized redraw pass;
+the sync path only accelerates state mutation by one executor hop. ChatView
+scroll proves the hop is imperceptible for the highest-frequency gesture.
+Risk and fallback in Risks.
+
+### D8: Deletions
+
+The dead code goes first: the `ui/gesture.rs` pinch node (with its
+`create_gesture` registration and `Pimpl::Gesture` variant) and `win/gesture.rs`
+(with the dead `gesture_proc` dispatch in `Window::handle_touch`) are removed
+when the module lands — the new `ui/gesture/` directory takes the pinch node's
+module path, and Rust rejects `gesture.rs` and `gesture/mod.rs` coexisting.
+All of it is dead code, so the early deletion is behavior-neutral.
+
+At the end: `handle_touch`/`handle_touch_sync` leave `UIObject`; the four
+widget `TouchInfo` machines are removed.
+
+## The API
+
+Constants and stream:
+
+```rust
+pub struct GestureConstants {
+    /// Maximum travel between down and up that still counts as a tap.
+    pub touch_slop: f32,
+    pub tap_max_duration: u32,
+    pub long_press_timeout: u32,
+    pub move_delivery_period: u32,
+    pub sample_window_ms: u32,
+}
+
+pub enum GestureAction {
+    Down { pos: Point },
+    Up { pos: Point },
+    Tap { pos: Point },
+    LongPress { pos: Point },
+    DragStart { start: Point },
+    DragMove { start: Point, prev: Point, curr: Point },
+    DragEnd { start: Point, curr: Point, vel: Vector },
+}
+```
+
+Widget contract on `UIObject`. `gesture_set` and `gesture_hit_test` are new;
+`handle_gesture` already exists as dead dispatch taking the old
+`win::GestureAction` and is re-pointed to the new type when the module lands
+(the `ui::GestureAction` re-export moves from `win` to `gesture` in the same
+step, or the two collide at `ui::` scope). Defaults keep non-participating
+nodes inert:
+
+```rust
+fn gesture_set(&self) -> GestureSet {
+    GestureSet::NONE
+}
+
+fn gesture_hit_test(&self, pos: Point) -> bool {
+    false
+}
+
+async fn handle_gesture(&self, gesture: GestureAction) -> bool {
+    false
+}
+```
+
+`GestureSet` composes recognizer configs:
+
+```rust
+pub struct GestureCfg {
+    pub tap: Option<TapCfg>,
+    pub long_press: Option<LongPressCfg>,
+    pub drag: Option<DragCfg>,
+}
+
+pub struct TapCfg {
+    pub axes: Axes,
+}
+
+pub struct DragCfg {
+    pub axes: Axes,
+    pub direction: Direction,
+    pub min_travel: f32,
+}
+```
+
+`Axes` (both/y-only/x-only) and `Direction` (any/vertical/horizontal) encode
+the semantic per-widget differences; all numeric thresholds come from
+`GestureConstants`.
+
+Layer forwarding mirrors `handle_touch` today — subtract the layer origin,
+recurse in priority order — and is written once:
+
+```rust
+async fn handle_gesture(&self, gesture: GestureAction) -> bool {
+    if !self.is_visible.get() {
+        return false
+    }
+
+    let mut gesture = gesture;
+    gesture.translate(-self.rect.get().pos());
+
+    for child in self.get_children() {
+        let obj = get_ui_object3(&child);
+        if obj.handle_gesture(gesture.clone()).await {
+            return true
+        }
+    }
+
+    false
+}
+```
+
+## Migration samples
+
+### Button (tap)
+
+Before: `handle_touch` + `handle_touch_sync` + `handle_mouse_btn_down/up`
+(~110 lines) simulating mouse events, gated by an atomic `mouse_btn_held`
+flag, with no movement threshold.
+
+After — the entire touch surface:
+
+```rust
+fn gesture_set(&self) -> GestureSet {
+    GestureSet::TAP
+}
+
+fn gesture_hit_test(&self, pos: Point) -> bool {
+    self.is_active.get() && self.rect.get().contains(pos)
+}
+
+async fn handle_gesture(&self, gesture: GestureAction) -> bool {
+    let GestureAction::Tap { pos: _ } = gesture else {
+        return false
+    };
+
+    let node = self.node.upgrade().unwrap();
+    node.trigger("click", vec![]).await.unwrap();
+
+    true
+}
+```
+
+The `mouse_btn_held` gate disappears: down→up within slop *is* the click
+validity check. Mouse handlers remain for desktop. Accepted delta: the
+sloppy-drag-that-returns no longer clicks (now slop-bounded, standard).
+
+### EmojiPicker (scroll + tap)
+
+Before: 65 lines of local `TouchInfo { start_pos, start_scroll, is_scroll }`
+deciding scroll-vs-tap at a 0.5px y threshold.
+
+After:
+
+```rust
+fn gesture_set(&self) -> GestureSet {
+    GestureSet::SCROLL_VERT
+}
+
+fn gesture_hit_test(&self, pos: Point) -> bool {
+    self.rect.get().contains(pos)
+}
+
+async fn handle_gesture(&self, gesture: GestureAction) -> bool {
+    match gesture {
+        GestureAction::DragStart { start } => {
+            *self.drag_state.lock() = Some((start.y, self.scroll.get()));
+            true
+        }
+        GestureAction::DragMove { curr, .. } => {
+            let Some((start_y, start_scroll)) = *self.drag_state.lock() else {
+                return false
+            };
+
+            let scroll = (start_scroll + start_y - curr.y).clamp(0., self.max_scroll());
+            let atom = &mut self.redraw.make_guard(gfxtag!("EmojiPicker::drag"));
+            self.scroll.set(atom, scroll);
+            self.draw_cache.clear();
+            true
+        }
+        GestureAction::Tap { pos } => {
+            let rect = self.rect.get();
+            self.click_emoji(pos - rect.pos()).await;
+            true
+        }
+        _ => false,
+    }
+}
+```
+
+Flick inertia for EmojiPicker is an open adoption decision (see Open
+Questions); the `DragEnd { vel }` input makes it a five-line addition.
+
+### ChatView (scroll + flick inertia + long-press select + tap)
+
+Before: ~250 lines inline in `handle_touch` — `TouchInfo` with a 40ms sample
+queue, a long-press timer task with a `touch_hold_version` guard, 20ms move
+throttling, tap forwarding gated on 0.05px y-travel, `end_touch_phase`
+acceleration math, grab-stops-inertia via a 200ms rule.
+
+After: `handle_touch` disappears; the recognizer supplies the semantics:
+
+```rust
+fn gesture_set(&self) -> GestureSet {
+    GestureSet::CHATVIEW
+}
+
+async fn handle_gesture(&self, gesture: GestureAction) -> bool {
+    match gesture {
+        GestureAction::DragStart { .. } => {
+            // A grab kills running inertia (today's >200ms rule)
+            self.speed.store(0., Ordering::Relaxed);
+            *self.drag_state.lock() = Some(self.scroll.get());
+            true
+        }
+        GestureAction::DragMove { curr, .. } => {
+            // 1:1 finger scroll via scrollview(), clamped
+            true
+        }
+        GestureAction::DragEnd { vel, .. } => {
+            // Feed inertia: accel = scroll_start_accel * vel.y
+            self.speed.fetch_add(accel, Ordering::Relaxed);
+            self.motion_cv.notify();
+            true
+        }
+        GestureAction::LongPress { pos } => {
+            // URL toast if on_url, else select_line + select mode
+            true
+        }
+        GestureAction::Tap { pos } => {
+            // Forward to message (URL/file), else toggle line selection
+            true
+        }
+        _ => false,
+    }
+}
+```
+
+The inertia loop, `scroll_resist` decay, and the motion task are untouched —
+recognition and physics stay separated. `PrivMessage`/`FileMessage` handlers
+keep their exact code and are invoked from the `Tap` branch instead of the
+inline tap check.
+
+### Menu (long-press edit mode + reorder drag + tap)
+
+Before: sync `Started`/`Moved` + async `Ended` juggling, `TouchInfo` +
+`DragInfo`, a cancellable long-press task, double long-press evaluation
+(timer during hold and `elapsed` at end).
+
+After: one `LongPress` event (single-fire by construction). The hamburger
+item-reorder grab stays a `Down`-armed precision drag — grabbing an icon is a
+zero-threshold action, not a recognized gesture:
+
+```rust
+async fn handle_gesture(&self, gesture: GestureAction) -> bool {
+    match gesture {
+        GestureAction::Down { pos } => {
+            // Arm DragInfo if pos is on the hamburger of a row in edit mode
+            true
+        }
+        GestureAction::DragMove { curr, .. } => {
+            // Update insert_idx of the armed reorder, invalidate draw
+            true
+        }
+        GestureAction::Up { .. } => {
+            // Commit reorder if armed and indices differ
+            true
+        }
+        GestureAction::LongPress { .. } => {
+            // Enter edit mode, fire "edit_active"
+            true
+        }
+        GestureAction::Tap { pos } => {
+            // handle_selection or X-delete in edit mode
+            true
+        }
+        _ => false,
+    }
+}
+```
+
+### BaseEdit (hybrid, deliberately partial)
+
+Selection-handle dragging needs sub-slop precision and stage-adjacent
+latency, so `Down` arms it and a `min_travel: 0.` drag recognizer drives it;
+long-press and tap move to the session, fixing the still-finger latent bug
+(move-triggered long-press today):
+
+```rust
+fn gesture_set(&self) -> GestureSet {
+    GestureSet::EDIT
+}
+
+async fn handle_gesture(&self, gesture: GestureAction) -> bool {
+    match gesture {
+        GestureAction::Down { pos } => {
+            // try_handle_drag(): grab a selection handle by radius,
+            // or arm word-select/cursor state
+            true
+        }
+        GestureAction::DragMove { curr, .. } => {
+            // Handle drag with select+autoscroll, or ScrollVert, or
+            // SetCursorPos per the armed mode
+            true
+        }
+        GestureAction::LongPress { pos } => {
+            // start_touch_select(): word select + action menu
+            true
+        }
+        GestureAction::Tap { pos } => {
+            // touch_set_cursor_pos() + focus_request
+            true
+        }
+        GestureAction::Up { pos } => {
+            // handle_touch_end(): cursor set, stop autoscroll, focus
+            true
+        }
+        _ => false,
+    }
+}
+```
+
+## Risks / Trade-offs
+
+- [Async-only delivery regresses selection-handle drag latency] → All visual
+  feedback already waits on the serialized redraw pass; the sync path only
+  saves one executor hop. Verify on device during the BaseEdit migration
+  step; if measurable, add a `handle_gesture_sync` delivery variant for
+  `Down`/`DragMove` consumers as a contained fallback (D7 does not preclude
+  it).
+- [Slop-bounded taps feel different in ChatView/Menu (0.05px → 10px)] →
+  Accepted delta toward platform-standard feel; the mechanism for per-node
+  tightening exists (`TapCfg`) if field use disagrees.
+- [Mixed raw/gesture widgets during migration can double-act] → Migration is
+  per-widget and the raw path stays intact until D8; a migrated widget stops
+  claiming raw phases, and ownership stickiness (D4) prevents siblings from
+  seeing the strays. Each step ships green.
+- [Touch ownership stickiness changes edge behavior] → Called-out delta; a
+  touch that starts on widget A and ends over widget B now belongs to A
+  entirely. Matches iOS; more predictable than per-phase re-propagation.
+- [Sequencing collision with `app-chatview`] → ChatView migration here is the
+  proof of composition; if chatview2 lands first, this change skips task 8
+  and chatview2 consumes the session directly. Decide at task-8 time.
+- [Recognizer regressions] → Recognizers are pure state machines; synthetic
+  touch-stream unit tests pin tap/drag/long-press/velocity behavior before
+  any widget migrates.
+
+## Migration Plan
+
+1. Delete the dead code (both old gesture files, see D8), then land the gesture
+   module (constants, `GestureAction`, recognizers + unit tests), session
+   (Stage feed, targeting, timers, throttling, arbitration), `UIObject`
+   additions and the `handle_gesture` re-point, Layer forwarding. Raw path
+   untouched; nothing behavior-visible yet.
+2. Migrate leaf widgets: Button, TokenTable, EmojiPicker. Desktop verify via
+   `make compile-dev` + `EMULATE_TOUCH`.
+3. Migrate Menu.
+4. Migrate ChatView (or hand to chatview2 — see Risks).
+5. Migrate BaseEdit; on-device latency check (Risks).
+6. Deletions (D8) + `make compile-apk` + on-device feel pass over: chat
+   scrolling/flick/grab-stop, URL tap, line select, edit word-select/handles,
+   menu edit-mode/reorder, emoji scroll, button taps.
+
+Rollback: every step is an independent commit reverting to the previous
+shippable state; step 1 is inert by construction.
+
+## Open Questions
+
+- EmojiPicker flick inertia: adopt for feel-consistency or preserve its
+  current dead-stop? Decide during its migration task; input (`DragEnd
+  { vel }`) is available either way.
+- Menu reorder: drive updates from `DragMove` with an armed flag (as sketched)
+  or from a `min_travel: 0.` drag recognizer like BaseEdit's handles?
+  Task-level decision, both supported.
+- Pinch: resurrect the dead node's behavior as a `Pinch` recognizer when a
+  consumer appears (image zoom is the plausible one). Deferred by design.

+ 82 - 0
openspec/changes/app-gesture/proposal.md

@@ -0,0 +1,82 @@
+## Why
+
+The app UI has five overlapping hand-rolled gesture recognizers (`GestureProcessor`,
+`ChatView::TouchInfo`, `Menu::TouchInfo`+`DragInfo`, `BaseEdit::TouchStateAction`,
+`EmojiPicker::TouchInfo`) plus a dead pinch node, each re-deriving movement, dwell
+time, and velocity with wildly divergent thresholds (tap strictness ranges from
+0.05px to 10px across widgets). Touch dispatch is split-brained: a sync Stage-thread
+path that can swallow events before the async path — and before the window gesture
+processor — ever sees them, no hit-testing, and `handle_gesture` cannot propagate
+past the first `Layer`. Widget touch code is the largest block of incidental
+complexity in `bin/app/src/ui/`.
+
+## What Changes
+
+- New gesture subsystem in `bin/app/src/ui/gesture/`: a window-level
+  `GestureSession` owning the touch stream, target resolution, long-press timers,
+  version-guarded cancellation, move throttling, and recognizer arbitration; plus a
+  recognizer library (tap, long-press, drag lifecycle, flick) that exists exactly
+  once.
+- Unified gesture constants (touch slop, tap duration, long-press timeout from the
+  system `long_press_timeout()`, flick velocity, move delivery period) replacing the
+  per-widget scatter. Per-widget config survives only where semantic: axis lock,
+  drag direction, `min_travel: 0.` for precision drags.
+- `UIObject` gains `gesture_set()` and `gesture_hit_test()`; the existing
+  (dead-dispatch) `handle_gesture()` is re-pointed to the new `GestureAction`;
+  `Layer`/`ScrollLayer` forward gestures with coordinate translation.
+- New `GestureAction` stream: `Down`/`Up` passthrough for immediate feedback,
+  `Tap`, `LongPress`, `DragStart`/`DragMove`/`DragEnd { vel }` (flick is derived
+  from `DragEnd` velocity by the consumer).
+- The session is fed from the Stage thread (`gfx` touch entry) so it observes all
+  touches regardless of sync claiming; `EMULATE_TOUCH` mouse emulation routes
+  through the same session so desktop development produces gestures.
+- All touch widgets migrate to the new model: Button, TokenTable, EmojiPicker,
+  Menu, ChatView, BaseEdit (hybrid: `Down`-armed precision drags for selection
+  handles). Recognition semantics live in recognizers; controller physics (scroll
+  inertia, grab-to-stop) stay widget-side.
+- **BREAKING** (internal app API): `handle_touch` and `handle_touch_sync` are
+  removed from `UIObject` once every widget is migrated; the four widget
+  `TouchInfo` state machines and `win/gesture.rs` (`GestureProcessor` and the
+  old `GestureAction`) are deleted at the end; the dead `ui/gesture.rs` pinch
+  node is removed up front — the new `ui/gesture/` directory takes its module
+  path (pinch returns later as a recognizer if wanted).
+- Accepted behavior deltas toward platform-standard feel: slop-bounded taps
+  everywhere (replaces 0.05px strictness in ChatView/Menu), slop dead-zone before
+  scroll starts, long-press fires once during hold by timer, touch ownership
+  sticks to the Started target, EmojiPicker may gain flick inertia, all gesture
+  delivery is async (the sync path dies; on-device latency to be verified during
+  migration with a sync hatch as fallback).
+
+## Capabilities
+
+### New Capabilities
+- `gesture`: the gesture recognition and delivery system for the app UI — session
+  semantics (stream ownership, targeting, timers, throttling, arbitration),
+  recognizer contracts and unified constants, the `GestureAction` event stream,
+  the `UIObject` gesture contract
+  (`gesture_set`/`gesture_hit_test`/`handle_gesture`),
+  coordinate-translation forwarding, and the migrated behavior of each widget
+  under the new system (button taps, scrollers, chat selection/scroll/flick,
+  edit hybrid handling, menu reorder/edit-mode) including the accepted behavior
+  deltas above.
+
+### Modified Capabilities
+
+(none — no main specs exist yet; widget behavior deltas are captured as
+requirements of the new `gesture` capability)
+
+## Impact
+
+- Code: `bin/app/src/ui/` (new `gesture/` module; rewrites of `win/mod.rs` touch
+  entry, `layer.rs`, `scroll_layer.rs`, `button.rs`, `tokentable/`,
+  `emoji_picker/`, `menu/`, `chatview/`, `edit/`; deletion of the dead
+  `ui/gesture.rs` and `win/gesture.rs` up front), and `bin/app/src/gfx/mod.rs`
+  (Stage-thread session feed).
+  No workspace crates, no dependencies, no consensus/ZK surfaces.
+- Parallel work: `app-chatview` (chatview2) is specified to consume drag/flick as
+  scroll-controller inputs — this change should land its session and recognizers
+  first so chatview2 builds on it; ChatView's own migration is the proof of
+  composition (or is absorbed by chatview2 if it lands first).
+- Verification: `make compile-dev` (desktop), `make compile-apk` (Android), and
+  on-device touch-feel verification for the async-only delivery (edit selection
+  handles are the latency-sensitive case).

+ 222 - 0
openspec/changes/app-gesture/specs/gesture/spec.md

@@ -0,0 +1,222 @@
+## Purpose
+
+Defines how touch input is recognized into gesture events and delivered to UI
+widgets of the app: a single recognition system with unified thresholds, the
+widget contract for receiving gestures, and the touch behavior of every
+migrated widget.
+
+## ADDED Requirements
+
+### Requirement: Gesture event stream
+The system SHALL recognize touch input into a stream of gesture events:
+`Down` and `Up` passthrough events delivered immediately at touch start and
+end, `Tap`, `LongPress`, and a drag lifecycle of `DragStart`, `DragMove`, and
+`DragEnd` carrying the release velocity. All gesture positions SHALL be
+delivered in the receiving widget's local coordinate space.
+
+#### Scenario: Immediate feedback at touch start
+- **WHEN** a touch begins on a widget that hit-tests at that position
+- **THEN** the widget receives a `Down` event without waiting for any gesture
+  to resolve
+
+#### Scenario: Drag lifecycle completes exactly once
+- **WHEN** a touch moves beyond the drag threshold and is later released
+- **THEN** the target widget receives one `DragStart`, zero or more
+  `DragMove`, and exactly one `DragEnd` whose velocity reflects the recent
+  movement history at release
+
+#### Scenario: Coordinates are local
+- **WHEN** a gesture is delivered to a widget nested inside layers
+- **THEN** event positions are translated into the widget's own coordinate
+  space
+
+### Requirement: Unified recognition thresholds
+The system SHALL use one set of recognition constants for all widgets: touch
+slop bounding tap travel and long-press stationarity, a tap duration bound,
+the system long-press timeout, a drag start threshold equal to touch slop, a
+move delivery period of 20ms, and a velocity sample window of 40ms. A `Tap`
+SHALL require travel within slop and duration within the bound.
+
+#### Scenario: Tap within slop
+- **WHEN** a touch goes down and up within slop travel and within the tap
+  duration bound
+- **THEN** a `Tap` is delivered
+
+#### Scenario: Movement beyond slop cancels tap
+- **WHEN** travel exceeds slop before release
+- **THEN** no `Tap` fires and drag recognition proceeds instead
+
+#### Scenario: Move delivery is throttled
+- **WHEN** touch moves arrive faster than the move delivery period
+- **THEN** `DragMove` is delivered at most once per period while velocity
+  sampling still observes every move
+
+### Requirement: Long-press fires during hold
+`LongPress` SHALL fire while the finger is still down, once the system
+long-press timeout elapses with travel within slop. It SHALL fire at most
+once per touch and SHALL be cancelled by travel beyond slop before the
+timeout or by touch cancellation.
+
+#### Scenario: Stationary hold fires during contact
+- **WHEN** a touch is held past the long-press timeout without exceeding slop
+- **THEN** `LongPress` is delivered while the touch is still down
+
+#### Scenario: Movement cancels long-press
+- **WHEN** travel exceeds slop before the timeout elapses
+- **THEN** no `LongPress` fires for that touch
+
+### Requirement: Touch ownership
+The target of a touch SHALL be resolved by hit-testing the widget tree in
+priority order at touch start. All gesture events for that touch SHALL be
+delivered only to the resolved target chain until the touch ends or is
+cancelled. A touch that moves over a different widget mid-gesture SHALL NOT
+hand off ownership.
+
+#### Scenario: Ownership is sticky
+- **WHEN** a touch starts on widget A and moves over sibling widget B before
+  release
+- **THEN** only widget A's chain receives events for that touch
+
+#### Scenario: Priority ordering
+- **WHEN** two overlapping widgets both hit-test at the touch start position
+- **THEN** the higher-priority widget owns the touch
+
+### Requirement: Recognition observes all touches
+Gesture recognition SHALL observe every touch event regardless of which
+widget handles it, including touches that begin on widgets that previously
+claimed events synchronously. Secondary touches (additional simultaneous
+touch ids) SHALL NOT alter or cancel recognition of the primary touch.
+
+#### Scenario: Touch on a latency-sensitive widget still recognized
+- **WHEN** a touch begins on a widget whose interaction previously suppressed
+  event delivery to the recognition system
+- **THEN** gesture events for that touch are still recognized and delivered
+  to its target chain
+
+#### Scenario: Second finger is inert
+- **WHEN** a second touch id appears during an active drag
+- **THEN** the active touch's recognition and delivery are unaffected
+
+### Requirement: Gesture arbitration
+When multiple widgets in the target chain accept competing gestures, the
+first recognizer to resolve SHALL claim the gesture and competing
+recognizers SHALL be cancelled. Within slop the descendant's tap wins over an
+ancestor's drag; beyond slop the ancestor's drag wins over the descendant's
+pending tap.
+
+#### Scenario: Row tap inside a scrollable menu
+- **WHEN** a touch on a menu row is released within slop
+- **THEN** the row receives the `Tap` and the menu's scroll is not engaged
+
+#### Scenario: Scroll wins on movement
+- **WHEN** the same touch instead travels beyond slop
+- **THEN** the menu's drag claims the gesture and the row's pending tap is
+  cancelled
+
+### Requirement: Widget gesture contract
+Widgets SHALL declare the gestures they accept and a hit-test region.
+Widgets that declare no gestures SHALL receive only `Down`/`Up` passthrough;
+widgets whose hit-test excludes a position SHALL receive no gesture events
+for that touch.
+
+#### Scenario: Non-participating widget is inert
+- **WHEN** a touch passes over a widget that declares no gestures
+- **THEN** that widget receives no recognized gesture events
+
+### Requirement: Touch cancellation
+Touch cancellation SHALL tear down all pending recognition state and timers
+for that touch, and no further gesture events SHALL be emitted for it after
+cancellation.
+
+#### Scenario: Cancelled touch emits nothing further
+- **WHEN** the system cancels an active touch mid-gesture
+- **THEN** pending long-press timers are invalidated and no `Tap`,
+  `LongPress`, or `DragEnd` fires for it
+
+### Requirement: Emulated touch parity
+Mouse-emulated touches on desktop SHALL produce the same gesture recognition
+and delivery as real touches on Android.
+
+#### Scenario: Desktop emulated tap
+- **WHEN** a click is performed through mouse emulation of touch
+- **THEN** the same `Tap` is recognized and delivered as on device
+
+### Requirement: Button and token table activation
+The button SHALL emit its click signal on `Tap` within its hit region, and
+the token table SHALL emit its row click signal on `Tap` within a row.
+Activation by mouse remains unchanged.
+
+#### Scenario: Button tap activates
+- **WHEN** a `Tap` lands inside the button's hit region
+- **THEN** the button's click signal fires
+
+### Requirement: Emoji picker scroll and selection
+The emoji picker SHALL scroll one-to-one with vertical drag after slop,
+clamped to its scroll bounds, and SHALL activate the emoji under a `Tap`.
+
+#### Scenario: Drag scrolls, tap selects
+- **WHEN** a vertical drag moves within the picker
+- **THEN** scroll follows the finger clamped to bounds and no emoji is
+  activated; on `Tap` within slop the emoji under the position is activated
+
+### Requirement: Menu edit mode, selection, and reorder
+The menu SHALL enter edit mode on `LongPress`, SHALL select or delete items
+on `Tap`, and SHALL reorder items via a drag armed by touching the reorder
+handle at touch start.
+
+#### Scenario: Long-press enters edit mode
+- **WHEN** a touch is held on the menu past the long-press timeout within
+  slop
+- **THEN** edit mode activates while the finger is still down
+
+#### Scenario: Reorder drag
+- **WHEN** a touch starts on an item's reorder handle in edit mode and moves
+- **THEN** the item's insertion index follows the drag and the reorder
+  commits at touch end
+
+### Requirement: Chat view scroll, selection, and taps
+The chat view SHALL scroll one-to-one with vertical drag, SHALL drive its
+scroll inertia from the `DragEnd` velocity, SHALL stop inertia when a new
+drag starts, SHALL start line selection or show a URL toast on `LongPress`,
+and on `Tap` SHALL forward to the message under the touch (opening URLs,
+downloading files) or toggle line selection when selection is active.
+
+#### Scenario: Flick continues after release
+- **WHEN** a fast vertical drag is released
+- **THEN** the view keeps scrolling with inertia derived from the release
+  velocity and decays to a stop
+
+#### Scenario: Grab stops inertia
+- **WHEN** a new touch begins during inertial scrolling
+- **THEN** inertia stops and the new drag owns the scroll
+
+#### Scenario: Tap forwards to message content
+- **WHEN** a `Tap` lands on a message URL
+- **THEN** the URL opens and the view does not treat it as a scroll
+
+### Requirement: Text edit hybrid gestures
+The text edit SHALL arm selection-handle dragging at `Down` by grabbing a
+handle within its radius, SHALL select the word under the touch and show the
+action menu on `LongPress`, SHALL set the cursor and request focus on `Tap`,
+and SHALL scroll vertically on vertical drag while fingers move text-selection
+handles.
+
+#### Scenario: Long-press selects word
+- **WHEN** a touch is held on the edit past the long-press timeout within
+  slop
+- **THEN** the word under the touch is selected and the copy/paste menu is
+  shown while the finger is still down
+
+#### Scenario: Handle drag adjusts selection
+- **WHEN** a touch starts within a selection handle's grab radius and moves
+- **THEN** the selection endpoint follows the finger from the first movement
+
+### Requirement: Touch interaction expressed only through gestures
+After migration, widget touch interaction SHALL be expressed exclusively
+through the gesture stream. The per-widget raw phase handlers and the
+separate synchronous touch dispatch path SHALL NOT exist.
+
+#### Scenario: Single dispatch path
+- **WHEN** any touch event is processed
+- **THEN** it feeds one recognition system and gestures are delivered through
+  one path in one ordering

+ 89 - 0
openspec/changes/app-gesture/tasks.md

@@ -0,0 +1,89 @@
+## 1. Gesture core
+
+- [ ] 1.1 Delete the dead code up front: `ui/gesture.rs` (pinch node, with its
+       `create_gesture` registration and `Pimpl::Gesture` variant — the new
+       module takes over its path) and `win/gesture.rs` (`GestureProcessor`
+       and the old `GestureAction`, with the dead `gesture_proc` dispatch in
+       `Window::handle_touch`); then create `bin/app/src/ui/gesture/` module
+       with `GestureConstants` (slop 10px, tap 300ms, sys long-press timeout,
+       20ms move period, 40ms sample window), the `GestureAction` stream
+       (`Down`/`Up`/`Tap`/`LongPress`/`DragStart`/`DragMove`/`DragEnd { vel }`),
+       and the `GestureSet`/config types (`Axes`, `Direction`, `min_travel`);
+       repoint the `ui::GestureAction` re-export and the dead
+       `UIObject::handle_gesture` signature to the new types; verify
+       `make compile-dev` in `bin/app`
+- [ ] 1.2 Implement the recognizer library as pure state machines (tap,
+       long-press, drag lifecycle with 40ms velocity sampling) taking
+       `GestureConstants`; verify unit tests pass covering: tap within/beyond
+       slop, tap duration bound, long-press firing during hold, long-press
+       cancelled by movement, throttled delivery vs full sampling, and
+       `DragEnd` velocity from the sample window
+- [ ] 1.3 Implement gesture arbitration rules (first-resolved-wins, tap vs
+       drag via slop, cascade cancellation); verify unit tests cover
+       descendant-tap-wins-within-slop and ancestor-drag-wins-beyond-slop
+
+## 2. Session and dispatch
+
+- [ ] 2.1 Implement `GestureSession`: fed from the Stage thread at the `gfx`
+       touch entry before any sync claiming; resolves the hit-test target
+       chain at `Down` (priority order, sticky ownership until `Up`/cancel);
+       runs version-guarded long-press timers on the executor; throttles
+       `DragMove` delivery to 20ms; ignores secondary touch ids; verify unit
+       tests pass for sticky ownership, priority ordering, and cancellation
+       teardown
+- [ ] 2.2 Add `gesture_set()`/`gesture_hit_test()` defaults to `UIObject` and
+       implement `Layer`/`ScrollLayer` gesture forwarding with
+       coordinate translation (mirroring `handle_touch` translation); verify
+       `make compile-dev` and a unit test pinning local-coordinate delivery
+       through a nested layer
+- [ ] 2.3 Route `EMULATE_TOUCH` mouse-emulated touches through the session so
+       desktop emulation produces real gestures; verify an emulated tap
+       triggers the gesture path on a desktop build with `emulate-android`
+       enabled
+
+## 3. Widget migrations
+
+- [ ] 3.1 Migrate Button: `Tap` fires the click signal; delete
+       `handle_touch`/`handle_touch_sync` and the `mouse_btn_held` gate; mouse
+       path unchanged; verify emulated tap clicks on desktop with no double
+       activation
+- [ ] 3.2 Migrate TokenTable: row click on `Tap`; delete the touch-to-mouse
+       simulation; verify emulated row tap fires `row_click`
+- [ ] 3.3 Migrate EmojiPicker: 1:1 clamped scroll on vertical drag, emoji
+       activation on `Tap`; resolve the flick-inertia open question (adopt or
+       preserve dead-stop) and record the choice; verify scroll clamps at
+       bounds and tap selects under emulation
+- [ ] 3.4 Migrate Menu: `LongPress` enters edit mode (single fire during
+       hold), `Tap` selects/deletes, reorder drag armed at `Down` on the
+       reorder handle; delete `TouchInfo`/`DragInfo` and the long-press task
+       juggling; verify edit mode, reorder commit, and item selection under
+       emulation
+- [ ] 3.5 Migrate ChatView: 1:1 scroll on drag, inertia fed from `DragEnd`
+       velocity, grab-stops-inertia on `DragStart`, `LongPress` for line
+       select / URL toast, `Tap` forwarding to message content (URLs, file
+       downloads) and line-toggle; delete `TouchInfo`, the
+       `touch_hold_version` timer, and `end_touch_phase`; first coordinate
+       with `app-chatview` whether this task or chatview2 performs the
+       migration; verify scroll, flick, grab-stop, and URL tap under
+       emulation
+- [ ] 3.6 Migrate BaseEdit (hybrid): `Down` arms selection-handle grab and
+       word-select/cursor state, `min_travel: 0.` drag drives handle movement
+       and vertical scroll, `LongPress` selects word + shows action menu,
+       `Tap` sets cursor + requests focus, `Up` finalizes; delete
+       `TouchStateAction` and the sync/async phase split; verify word-select,
+       handle drag, cursor tap, and focus under emulation
+
+## 4. Cleanup and verification
+
+- [ ] 4.1 Delete the old paths: `handle_touch`/`handle_touch_sync` from
+       `UIObject` and all implementors, and the four widget `TouchInfo`
+       state machines; verify `make compile-dev` with no remaining
+       references to the removed APIs or the old `GestureAction`
+- [ ] 4.2 Verify `make compile-apk` succeeds
+- [ ] 4.3 On-device feel pass: chat scroll/flick/grab-stop, URL tap, line
+       selection, edit word-select and selection handles (latency check —
+       if handle drag feels laggy, implement the documented
+       `handle_gesture_sync` fallback for `Down`/`DragMove`), menu
+       edit-mode/reorder, emoji scroll, button taps
+- [ ] 4.4 Cross-check every scenario in `specs/gesture/spec.md` against unit
+       tests and the on-device pass; record any scenario lacking coverage

+ 2 - 0
openspec/changes/app-pydrk-cli/.openspec.yaml

@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-08-31

+ 381 - 0
openspec/changes/app-pydrk-cli/design.md

@@ -0,0 +1,381 @@
+## Context
+
+The netdebug backend (`bin/app/src/net.rs`, feature `enable-netdebug`,
+dev builds only) serves ZeroMQ REQ/REP on `:9484` and PUB on `:9485`.
+Requests are 2 frames `[cmd:1][payload]`, replies `[errc:1][body]`, with
+`darkfi-serial` encoding inside frames. The Python client `pydrk`
+(`bin/app/pydrk/`) mirrors the codec (`serial.py`), the command/error
+tables (`api.py`), and shape builders (`vector_shape.py`).
+
+The scene graph is now a tree of `SceneNode` addressed by `/`-separated
+paths (`ScenePath`), looked up from `sg_root` by walking child names.
+Nodes are built by Rust factories (`create_layer`, `create_vector_art`,
+... in `src/app/node.rs`) that attach the factory's properties, then
+`.setup(|me| Layer::new(me, renderer, redraw)).await` installs the pimpl,
+then `parent.link(node)` attaches it. The old central node registry is
+gone, which is why the id-based `AddNode`/`LinkNode`/... arms in
+`net.rs` are commented out — they reference a `scene_graph` object that
+no longer exists. `SceneNode::link()` asserts the child has no parent
+yet, and the pimpl types clean up GPU draw calls in `Drop`
+(`VectorArt::drop` calls `replace_draw_calls`).
+
+Only a subset of `Command` arms are live today: `Hello`, `GetChildren`,
+`GetProperties`, `GetPropertyValue`, `SetPropertyValue` (incl. expr
+compile and full `VectorShape` push), `GetSignals`, `RegisterSlot`,
+`GetSlots`, `GetMethods`, `GetMethod`, `CallMethod`.
+
+The existing entry points are example scripts (`bin/app/script/`) and the
+`pydrk` library — no CLI exists. `pydrk` has no packaging; it is run with
+cwd `bin/app`. Its sole dependency is pyzmq. Self-testing convention is an
+`if __name__ == "__main__":` block (see `vector_shape.py`).
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- One-shot CLI subcommands and an interactive shell over the same code,
+  usable by a junior dev to explore and drive a running dev-mode app.
+- Wire-level creation/removal of `Layer` and `VectorArt` nodes with full
+  factory properties and live pimpls, so shapes drawn over the wire show
+  up and clean up correctly.
+- Keep both sides of the protocol inside this repo and in lockstep.
+
+**Non-Goals:**
+
+- No packaging, no new Python dependencies (stdlib `readline` +
+  `argparse` + `shlex`; pyzmq stays the only third-party import).
+- No wire support for widget types needing app context (`Text`, `Edit`,
+  `ChatView`, plugins, ...). Only `Layer` and `VectorArt`.
+- No rename/relink of pre-existing nodes; no event subscription commands
+  (the PUB-side `EventLoop` in `pydrk/event.py` stays as is).
+- No changes to release builds; netdebug stays behind the dev feature.
+
+## Decisions
+
+### D1: Node creation is path-based and atomic
+
+`AddNode` carries `(parent_path: String, name: String,
+node_type: SceneNodeType)` and replies the new `node_id: u32`. The server
+resolves the parent, builds the node, links it, and registers it — one
+round trip, no dangling state.
+
+Alternative considered: resurrect the old two-step
+`AddNode(name, type) -> id` + `LinkNode(child_id, parent_id)`. That needs
+a server-side `id -> node` registry to keep dangling nodes addressable,
+which is exactly the machinery that was deleted with the central
+registry. The CLI never wants a dangling node. Atomic attach is simpler
+and matches the path-addressed model.
+
+### D2: Only `Layer` and `VectorArt` factories are exposed
+
+Both pimpls take only `(SceneNodeWeak, Renderer, RedrawTrigger)` — the
+two handles the adapter can carry. The `AddNode` arm (conceptually):
+
+```rust
+Command::AddNode => {
+    let parent_path: ScenePath = String::decode(&mut cur).unwrap().parse()?;
+    let node_name = String::decode(&mut cur).unwrap()?;
+    let node_type = SceneNodeType::decode(&mut cur).unwrap();
+    debug!(target: "req", "{cmd:?}({parent_path}, {node_name}, {node_type:?})");
+
+    let parent = self.sg_root.lookup_node(parent_path).ok_or(Error::NodeNotFound)?;
+
+    if parent.get_children().iter().any(|c| c.name == node_name) {
+        return Err(Error::NodeSiblingNameConflict)
+    }
+
+    let node = match node_type {
+        SceneNodeType::Layer => {
+            create_layer(&node_name)
+                .setup(|me| Layer::new(me, self.renderer.clone(), self.redraw.clone()))
+                .await
+        }
+        SceneNodeType::VectorArt => {
+            create_vector_art(&node_name)
+                .setup(|me| VectorArt::new(me, self.renderer.clone(), self.redraw.clone()))
+                .await
+        }
+        _ => return Err(Error::UnsupportedNodeType),
+    };
+
+    self.redraw.make_guard(gfxtag!("ZeroMQAdapter::AddNode"));
+    parent.link(node.clone());
+    node.id.encode(&mut reply).unwrap();
+}
+```
+
+Notes: `SceneNode::setup` must run before `link` (it asserts
+`strong_count == 1`), same ordering as every schema call site. After
+link, the pimpl's `start(ex)` is spawned (`self.ex.spawn(...)`) so
+`OnModify` handlers (redraw on property change) are armed exactly like
+window-owned nodes — copy the pattern from `src/ui/win/mod.rs`
+(`obj.start(ex.clone()).await`). Factories and pimpls are imported from
+where the schema uses them (`crate::app::node::{create_layer,
+create_vector_art}`, `crate::ui::{Layer, VectorArt}`).
+
+Other node types fail with a new `Error::UnsupportedNodeType` (D4). The
+client additionally rejects unknown type strings locally before sending
+(friendlier message, no round trip).
+
+### D3: Removal unlinks the subtree, with full graph access
+
+`RemoveNode` carries `(node_path: String)`. Flow: reject `/` (the root
+has no parent, so removal is meaningless) with `Error::NodeNotRemovable`;
+look up the node; `node.unlink()`; `self.redraw.trigger()`. When the
+parent drops its last `Arc`, the pimpl `Drop` impls clear GPU draw calls
+and the `OnModify` tasks die with the node.
+
+This is a debugging tool, so removal is deliberately NOT restricted:
+built-in nodes are removable exactly like wire-created ones, giving the
+tool full access to the scene graph. The safety story is that netdebug
+is dev-only and every change is runtime-only — restarting the app
+restores the schema-built tree. Removing nodes the render pass depends
+on (e.g. `/window`) can leave a blank window or, in the worst case, an
+app panic; that is an accepted, documented trade-off for a debug tool,
+and the app is simply restarted.
+
+Alternative considered: restrict removal to wire-created nodes (a
+`wire_nodes` id set in the adapter). Rejected per requirements — the
+point of the tool is to experiment on the real tree, including taking
+subtrees away.
+
+### D4: Two new error codes, mirrored on both sides
+
+`Error::UnsupportedNodeType = 50` and `Error::NodeNotRemovable = 51` (the
+latter used only to reject removing the scene root) in `src/error.rs`
+(the enum ends at 49). pydrk mirrors them: `ErrorCode` entries + `exc.py`
+classes + `_make_request` match arms. Existing errors cover everything
+else (sibling conflict → `NodeSiblingNameConflict`, missing parent →
+`NodeNotFound`).
+
+### D5: The adapter carries the `Renderer`
+
+`ZeroMQAdapter::new(sg_root, renderer, redraw, ex)` — `main.rs` already
+has `app.renderer` in scope where the adapter is spawned (currently
+`main.rs:196-207`), so this is a one-line call-site change plus the
+struct field.
+
+### D6: pydrk client updates
+
+- New: `add_node(parent_path, name, node_type) -> int` and
+  `remove_node(node_path)` in `api.py`, encoded per D1/D3.
+- Removed: dead client methods whose server arms are gone (`get_info`,
+  `get_parents`, `link_node`, `unlink_node`, `rename_node`,
+  `scan_dangling`, `lookup_node_id`, `add_property`, `unregister_slot`,
+  `lookup_slot_id`) and the `vertex()`/`face()` legacy mesh helpers.
+  They misparse the empty errc=0 replies and only encode the removed
+  registry world.
+- Fixed: `get_method()` result decoding. The server encodes
+  `Option<Vec<CallArg>>` (`net.rs` `method.result.encode(...)`);
+  `api.py` currently decodes a bare array, which silently truncates for
+  `Some(...)` results. Read the option tag first:
+
+```python
+args = serial.decode_arr(cur, read_arg)
+results = serial.decode_opt(cur, lambda cur: serial.decode_arr(cur, read_arg))
+```
+
+### D7: CLI architecture — one module, shared handlers
+
+```
+bin/app/pydrk/cli.py      argparse subcommands + handler functions + REPL + completer
+bin/app/pydrk/__main__.py import cli; cli.main()
+```
+
+- `main()` builds a parser with `--addr`/`--port` and one subparser per
+  command. With a subcommand: run the handler once, print errors as
+  `error: <name>`, `sys.exit(1)`. With no subcommand: enter the shell.
+- Handlers are small functions `cmd_ls(api, args)`, `cmd_set(api, args)`
+  ... shared by both modes. The shell re-parses each input line with
+  `shlex.split` and dispatches to the same per-command arg parsers, so
+  usage/help stays single-sourced in argparse.
+- Every handler receives paths already resolved to absolute (see D9), so
+  handlers never see cwd.
+- Errors: pydrk exceptions are caught at the dispatch boundary; one-shot
+  mode exits non-zero, shell mode prints and continues.
+
+### D8: Typed property get/set/show driven by server metadata
+
+`set`, `get` and `show` share one positional grammar with optional parts:
+`set [path] PROP [idx] VAL`, `get [path] PROP [idx]`, `show [path] PROP`.
+Positionals are parsed right-to-left so no flags are needed for the
+common cases (`VAL` is always last; an integer right before it is the
+index; what is left at the front is the path, joined with `/` when it
+spans several tokens). Flags (`--expr`) are stripped first:
+
+```python
+def parse_set_args(tokens, default_path):
+    value = tokens.pop()
+    idx = 0
+    if tokens and tokens[-1].isdigit():
+        idx = int(tokens.pop())
+    if not tokens:
+        raise UsageError("missing property name")
+    prop = tokens.pop()
+    path = resolve_path(default_path, "/".join(tokens)) if tokens else default_path
+    return (path, prop, idx, value)
+```
+
+`parse_get_args`/`parse_show_args` are the same idea without `VAL`
+(property names are never integers, so the right-to-left split is
+unambiguous; the one-shot mode passes `default_path="/"`). `show` prints
+one property's metadata block (same fields as `props`, plus depends)
+followed by its per-index values — the "everything about this property"
+view. `set` fetches `api.get_properties(path)` once, finds the property,
+and encodes by its declared type:
+
+| declared type | encoding | value parsing |
+|---|---|---|
+| bool | `set_property_bool` | `true`/`false` |
+| uint32 / scene_node_id | `set_property_u32` / `set_property_node_id` | `int(token, 0)` |
+| float32 | `set_property_f32` | `float(token)` |
+| str | `set_property_str` | token as-is (quote in shell for spaces) |
+| enum | `set_property_enum` | must be in `enum_items`, else local error |
+| null literal `null` | `set_property_null` | n/a |
+
+The `--expr` flag switches to `set_property_expr` and sends the value as
+expr source (the server compiles with the const-free compiler; `w`/`h`
+are the available globals). Enum membership and numeric parsing are
+validated client-side so mistakes produce a local usage error instead of
+a wire round trip. This "ask the server for the type" approach means
+juniors never have to know the type — `set alpha 0.5` just works.
+
+### D9: Interactive shell
+
+State: `Shell` class holding the `Api`, `cwd: list[str]` (tokens, `[]` =
+root), and the completion cache. Prompt: `pydrk:/window/content> `
+(rendered from cwd). Builtins: `cd` (no arg → `/`; `..` pops; otherwise
+resolve and verify with `api.get_children(parent)` before committing),
+`pwd`, `exit`/`quit` (and EOF). Everything else dispatches through the
+same handlers as one-shot mode.
+
+Path resolution (pure function, unit-tested):
+
+```python
+def resolve_path(cwd, arg):
+    if arg.startswith("/"):
+        tokens = arg.split("/")
+    else:
+        tokens = cwd + arg.split("/")
+    out = []
+    for token in tokens:
+        if token in ("", "."):
+            continue
+        if token == "..":
+            if out:
+                out.pop()
+            continue
+        out.append(token)
+    return "/" + "/".join(out)
+```
+
+Absolute arguments (leading `/`) pass through unchanged; all others are
+taken relative to cwd. The `cd` of a resolvable-but-childless path is
+still valid (leaves can be cwd for `set`/`get`); `cd` into a
+non-resolvable path fails with `node_not_found` and leaves cwd alone —
+verified by looking the path up (`api.get_children` of the parent, or a
+cheap `api.get_properties(path)`).
+
+Line tokenization is `shlex.split` so values containing spaces can be
+quoted: `set nick "hello world"`.
+
+### D10: Tab completion via stdlib readline
+
+A `Completer` class registered with `readline`:
+
+- First token: complete from the command-name table.
+- `get`/`set`/`show` first argument: complete from the union of the cwd
+  node's property names (`api.get_properties`, cached) and its child node
+  paths — both are valid leading tokens under the optional-path grammar
+  of design D8.
+- Any other argument: complete child node names. Split the token into
+  dir part + prefix (last `/`), resolve the dir part against cwd, fetch
+  `api.get_children` (cached), return `name + "/"` matches. Matches come
+  from the live app, so freshly `mknode`-ed nodes complete immediately.
+- The cache is a dict `path -> [child names / prop names]` cleared at
+  every prompt redraw (i.e. after each executed command), so mutations
+  are picked up without staleness bugs. One REQ per uncached directory
+  per line — the REQ/REP socket is lockstep anyway.
+- `import readline` is wrapped in try/except; without it the shell runs
+  without completion (e.g. exotic platforms). Dev target is Linux.
+
+No `rlcompleter`/`prompt_toolkit`: zero new dependencies, and
+`Completer` needs app state (cwd, live children) that the generic
+completer doesn't have.
+
+### D11: `set-shape` composes `vector_shape.VectorShape` from flags
+
+Each primitive flag is `action="append"` and carries its colors inline as
+trailing `R G B A` float args (no hidden color state):
+
+```
+--box X1 Y1 X2 Y2 R G B A
+--gbox X1 Y1 X2 Y2 R G B A R G B A        (top color, bottom color)
+--vgradient X1 Y1 X2 Y2 R G B A R G B A STRIPS GAMMA
+--outline X1 Y1 X2 Y2 BORDERPX R G B A
+--line X1 Y1 X2 Y2 THICKNESS R G B A
+--glow CX CY W H SEGMENTS R G B A
+```
+
+Coordinates are passed through to `vector_shape` as-is: plain numbers
+are normalized to float literals, anything else (e.g. `w/2`, `h - 10`)
+is expr source — exactly what `VectorShape._coord` already does. Flags
+apply in command-line order via `shape.join(...)`. Client-side guard:
+`len(shape.verts) < 65536` before sending (indices are u16 on the wire).
+Then `shape.set(api, path, prop_name)` pushes it. Example:
+
+```
+python -m pydrk set-shape /window/content/dbg/art1 --box 0 0 w 10 1 0 0 1
+```
+
+### D12: Testing strategy
+
+- Rust: `make compile-dev` after every Rust task (per `bin/app/AGENTS.md`).
+- Python pure logic (path resolution, typed-value parsing, color/coord
+  parsing, shape flag composition) is exercised by
+  `python -m pydrk.cli --selftest` — an `if __name__ == "__main__"`-style
+  assert block, same convention as `python -m pydrk.vector_shape`.
+- Live behavior: run `make dev` in one terminal, CLI in another; each
+  task lists the exact commands and expected output. The drawing tasks
+  are verified by looking at the window.
+- Commits after every task, message prefix `app:` or `app/netdebug:`.
+
+## Risks / Trade-offs
+
+- [All mknode/rmnode changes are runtime-only and lost on restart] →
+  Documented behavior and the recovery path for destructive removals;
+  the schema-built tree is rebuilt on every launch.
+- [Removing a node the render pass depends on can blank the window or
+  panic the app] → Accepted for a dev-only debug tool with full graph
+  access; restart recovers. Documented in `rmnode --help`.
+- [Dropping a subtree relies on the parent holding the last strong ref]
+  → Verified live in the removal task (window shows removal + no stale
+  geometry). `OnModify` holds only weak refs; if anything is found
+  holding a strong ref, removal degrades to "hidden but alive", which is
+  still safe — escalate before shipping if observed.
+- [Shape eval errors are silent to the client] → The server logs a warn
+  and draws nothing; the CLI cannot see it. Documented in usage; the
+  invalid-expr rejection path (`sexpr_global_not_found`) is still
+  surfaced because it fails at compile time, before eval.
+- [readline differs on macOS/libedit] → Completion is best-effort and
+  guarded; Linux dev machines are the target.
+- [REQ/REP lockstep] → The shell is strictly one request at a time;
+  completion caches prevent request storms while tabbing.
+- [Protocol change for AddNode/RemoveNode payloads] → Both peers ship in
+  the same repo and the backend is dev-only; no migration needed. Old
+  clients against a new app fail fast on decode, not silently.
+
+## Migration Plan
+
+None. The netdebug backend is feature-gated out of release builds; dev
+workflows rebuild both peers from the same tree.
+
+## Open Questions
+
+- Should a `/debug` parent layer be created at app boot to host wire
+  nodes by default? Deferred: users can `mknode` anywhere under
+  `/window/content` today; adding a fixed parent is cosmetic and can be
+  a follow-up.
+- Should `pydrk/event.py`'s `EventLoop` keyboard path be repointed to a
+  live node path? Deferred: out of scope for this change (PUB-side
+  tooling unchanged).

+ 94 - 0
openspec/changes/app-pydrk-cli/proposal.md

@@ -0,0 +1,94 @@
+## Why
+
+The `enable-netdebug` backend (ZeroMQ REQ/REP on 9484, PUB on 9485) is the
+supported way to inspect and drive a running `app` GUI, but the only clients
+are ad-hoc scripts (`bin/app/script/`) and the `pydrk` library — there is no
+CLI. Worse, the node-mutation commands (`AddNode`, `LinkNode`, `RemoveNode`,
+...) in `bin/app/src/net.rs` were commented out when the central scene-graph
+registry was removed, so the `pydrk` client methods for them are dead: the
+server replies `errc=0` with an empty body and the client misparses it.
+Inspecting and experimenting with the live scene graph currently requires
+writing a custom Python script for every question. A junior dev learning the
+wallet UI has no safe, incremental tool to explore nodes, tweak properties,
+or draw shapes without recompiling the app.
+
+## What Changes
+
+- Re-enable wire-level node creation in `bin/app/src/net.rs` against the
+  current path-addressed tree model:
+  - `AddNode` becomes atomic and path-based: payload
+    `(parent_path, name, node_type)` → replies the new `node_id`; the node
+    is created via the existing Rust factories and linked immediately.
+    Supported types for v1: `Layer` and `VectorArt` (the two factories whose
+    pimpls only need `Renderer` + `RedrawTrigger`); other types fail with
+    `PropertyWrongType`-style rejection (see design for error choice).
+  - `RemoveNode` becomes path-based: payload `(node_path)`; unlinks the
+    subtree from its parent and triggers a redraw (existing `Drop` impls
+    clear GPU draw calls).
+  - `ZeroMQAdapter` gains the `Renderer` handle so `Layer::new` /
+    `VectorArt::new` pimpls can be wired for wire-created nodes.
+- Update `pydrk/api.py` to match: new `add_node(parent_path, name, node_type)`
+  and `remove_node(node_path)` methods; remove the dead id-based client
+  methods that no longer have server arms (`get_info`, `get_parents`,
+  `link_node`, `unlink_node`, `rename_node`, `scan_dangling`,
+  `lookup_node_id`, `add_property`, `unregister_slot`, `lookup_slot_id`).
+- Fix `pydrk` `get_method()` result parsing: the server sends
+  `Option<Vec<CallArg>>` but the client decodes a bare array, which
+  misparses any method that declares a result.
+- Add a `pydrk` CLI (`python -m pydrk ...` via a new `__main__.py` +
+  `cli.py`, argparse-based, only dependency stays pyzmq) with subcommands
+  for the whole scene-graph workflow: connectivity check, tree navigation
+  (`ls`, `tree`), display (`props`, `get`), property setting (`set`,
+  type-driven by server metadata, incl. exprs), node creation/removal
+  (`mknode`, `rmnode`), shape data (`set-shape` composing the existing
+  `pydrk.vector_shape` builders), and introspection of signals/methods
+  (`signals`, `methods`, `call`).
+- Add an interactive shell mode: running `python -m pydrk` with no
+  subcommand drops into a REPL over the same command set, maintaining a
+  current working node path (`cd`, `pwd`) so `ls` lists the current node's
+  children and properties and `set foo XXX` writes a property of the
+  current node. Tab completion (stdlib `readline`) completes command
+  names, node paths (children fetched live from the app), and property
+  names.
+
+Non-goals (recorded so they are not silently assumed): no packaging
+(pyproject/console script) — the CLI runs from `bin/app` like the existing
+scripts; no new prompt/REPL dependency (prompt_toolkit & co.) — stdlib
+`readline` only; no rename/link/unlink of pre-existing nodes; no new
+event/PUB commands; no changes to release builds (`enable-netdebug` stays
+dev-only).
+
+## Capabilities
+
+### New Capabilities
+- `pydrk-cli`: Command-line access to a running app's scene graph over the
+  netdebug backend, in one-shot and interactive forms — tree navigation and
+  display, property get/set (typed values and exprs), node creation/removal
+  for Layer and VectorArt, shape data composition, method introspection,
+  and a shell mode with `cd` and tab completion.
+
+### Modified Capabilities
+
+(none — `openspec/specs/` is empty; there are no existing main specs to
+modify.)
+
+## Impact
+
+- Rust: `bin/app/src/net.rs` (re-enable + redesign `AddNode`/`RemoveNode`,
+  carry `Renderer`), `bin/app/src/main.rs` (pass renderer into
+  `ZeroMQAdapter::new`), possibly `bin/app/src/error.rs` (only if a new
+  error code is needed — design prefers reusing existing ones).
+- Python: `bin/app/pydrk/api.py` (new/removed methods, `get_method` fix),
+  new `bin/app/pydrk/cli.py` and `bin/app/pydrk/__main__.py`; existing
+  modules (`serial.py`, `print_tree.py`, `vector_shape.py`, `exc.py`) are
+  reused as-is.
+- Wire protocol: payloads of commands 1 (`AddNode`) and 9 (`RemoveNode`)
+  change shape. Both sides live in this repo and ship together; the
+  netdebug backend is dev-only (feature-gated out of release builds), so
+  there are no compatibility constraints.
+- Docs: usage examples live in this change's design; `doc/src/arch/wallet.md`
+  still references the pre-rename `bin/darkwallet/pydrk` path — fixing that
+  is left to a docs change.
+- No workspace-level `make` targets are affected; Rust verification is
+  `make compile-dev` (and `make compile-apk` for android), Python is run
+  from `bin/app`.

+ 308 - 0
openspec/changes/app-pydrk-cli/specs/pydrk-cli/spec.md

@@ -0,0 +1,308 @@
+## Purpose
+
+Command-line access to a running `app` GUI's live scene graph over the
+netdebug ZeroMQ backend: navigate and display nodes and properties, set
+typed values and exprs, create and remove `Layer`/`VectorArt` nodes, and
+push shape data — all without recompiling the app.
+
+## ADDED Requirements
+
+### Requirement: CLI entrypoint and connection
+
+The system SHALL provide a `python -m pydrk` command (runnable from
+`bin/app`, argparse-based) whose commands talk to a running app's netdebug
+REQ/REP endpoint. The endpoint SHALL default to `127.0.0.1:9484` and be
+overridable with `--addr`/`--port` on every subcommand. When no app is
+listening, commands SHALL print an error naming the endpoint they tried and
+exit non-zero.
+
+#### Scenario: connectivity check
+
+- **WHEN** `python -m pydrk ping` runs against a running dev-mode app
+- **THEN** the command prints `hello` and exits 0
+
+#### Scenario: app not running
+
+- **WHEN** a command runs with `--port 9999` and nothing is listening there
+- **THEN** the command prints an error containing `127.0.0.1:9999` and exits non-zero
+
+### Requirement: Tree navigation and listing
+
+The `ls [path]` subcommand SHALL list the contents of a scene node: first
+its child nodes as one row per child showing the child name, numeric node
+id, and lowercase type name (e.g. `content 1234567890 layer`), then its
+properties as one row each showing name, type, and current value summary
+(exprs as source, shapes as a placeholder). Paths that do not resolve SHALL
+produce a readable `node_not_found` error and non-zero exit.
+
+#### Scenario: list the scene root
+
+- **WHEN** `python -m pydrk ls /`
+- **THEN** the built-in top-level nodes are listed (at least `setting` and `window`) followed by the root's properties
+
+#### Scenario: unknown path
+
+- **WHEN** `python -m pydrk ls /nope`
+- **THEN** the command reports `node_not_found` for `/nope` and exits non-zero
+
+### Requirement: Recursive tree display
+
+The `tree` subcommand SHALL recursively print a node's descendants with a
+`--depth N` limit, showing for every node: its name, id, type, properties
+with current values, signals with registered slots, and methods with full
+signatures. Property values SHALL be rendered distinctly per status: plain
+values as literals, exprs as their decompiled source (e.g. `w/2`), null as
+`null`, unset-with-default as the default, and vector shapes as a
+placeholder (shapes are write-only over the wire). Methods that declare a
+result SHALL show the result argument types (not be silently truncated).
+
+#### Scenario: shallow dump
+
+- **WHEN** `python -m pydrk tree / --depth 2`
+- **THEN** two levels of the tree print, each property line showing name,
+  type and value, and the command exits 0
+
+#### Scenario: method with result renders its signature
+
+- **WHEN** `python -m pydrk tree /plugin/drk`
+- **THEN** the `get_default_address` method line includes its result
+  signature (a `str` result), demonstrating result decoding
+
+### Requirement: Property metadata and value display
+
+The `props [path]` subcommand SHALL list a node's properties with their
+metadata: name, type, subtype, array length (marking unbounded), null/expr
+allowance, ranges when bounded, enum items when present, and UI text. The
+`show [path] PROP` subcommand SHALL print all info for one property: its
+full metadata (as listed for `props`, plus its depends list) followed by
+the current per-index values with their statuses. The `get [path] PROP
+[idx]` subcommand SHALL print the property's per-index values (only index
+`idx` when given), each on its own line annotated with its status
+(`value`, `expr`, `null`, or `unset`). For all three, a leading path
+argument is optional and defaults to the shell cwd (or `/` in one-shot
+mode); in `get`, when the final argument is an integer it is taken as the
+index.
+
+#### Scenario: metadata shows bounds and enums
+
+- **WHEN** `python -m pydrk props /window/content`
+- **THEN** the `alpha` property (or another bounded one) shows its `[0.0, 1.0]` range
+
+#### Scenario: show a single property
+
+- **WHEN** `python -m pydrk show /window/content alpha`
+- **THEN** the output contains the property's metadata (type `float32`,
+  the `[0.0, 1.0]` range, its UI text) followed by `0: value 1.0`
+
+#### Scenario: expr value is distinguishable
+
+- **WHEN** a property index holds an expr and `python -m pydrk get` is run for it
+- **THEN** the output line is annotated `expr` and shows the expr source string
+
+### Requirement: Typed property setting
+
+The `set` subcommand SHALL set values using the property's server-declared
+type for encoding (bool, uint32, float32, str, enum, scene_node_id as
+decimal). Its grammar is `set [path] PROP [idx] VAL`: the last argument
+is always the value; when the argument before the value is an integer it
+is taken as the array index (default 0); any remaining leading arguments
+(joined with `/`) are the node path, optional and defaulting to the shell
+cwd (or `/` in one-shot mode). The `--expr` flag sends the value as expr
+source to be compiled server-side. After a successful set the app SHALL
+redraw. Server rejections (wrong type, out-of-range, invalid enum item,
+invalid expr syntax, unknown expr global) SHALL be printed readably with
+the error name and exit non-zero, and a usage error SHALL be reported
+when the arguments cannot be parsed into the grammar (e.g. a property
+name is missing).
+
+#### Scenario: set a boolean with an explicit path
+
+- **WHEN** `python -m pydrk set /window/content/chat is_visible false`
+- **THEN** the command exits 0, a subsequent `get` shows `false`, and the app window updates
+
+#### Scenario: index and path in one command
+
+- **WHEN** `python -m pydrk set /window/content rect 2 "w/2" --expr`
+- **THEN** the command exits 0 and `get /window/content rect 2` shows `expr "w/2"`
+
+#### Scenario: out-of-range rejection
+
+- **WHEN** setting a bounded float32 property to `5.0` when its range is `[0.0, 1.0]`
+- **THEN** the command prints `property_out_of_range` and exits non-zero
+
+#### Scenario: expr set round-trip
+
+- **WHEN** `python -m pydrk set <path> rect 2 "w/2" --expr`
+- **THEN** the command exits 0 and `get` for that index shows `expr "w/2"`
+
+### Requirement: Node creation
+
+The `mknode` subcommand SHALL create and attach a node in one step:
+`mknode <parent_path> <name> <type>` where `<type>` is `layer` or
+`vector_art`. On success it SHALL print the new node's id and full path,
+and the node SHALL immediately appear in `ls <parent_path>` with all its
+factory properties (queryable via `props`). Creating a node whose parent
+path does not resolve SHALL fail with `node_not_found`; a name colliding
+with an existing sibling SHALL fail with a name-conflict error; any other
+type string SHALL fail with a readable `unsupported node type` message
+without touching the tree.
+
+#### Scenario: create a debug layer with vector art
+
+- **WHEN** `python -m pydrk mknode /window/content debug_layer layer` then
+  `python -m pydrk mknode /window/content/debug_layer art1 vector_art`
+- **THEN** both commands print ids and paths, and
+  `props /window/content/debug_layer/art1` lists the factory properties
+  including `shape`
+
+#### Scenario: unsupported type
+
+- **WHEN** `python -m pydrk mknode /window/content debug_layer chatview`
+- **THEN** the command reports the type as unsupported and exits non-zero
+
+### Requirement: Node removal
+
+The `rmnode <path>` subcommand SHALL remove any node subtree from its
+parent (the node and its descendants disappear from listings) and trigger
+an app redraw; GPU resources owned by removed nodes SHALL be released by
+the app. This is a debugging tool with full scene-graph access: built-in
+nodes are removable the same way as wire-created ones. Removing the scene
+root `/` SHALL fail with a readable error. All removals are runtime-only
+and undone by restarting the app.
+
+#### Scenario: remove a wire-created layer
+
+- **WHEN** a layer was created with `mknode` and `python -m pydrk rmnode /window/content/debug_layer` runs
+- **THEN** `ls /window/content` no longer lists `debug_layer` and the app redraws
+
+#### Scenario: remove a built-in node
+
+- **WHEN** `python -m pydrk rmnode <path-to-a-built-in-layer>` runs against a running dev app
+- **THEN** the subtree disappears from listings and rendering, and restarting the app restores it
+
+#### Scenario: the scene root is not removable
+
+- **WHEN** `python -m pydrk rmnode /`
+- **THEN** the command fails with `node_not_removable` and the tree is unchanged
+
+### Requirement: Shape data creation
+
+The `set-shape <path> [--prop NAME] [--index N]` subcommand SHALL build a
+vector shape from repeatable primitive flags and push it as the property's
+value: `--box X1 Y1 X2 Y2`, `--gbox X1 Y1 X2 Y2` (top and bottom colors),
+`--vgradient X1 Y1 X2 Y2 TOPCOLOR BOTCOLOR STRIPS GAMMA`, `--outline X1 Y1
+X2 Y2 BORDERPX`, `--line X1 Y1 X2 Y2 THICKNESS`, and `--glow CX CY W H
+SEGMENTS COLOR`, each taking colors as `R G B A` float groups. Coordinates
+SHALL accept both plain numbers and expr source strings (e.g. `w/2`), and
+primitives SHALL join into a single shape in flag order. Shape indices are
+16-bit; vertex counts beyond that SHALL be rejected client-side with a
+readable message. After a successful set the shape SHALL be visible in the
+app window at the next frame (given a non-empty `rect` and `is_visible`).
+
+#### Scenario: draw a red bar over the wire
+
+- **WHEN** a `vector_art` node exists with `rect` set, and
+  `python -m pydrk set-shape /window/content/debug_layer/art1 --box 0 0 w 10 --color 1 0 0 1`
+  runs (with `w` passed as an expr coordinate)
+- **THEN** the command exits 0 and a red bar renders along the top of the node's rect in the app
+
+#### Scenario: invalid expr in shape coordinates
+
+- **WHEN** a coordinate references an unknown global (e.g. `q/3`)
+- **THEN** the server rejects the shape and the CLI prints the error name (e.g. `sexpr_global_not_found`) and exits non-zero
+
+### Requirement: Method and signal introspection
+
+The `methods <path>` subcommand SHALL list each method with its argument
+signatures and, when declared, result signatures; `signals <path>` SHALL
+list signal names. The `call <path> <method> [ARGS...]` subcommand SHALL
+encode positional ARGS according to the method's declared argument types
+(uint32/uint64/float32/bool/str; hash as 64-char hex), print the decoded
+result when the method returns one, and `void` when it does not.
+
+#### Scenario: call a no-result method
+
+- **WHEN** `python -m pydrk call /window/content/chat/view copy_select`
+- **THEN** the command prints `void` and exits 0
+
+#### Scenario: argument type mismatch
+
+- **WHEN** calling a method whose first declared argument is `str` with a non-string token where coercion is impossible, or supplying the wrong number of arguments
+- **THEN** the CLI rejects it locally with a readable usage error before sending anything
+
+### Requirement: Server error reporting
+
+Every subcommand SHALL map netdebug error frames to the human-readable
+error name from the netdebug error table (e.g. `property_not_found`)
+together with command context (path, property, method as applicable), and
+exit non-zero. Unknown error codes SHALL be printed with their numeric
+value.
+
+#### Scenario: unknown property
+
+- **WHEN** `python -m pydrk get /window no_such_prop`
+- **THEN** the output contains `property_not_found` and the exit code is non-zero
+
+### Requirement: Interactive shell mode
+
+Running `python -m pydrk` with no subcommand SHALL start an interactive
+shell connected to the same endpoint, maintaining a current working node
+path (cwd, initially `/`) shown in the prompt (e.g. `pydrk:/window> `).
+Shell commands SHALL reuse the one-shot command set with path arguments
+resolved against cwd; absolute paths starting with `/` SHALL be honored as
+absolute. `pwd` SHALL print the cwd; `cd <path>` SHALL change it, `cd`
+with no argument SHALL go to `/`, `..` SHALL pop one node, and `cd` into a
+path that has children but is not itself resolvable SHALL fail with
+`node_not_found` leaving cwd unchanged; `exit` (or EOF) SHALL quit the
+shell. In the shell, the optional-path grammar of `set`, `get` and
+`show` SHALL default to the cwd node, so the workflow `cd` into a node,
+`ls` its contents, `show foo` for full property info, `set foo XXX` (or
+`set foo 2 XXX` for an array index) works without repeating paths.
+Server errors in the shell SHALL print the readable error name and return
+to the prompt (the shell SHALL NOT exit on a failed command).
+
+#### Scenario: cd, ls, show, set workflow
+
+- **WHEN** in the shell the user runs `cd /window`, then `cd content`,
+  then `ls`, then `show is_visible`, then `set is_visible false`
+- **THEN** `ls` lists the content node's children and properties, `show`
+  prints the `is_visible` metadata and current value, `set` reports
+  success, the app redraws, and a subsequent `get is_visible` prints
+  `false`
+
+#### Scenario: shell survives errors
+
+- **WHEN** a shell command fails (e.g. `get no_such_prop`)
+- **THEN** the error name is printed and the prompt returns; the shell is
+  still usable and cwd is unchanged
+
+### Requirement: Shell tab completion
+
+The interactive shell SHALL provide tab completion (stdlib `readline`):
+completing the first word yields command names; completing a later word
+that looks like a path yields child node names of the referenced parent,
+fetched live from the running app (so newly created `mknode` nodes
+complete after creation); completing the first positional argument of
+`get`, `set` and `show` yields the cwd node's property names together
+with its child node paths (both are valid leading arguments under the
+optional-path grammar). Completion SHALL NOT
+print duplicates, and completing a partial token SHALL offer all matches
+when ambiguous. If `readline` is unavailable the shell SHALL still work
+without completion.
+
+#### Scenario: complete a node path
+
+- **WHEN** the user types `cd /win` and presses Tab
+- **THEN** the token completes to `/window/`
+
+#### Scenario: complete a property name
+
+- **WHEN** the cwd is a node with an `is_visible` property and the user
+  types `set is_v` and presses Tab
+- **THEN** the token completes to `is_visible `
+
+#### Scenario: complete a freshly created node
+
+- **WHEN** `mknode /window/content debug_layer layer` was run and the user
+  types `cd /window/content/debug` and presses Tab
+- **THEN** the token completes to `/window/content/debug_layer/`

+ 235 - 0
openspec/changes/app-pydrk-cli/tasks.md

@@ -0,0 +1,235 @@
+## 1. CLI scaffold and inspection commands (Python only)
+
+Work happens in `bin/app/`. For live tests run `make dev` in a second
+terminal and keep it running; every `python -m pydrk ...` line below is
+run from `bin/app`.
+
+- [ ] 1.1 Create `pydrk/cli.py` (argparse `main()`, global `--addr`/`--port`
+  defaulting to `127.0.0.1:9484`, subcommand dispatch, top-level
+  try/except printing `error: <name>` and exiting 1) and
+  `pydrk/__main__.py` (`from pydrk import cli; cli.main()`). Implement
+  only `ping` using `Api.hello()`. Verify: `python -m pydrk ping` prints
+  `hello` against the running app; `python -m pydrk ping --port 9999`
+  prints an error naming `127.0.0.1:9999` and exits non-zero. Commit as
+  `app: add pydrk CLI skeleton with ping`.
+- [ ] 1.2 Implement `ls [path]`: child rows as `name <id> type` (type via
+  `SceneNodeType` names) followed by property rows `name: type = value`
+  (value from `get_property_value`, exprs shown as their source,
+  `<shape>` placeholder for shapes). Verify: `python -m pydrk ls /`
+  lists `setting` and `window` plus the root's properties;
+  `python -m pydrk ls /nope` prints `node_not_found`. Commit as
+  `app: pydrk CLI ls command`.
+- [ ] 1.3 Implement `tree [path] [--depth N]` by wiring
+  `pydrk.print_tree.print_tree` into the CLI. Verify: `python -m pydrk
+  tree / --depth 2` prints two levels with properties, signals and
+  methods. Commit as `app: pydrk CLI tree command`.
+- [ ] 1.4 Implement `props <path>`: one block per property showing name,
+  type, subtype, array_len (mark unbounded when 0), null/expr allowance,
+  min/max range when present, enum items when present, ui_name and desc.
+  Verify: `python -m pydrk props /window/content` shows `alpha` with its
+  `[0.0, 1.0]` range. Commit as `app: pydrk CLI props command`.
+- [ ] 1.5 Implement `get [path] PROP [idx]` with the shared positional
+  grammar from design D8 (right-to-left parse via `parse_get_args`, path
+  optional defaulting to `/` in one-shot mode, trailing integer = index):
+  one line per index annotated `value`/`expr`/`null`/`unset`, only the
+  given index when `idx` is present. Add `parse_get_args` cases to the
+  selftest. Verify against the running app: `python -m pydrk get
+  /window/content alpha` prints `0: value 1.0`; `python -m pydrk get
+  /window/content rect 2` prints only index 2. Commit as
+  `app: pydrk CLI get command`.
+- [ ] 1.6 Implement `show [path] PROP`: the full single-property view
+  from design D8 — metadata block (name, type, subtype, array_len,
+  null/expr allowance, min/max range, enum items, ui_name, desc,
+  depends) followed by the per-index values with statuses. Verify: `python
+  -m pydrk show /window/content alpha` prints the metadata including the
+  `[0.0, 1.0]` range and then `0: value 1.0`; `python -m pydrk show
+  /window/content no_such_prop` prints `property_not_found`. Commit as
+  `app: pydrk CLI show command`.
+- [ ] 1.7 Fix `Api.get_method()` in `pydrk/api.py`: decode the results as
+  `Option<Vec<CallArg>>` (read the u8 tag, then the array only when
+  some) per design D6. Implement `methods <path>` (name + arg/result
+  signature per method) and `signals <path>` (signal names). Verify:
+  `python -m pydrk methods /plugin/drk` lists `get_default_address`
+  with its `str` result signature, and `python -m pydrk tree /plugin/drk`
+  no longer truncates method results. Commit as
+  `app: fix pydrk get_method result decoding, add methods/signals commands`.
+- [ ] 1.8 Add `--selftest` handling in `cli.py`: a `run_selftests()`
+  function with assert-based checks of the pure helpers introduced so
+  far (path/type/value formatting, `parse_get_args`), so `python -m
+  pydrk.cli --selftest` prints `cli self-test OK` without a running app.
+  Commit as `app: pydrk CLI selftest harness`.
+
+## 2. Typed property setting (Python only)
+
+- [ ] 2.1 Implement the typed value table from design D8: a pure
+  `encode_set_value(api, path, prop_meta, token, index)` helper that
+  looks up the property via `get_properties` and dispatches to the right
+  `Api.set_property_*` call (bool/uint32/float32/str/enum/scene_node_id,
+  `null` literal → `set_property_null`, enum membership validated
+  locally). Wire it into `set [path] PROP [idx] VAL` with the
+  right-to-left `parse_set_args` (path optional, trailing-integer index,
+  leading path tokens joined with `/`, usage error when the property
+  name is missing). Verify live: `python -m pydrk set
+  /window/content/chat is_visible false` hides the chat UI, then `true`
+  restores it; `python -m pydrk get /window/content/chat is_visible`
+  round-trips both values. Add `parse_set_args` cases to `--selftest`.
+  Commit as `app: pydrk CLI typed set command`.
+- [ ] 2.2 Add `--expr` to `set` (sends via `set_property_expr`). Verify
+  live: `python -m pydrk set /window/content rect 2 "w/2" --expr`
+  exits 0 and `python -m pydrk get /window/content rect 2` shows
+  `2: expr "w/2"`. Commit as `app: pydrk CLI set --expr`.
+- [ ] 2.3 Verify the failure paths end-to-end: `python -m pydrk set
+  /window/content alpha 5.0` prints `property_out_of_range`;
+  `python -m pydrk set /window no_such_prop 1` prints
+  `property_not_found`; `set --expr "q/3"` on a rect index prints
+  `sexpr_global_not_found`; `python -m pydrk set` alone prints a usage
+  error; all exit non-zero and none change app state.
+  Fix anything that prints a raw traceback instead of `error: <name>`.
+  Commit as `app: pydrk CLI set error reporting`.
+
+## 3. netdebug backend: node creation and removal (Rust)
+
+- [ ] 3.1 Add `Error::UnsupportedNodeType = 50` and
+  `Error::NodeNotRemovable = 51` (used to reject removing the scene
+  root) to `bin/app/src/error.rs` following the existing variant style.
+  Verify: `make compile-dev` succeeds. Commit as
+  `app: add netdebug error codes for node create/remove`.
+- [ ] 3.2 Mirror the two codes in pydrk: `ErrorCode` constants, `exc.py`
+  exception classes, `_make_request` match arms raising them. Verify:
+  `python -m pydrk.cli --selftest` and `python -m pydrk ping` still
+  work. Commit as `app: pydrk error codes for node create/remove`.
+- [ ] 3.3 Thread the renderer into the adapter per design D5: add the
+  `renderer: Renderer` field to `ZeroMQAdapter`, change
+  `ZeroMQAdapter::new` to take it, update the call site in `main.rs`
+  (it already has `app.renderer` in scope). Verify: `make compile-dev`
+  succeeds and `python -m pydrk ping` still works. Commit as
+  `app/netdebug: pass renderer into ZeroMQAdapter`.
+- [ ] 3.4 Implement the `AddNode` arm per design D2: decode
+  `(parent_path, name, node_type)`; look up the parent; reject duplicate
+  sibling names with `NodeSiblingNameConflict`; match `Layer` and
+  `VectorArt` through `create_layer`/`create_vector_art` +
+  `.setup(...)` + spawn pimpl `start(ex)` after `link`; reject other
+  types with `UnsupportedNodeType`; reply the id. Verify: `make
+  compile-dev` succeeds. Commit as
+  `app/netdebug: path-based AddNode for layer and vector_art`.
+- [ ] 3.5 Implement the `RemoveNode` arm per design D3: decode
+  `(node_path)`; reject `/` with `NodeNotRemovable`; look up the node;
+  `unlink()`; `redraw.trigger()`. No restrictions on which nodes are
+  removable — full scene-graph access is intentional for this debugging
+  tool. Verify: `make compile-dev` succeeds. Commit as
+  `app/netdebug: path-based RemoveNode`.
+
+## 4. Node lifecycle commands (Python)
+
+- [ ] 4.1 Add `Api.add_node(parent_path, name, node_type)` to `api.py`
+  and the `mknode <parent_path> <name> <type>` subcommand accepting only
+  `layer`/`vector_art` (anything else fails locally with
+  `unsupported node type`), printing `id=... path=...`. Verify live
+  against the rebuilt app: `python -m pydrk mknode /window/content dbg
+  layer` prints the id; `python -m pydrk ls /window/content` lists
+  `dbg`; `python -m pydrk mknode /window/content/dbg art1 vector_art`
+  works and `python -m pydrk props /window/content/dbg/art1` lists the
+  factory properties including `shape`; `python -m pydrk mknode
+  /window/content dbg layer` again prints `node_sibling_name_conflict`.
+  Commit as `app: pydrk CLI mknode command`.
+- [ ] 4.2 Add `Api.remove_node(node_path)` and the `rmnode <path>`
+  subcommand (full graph access, documented in `--help` as runtime-only).
+  Verify live: create `dbg` as above then `python -m pydrk rmnode
+  /window/content/dbg` — `ls /window/content` no longer lists it and the
+  window redraws; `python -m pydrk rmnode /window/content/chat` removes
+  the built-in chat layer (restart the app afterwards to restore it);
+  `python -m pydrk rmnode /` prints `node_not_removable` and `python -m
+  pydrk ls /` still lists everything. Commit as
+  `app: pydrk CLI rmnode command`.
+- [ ] 4.3 Delete the dead client methods from `api.py` listed in design
+  D6 plus the legacy `vertex()`/`face()` helpers. Verify: `grep -rn
+  "link_node\|scan_dangling\|add_property" bin/app/pydrk bin/app/script`
+  is empty, `python -m pydrk.cli --selftest` passes, and the commands
+  from tasks 1-2 still work live. Commit as
+  `app: drop dead pydrk client methods`.
+
+## 5. Shape data (Python)
+
+- [ ] 5.1 Implement `set-shape <path> [--prop NAME] [--index N]` with the
+  `--box` flag from design D11 (argparse `nargs=8`, `action="append"`),
+  building on `pydrk.vector_shape.VectorShape`, with the
+  `< 65536` vertex guard. Verify live: `mknode /window/content dbg
+  layer`, `mknode /window/content/dbg art1 vector_art`, set the art
+  node's `rect` (e.g. `--expr "w"` at index 2 and `--expr "h"` at
+  index 3), `set is_visible true`, then `python -m pydrk set-shape
+  /window/content/dbg/art1 --box 0 0 w 10 1 0 0 1` — a red bar renders
+  along the top of the window. Commit as
+  `app: pydrk CLI set-shape with box primitive`.
+- [ ] 5.2 Add the remaining primitives from design D11: `--gbox`,
+  `--vgradient`, `--outline`, `--line`, `--glow` (colors inline as
+  trailing R G B A float args; coordinates may be expr strings). Verify
+  live by composing one shape using at least `--vgradient`, `--outline`
+  and `--glow` in a single command and seeing all three render; verify
+  `set-shape ... --box 0 0 q/3 10 1 0 0 1` prints
+  `sexpr_global_not_found`; verify a >65535-vertex construction is
+  rejected client-side. Extend `--selftest` with flag-parsing checks.
+  Commit as `app: pydrk CLI set-shape gradient/outline/line/glow`.
+
+## 6. Method calls (Python)
+
+- [ ] 6.1 Implement `call <path> <method> [ARGS...]`: fetch the
+  signature with `Api.get_method`, encode each positional arg per its
+  declared type (`uint32`/`uint64`/`float32`/`bool`/`str`; `hash` as
+  64-char hex → 32 bytes), reject wrong arg counts or unparseable
+  tokens locally before sending, print decoded results for `str`/`hash`
+  result types and a short hex dump otherwise, `void` when none. Verify
+  live: find the chatview node with `python -m pydrk methods
+  /window/content/chat` (or `tree`) and run `call <chatview-path>
+  copy_select` → prints `void`; `python -m pydrk call /plugin/drk
+  get_default_address` prints an address string. Commit as
+  `app: pydrk CLI call command`.
+
+## 7. Interactive shell
+
+- [ ] 7.1 Implement `resolve_path(cwd_tokens, arg)` exactly per design
+  D9 plus its `--selftest` cases (absolute paths, `..`, `.`, empty,
+  relative tokens, leading/trailing slashes). Verify: `python -m
+  pydrk.cli --selftest` passes with no app running. Commit as
+  `app: pydrk CLI path resolution helper`.
+- [ ] 7.2 Implement the shell per design D9: entered when `python -m
+  pydrk` runs with no subcommand; prompt `pydrk:/window/content> `;
+  `shlex.split` line tokenization; dispatch each line to the same
+  per-command handlers as one-shot mode, with the optional-path grammar
+  of `set`/`get`/`show` defaulting to cwd (so `set is_visible false`,
+  `set rect 2 "w/2" --expr` and `show alpha` all target the cwd node);
+  builtins `cd` (no arg → `/`, `..` pops, target existence
+  verified, cwd unchanged on failure), `pwd`, `exit`/`quit` + EOF;
+  failed commands print `error: <name>` and return to the prompt.
+  Verify live session: `cd /window`, `cd content`, `ls`, `show
+  is_visible`, `set is_visible false` (chat hides), `get is_visible`
+  prints `false`, `set is_visible true`, `cd ..`, `pwd`, `get
+  no_such_prop` prints `property_not_found` and the shell survives,
+  `exit`. Commit as `app: pydrk interactive shell mode`.
+- [ ] 7.3 Implement the readline completer per design D10: command-name
+  completion for the first token, live child-path completion (dir part +
+  prefix, `api.get_children`, per-prompt cache cleared after each
+  executed command), completion of the first positional argument of
+  `get`/`set`/`show` from the union of the cwd's property names and
+  child paths, guarded `import readline`. Verify live: `cd /win<TAB>`
+  completes to `/window/`; `set is_v<TAB>` completes to `is_visible `;
+  `show alp<TAB>` completes to `alpha ` on `/window/content`; `mknode
+  /window/content dbg layer` then `cd /window/content/db<TAB>`
+  completes to `dbg/`; ambiguous prefixes list all matches. Commit as
+  `app: pydrk shell tab completion`.
+
+## 8. Final verification
+
+- [ ] 8.1 Run the full junior walkthrough end-to-end against a fresh
+  `make dev` instance: `ping`; `ls /`; `tree / --depth 2`; enter the
+  shell; `cd /window/content`; `mknode dbg layer` style flow for layer +
+  vector_art (via subcommand or shell); set `rect` and `is_visible`;
+  `set-shape` a box; `ls` and `get` to confirm state; `rmnode` the debug
+  layer and confirm the window redraws clean with no leftover geometry.
+  Fix anything broken found during the walkthrough and amend the
+  selftest. Commit as `app: pydrk CLI end-to-end walkthrough fixes`.
+- [ ] 8.2 Final gates: `make compile-dev` succeeds with no warnings
+  introduced; `python -m pydrk.cli --selftest` and `python -m
+  pydrk.vector_shape` pass; `python -m pydrk ping` works; `git status`
+  shows a clean tree after the last commit. Confirm the spec scenarios
+  in `openspec/changes/app-pydrk-cli/specs/pydrk-cli/spec.md` have each
+  been exercised at least once during tasks 1-7.

+ 2 - 0
openspec/changes/app-theme/.openspec.yaml

@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-08-31

+ 821 - 0
openspec/changes/app-theme/design.md

@@ -0,0 +1,821 @@
+## Context
+
+See proposal.md for motivation. The machinery this builds on, verified
+against the code:
+
+- `Property` (`src/prop/mod.rs`) resolves reads in layers: `vals[i]` →
+  if it is an expression, `cache[i]` (else `defaults[i]`) → if unset,
+  `defaults[i]` → type default. `defaults` is `Vec<PropertyValue>` and
+  `PropertyValue::SExpr` exists, but defaults are only settable pre-`Arc`
+  via builder `set_defaults_*` (no expression variant), so schema code
+  can only write `vals`. `allow_exprs()` is also builder-only: a live
+  property can never gain expr support, so factories must opt in.
+- Exactly one evaluator exists: `PropertyRect::eval_with`
+  (`src/prop/wrap.rs:377`) — gathers globals from `add_depend` edges plus
+  extras (parent w/h), runs the s-expr machine per expr index, writes the
+  cache. Widgets run it in their per-pass paths (`MultiLine::eval_rect`,
+  etc.). Colors and other styled f32 props are always plain values today.
+- Batching (`src/prop/guard.rs`): a `PropertyAtomicGuard` collects
+  `(prop, role, action)` and on **Drop** notifies all subscribers with a
+  shared `BatchGuard`. `RedrawTrigger::make_guard` (`src/ui/mod.rs:136`)
+  binds the batch's `end_batch` to exactly one redraw token, fired when
+  the last `BatchGuard` reference drops; `trigger()` enqueues into a
+  bounded(1) channel, so multiple triggers coalesce into one pass.
+- Pubsub (`src/pubsub.rs`): `Publisher::notify` is a non-blocking
+  `try_send` into **unbounded** per-subscriber queues — events published
+  before a listener task starts polling are buffered and drained later.
+  Therefore applying a theme during `App::setup` (before widget listener
+  tasks exist in `App::start`) is safe: nothing is lost.
+- Widgets (`src/ui/text.rs:80-90, 231-264`) wrap their properties with
+  `Role::Internal` and use `when_change_external` handlers that only
+  clear the widget's draw cache and request a pass; the handler skips
+  `Role::Internal` echoes so draw-pass evaluations don't retrigger
+  passes forever. Evaluation belongs in the draw path; invalidation
+  belongs in handlers.
+- Node/task teardown precedent: `menu/mod.rs:486-509` channel deletion
+  does `clear_tasks()` + `unlink()` + `redraw.trigger()`.
+- Settings: `/setting` props persist via the `Setting` pimpl
+  (`src/setting.rs`); `Setting::new` loads persisted rows with
+  `get_property(&name).unwrap()` — an unknown persisted key panics.
+  Enums render as "unknown" in the settings screen. Builder enum
+  defaults are written as `PropertyValue::Str` (`set_defaults_str` on an
+  Enum property), a latent type mismatch.
+- Styling is inline across `src/app/schema/**` (23 files); the scifi
+  palette leaks into `src/app/node.rs` factories;
+  `COLOR_SCHEME`/`PaperLight` is a compile-time second look whose arms
+  are dead (`const DarkMode`).
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Layered theming that reuses property fallback semantics: baseline =
+  defaults, theme = `vals`, unload = unset.
+- Theme switching that is O(tokens), atomic, and covers nodes created at
+  runtime (no re-theming pass, no `on_link` signal needed).
+- A defined style/structure split: schema owns layout tree and functional
+  wiring; themes own styling, decorations, and reactions.
+
+**Non-Goals:**
+
+- Making `VectorShape` internal colors expression-driven (shape colors
+  are baked consts; scifi rebuilds themed shapes structurally — revisit
+  later if a theme needs it).
+- Dynamic/runtime-loaded themes; the registry is compile-time
+  (`minimal`, `scifi`).
+- Theming plugin-owned UI beyond what plugins inherit from shared tokens.
+- A themes-from-disk or remote theme format.
+
+## Decisions
+
+### D1: Layer on defaults/vals instead of rebuilding or walking
+
+Switching rebuilds nothing. Minimal installs defaults; themes set `vals`;
+unload unsets. Alternatives considered: (a) destroy and re-run
+`schema::make` per switch — loses runtime state (scroll, focus, joined
+channels wiring) and is O(everything); (b) walk the tree setting props —
+no `on_link` signal exists, so nodes created after a switch (new channel
+screens) would be missed, and unload needs a journal of everything.
+Defaults/vals gives restore semantics for free and pushes the dynamic-node
+problem into creation-time wiring (D5).
+
+### D2: Post-creation default installation, no modify events
+
+New methods on `PropertyPtr` (`src/prop/mod.rs`), mutating `defaults[i]`
+with the same type/length checks as the builder variants:
+
+```rust
+impl Property {
+    pub fn set_default_bool(&self, i: usize, val: bool) -> Result<()>;
+    pub fn set_default_u32(&self, i: usize, val: u32) -> Result<()>;
+    pub fn set_default_f32(&self, i: usize, val: f32) -> Result<()>;
+    pub fn set_default_f32_multi(&self, vals: &[f32]) -> Result<()>;
+    pub fn set_default_str<S: Into<String>>(&self, i: usize, val: S) -> Result<()>;
+    /// Writes PropertyValue::Enum (unlike builder set_defaults_str, which
+    /// writes Str onto Enum properties — a latent type mismatch).
+    pub fn set_default_enum<S: Into<String>>(&self, i: usize, val: S) -> Result<()>;
+    pub fn set_default_expr(&self, i: usize, code: SExprCode) -> Result<()>;
+    /// Raw variant used by the journal and token node construction.
+    pub fn set_default_value(&self, i: usize, val: PropertyValue) -> Result<()>;
+}
+```
+
+Installing a default emits **no** modification event: it is a
+construction-time operation (before first frame) or happens inside a
+switch batch where the accompanying unsets already notify. Rule:
+defaults are never mutated as a live styling mechanism. Alternative — a
+new `ModifyAction` for default changes — adds pubsub surface for no
+current consumer; revisit if a future feature needs live default editing.
+
+### D3: SExpr defaults with effective-source semantics; single shared cache
+
+`is_expr`/`get_expr` resolve the **effective expression**: the `vals`
+expression if present, else the `defaults` expression. `get_value`
+resolution per the `prop-defaults` spec — never returns an unresolved
+expression:
+
+```rust
+pub fn get_value(&self, i: usize) -> Result<PropertyValue> {
+    match self.get_raw_value(i)? {
+        PropertyValue::SExpr(_) => {
+            let cached = self.get_cached(i)?;
+            if !cached.is_null() { return Ok(cached) }
+            // fall through to the default layer
+            self.default_or_type_default(i)
+        }
+        PropertyValue::Unset => Ok(self.default_or_type_default(i)),
+        v => Ok(v),
+    }
+}
+
+/// default layer: plain default → default-expr cache → type default
+fn default_or_type_default(&self, i: usize) -> PropertyValue {
+    match &self.defaults[i] {
+        PropertyValue::SExpr(_) => match self.get_cached(i) {
+            Ok(c) if !c.is_null() => c,
+            _ => self.typ.default_value(),
+        },
+        d => d.clone(),
+    }
+}
+```
+
+One cache per index is shared between the two expression sources — after
+a source switch the cache is stale until the next evaluation. With
+draw-side evaluation (D7) **every draw pass recomputes expr indices from
+current dependency values**, so no pass can observe a stale cache: the
+window closes at the next pass, which is exactly the pass the switch
+triggers. Two separate caches would double the state for zero observable
+gain. Alternative rejected.
+
+### D4: Themes may override any property, including rect; ownership rule
+
+Theme overrides are plain `vals` (value or expression) over baseline
+default-exprs; unset restores. Exception — properties the owning widget
+itself writes at runtime (e.g. multiline edit writes `rect[3]` height via
+`Role::Internal` in `eval_rect`, `src/ui/edit/behave.rs:140`) are
+last-writer-wins: themes MUST NOT override them, or the widget clobbers
+the theme. This rule is **enforced** by D13: widget-written properties
+carry write masks without `Theme`, so a theme attempt fails with
+`PropertyPermissionDenied` instead of silently losing a write race. The
+5.1 scrub assigns the masks; the D4 audit becomes that assignment.
+
+### D5: Live dependency rewiring with automatic listener resync
+
+`when_change` subscriptions snapshot `get_depends()` at widget
+construction and the poll loop rebuilds its poll set from that list
+every iteration (`ui/mod.rs:246-312`) — the list is only fixed because
+it is captured by value. Instead of forbidding later wiring, make the
+depends list a live thing:
+
+1. `Property` (`src/prop/mod.rs`) gains a depends-changed publisher and
+   a remover; `add_depend` notifies:
+
+```rust
+depends_pub: Publisher<()>,   // new field, beside on_modify
+
+pub fn add_depend<S: Into<String>>(&self, prop: &PropertyPtr, i: usize, local_name: S) {
+    self.depends.lock().unwrap().push(PropertyDepend { /* … */ });
+    self.depends_pub.notify(());
+}
+
+/// Remove edges matching (dep prop, index, local name) — theme unload
+/// restores original wiring with it.
+pub fn remove_depend(&self, prop: &PropertyPtr, i: usize, local_name: &str);
+```
+
+2. `when_change_impl` (`src/ui/mod.rs`) shares the subscription list in
+   an `Arc<Mutex<Vec<_>>>`, subscribes to `prop.depends_pub`, and on
+   receipt rebuilds the dependency entries from a fresh `get_depends()`
+   snapshot (entry 0 — the property itself — never changes), then runs
+   the handler once more: dropping a receiver discards its queued
+   messages, so the extra run closes any event missed during the swap.
+   It is the existing invalidate+trigger handler, coalesced by the
+   bounded(1) redraw channel.
+
+Theme rules under this mechanism:
+
+- Theme expressions may reference **any** property (not just
+  creation-wired names) by adding depends at apply time; the resync
+   makes the widget hear them. D4 still applies: never on properties
+  the owning widget writes at runtime.
+- Theme-added edges are journaled (D8) and removed on unload, so
+  repeated switches don't accumulate stale edges.
+- Theme-local names must be fresh (convention: prefixed, e.g.
+  `th_*`) — a duplicate local name would shadow another in the eval
+  globals.
+- Startup ordering converges either way: if the theme applies before
+  widget tasks start, the construction snapshot already includes the
+  edges; if after, the buffered depends-changed event triggers resync.
+
+The explicit `stop()`/`start()` walk (`win/mod.rs:276-291`) remains as a
+coarse re-init fallback (rebuilds every handler of a widget); the theme
+engine does not need it.
+
+Alternatives considered: (a) creation-only wiring — rejected: too
+restrictive for theme expressions; (b) explicit stop()/start() as the
+primary mechanism — rejected: coarse (whole widget), async churn, and
+the engine would have to locate affected widgets; kept as fallback.
+
+### D6: `/theme` token node + wiring helpers ("classes")
+
+A root-level `/theme` node (sibling of `/setting`) created **before**
+`schema::make`, holding the themeable vocabulary as token properties:
+colors as 4×f32 (`PropertySubType::Color`), sizes/spacing as f32. Token
+defaults = minimal palette, installed at node construction (D2, no
+events). Helpers replace inline styling blocks in schema code:
+
+```rust
+// src/theme/mod.rs (sketch)
+/// Wire `prop_name` on `node` so each component follows the token
+/// `token_name` on the /theme node. Installs default-exprs + depends.
+/// Replaces the 4× set_f32 styling blocks used across the schema.
+pub fn wire_color(
+    node: &SceneNodePtr,
+    prop_name: &str,
+    theme: &SceneNodePtr,
+    token_name: &str,
+) -> Result<()> {
+    let prop = node.get_property(prop_name).ok_or(Error::PropertyNotFound)?;
+    let token = theme.get_property(token_name).ok_or(Error::PropertyNotFound)?;
+    for i in 0..4 {
+        let local = format!("{token_name}_{i}");
+        prop.set_default_expr(i, expr::load_var(&local))?;
+        prop.add_depend(&token, i, local);
+    }
+    Ok(())
+}
+
+/// Single-f32 variant for font_size, padding, spacing, etc.
+pub fn wire_f32(
+    node: &SceneNodePtr,
+    prop_name: &str,
+    theme: &SceneNodePtr,
+    token_name: &str,
+) -> Result<()> { /* same, index 0 */ }
+```
+
+The token is the class; the helper call is the class assignment. Theme
+apply = set `vals` on ~30 tokens. **Prerequisite**: `allow_exprs()` is
+builder-only, so every factory in `src/app/node.rs` must call it for
+every themeable prop (`text_color`, `*_color`, `font_size`, `padding`,
+`lineheight`, spacing…) — otherwise `set_default_expr`/`set_expr` fail
+with `PropertySExprNotAllowed`. This factory pass happens with the Tier-0
+scrub (tasks 5.1).
+
+**Theme-defined tokens.** The `/theme` root carries only the *shared*
+vocabulary — the tokens the schema wires defaults to, present in every
+theme, forming the minimal palette. Themes are not boxed in by it:
+`add_property` is builder-only (`&mut self` on an owned node), so live
+properties cannot be appended to the linked `/theme` node — but child
+nodes link to live parents freely (that is how the whole schema is
+built). A theme therefore mints its private vocabulary as a tracked
+child node:
+
+```rust
+// src/theme/mod.rs (sketch) — ThemeCtx helper
+/// Create `/theme/<name>` carrying the theme's own token properties
+/// (built pre-Arc with builder add_property + set_defaults_*), null
+/// pimpl, linked under /theme and tracked for unload.
+pub fn create_token_child(&self, props: Vec<Property>) -> Result<SceneNodePtr>;
+```
+
+Rules that fall out:
+
+- `/theme` root = shared tokens, always present. Schema default-wiring
+  (D6 helpers) may reference **only** these — a default must never
+  dangle when its theme is inactive.
+- `/theme/<name>` child = theme-private tokens (e.g. scifi's
+  `glow_color`), present only while that theme is applied. They appear
+  in theme-installed **vals** expressions and wired overrides
+  (journaled, D5/D8) — never in schema defaults.
+- Unload ordering matters: unset widget vals → remove journaled dep
+  edges → unlink the token child. A dead dep edge makes
+  `dep.prop.upgrade()` fail and eval error on every pass, so edges are
+  removed before the node that holds their targets dies. (Defense in
+  depth: eval may skip dead edges rather than error — implementer's
+  choice, the ordering rule is the contract.)
+- Token children are data-only nodes: null pimpl, nothing persisted
+  (only the `theme` enum is), lookup-friendly for debugging
+  (`/theme/scifi`).
+
+### D7: Generalize evaluation from rect to bounded f32 arrays — in the draw path
+
+Extract the `eval_with` pattern into a shared free function; add wrappers:
+
+```rust
+// src/prop/wrap.rs (sketch)
+pub fn eval_f32_multi(
+    prop: &PropertyPtr,
+    atom: &mut PropertyAtomicGuard,
+    role: Role,
+    range: &[usize],
+    extras: Vec<(String, f32)>,
+) -> Result<()> {
+    let mut globals = vec![];
+    for dep in prop.get_depends() {
+        let Some(dep_prop) = dep.prop.upgrade() else {
+            return Err(Error::PropertyNotFound)
+        };
+        globals.push((dep.local_name, SExprVal::Float32(dep_prop.get_f32(dep.i)?)));
+    }
+    globals.extend(extras);
+    let mut changes = vec![];
+    for &i in range {
+        if !prop.is_expr(i)? { continue }
+        let expr = prop.get_expr(i)?;
+        let mut machine = SExprMachine { globals: globals.clone(), stmts: &expr };
+        changes.push((i, machine.call()?.as_f32()?));
+    }
+    prop.set_cache_f32_multi(atom, role, changes).unwrap();
+    Ok(())
+}
+
+impl PropertyColor {
+    /// Globals only (no w/h extras). Called at the top of draw().
+    pub fn eval(&self, atom: &mut PropertyAtomicGuard) -> Result<()> {
+        eval_f32_multi(self.prop(), atom, self.role, &[0, 1, 2, 3], vec![])
+    }
+}
+```
+
+`PropertyRect::eval_with` becomes a thin wrapper (extras = parent w/h).
+`PropertyFloat32::eval` serves `font_size`/spacing (index 0).
+
+**Call sites are in the draw path, not in when_change handlers** — this
+is the same pattern rects already use, and it is what makes evaluation
+timing irrelevant: the pass triggered by a switch recomputes every expr
+index from current token values before reading them. Handlers stay
+invalidation-only (`draw_cache.clear(); redraw.trigger()` — the existing
+`text.rs:257-260` shape). The cache writes use the widget wraps'
+`Role::Internal`, so `when_change_external` skips them as eval echoes,
+exactly like rect evals today.
+
+### D8: Theme engine with tracked nodes, tasks, and a journal
+
+`src/theme/`: trait, compile-time registry, and a per-application ctx:
+
+```rust
+pub trait Theme: Send + Sync {
+    fn name(&self) -> &'static str;
+    fn apply<'a>(&'a self, ctx: &'a ThemeCtx) -> BoxFuture<'a, Result<()>>;
+}
+
+/// Unload bookkeeping. Nodes are the storage: theme-created properties
+/// live on tracked nodes, theme tasks are pushed onto tracked nodes
+/// (SceneNode::push_task) and cancel with clear_tasks(); the ctx only
+/// records what cannot be reconstructed.
+///
+/// The touched-set is required, not an optimization: `vals` carries no
+/// authorship, so a theme override is indistinguishable from schema
+/// setup or runtime state (scroll, typed text, is_visible) — unloading
+/// by deep-walking and resetting every property would destroy runtime
+/// state. Unload therefore resets exactly the (prop, i) pairs the
+/// theme recorded, which also means the ctx is the ONLY sanctioned way
+/// for a theme to modify properties it does not own.
+pub enum JournalEntry {
+    /// Bounded `vals` override — unload unsets it (falls to default).
+    /// No prior value needed: defaults ARE the baseline.
+    Touched { prop: PropertyPtr, i: usize },
+    /// Unbounded/direct-list override (e.g. nick_colors) — unbounded
+    /// props have no defaults tier, so the prior entries are recorded.
+    List { prop: PropertyPtr, prior: Vec<PropertyValue> },
+    /// Theme-added dependency edge (D5), removed on unload.
+    Depend { prop: PropertyPtr, dep_prop: PropertyPtr, i: usize, local_name: String },
+}
+
+/// What a theme (and the engine) may do; everything done through the
+/// ctx is undone by unload.
+pub struct ThemeCtx {
+    app: AppPtr,
+    /// Roots the theme linked into pre-existing (schema) trees —
+    /// including its `/theme/<name>` token child. Descendants ride
+    /// along: unlink drops the subtree.
+    nodes: SyncMutex<Vec<SceneNodePtr>>,
+    journal: SyncMutex<Vec<JournalEntry>>,
+}
+
+impl ThemeCtx {
+    /// Link a theme-owned node under an existing parent; unlinked on
+    /// unload (clear_tasks() cancels its tasks, unlink() drops the
+    /// subtree).
+    pub fn link_tracked(&self, parent: &SceneNodePtr, child: SceneNodePtr);
+    /// Build `/theme/<name>` carrying theme-defined token properties
+    /// (D6); linked, tracked, data-only — also the home for theme
+    /// tasks (created lazily for themes with no other nodes).
+    pub fn create_token_child(&self, name: &str, props: Vec<Property>) -> Result<SceneNodePtr>;
+    /// Push a task onto a theme-owned node so teardown cancels it.
+    pub fn push_task(&self, node: &SceneNodePtr, task: smol::Task<()>);
+    /// Set a bounded property value (stamped Role::Theme) — journal
+    /// records only (prop, i); dedups repeat entries (e.g. per-step
+    /// animation writes).
+    pub fn set_touched(&self, atom: &mut PropertyAtomicGuard, prop: &PropertyPtr,
+                       i: usize, val: PropertyValue) -> Result<()>;
+    /// Overwrite an unbounded list (stamped Role::Theme) — journal
+    /// records the prior entries.
+    pub fn set_list(&self, atom: &mut PropertyAtomicGuard, prop: &PropertyPtr,
+                    vals: Vec<PropertyValue>) -> Result<()>;
+    /// Add a dependency edge, recording it for removal (D5).
+    pub fn depend_journaled(&self, prop: &PropertyPtr, dep: &PropertyPtr,
+                            i: usize, local_name: String);
+}
+
+pub fn registry_lookup(name: &str) -> Option<&'static dyn Theme>;
+```
+
+`minimal` is the registry's identity element: it has no `apply`
+implementation (or an empty one) — the minimal look *is* the unloaded
+state (token defaults + schema defaults), so switching to minimal is
+just unload. Theme **behavior** is property watchers — scifi's fade:
+
+```rust
+// src/theme/scifi.rs (sketch)
+async fn apply(ctx: &ThemeCtx) -> Result<()> {
+    let tokens = ctx.create_token_child("scifi", private_token_props())?;
+    set_tokens(ctx, &[("accent_color", [0., 0.94, 1., 1.]), /* … */]).await?;
+    king_video_node(ctx).await?;                 // tracked structural node
+    if ctx.app.is_first_time.load(Ordering::Relaxed) {
+        scramble_splash(ctx).await?;             // tracked node + hide task (D12)
+    }
+
+    // Watch the overlay toggle (existing property, existing pubsub) and
+    // animate alpha. Replaces the fade inside the reconnect click
+    // handler in src/app/schema/mod.rs. The watcher is a task pushed
+    // onto the theme's token child — cancelled with clear_tasks() when
+    // that node is unlinked (tasks live on nodes, D8).
+    let overlay = ctx.app.sg_root.lookup_node("/window/content/chat/netstatus_overlay").unwrap();
+    let is_visible = overlay.get_property("is_visible").unwrap();
+    let alpha = overlay.get_property("alpha").unwrap();
+    let sub = is_visible.subscribe_modify();
+    let ex = ctx.app.ex.clone();
+    let fade_task = ex.spawn(async move {
+        loop {
+            let Ok((_, _, guard)) = sub.receive().await else { break };
+            if !is_visible.get_bool(0).unwrap() { continue }
+            for step in 1..=50 {
+                msleep(20).await;
+                let atom = &mut guard.spawn();
+                // Role::Theme via ctx; touched-set entry dedups across steps
+                ctx.set_touched(atom, &alpha, 0, PropertyValue::Float32(step as f32 / 50.))?;
+            }
+        }
+    });
+    ctx.push_task(&tokens, fade_task);
+    Ok(())
+}
+```
+
+The engine (not the trait) performs unload from the ctx state, so themes
+cannot leak by forgetting cleanup. Unload order is part of the contract:
+`clear_tasks()` on tracked nodes **first** (cancel the actors — an
+in-flight watcher writing `alpha` after its unset would leave a stale
+value nothing resets) → unset `Touched` entries → restore `List` entries
+→ remove `Depend` edges → `clear_values` on shared tokens → `unlink()`
+per tracked node (drops subtrees and their properties; token children
+included). Edges come off before the nodes holding their targets die, so
+no eval ever sees a dangling dep (D6).
+
+**`Role::Theme`.** `vals` carries no authorship, so attribution lives on
+the event stream: a new `Role::Theme` variant, stamped by every ctx
+setter (`set_touched`, `set_list`, shared/private token sets). Consumer
+audit: `when_change_impl` filters by equality (`ui/mod.rs:279-281`,
+skipping only `Internal`/`Ignored`), so `Theme` events reach widgets
+exactly like `App` ones — no filter change, but verified during
+implementation; `net.rs`/`setting.rs` produce roles rather than match
+them, and any external Role mapping needs a `Theme` arm. Role is
+bin/app-local (not serialized), so no wire impact. Bonus this buys: a
+dev-mode leak audit — a `Role::Theme` event arriving on a (prop, i) not
+in the touched-set means something bypassed the ctx; warn-log it. The
+ctx setters take no role parameter; `Role::Theme` is not caller-choice.
+
+### D9: Atomic switch flow — real guard mechanics
+
+One batch for the whole switch; structural changes get an explicit
+trigger because unlinking notifies no properties (same as `menu/mod.rs`):
+
+```rust
+// src/theme/mod.rs (sketch)
+pub async fn switch(app: &App, current: &mut InstalledTheme, next_name: &str) -> Result<()> {
+    let ctx = ThemeCtx::new(app);
+    {
+        let atom = &mut app.redraw_trigger.make_guard(gfxtag!("theme switch"));
+
+        // Unload: clear_tasks (cancel actors) → unset Touched →
+        // restore Lists → remove Depend edges → unset shared token
+        // vals → unlink tracked nodes
+        unload_current(current, &ctx, atom);
+
+        // Load: next theme's tokens/nodes/watchers
+        let next = registry_lookup(next_name)
+            .or_else(|| registry_lookup(DEFAULT_THEME))
+            .ok_or(Error::ThemeNotFound)?;
+        if let Some(apply) = next.apply_fn() { apply(&ctx).await?; }
+        *current = InstalledTheme::from(next, ctx);
+    } // guard Drop: notify all queued actions under one BatchGuard id
+
+    // Unlinks changed the tree but no properties; request a pass
+    // explicitly (coalesced by the bounded(1) channel — free if the
+    // batch already triggered one).
+    app.redraw_trigger.trigger();
+    Ok(())
+}
+```
+
+What "atomic" concretely means here, per `guard.rs`/`ui/mod.rs`:
+
+1. All unload+apply property actions are queued in one guard and
+   notified together at Drop under a single `BatchGuardId` — no listener
+   ever sees a half-switched batch.
+2. Widget handlers that react clear draw caches and enqueue triggers;
+   the bounded(1) redraw channel coalesces every trigger into one pass.
+3. That pass re-evaluates all expr indices draw-side (D7) from the
+   already-settled token values, so the frame is internally consistent
+   by construction — mixed state cannot be drawn, not merely unlikely.
+
+Startup application and live switching share this function; the
+`/setting/theme` watcher is an **engine-owned** task (not theme-tracked,
+never unloaded with a theme).
+
+### D10: Setting integration and robustness
+
+`theme` enum property in `create_setting` (items `minimal`, `scifi`;
+default `scifi` installed via D2's `set_default_enum` — Enum variant,
+not Str). Persistence is free via the `Setting` pimpl. Read-side guard:
+unknown persisted theme → default (spec scenario). Fix `Setting::new`
+to **skip** persisted keys that no longer exist as node properties
+instead of `unwrap()`-panicking — a pre-existing latent bug this change
+makes reachable (persisted `theme` vs. older binary, or a renamed
+setting later). Settings screen gains a minimal enum control
+(cycle-on-tap row); theme names are shown raw (no i18n) in v1, matching
+existing settings rows.
+
+### D11: Sequencing — PaperLight dies first
+
+All 14 PaperLight-bearing files are reworked again by the split; deleting
+the dead scheme first halves the branch noise and makes the minimal/scifi
+diff reviewable. Mechanical collapse: `match COLOR_SCHEME` arms inline to
+their DarkMode values; `ColorScheme`/`COLOR_SCHEME` and the window-linked
+`bg` vector-art block are deleted.
+
+### D12: The splash is scifi-owned
+
+The first-run scramble splash is theme content: the scramble effect and
+its colors are scifi flavor, not baseline structure. It moves out of
+`schema::make` into scifi's `apply` as a tracked node plus a tracked
+hide task, gated on `is_first_time` (readable from the app context at
+apply time, before it is consumed in `App::start`). Consequence: the
+minimal theme has no splash — first run under minimal goes straight to
+the baseline UI, which the completeness requirement already covers.
+
+### D13: Property permissions — Role bitflags + read/write masks
+
+`Role` becomes a bitflag set and properties carry a permission pair;
+setters/getters check the acting role against the mask and fail with a
+new `Error::PropertyPermissionDenied`:
+
+```rust
+// src/prop/mod.rs (sketch) — hand-rolled u8 bit ops, no new dependency
+pub struct Role: u8 {
+    const User     = 1 << 0;
+    const App      = 1 << 1;
+    const Internal = 1 << 2;
+    const Ignored  = 1 << 3;   // marker ("don't notify"), rarely in masks
+    const Theme    = 1 << 4;
+}
+
+pub struct PropertyPermission {
+    /// Roles allowed to read.
+    pub read: Role,
+    /// Roles allowed to write (set/unset/push/insert/remove/clear/expr).
+    pub write: Role,
+}
+
+impl Property {
+    pub fn new<S: Into<String>>(name: S, typ: PropertyType, subtype: PropertySubType,
+                                permission: PropertyPermission) -> Self;
+    pub fn can_read(&self, role: Role) -> bool;
+    pub fn can_write(&self, role: Role) -> bool;
+}
+```
+
+Hand-rolled bit ops rather than the `bitflags` crate: `bitflags` is not
+a dependency of `bin/app` today, and adding one is a supply-chain
+decision requiring human review (repo rules) — for a two-flag type the
+newtype is trivial.
+
+**Write enforcement is centralized and immediate**: every mutating API
+already carries a role (`set_*`, `set_expr`, `unset`, `clear_values`,
+`push_*`, `insert_*`, `remove_*`) — a single `can_write` check before
+any mutation or journal entry; denial returns
+`PropertyPermissionDenied` with the property untouched. This makes two
+existing conventions *enforced invariants*: D4 (themes cannot override
+widget-written properties — the write mask simply lacks `Theme`) and
+the ctx-only rule (a theme writing outside `set_touched` still stamps
+`Role::Theme` and is denied at the property layer if the mask forbids
+it).
+
+**Read enforcement is staged by where an actor is attributable** — raw
+`get_*` calls carry no role (145 sites) and stay trusted in-crate for
+now:
+
+1. Wrap layer: every widget reads through `Property{Float32,Color,…}`
+   wraps, which already hold a role from `wrap()` — `get()` checks
+   `can_read`, and `wrap()` itself validates the role upfront (failing
+   construction instead of at first read).
+2. Expr evaluation: `eval_f32_multi` reads dependencies on behalf of a
+   wrap role — dep reads go through the check.
+3. External boundary: `net.rs` RPC property reads check against the
+   remote actor's role.
+
+Full role parameters on raw getters is future hardening, deliberately
+not in this change (each of the 145 sites would need a reasoned role,
+not a mechanical one).
+
+**Exemptions** (by design): `set_default_*` (construction metadata, D2),
+`set_cache_*` (derived eval artifacts, written `Internal`), and
+`add_depend` (wiring metadata). Modifying a default still cannot be
+gated by write masks — defaults are installed before the tree is live.
+
+**Factory masks make the style/structure/function split concrete**
+(assigned during the 5.1 scrub; transitional default
+`PropertyPermission { read: all, write: all }` keeps current behavior
+until then):
+
+```
+prop class                     read                write
+─────────────────────────────  ──────────────────  ────────────────────
+themeable style (colors,       all internal roles  App | Theme
+font_size, padding, spacing)
+widget-computed/runtime        all internal roles  Internal (| App for
+(scroll, is_focused,                               schema bootstrap)
+height, select_text, alpha
+on multiline rect[3])
+behavior toggles               all internal roles  App (never Theme —
+(is_active, is_visible)                            themes don't change
+                                                   what the UI does)
+content/data (text, items,     all internal roles  App (User where
+nick_colors)                                       user input persists)
+settings (/setting/*)          all                 User | App
+tokens (/theme/*)              all                 Theme (set at
+                                                   apply, unset at
+                                                   unload — nobody
+                                                   else writes them)
+```
+
+`Role::Theme` stamping (D8) and this table are two views of the same
+contract: the mask says who may, the role on the event says who did.
+
+## Structural changes
+
+```
+bin/app/src/
+├── prop/mod.rs        + set_default_{bool,u32,f32,str,enum,expr,value}
+│                        (post-Arc, D2); effective is_expr/get_expr/get_value (D3);
+│                        builder set_defaults_expr; Role bitflags +
+│                        PropertyPermission on Property::new + can_read/
+│                        can_write + write-path enforcement (D13); unit tests
+├── error.rs           + Error::PropertyPermissionDenied (D13)
+├── prop/wrap.rs       + eval_f32_multi free fn; PropertyColor::eval;
+│                        PropertyFloat32::eval; PropertyRect::eval_with delegates (D7)
+├── app/node.rs        factories: allow_exprs() on themeable props + Tier-0 scrub (D6)
+├── theme/mod.rs   NEW Theme trait, ThemeCtx (nodes-as-storage: touched-set,
+│                        list/dep journal, token children, tasks on nodes),
+│                        registry, switch(), apply_startup(),
+│                        create_token_node(), create_token_child(),
+│                        wire_color/wire_f32 (D6-D9)
+├── theme/scifi.rs NEW scifi impl: tokens, king video, splash, fade watcher (D8, D12)
+├── app/mod.rs         App::setup: link /theme before schema::make; apply_startup after
+├── setting.rs         theme enum prop; skip unknown persisted keys (D10)
+├── app/schema/**      PaperLight removal; Tier-1 defaults + wiring helpers (D1, D11)
+└── app/schema/settings.rs  enum rendering (cycle-on-tap) (D10)
+```
+
+`minimal` has no file: it is the unloaded state.
+
+## Flows
+
+### Setup and startup application
+
+```
+main.rs
+ └─ App::setup(kv_db, app_db)
+     ├─ link /setting      Setting pimpl loads persisted props synchronously
+     │                     (incl. `theme`; unknown keys skipped — D10)
+     ├─ link /window
+     ├─ link /theme        NEW: token props constructed with minimal
+     │                     palette as defaults (D2, no events)
+     ├─ schema::make(...)  structure + Tier-1 defaults + wire_color/wire_f32
+     │                     (helpers read /theme; all depends wired here — D5)
+     └─ theme::apply_startup()
+         ├─ read /setting/theme → "scifi" (unknown → default — D10)
+         ├─ switch(app, ∅ → scifi)                       (D9)
+         │   └─ scifi tokens set → notifications queue (unbounded — safe,
+         │      widget listener tasks don't exist yet; they drain later)
+         └─ spawn engine-owned /setting/theme watcher (live switches)
+
+ App::start(event_pub, epoch)
+ ├─ window.init(); redraw_trigger.trigger()     (first pass queued)
+ └─ start_procs → Window::start                 draw loop + widget OnModify
+                                                tasks start; queued token
+                                                notifications drain into
+                                                handlers (cache clears)
+      └─ first draw pass: every widget re-evals its expr-bound props
+         (rect + colors + fonts — D7) from settled scifi token values
+         → first frame is fully scifi (spec: applied before first frame)
+```
+
+### Live switch (scifi → minimal)
+
+```
+user taps theme row in settings screen
+ └─ setting prop `theme` set → Setting pimpl persists; engine watcher fires
+     └─ switch(app, scifi → minimal)
+         ┌──────────────────────────────────────────────────────────┐
+         │ atom = redraw.make_guard("theme switch")                  │
+         │                                                            │
+         │ UNLOAD scifi (journal replay in reverse + teardown):        │
+         │ UNLOAD scifi (cancel actors, then unwind):                  │
+         │   clear_tasks() on tracked nodes FIRST (in-flight fade      │
+         │     stops before it can write stale vals)                   │
+         │   unset Touched entries → vals fall to schema defaults      │
+         │   restore List entries (nick_colors priors)                 │
+         │   remove Depend edges (incl. private-token refs)           │
+         │   shared token.clear_values() × ~30  (Unset, queued)       │
+         │   unlink() tracked nodes — /theme/scifi dies with its      │
+         │     props and tasks, king video, splash                    │
+         │                                                            │
+         │ LOAD minimal: registry identity element — nothing to do   │
+         └──────────────────────────────────────────────────────────┘
+         drop(atom) ─▶ one notification wave (single BatchGuardId)
+                         │
+                         ├─ widget handlers: draw_cache.clear() + trigger()
+                         │  (bounded(1) channel coalesces all triggers)
+                         └─ last BatchGuard ref drops ─▶ one redraw token
+                                                        (plus the explicit
+                                                        trigger for unlinks)
+                         │
+                         ▼
+         draw pass: draw-side eval reads token *defaults* (= minimal
+         palette) → one consistent minimal frame; scifi nodes absent
+         from the tree; no residue (spec: atomic switching, teardown)
+```
+
+### Redraw guarantee (why no mixed frame is possible)
+
+- Property mutations are only observed through the draw pass; a pass
+  only starts by draining a token from the bounded(1) channel, and tokens
+  are enqueued after state is settled (`make_guard` defers to end-of-
+  batch; handlers mutate then trigger).
+- Passes re-evaluate every expr index from current dependency values
+  before reading them (D7) — a pass cannot draw a stale cache even if it
+  races a handler.
+- Structural-only changes (unlink) notify nothing, hence the explicit
+  `trigger()` in `switch` — mirroring the `menu/mod.rs` precedent.
+
+## Risks / Trade-offs
+
+- [Theme overrides on widget-written properties get clobbered] → D4
+  ownership rule, audited per widget during the schema split; enforcement
+  metadata deferred.
+- [Live dependency rewiring touches the widget hot path
+  (`when_change_impl`)] → D5 keeps the existing poll loop; resync is
+  rare (theme switches), adds one subscription per watched prop, and
+  the post-resync handler run is the existing coalesced
+  invalidate+trigger.
+- [Draw-side eval adds per-pass cost] → same order as today's rect
+  evals; exprs are tiny (var loads + arithmetic); only wired (themeable)
+  props carry exprs.
+- [`Setting::new` panics on unknown persisted keys] → fixed as part of
+  D10 (skip-and-log), before the new `theme` key ships.
+- [Theme node z-order collisions with schema layers] → theme-owned
+  background/decoration nodes take a reserved low band (`z_index` 0 under
+  `/window/content`); convention documented with the engine.
+- [Volume: 23 schema files to convert] → helpers land first, then
+  per-area conversion (menu, chat, wallet, settings, root); PaperLight
+  removal (D11) precedes; visual check per area under both themes.
+- [VectorArt shape colors not tokenizable] → scifi rebuilds its themed
+  shapes structurally; neutral shapes keep baked colors (non-goal).
+- [First-frame ordering: theme applied before widget tasks exist] → safe
+  by construction: pubsub queues are unbounded (events buffer), and the
+  first pass evaluates draw-side from settled token values (Flows).
+
+## Migration Plan
+
+Single change, no flag: default `scifi` means the shipped look is
+unchanged on upgrade — the new `/setting/theme` key is the only persisted
+addition, and the D10 skip-guard makes it harmless to older binaries.
+Rollback is revert; any persisted `theme` value is ignored (skipped)
+by pre-change code once D10's guard is in.
+
+Implementation order is the tasks order: PaperLight removal → property
+APIs → evaluation → tokens/wiring → schema split → engine → scifi →
+setting/UI. Each step keeps `make compile-dev` green; behavior
+verification per area under both themes.
+
+## Open Questions
+
+- Exact shared token vocabulary (`accent_color` vs split accent/dim/
+  accent2, how many `edit.*`/`menu.*`/`chatview.*` tokens): grown by
+  trial and error during the split; does not affect the mechanism. The
+  pressure is lower than it first looks — only *schema-referenced*
+  tokens must exist up front; themes mint anything else privately (D6).

+ 103 - 0
openspec/changes/app-theme/proposal.md

@@ -0,0 +1,103 @@
+## Why
+
+The app's entire look — the scifi cyan palette, the king video background,
+the scramble splash, overlay fades — is hard-coded inline across ~14 schema
+files (233 `set_property_*` styling calls, 125 `text_color` touches) and has
+even leaked into the node factories in `node.rs` (`action_fg_color`,
+`url_copy_*`, `scramble_color`, menu role colors). The only variation
+mechanism is a compile-time `COLOR_SCHEME` switch whose PaperLight arm is
+dead code. There is no way to change the look at runtime, no defined
+boundary between "style" and "structure", and no reset point themes could
+start from. This change makes the UI themable at runtime by layering themes
+on the property system's existing defaults/vals fallback, turning the
+current look into one selectable theme (`scifi`) over a neutral `minimal`
+baseline.
+
+## What Changes
+
+- **Property system (`bin/app/src/prop/`)**: defaults become installable on
+  live (already-linked) properties via a post-creation `set_default_*` API
+  for every type; defaults may hold SExpr; "effective expr" semantics —
+  `is_expr`/`get_expr` fall through `vals` → `defaults`, and `get_value`
+  never returns an unresolved SExpr (resolves via cache, else type
+  default). Factories opt in `allow_exprs()` for themeable props (it is
+  builder-only today, so live properties can never gain expr support).
+  `Role` becomes a bitflag set and properties gain
+  `PropertyPermission` (read mask, write mask) enforced on writes
+  (every mutating API already carries a role) and on attributed reads
+  (wrap layer, expr evaluation, RPC boundary), failing with
+  `PropertyPermissionDenied` — which turns "themes don't override
+  widget-owned properties" from convention into a hard invariant.
+- **Expr evaluation beyond rects**: f32-array properties (4-component
+  colors, font sizes, spacing) gain per-index expr evaluation against
+  dependency globals, re-evaluated in widgets' draw paths alongside rect
+  evaluation (today only `PropertyRect` has this); `when_change` handlers
+  stay invalidation-only.
+- **`/theme` token node**: a scene-root node holding the shared
+  themeable vocabulary as token properties whose defaults are the
+  `minimal` palette. Schema creation code wires styled properties to
+  tokens via helper functions ("classes") that install default-exprs
+  plus `add_depend` edges — one wiring, every wired widget tracks the
+  token forever, including nodes created at runtime. Themes can also
+  mint their own private tokens as tracked child nodes
+  (`/theme/<name>`), usable in overrides, gone on unload — themes are
+  not boxed in by the shared list.
+- **Minimal baseline**: the schema installs Tier-1 defaults (complete,
+  neutral, usable) instead of inline styling; factories are scrubbed of
+  theme leaks back to neutral Tier-0 type defaults.
+- **Theme engine (`bin/app/src/theme/`)**: `Theme` trait + registry;
+  `apply`/unload with nodes as the storage unit — theme-created
+  properties live on tracked nodes (private tokens under
+  `/theme/<name>`), theme tasks are pushed onto those nodes and cancel
+  with them; every theme modification is stamped with a new
+  `Role::Theme` (attribution on the event stream — `vals` stores no
+  authorship); unload cancels theme tasks first, then resets only the
+  recorded touched properties (a deep-walk reset is neither possible
+  nor safe), restores unbounded-list priors (e.g. `nick_colors`, which
+  have no defaults tier), removes theme-added dependency edges, and
+  unlinks tracked nodes — all as one atomic property batch. Theme
+  behavior (e.g. the netstatus overlay fade) is implemented as property
+  watchers, not schema hooks.
+- **scifi extracted**: the first real theme — cyan token palette, king
+  video background node, fade watchers.
+- **`/setting/theme` enum** (`minimal` | `scifi`, default `scifi`),
+  persisted through the existing `Setting` pimpl, applied after schema
+  load, live-switched on change; the settings screen gains enum
+  rendering (enums currently display as "unknown").
+- **PaperLight removed**: `ColorScheme` enum, the `COLOR_SCHEME` const, and
+  all two-arm style branches (collapsing to the DarkMode values) are
+  deleted across 14 files, including the dead window-linked `bg`
+  vector-art block.
+
+## Capabilities
+
+### New Capabilities
+
+- `prop-defaults`: post-creation default installation on live properties
+  (all types incl. SExpr), effective-expr resolution order for reads, and
+  f32-array expr evaluation with dependency-triggered re-evaluation.
+- `app-theme`: the runtime theme system — token node and class wiring,
+  minimal baseline, theme apply/unload lifecycle (tracked nodes, tasks,
+  journal), atomic switching, the `theme` setting, and scifi as first
+  theme.
+
+### Modified Capabilities
+
+(none — no existing specs in this repo; the property-system extensions are
+new behavior captured in `prop-defaults`.)
+
+## Impact
+
+- `bin/app/src/prop/mod.rs`, `src/prop/wrap.rs` (default APIs, effective
+  exprs, f32-multi evaluation) + unit tests in the existing test module.
+- `bin/app/src/ui/` — color/font expr re-evaluation call sites in
+  widgets' `when_change` update paths.
+- `bin/app/src/app/node.rs` (factory default scrub), `src/setting.rs`
+  (theme enum), `src/app/schema/settings.rs` (enum rendering).
+- `bin/app/src/app/schema/**` (all files: PaperLight removal, minimal
+  defaults, token wiring) and `src/app/mod.rs` (apply theme after
+  `schema::make`).
+- New `bin/app/src/theme/` module.
+- No changes outside `bin/app`; no new dependencies. Verified via
+  `bin/app` Makefile (`make compile-dev`, `make compile-apk`) and the
+  property unit tests.

+ 163 - 0
openspec/changes/app-theme/specs/app-theme/spec.md

@@ -0,0 +1,163 @@
+## Purpose
+
+Runtime theme system for the app UI: a neutral minimal baseline installed
+as property defaults, a token vocabulary wired into styled properties, and
+a switchable theme lifecycle (apply, unload, live switch) layered on top —
+so the entire visual style, including theme-owned decorations and
+animations, can change at runtime without restart.
+
+## ADDED Requirements
+
+### Requirement: Theme selection setting
+
+The app SHALL expose a `theme` setting under `/setting` as an enum of the
+available theme names, persisted across restarts. The default SHALL be
+`scifi`. A persisted value that is not among the available themes SHALL
+be treated as the default.
+
+#### Scenario: First run defaults to scifi
+
+- **WHEN** the app starts with no persisted theme setting
+- **THEN** the active theme is scifi and the UI matches the current shipped look
+
+#### Scenario: Selection persists across restart
+
+- **WHEN** the user switches the theme and later restarts the app
+- **THEN** the persisted theme is active on startup
+
+#### Scenario: Unknown persisted value falls back
+
+- **WHEN** the persisted theme is not an available theme (e.g. removed in a
+  later version)
+- **THEN** the app starts with the default theme instead of failing
+
+### Requirement: Theme application at startup
+
+After the schema is constructed, the persisted theme SHALL be applied
+before the first drawn frame. With `minimal` selected, the UI SHALL render
+the minimal baseline with no theme decorations.
+
+#### Scenario: App starts themed
+
+- **WHEN** the app starts with any valid persisted theme
+- **THEN** the first drawn frame already reflects that theme
+
+#### Scenario: Minimal at startup
+
+- **WHEN** the app starts with `minimal` persisted
+- **THEN** the UI renders the baseline with no theme-owned nodes (no video
+  background, no splash, no themed fades)
+
+### Requirement: Layered theming over a minimal baseline
+
+The minimal baseline SHALL be installed as property defaults during schema
+construction. A theme SHALL override properties by setting values (plain
+or expression) in the value slot, and MAY override any property type,
+including rect geometry. Unloading a theme SHALL restore the baseline for
+every property it touched, without recreating nodes or restarting.
+
+#### Scenario: Rect override restores on switch
+
+- **WHEN** a theme overrides a widget's rect and the theme is later unloaded
+- **THEN** the widget's rect returns to the baseline geometry
+
+#### Scenario: No styling residue after unload
+
+- **WHEN** a theme that set colors, fonts, and spacing is unloaded
+- **THEN** every property it touched reads its baseline value
+
+### Requirement: Atomic theme switching
+
+Switching themes SHALL unload the current theme and apply the new one as a
+single atomic property modification batch, followed by a redraw. The UI
+SHALL reflect only the new theme afterward, with no restart and no
+permanently mixed state.
+
+#### Scenario: Live switch end to end
+
+- **WHEN** the user switches from scifi to minimal while a screen is open
+- **THEN** in the next redraw the whole UI shows the minimal look (accents,
+  background, and decorations all changed together)
+
+### Requirement: Theme-created node and task teardown
+
+Nodes created by a theme SHALL be tracked, and on unload SHALL be removed
+from the scene tree with their tasks cancelled. Tasks spawned by a theme
+(including animations reacting to property changes) SHALL stop when the
+theme unloads.
+
+#### Scenario: Theme background node disappears
+
+- **WHEN** switching away from a theme that inserted a background node
+- **THEN** the node is gone from the scene tree and nothing it drew remains
+
+#### Scenario: In-flight animation stops on switch
+
+- **WHEN** a theme-driven fade animation is mid-flight and the theme is
+  switched away
+- **THEN** the animation stops making further changes
+
+### Requirement: Token propagation to late-created nodes
+
+Changing a theme token SHALL update every property wired to that token.
+Nodes created after a theme is applied (e.g. a chat screen for a channel
+joined later) SHALL render with the active theme's styling without any
+re-theming pass.
+
+#### Scenario: Token change updates all wired properties
+
+- **WHEN** a token value changes while the app is running
+- **THEN** all properties wired to that token reflect the new value after the
+  next evaluation
+
+#### Scenario: Late-created node is themed
+
+- **WHEN** a channel is joined and its chat screen constructed while a
+  non-minimal theme is active
+- **THEN** the new screen renders with the active theme's styling
+
+### Requirement: Minimal baseline completeness
+
+The minimal baseline SHALL be a complete, usable look on its own: all
+user-visible surfaces (chat, channel/contact menus, wallet flows, send
+and receive screens, settings, netstatus overlay) SHALL render with
+readable text and visible controls when only the baseline is active.
+
+#### Scenario: Full walkthrough under minimal
+
+- **WHEN** the app runs with the minimal theme and the user visits each
+  surface
+- **THEN** text is legible against its background and interactive controls
+  are visible and usable on every surface
+
+### Requirement: Theme-defined tokens
+
+A theme SHALL be able to define additional token properties beyond the
+shared vocabulary, existing only while that theme is applied. Theme
+overrides and expressions SHALL be able to reference these tokens. On
+unload, theme-defined tokens SHALL cease to exist along with the
+dependencies referencing them, leaving no residue.
+
+#### Scenario: Private token drives an override
+
+- **WHEN** a theme defines a private token, wires a widget property to
+  it, and the token's value is set
+- **THEN** the widget renders per the private token's value while the
+  theme is active
+
+#### Scenario: Private tokens vanish on switch
+
+- **WHEN** the theme that defined private tokens is unloaded
+- **THEN** those tokens and any dependency edges referencing them are
+  gone, and properties that referenced them read their baseline values
+
+### Requirement: Restoration of non-expression themeable properties
+
+For themeable properties that cannot hold expressions (e.g. unbounded
+value lists such as chat nick colors), a theme SHALL record the prior
+value before setting it and restore that value on unload.
+
+#### Scenario: Nick colors restore after switching away
+
+- **WHEN** a theme sets a nick color list and is then unloaded
+- **THEN** the previous nick color list is restored

+ 131 - 0
openspec/changes/app-theme/specs/prop-defaults/spec.md

@@ -0,0 +1,131 @@
+## Purpose
+
+Defines layered value resolution for the app's property system: defaults
+installable on live properties (including expression defaults), the read
+resolution order across set values, expressions, and defaults, and
+per-index float expression evaluation with dependency-triggered
+re-evaluation. This is the substrate that lets any styled or geometric
+property be overridden and later restored to its baseline without
+recreating nodes.
+
+## ADDED Requirements
+
+### Requirement: Post-creation default installation
+
+A default value SHALL be installable on any property of a live, already
+linked node, for every property type (bool, uint32, float32, string,
+enum, node id, shape, and expression). Installing a default on a property
+whose value is unset SHALL change the property's effective value. A value
+set explicitly in the value slot SHALL take precedence over the default,
+and clearing the value SHALL fall back to the installed default.
+
+#### Scenario: Default installed on a live node is effective
+
+- **WHEN** a default is installed on a property of a linked node whose value is unset
+- **THEN** reading the property yields the installed default
+
+#### Scenario: Explicit value overrides an installed default
+
+- **WHEN** a property has both an installed default and an explicitly set value
+- **THEN** reading the property yields the explicitly set value
+
+#### Scenario: Clearing the value falls back to the default
+
+- **WHEN** an explicitly set value is cleared (unset) and a default is installed
+- **THEN** reading the property yields the installed default again
+
+### Requirement: Expression defaults and effective expression source
+
+Defaults SHALL be allowed to hold expressions. When both the value slot
+and the default can supply an expression, the value slot's expression
+SHALL be the active source; when the value slot holds no expression and
+no plain value, the default's expression SHALL be the active source.
+Evaluators SHALL evaluate whichever expression is active.
+
+#### Scenario: Unsetting an override returns control to the default expression
+
+- **WHEN** a property's geometry is governed by a default expression, a theme
+  overrides it with a plain value or its own expression, and the override
+  is then unset
+- **THEN** the property is once again computed from the default expression
+
+#### Scenario: Both slots holding expressions prefers the value slot
+
+- **WHEN** the value slot holds an expression and the default also holds an
+  expression
+- **THEN** evaluation uses the value slot's expression
+
+### Requirement: Reads never expose unresolved expressions
+
+Reading a property's effective value SHALL never yield an expression
+object. Resolution SHALL proceed: set value, then the set expression's
+last computed result, then the default, then the default expression's
+last computed result, then the type's neutral default. Before an
+expression has been evaluated for the first time, concrete-type reads
+SHALL succeed with the next available layer rather than fail.
+
+#### Scenario: Read before first evaluation
+
+- **WHEN** a float property holds an expression that has never been evaluated
+  and no default is installed
+- **THEN** reading it as a float yields the type default instead of an error
+
+#### Scenario: Read after the expression is evaluated
+
+- **WHEN** an expression property has been evaluated and cached
+- **THEN** reading it as a float yields the cached computed result
+
+### Requirement: Role-based property permissions
+
+Roles SHALL be a bitflag set, and every property SHALL carry a
+permission pair (readable roles, writable roles) supplied at creation.
+A write attempt (set, unset, clear, expr, push, insert, remove) by a
+role not in the write mask SHALL fail with a permission-denied error
+and leave the property unmodified. A read by a role not in the read
+mask SHALL fail the same way wherever the acting role is attributable:
+wrapped property handles, expression evaluation of dependencies, and
+external (RPC) property access. Default installation and evaluation
+cache writes SHALL be exempt from write checks. Until factories assign
+masks, a default permission allowing all roles SHALL preserve current
+behavior.
+
+#### Scenario: Denied write does not mutate
+
+- **WHEN** a role lacking the write bit sets a property
+- **THEN** the call returns a permission-denied error and the property's
+  value is unchanged
+
+#### Scenario: Denied wrapped read errors
+
+- **WHEN** a wrapped property handle constructed with a role lacking the
+  read bit reads the property
+- **THEN** the read returns a permission-denied error instead of a value
+
+#### Scenario: Theme cannot write widget-owned properties
+
+- **WHEN** a theme writes a runtime-computed property whose write mask
+  excludes the theme role
+- **THEN** the write is denied and the widget's computed value stands
+
+### Requirement: Float-array expression evaluation with dependency re-evaluation
+
+Bounded float-array properties (for example 4-component colors) SHALL
+support per-index expressions evaluated against globals provided by the
+property's dependencies plus any evaluation extras. Indices holding plain
+values SHALL be left untouched by evaluation. When a dependency of such a
+property changes, the consuming widget SHALL re-evaluate the affected
+indices so that subsequent reads observe the new computed result.
+
+#### Scenario: Dependency change recolors a wired property
+
+- **WHEN** a color property's four indices are expressions referencing a
+  token property, and the token's value changes
+- **THEN** the color property's subsequent reads yield the color computed
+  from the new token value
+
+#### Scenario: Mixed plain and expression indices
+
+- **WHEN** one index of a float-array property holds a plain value and the
+  others hold expressions, and evaluation runs
+- **THEN** only the expression indices are recomputed and the plain index
+  keeps its value

+ 198 - 0
openspec/changes/app-theme/tasks.md

@@ -0,0 +1,198 @@
+## 1. Remove PaperLight (dead code)
+
+- [ ] 1.1 Delete `ColorScheme`, the `COLOR_SCHEME` const, and the dead
+  window-linked `bg` vector-art block in `src/app/schema/mod.rs`; collapse
+  the `if COLOR_SCHEME == ...` background branch to the unconditional
+  video path. Verify `make compile-dev` in `bin/app`.
+- [ ] 1.2 Collapse every `match COLOR_SCHEME` / `if COLOR_SCHEME ==`
+  two-arm branch in `chat.rs`, `menu/{mod,channel,contact}.rs`,
+  `settings.rs`, and `wallet/*.rs` to the DarkMode values inlined, and
+  drop the now-unused `ColorScheme`/`COLOR_SCHEME` imports (14 files
+  total). Verify `make compile-dev` and that the app renders as before.
+
+## 2. Property default APIs and permissions
+
+- [ ] 2.1 Add post-creation default installation on `PropertyPtr` in
+  `src/prop/mod.rs` (`set_default_*` for bool, u32, f32, str, enum, node
+  id, shape, null) mutating `defaults[i]` with no modify event, enforcing
+  the same length/type checks as the builder variants. Add unit tests:
+  install-on-live-node changes effective read; explicit value wins;
+  unset falls back to installed default.
+- [ ] 2.2 Add expression defaults: builder `set_defaults_expr` plus
+  post-creation `set_default_expr`, and make `is_expr`/`get_expr` resolve
+  the effective expression source (`vals` expression, else `defaults`
+  expression). Update `get_value` so it never returns an unresolved
+  expression (resolution order per the `prop-defaults` spec; unevaluated
+  expression reads yield the type default). Unit tests: override+unset
+  returns to default expression; value-slot expression wins over default
+  expression; concrete-type read before first evaluation succeeds.
+  Verify `cargo test prop::` in `bin/app` (crate `darkfi-app`) and
+  `make compile-dev`.
+- [ ] 2.3 Make `Role` a hand-rolled u8 bitflag set (User, App, Internal,
+  Ignored, Theme — no new dependency) and add `PropertyPermission`
+  {read, write} taken by `Property::new` (transitional default = all
+  roles, so all current call sites keep behavior) plus `can_read`/
+  `can_write` and `Error::PropertyPermissionDenied` (design D13).
+  Enforce write masks centrally in every mutating API (role is already
+  a parameter) before any mutation or journaling; enforce read masks in
+  the wrap layer (`wrap()` validates upfront, `get()` checks), in
+  `eval_f32_multi` dependency reads, and at the `net.rs` RPC boundary;
+  exempt `set_default_*` and `set_cache_*`. Unit tests: denied write
+  leaves value unchanged; denied wrapped read errors; theme role denied
+  on a mask without Theme. Verify `cargo test prop::` and `make
+  compile-dev`.
+
+## 3. f32-array expression evaluation beyond rect
+
+- [ ] 3.1 Generalize the `PropertyRect::eval_with` pattern in
+  `src/prop/wrap.rs` into a shared bounded f32-array evaluator and expose
+  `PropertyColor::eval` (globals-only, no extras) plus a single-f32
+  variant for `font_size`/spacing. Unit tests: per-index exprs recompute
+  from dependency globals; plain indices untouched; evaluated results
+  land in the cache and are returned by `get_f32` (note: cache writes
+  bypass `set_f32` range validation by design). Verify `cargo test
+  prop::`.
+- [ ] 3.2 Add draw-path re-evaluation call sites in color/font-consuming
+  widgets (`Text`, `Edit`, `ChatView`, `Menu`, `TextScramble`): at the
+  top of `draw()`, alongside the existing rect evaluation, re-evaluate
+  expr-bound styled props (`PropertyColor::eval`, f32 eval) so every
+  pass computes from current dependency values; keep `when_change`
+  handlers invalidation-only (clear draw cache + trigger), with cache
+  writes as `Role::Internal` so `when_change_external` echo-skips them
+  (same pattern as rect evals today). Verify with a scratch wiring
+  (expr-bound `text_color` reacting to a test property) under `make
+  compile-dev`.
+
+## 4. `/theme` token node and class-wiring helpers
+
+- [ ] 4.1 Create the `/theme` node (linked at scene root beside
+  `/setting`) with an initial token vocabulary: `text_color`,
+  `text_dim_color`, `accent_color`, `bg_color`, `edit.*` colors,
+  `menu.*` colors, `chatview.*` colors, font/spacing f32s — defaults
+  form the neutral minimal palette; write mask = Theme only (D13).
+  Node is created in the app setup path before `schema::make`. Verify
+  node exists at `/theme` with defaults via a startup log or debug
+  lookup; `make compile-dev`.
+- [ ] 4.2 Add wiring helpers (`wire_color`, `wire_f32`) that install
+  default-exprs + `add_depend` edges from a node property onto token
+  components in one call, replacing 4×`set_f32` styling blocks. All
+  wiring happens at creation (design D5); theme-time rewiring goes
+  through `ctx.depend_journaled`. Verify a helper-wired `text_color`
+  tracks a token change at runtime under `make compile-dev`.
+
+## 5. Minimal baseline: schema split and factory scrub
+
+- [ ] 5.1 Scrub theme leaks in `src/app/node.rs` factories back to
+  neutral Tier-0 defaults: `action_fg_color`/`action_bg_color`,
+  `url_copy_*` colors, `scramble_color`, menu `role1/role2` colors,
+  `tokentable` colors; enable `allow_exprs()` on every themeable prop
+  (colors, `font_size`, `padding`, `lineheight`, spacing) — it is
+  builder-only, so without this the wiring helpers' `set_default_expr`
+  calls fail with `PropertySExprNotAllowed`; and assign real
+  `PropertyPermission` masks per the D13 table (themeable style =
+  write App|Theme; widget-computed/runtime = write Internal without
+  Theme, enforcing D4; behavior toggles = write App; content/data =
+  write App) — replacing the transitional allow-all default. Verify
+  `make compile-dev` and note any visual deltas are restored later via
+  tokens/scifi.
+- [ ] 5.2 Convert `src/app/schema/mod.rs` (root, netstatus icons and
+  overlay) to minimal defaults + token wiring: inline styling blocks
+  become `set_default_*` or helper wirings; geometry exprs move to
+  default-exprs; the first-run splash block is removed from the schema
+  (re-implemented by scifi in 7.1 per design D12). Verify both a
+  token-flip recolors the overlay and that with untouched tokens the UI
+  looks minimal-but-correct; `make compile-dev`.
+- [ ] 5.3 Convert `menu/` (mod, channel, contact, edit helpers) to
+  minimal defaults + tokens. Verify menu, channel/contact lists, and
+  edit mode render correctly under the baseline; `make compile-dev`.
+- [ ] 5.4 Convert `chat.rs` per-channel screens (chatview styling, edit
+  composer, selection overlay) to minimal defaults + tokens, including
+  journaled handling for `nick_colors` (unbounded list, no exprs).
+  Verify a freshly joined channel's screen renders themed-if-tokens-set;
+  `make compile-dev`.
+- [ ] 5.5 Convert `wallet/` screens (main, send steps 1-4, receive,
+  tx_status, data, util helpers) and `settings.rs` to minimal defaults +
+  tokens. Verify full wallet flow walkthrough under baseline; `make
+  compile-dev`.
+
+## 6. Theme engine
+
+- [ ] 6.1 Create `src/theme/` with the `Theme` trait (`name()`, async
+  `apply(ctx)`), compile-time registry (`minimal`, `scifi`), and
+  `ThemeCtx` using nodes-as-storage (design D8): tracked node roots
+  (incl. theme-defined token children under `/theme/<name>`, built
+  pre-Arc with builder `add_property`), theme tasks pushed onto
+  theme-owned nodes (`push_task` → cancelled by `clear_tasks()` on
+  unload), and a journal of `Touched` (prop, i — unset on unload),
+  `List` (unbounded-list priors, e.g. nick_colors — unbounded props
+  have no defaults tier), and `Depend` (theme-added edges — removed on
+  unload). All ctx setters stamp `Role::Theme` on the modify events
+  (attribution lives on the event stream since `vals` has none); verify
+  `when_change_impl`'s equality filters pass `Theme` through like `App`
+  (only Internal/Ignored are skipped) and audit other Role consumers
+  (`net.rs`, `setting.rs` produce roles; any external Role mapping
+  needs a `Theme` arm); optional dev-mode leak audit warns on
+  `Role::Theme` events for (prop, i) pairs not in the touched-set. The
+  touched-set is required for correctness (`vals` has no authorship; a
+  deep-walk reset would destroy runtime state) — the ctx is the only
+  sanctioned path for themes to modify properties they don't own (and
+  D13 masks deny off-path writes outright). Unload order:
+  `clear_tasks()` on tracked nodes FIRST (cancel actors — an in-flight
+  watcher writing after its unset would leave stale vals) → unset
+  Touched → restore Lists → remove Depend edges → unset shared tokens →
+  `unlink()` per tracked node. Verify a synthetic theme that touches
+  one token, defines one private token wired to a widget, overrides a
+  bounded prop and an unbounded list, inserts one node, and pushes one
+  watcher task unloads cleanly (second apply leaves no residue, no
+  dangling edges, watcher cancelled) via a temporary schema-test hook
+  or unit test; `make compile-dev`.
+- [ ] 6.2 Implement the atomic switch flow per design D9: single
+  `redraw.make_guard` batch (unload + apply), explicit `trigger()` after
+  the drop for structural-only changes (unlinks notify nothing); wire it
+  to the `/setting/theme` property — `apply_startup()` in `App::setup`
+  after `schema::make`, plus an engine-owned watcher task (not
+  theme-tracked) for live switches. Verify startup applies the persisted
+  theme before the first frame (pubsub queues buffer events until widget
+  tasks start; the first pass evaluates draw-side) and a live setting
+  change switches atomically; `make compile-dev`.
+
+## 7. scifi theme
+
+- [ ] 7.1 Extract the scifi look into `src/theme/scifi.rs`: cyan token
+  values, at least one theme-defined private token (proves the D6
+  private-token path, e.g. the netlogo accent), the king video
+  background as a tracked structural node (reserved low z band under
+  `/window/content`), the first-run scramble splash as a tracked node +
+  hide task gated on `is_first_time` (design D12), and the netstatus
+  overlay fade as a tracked watcher on `is_visible` writing `alpha` via
+  `ctx.set_touched` (remove the in-handler fade from the reconnect
+  click handler in `src/app/schema/mod.rs`). Verify: with `theme=scifi`
+  the app is pixel-comparable to today's look (video bg, splash on
+  first run, cyan accents, fade on overlay open); `make compile-dev`
+  and `make compile-apk`.
+
+## 8. Theme setting and settings UI
+
+- [ ] 8.1 Add the `theme` enum property to `create_setting` in
+  `src/setting.rs` (items `minimal`, `scifi`; default `scifi`); fix
+  `Setting::new` to skip-and-log persisted keys that no longer exist as
+  node properties instead of unwrapping; guard reads so an unknown
+  persisted theme falls back to the default. Verify persistence across
+  restart and the fallback path (hand-edit the persisted value);
+  `make compile-dev`.
+- [ ] 8.2 Add enum rendering to the settings screen (`settings.rs`):
+  cycle-on-tap row for enum values (starts with `theme`; also fixes
+  `net.transport` showing "unknown"). Verify both enums display and
+  modify correctly, and switching `theme` there drives the live switch;
+  `make compile-dev`.
+
+## 9. End-to-end verification
+
+- [ ] 9.1 Full walkthrough under both themes (chat, menus, wallet send
+  flow, receive, settings, netstatus overlay, emoji picker): minimal is
+  complete and usable per the `app-theme` spec; scifi matches the
+  current shipped look; live switch mid-session leaves no mixed state,
+  no orphaned visuals, no running animations from the old theme; theme
+  writes outside permitted masks are denied (spot-check a widget-owned
+  prop via a debug theme write). Verify `make compile-dev` clean,
+  `cargo test` in `bin/app` green, and `make compile-apk` for Android.