diff --git a/ideas/parser.tdsl b/ideas/parser.tdsl deleted file mode 100644 index 03ba9563ea8c73b7411cd1aa42dda9c7c313cd39..0000000000000000000000000000000000000000 --- a/ideas/parser.tdsl +++ /dev/null @@ -1,32 +0,0 @@ -The parser is a series of edges/transitions, for example: -r -> reddit.com/ -where -> means that all of - r->reddit.com/ - re->reddit.com/ - red->reddit.com/ - redd->reddit.com/ - reddi->reddit.com/ - reddit->reddit.com/ - reddit.->reddit.com/ - reddit.c->reddit.com/ - reddit.co->reddit.com/ - reddit.com->reddt.com/ - are defined as autocomplete suggestions - -re -> reddit.com/ -reddit.com/ -> {show infographic explaining that u (sort user posts) and r (sort subreddit posts)} -> reddit.com/r/ -reddit.com/r/ -> reddit.com/r/{randomly chose sub from list} -reddit.com/r/{sub} -> {show subrdedit view} -> reddit.com/r/{sub}/ -reddit.com/r/{sub}/ -> {show infographic or something} -> reddit.com/r/{sub}/comments/{randomly chose comment from sql} - - -h->https:// (show supported domains) - -// the edges are composable such that this is possible, while reusing the above reddit code. -https://r->https://reddit.com/ -https://w->https://www. -https://www.r->https://www.reddit.com/ - - -// and i also want to have commands after you press space, like -reddit.com/r/programming !vote(reddit.comment{t3_123, t4_4444})% diff --git a/ideas/url.tdsl b/ideas/url.tdsl new file mode 100644 index 0000000000000000000000000000000000000000..d2397548d6ab2b8ca6af7a69782b809271536e9f --- /dev/null +++ b/ideas/url.tdsl @@ -0,0 +1,4 @@ +ets zoom in on the data model. i want to import reddit data using the reddit api. i also want to import, eventually, every object on the internet. +i want to be able to paste in a reddit url like +https://old.reddit.com/r/AmItheAsshole/comments/1trnvdl/aita_for_cancelling_the_camping_trip_last_minute/ +and see the breadcrumbs (every segment of the url seperated by /) and click on each segment. as i click down, i want the children of that url to be visible and sorted. like the comments view of that one thread is visible with the full url, then i navigate to just r/amitheasshole, and i see every child of that node, whcih is all posts from that sub. then i nav to /r/ and i see all subs, sorted against eachother. etc. and to have this apply to every url on the intrenet eventually, but first work really well for reddit, with official reddit api support for importing data cleanly. \ No newline at end of file diff --git a/legacy/bundle.js b/legacy/bundle.js deleted file mode 100644 index 8d316f9a57cc7c379fe4bd8e42e092c7eac5d265..0000000000000000000000000000000000000000 --- a/legacy/bundle.js +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Slug web UI: only plumbing — fetch/eval/SSE. No product UI logic here. - */ -(function () { - function evalJs(js) { - if (js && String(js).trim()) { - eval(js); - } - } - - // Theme cookie sync (runs before paint; full reload if localStorage disagrees with cookie) - - function initSlugUi() { - // POST forms → eval response (except theme + full-navigation forms) - document.addEventListener('submit', async function (e) { - var f = e.target; - if (!f || f.tagName !== 'FORM') return; - if ((f.method || 'get').toLowerCase() !== 'post') return; - if (f.id === 'slug-theme-form') return; - if (f.getAttribute('data-navigate') === 'full') return; - e.preventDefault(); - var resp = await fetch(f.action, { - method: 'POST', - body: new URLSearchParams(new FormData(f)), - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - credentials: 'same-origin', - }); - evalJs(await resp.text()); - }); - - // SSE: server-pushed JS - function connectSSE() { - var ssePath = window.location.pathname + window.location.search; - var es = new EventSource('/sse?path=' + encodeURIComponent(ssePath)); - es.onmessage = function (e) { - evalJs(e.data); - }; - es.onerror = function () { - es.close(); - setTimeout(connectSSE, 3000); - }; - } - connectSSE(); - } - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initSlugUi); - } else { - initSlugUi(); - } -})(); - diff --git a/legacy/clj-test.sh b/legacy/clj-test.sh deleted file mode 100755 index b62a49b98ed60a65a46807b7ad80fa3142f14952..0000000000000000000000000000000000000000 --- a/legacy/clj-test.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -cd "$(dirname "$0")/.." -mkdir -p target -exec clojure -M:kaocha diff --git a/legacy/event_log.rs b/legacy/event_log.rs deleted file mode 100644 index eaae0d495e43a45d6590603892265a62cc92906e..0000000000000000000000000000000000000000 --- a/legacy/event_log.rs +++ /dev/null @@ -1,83 +0,0 @@ -use std::path::{Path, PathBuf}; - -use tokio::{ - fs::{self, OpenOptions}, - io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, -}; - -use crate::events::Event; - -#[derive(Debug, thiserror::Error)] -pub enum EventLogError { - #[error("io error: {0}")] - Io(#[from] std::io::Error), - #[error("json error: {0}")] - Json(#[from] serde_json::Error), -} - -#[derive(Debug, Clone)] -pub struct EventLog { - path: PathBuf, -} - -impl EventLog { - pub fn new(path: impl Into) -> Self { - Self { path: path.into() } - } - - pub fn path(&self) -> &Path { - &self.path - } - - pub async fn ensure_parent_dir(&self) -> Result<(), EventLogError> { - if let Some(parent) = self.path.parent() { - fs::create_dir_all(parent).await?; - } - Ok(()) - } - - pub async fn append(&self, event: &Event) -> Result<(), EventLogError> { - self.ensure_parent_dir().await?; - let mut f: tokio::fs::File = OpenOptions::new() - .create(true) - .append(true) - .open(&self.path) - .await?; - - let mut line = serde_json::to_string(event)?; - line.push('\n'); - f.write_all(line.as_bytes()).await?; - f.flush().await?; - Ok(()) - } - - /// Load events from JSONL. Corrupt lines are skipped and returned as `(line_no, line)`. - pub async fn load_all(&self) -> Result<(Vec, Vec<(usize, String)>), EventLogError> { - if !fs::try_exists(&self.path).await? { - return Ok((vec![], vec![])); - } - - let f = fs::File::open(&self.path).await?; - let mut reader = BufReader::new(f).lines(); - - let mut events = Vec::new(); - let mut bad_lines = Vec::new(); - - let mut line_no: usize = 0; - while let Some(line) = reader.next_line().await? { - line_no += 1; - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - match serde_json::from_str::(trimmed) { - Ok(ev) => events.push(ev), - Err(_) => bad_lines.push((line_no, line)), - } - } - - Ok((events, bad_lines)) - } -} - - diff --git a/legacy/forms.rs b/legacy/forms.rs deleted file mode 100644 index d509fd889fde3fed2662ce0a39006b7a51ae762a..0000000000000000000000000000000000000000 --- a/legacy/forms.rs +++ /dev/null @@ -1,145 +0,0 @@ -tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒ cat server/src/form_template.rs -//! Plan2-style JSON templates with `{"$form": "field_name"}` holes, filled from -//! `application/x-www-form-urlencoded` (or any `String` → `String` map) **before** -//! deserializing into a typed struct. -//! -//! # Wire format -//! -//! Templates are **compact JSON** (`serde_json::to_string`): one line, no pretty -//! printing, strings escaped per JSON rules (`\"`, `\n`, etc.). Embed that string -//! in HTML attributes or text nodes with normal HTML escaping (e.g. maud), not -//! bespoke encodings. -//! -//! # Power vs flat hidden fields -//! -//! A form is always a string→string map. You can fake depth with dotted keys (`a.b.c`), -//! but one structured blob (`__rpc__` = compact JSON) gives you nested objects, -//! arrays, and optional fields without inventing a new naming scheme each time. -//! -//! # Security -//! -//! Substitution runs **before** `serde` into your command type. It does not fix -//! authorization: if the client can replace the hidden `__rpc__` value, they can -//! change the command shape unless you validate (signed blob, server-side session -//! context, or treat the blob as hints only). Same threat model as any hidden field. - -use serde::Serialize; -use serde_json::Value; -use std::collections::HashMap; - -/// Serialize a value to compact JSON for a hidden `__rpc__` (or similar) field. -pub fn template_json_compact(v: &T) -> serde_json::Result { - serde_json::to_string(v) -} - -/// Recursively walk the JSON AST and replace `{"$form": "key"}` with the submitted -/// string for `key` (empty if missing). Other keys are unchanged. -pub fn substitute_form_vars(val: &mut Value, form_data: &HashMap) { - match val { - Value::Object(map) => { - if map.len() == 1 { - if let Some(Value::String(field_name)) = map.get("$form") { - let submitted = form_data - .get(field_name.as_str()) - .map(|s| s.as_str()) - .unwrap_or(""); - *val = Value::String(submitted.to_string()); - return; - } - } - for v in map.values_mut() { - substitute_form_vars(v, form_data); - } - } - Value::Array(arr) => { - for v in arr.iter_mut() { - substitute_form_vars(v, form_data); - } - } - _ => {} - } -} - -/// Parse JSON, apply [`substitute_form_vars`], return the mutated value. -pub fn fill_template_from_form( - template_json: &str, - form_data: &HashMap, -) -> Result { - let mut v: Value = serde_json::from_str(template_json)?; - substitute_form_vars(&mut v, form_data); - Ok(v) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde::Deserialize; - - #[derive(Debug, Deserialize, PartialEq, Eq)] - struct Demo { - room: String, - thread_tag: String, - nested: Nested, - } - - #[derive(Debug, Deserialize, PartialEq, Eq)] - struct Nested { - text: String, - } - - #[test] - fn holes_become_strings() { - let json = r#"{ - "room": "public", - "thread_tag": {"$form": "tag"}, - "nested": {"text": {"$form": "body"}} - }"#; - let mut form = HashMap::new(); - form.insert("tag".into(), "foo".into()); - form.insert("body".into(), "hello\nworld".into()); - - let v = fill_template_from_form(json, &form).unwrap(); - let d: Demo = serde_json::from_value(v).unwrap(); - assert_eq!( - d, - Demo { - room: "public".into(), - thread_tag: "foo".into(), - nested: Nested { - text: "hello\nworld".into(), - }, - } - ); - } - - #[test] - fn missing_form_key_is_empty_string() { - let json = r#"{"x": {"$form": "nope"}}"#; - let mut form = HashMap::new(); - form.insert("other".into(), "y".into()); - let v = fill_template_from_form(json, &form).unwrap(); - assert_eq!(v["x"], ""); - } - - #[test] - fn array_of_holes() { - let json = r#"{"items": [{"$form": "a"}, {"$form": "b"}]}"#; - let mut form = HashMap::new(); - form.insert("a".into(), "1".into()); - form.insert("b".into(), "2".into()); - let v = fill_template_from_form(json, &form).unwrap(); - assert_eq!(v["items"], serde_json::json!(["1", "2"])); - } - - #[test] - fn template_json_compact_escapes_and_single_line() { - let s = template_json_compact(&serde_json::json!({ - "x": "quote\"and\nnewline" - })) - .unwrap(); - assert!(!s.contains('\n')); - assert!(s.contains("\\\"") || s.contains("\\n")); - } -} -tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒ - diff --git a/legacy/mod.rs b/legacy/mod.rs deleted file mode 100644 index b1dfe895515a1ba94304c52ac4ea82ff6bb84c16..0000000000000000000000000000000000000000 --- a/legacy/mod.rs +++ /dev/null @@ -1,929 +0,0 @@ -use axum::{ - body::Body, - extract::Path, - http::{header, HeaderValue, StatusCode, Uri}, - response::{IntoResponse, Response}, - Form, -}; -use axum_extra::extract::cookie::CookieJar; -use maud::{html, Markup, DOCTYPE}; -use serde::Deserialize; -use std::collections::{HashMap, HashSet}; - -mod auth; -mod breadcrumb_path; -mod editor; -mod forum; -mod garden; -pub mod routing; -mod search; -pub mod ui_action; -use breadcrumb_path::{ExternalOntologyPath, OntologyPath}; - -pub use auth::{ - auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment, - choose_username_page, -}; -pub use editor::{editor_check, editor_page}; -pub use forum::{ - home, room_page, room_thread_post_view, room_thread_view, thread_feed_html, - thread_feed_html_for_room, thread_feed_region_markup, thread_post_view, thread_view, ThreadNav, -}; - -pub use forum::user_profile_page; -pub(crate) use forum::{ - fragment_new_thread_slot, login_to_post_hint_markup, room_members_section_markup, - thread_ui_collapse_redacted_post, thread_ui_copy_thread, thread_ui_expand_post_full, thread_ui_expand_redacted_post, - user_can_post_room, user_can_view_room, -}; -pub(crate) use garden::{ - encode_pin_cookie_value, external_resolver_status_markup, vote_compare_post_success_js, - GARDEN_PIN_COOKIE, -}; -pub use garden::{ - external_garden_index, external_ontology_path, garden_index, ontology_path, - room_external_garden_index, room_external_ontology_path, room_garden_index, room_ontology_path, - room_vote_compare_page, vote_compare_page, -}; -pub use routing::RouteContext; -pub use search::{search_page, search_results_fragment}; -pub use ui_action::{parse_html_ui_from_form, HtmlUiAction, HtmlUiParseError, UI_RPC_FIELD}; - -/// Public profile URL path for a stored username (no `@`). -pub(crate) fn profile_href(username: &str) -> String { - format!("/u/{username}") -} - -/// Cookie name for UI theme (must match document.cookie migration in [`layout`]). -pub const SLUG_THEME_COOKIE: &str = "slug-theme"; - -/// Normalize a requested theme id to a known stylesheet key. -pub fn normalize_theme(raw: &str) -> &'static str { - match raw { - "retro" => "retro", - "retro_craft" => "retro_craft", - _ => "default", - } -} - -/// Resolved theme for rendering and cookie re-issue. -pub fn theme_from_jar(jar: &CookieJar) -> &'static str { - jar.get(SLUG_THEME_COOKIE) - .map(|c| normalize_theme(c.value())) - .unwrap_or("default") -} - -/// `Path` + optional `?query` for round-tripping after `POST /theme`. -pub fn theme_next_from_uri(uri: &Uri) -> String { - uri.path_and_query() - .map(|pq| pq.as_str().to_string()) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "/".to_string()) -} - -/// `Set-Cookie` for a validated theme (ASCII cookie value). -pub fn theme_cookie_header_value(theme: &str) -> HeaderValue { - let t = normalize_theme(theme); - let s = format!("{SLUG_THEME_COOKIE}={t}; Path=/; SameSite=Lax; Max-Age=31536000"); - HeaderValue::from_str(&s).expect("theme cookie must be ASCII") -} - -/// Re-issue theme cookie on responses that also set `slug_session`, so login does not drop theme. -pub fn theme_cookie_header_from_jar(jar: &CookieJar) -> Option { - let c = jar.get(SLUG_THEME_COOKIE)?; - Some(theme_cookie_header_value(c.value())) -} - -fn sanitize_theme_next(next: Option<&str>) -> String { - let s = next.unwrap_or("/").trim(); - if s.starts_with('/') && !s.starts_with("//") && s.len() < 8192 { - s.to_string() - } else { - "/".to_string() - } -} - -#[derive(Debug, Deserialize)] -pub struct ThemeForm { - theme: String, - next: Option, -} - -/// `POST /theme` — set theme cookie and redirect back (full navigation, no fetch). -pub async fn post_theme(Form(form): Form) -> impl IntoResponse { - let theme = normalize_theme(&form.theme); - let next = sanitize_theme_next(form.next.as_deref()); - let loc = - HeaderValue::try_from(next.as_str()).unwrap_or_else(|_| HeaderValue::from_static("/")); - Response::builder() - .status(StatusCode::SEE_OTHER) - .header(header::LOCATION, loc) - .header(header::SET_COOKIE, theme_cookie_header_value(theme)) - .body(Body::empty()) - .expect("theme redirect response") -} - -// Embed CSS and shared UI script at compile time -const THEME_DEFAULT_CSS: &str = include_str!("../../static/theme_default.css"); -const THEME_RETRO_CSS: &str = include_str!("../../static/theme_retro.css"); -const THEME_RETRO_CRAFT_CSS: &str = include_str!("../../static/theme_retro_craft.css"); -const SLUG_UI_JS: &str = include_str!("../../static/slug_ui.js"); - -pub async fn serve_static(Path(filename): Path) -> impl IntoResponse { - if filename == "slug_ui.js" { - return Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "text/javascript; charset=utf-8") - .header(header::CACHE_CONTROL, "public, max-age=3600") - .body(SLUG_UI_JS.to_string()) - .unwrap() - .into_response(); - } - - let theme = filename - .strip_prefix("theme_") - .and_then(|s| s.strip_suffix(".css")); - - let css = match theme { - Some("default") => THEME_DEFAULT_CSS, - Some("retro") => THEME_RETRO_CSS, - Some("retro_craft") => THEME_RETRO_CRAFT_CSS, - _ => return (StatusCode::NOT_FOUND, "static file not found").into_response(), - }; - - Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "text/css; charset=utf-8") - .header(header::CACHE_CONTROL, "public, max-age=3600") - .body(css.to_string()) - .unwrap() - .into_response() -} - -pub(crate) fn js_string_literal(s: &str) -> String { - serde_json::to_string(s).expect("javascript string escaping") -} - -/// `console.warn` as `text/javascript` — for `POST /ui` parse errors and inline morph failures. -pub(crate) fn ui_js_warn(msg: &str) -> Response { - let js = format!("console.warn({});", js_string_literal(msg)); - Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "text/javascript; charset=utf-8") - .body(Body::from(js)) - .unwrap() -} - -pub(crate) struct JsBuilder { - snippets: Vec, -} - -pub(crate) struct JsQueryBuilder { - builder: JsBuilder, - expr: String, -} - -impl JsBuilder { - pub(crate) fn new() -> Self { - Self { - snippets: Vec::new(), - } - } - - pub(crate) fn morph_selector(self, selector: &str, markup: Markup) -> Self { - self.morph_expr( - &format!("document.querySelector({})", js_string_literal(selector)), - markup, - None, - ) - } - - /// Morph **children** of `selector` so the outer element (e.g. `#new-thread-ui-slot`) keeps its `id`. - pub(crate) fn morph_inner_selector(self, selector: &str, markup: Markup) -> Self { - self.qs(selector).morph_inner(markup) - } - - pub(crate) fn morph_expr( - mut self, - expr: &str, - markup: Markup, - morph_style: Option<&str>, - ) -> Self { - let html = js_string_literal(&markup.into_string()); - let opts = morph_style - .map(|style| format!(", {{morphStyle: {}}}", js_string_literal(style))) - .unwrap_or_default(); - self.snippets.push(format!( - "var __slugEl = {expr}; if (__slugEl) {{ Idiomorph.morph(__slugEl, {html}{opts}); }}", - )); - self - } - - pub(crate) fn qs(self, selector: &str) -> JsQueryBuilder { - JsQueryBuilder { - builder: self, - expr: format!("document.querySelector({})", js_string_literal(selector)), - } - } - - pub(crate) fn id(self, id: &str) -> JsQueryBuilder { - self.qs(&format!("#{id}")) - } - - pub(crate) fn if_current_path_matches( - mut self, - path: &str, - f: impl FnOnce(JsBuilder) -> JsBuilder, - ) -> Self { - let inner = f(JsBuilder::new()).build(); - self.snippets.push(format!( - "var __slugHere = window.location.pathname + window.location.search; var __slugPath = {path}; if (__slugHere === __slugPath || __slugHere.indexOf(__slugPath + '?') === 0) {{ {inner} }}", - path = js_string_literal(path), - )); - self - } - - pub(crate) fn if_current_path_not_matches( - mut self, - path: &str, - f: impl FnOnce(JsBuilder) -> JsBuilder, - ) -> Self { - let inner = f(JsBuilder::new()).build(); - self.snippets.push(format!( - "var __slugHere = window.location.pathname + window.location.search; var __slugPath = {path}; if (!(__slugHere === __slugPath || __slugHere.indexOf(__slugPath + '?') === 0)) {{ {inner} }}", - path = js_string_literal(path), - )); - self - } - - pub(crate) fn redirect(mut self, to: &str) -> Self { - self.snippets - .push(format!("window.location = {};", js_string_literal(to))); - self - } - - pub(crate) fn clipboard_write_text_and_label_btn( - mut self, - text: &str, - btn_id: &str, - copied_label: &str, - ) -> Self { - self.snippets.push(format!( - "navigator.clipboard.writeText({text}).then(function(){{ var __slugCopyBtn = document.getElementById({btn_id}); if (__slugCopyBtn) {{ __slugCopyBtn.textContent = {label}; }} }}).catch(function(__slugErr){{ console.warn(__slugErr); }});", - text = js_string_literal(text), - btn_id = js_string_literal(btn_id), - label = js_string_literal(copied_label), - )); - self - } - - /// Focus first matching element (e.g. after morphing open a compose form). - pub(crate) fn focus_selector(mut self, selector: &str) -> Self { - self.snippets.push(format!( - "var __slugF = document.querySelector({}); if (__slugF && __slugF.focus) {{ __slugF.focus(); }}", - js_string_literal(selector), - )); - self - } - - pub(crate) fn build(self) -> String { - self.snippets.join(" ") - } - - pub(crate) fn into_response(self) -> Response { - Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "text/javascript; charset=utf-8") - .body(Body::from(self.build())) - .unwrap() - } -} - -impl JsQueryBuilder { - pub(crate) fn morph(mut self, markup: Markup) -> JsBuilder { - let html = js_string_literal(&markup.into_string()); - self.builder.snippets.push(format!( - "var __slugTarget = {expr}; if (__slugTarget) {{ Idiomorph.morph(__slugTarget, {html}); }}", - expr = self.expr, - )); - self.builder - } - - pub(crate) fn morph_inner(mut self, markup: Markup) -> JsBuilder { - let html = js_string_literal(&markup.into_string()); - self.builder.snippets.push(format!( - "var __slugTarget = {expr}; if (__slugTarget) {{ Idiomorph.morph(__slugTarget, {html}, {{morphStyle: 'innerHTML'}}); }}", - expr = self.expr, - )); - self.builder - } - - pub(crate) fn reset(mut self) -> JsBuilder { - self.builder.snippets.push(format!( - "var __slugTarget = {expr}; if (__slugTarget) {{ __slugTarget.reset(); }}", - expr = self.expr, - )); - self.builder - } -} - -#[allow(clippy::too_many_arguments)] -pub(super) fn layout( - title: &str, - view: &str, - body: Markup, - views: Option, - theme: &str, - theme_next: &str, - garden_room_wire: Option<&str>, - garden_path_prefix: Option<&str>, -) -> Markup { - layout_embed_controls( - title, - view, - body, - views, - theme, - theme_next, - garden_room_wire, - garden_path_prefix, - true, - ) -} - -/// Minimal document shell: no bottom controls, no garden HUD data attributes (`data-garden-room` / -/// `data-garden-prefix` empty). For routes that own the full viewport (e.g. vote compare). -pub(super) fn layout_full_bleed_chromeless( - title: &str, - view: &str, - body: Markup, - views: Option, - theme: &str, - theme_next: &str, -) -> Markup { - layout_embed_controls( - title, view, body, views, theme, theme_next, None, None, false, - ) -} - -#[allow(clippy::too_many_arguments)] -fn layout_embed_controls( - title: &str, - view: &str, - body: Markup, - views: Option, - theme: &str, - theme_next: &str, - garden_room_wire: Option<&str>, - garden_path_prefix: Option<&str>, - show_controls: bool, -) -> Markup { - let theme = normalize_theme(theme); - let css_href = format!("/static/theme_{theme}.css"); - html! { - (DOCTYPE) - html { - head { - meta charset="utf-8"; - meta name="viewport" content="width=device-width, initial-scale=1"; - title { (title) } - link rel="stylesheet" href=(css_href) id="theme-stylesheet"; - script src="https://unpkg.com/idiomorph@0.3.0/dist/idiomorph.min.js" {} - } - body class=(view) - data-garden-room=(garden_room_wire.unwrap_or("")) - data-garden-prefix=(garden_path_prefix.unwrap_or("")) { - @if let Some(n) = views { - span class="view-meta muted" { (n) " views" } - } - div id="errors" {} - (body) - @if show_controls { - div id="controls" { - a href="https://github.com/sortersocial/slug" id="src-link" { "src" } - div id="spread-control" { - span { "spread" } - input type="range" id="spread-slider" min="0" max="1" step="0.05" value="1"; - } - @if let (Some(gr), Some(ref gpx)) = (garden_room_wire, garden_path_prefix) { - @if !gr.is_empty() { - div id="slug-pin-hud" class="slug-pin-hud" data-garden-prefix=(gpx) {} - } - } - a id="search-btn" href="/search" { "search" } - form id="slug-theme-form" method="post" action="/theme" { - input type="hidden" name="next" value=(theme_next); - select id="theme-select" name="theme" onchange="this.form.submit()" aria-label="Theme" { - @for (val, label) in [("default", "default"), ("retro", "retro"), ("retro_craft", "craft")] { - @if theme == val { - option value=(val) selected { (label) } - } @else { - option value=(val) { (label) } - } - } - } - } - } - } - script src="/static/slug_ui.js" {} - } - } - } -} - -pub(super) fn now_ms() -> i64 { - let t = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default(); - t.as_millis() as i64 -} - -pub(super) fn ratio_pct(left: i32, right: i32) -> f64 { - let l = (left.max(0)) as f64; - let r = (right.max(0)) as f64; - let denom = l + r; - if denom <= 0.0 { - return 50.0; - } - (l / denom) * 100.0 -} - -/// Render a single breadcrumb segment with `/` separator. -pub(super) fn bc_segment(label: &str, href: &str, is_current: bool) -> Markup { - html! { - span class="bc-sep" { " / " } - @if is_current { - a href=(href) class="bc-current" { (label) } - } @else { - a href=(href) { (label) } - } - } -} - -/// Breadcrumb for external ontology `/-/https://…` -fn bc_path_external(path: &ExternalOntologyPath) -> Markup { - html! { - a href="/" { "slug.social" } - (bc_segment("-", "/-", path.is_root())) - @for (i, id) in path.breadcrumb_chain().iter().enumerate() { - @let disp = id.display_path(); - @let tail = disp.strip_prefix("-/").unwrap_or(disp.as_str()); - @let href = format!("/-/{}", tail); - @let is_last = i + 1 == path.breadcrumb_chain().len(); - (bc_segment(id.last_segment(), &href, is_last)) - } - } -} - -/// Breadcrumb for any ontology path segment, e.g. "parables" or "parables/counting-the-cost". -fn bc_path(path: &OntologyPath) -> Markup { - html! { - a href=(path.slug_root_href()) { "slug.social" } - (bc_segment("~", "/~", path.is_root())) - @for (i, seg) in path.segments().iter().enumerate() { - @let href = format!("/~/{}", path.segments()[..=i].join("/")); - @let is_last = i == path.segments().len() - 1; - (bc_segment(seg, &href, is_last)) - } - } -} - -/// Render the thread path breadcrumb: `slug.social / #tag` or `… / #tag / post #N` on a single post. -/// Root link toggles to `/~` only at thread-root (`/`). -pub(super) fn bc_threads(thread_tag: Option<&str>, focused_post: Option) -> Markup { - let root_href = if thread_tag.is_some() { "/" } else { "/~" }; - let root_is_current = thread_tag.is_none(); - html! { - @if root_is_current { - a href=(root_href) class="bc-current" { "slug.social" } - } @else { - a href=(root_href) { "slug.social" } - } - @if let Some(tag) = thread_tag { - @if let Some(idx) = focused_post { - (bc_segment(&format!("#{tag}"), &format!("/t/{tag}"), false)) - (bc_segment(&format!("post #{idx}"), &format!("/t/{tag}/{idx}"), true)) - } @else { - (bc_segment(&format!("#{tag}"), &format!("/t/{tag}"), true)) - } - } - } -} - -/// Short display label for a stored agent id (`uuid:rig:model`, no `@`). -pub(super) fn actor_label(agent_naked: &str) -> String { - let a = agent_naked.trim(); - let parts: Vec<&str> = a.split(':').collect(); - if parts.len() >= 3 { - let rig = parts[1].trim(); - let model = parts[2].trim(); - // X actors: show @handle instead of uuid hash. - if rig == "x.com" { - return format!("@{model}"); - } - let uuid = parts[0].trim(); - let uuid8 = uuid.chars().take(8).collect::(); - if !uuid8.is_empty() && !rig.is_empty() && !model.is_empty() { - return format!("{uuid8}:{rig}:{model}"); - } - } - a.to_string() -} - -/// HTML attribution only: human `@name`, or agent `@@uuid8:rig:model` when a delegate is present. -pub(super) fn authorship_address(principal: &str, delegate: &Option) -> String { - match delegate { - Some(d) => format!("@@{}", actor_label(d)), - None => format!("@{principal}"), - } -} - -/// Escape HTML special chars for safe injection. -fn escape_html(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for c in s.chars() { - match c { - '&' => out.push_str("&"), - '<' => out.push_str("<"), - '>' => out.push_str(">"), - '"' => out.push_str("""), - _ => out.push(c), - } - } - out -} - -/// Max characters from an item body placed in a `title` tooltip (native hover). -const ITEM_LINK_TITLE_MAX_CHARS: usize = 500; - -fn collapse_whitespace_for_title(s: &str) -> String { - let mut out = String::with_capacity(s.len().min(ITEM_LINK_TITLE_MAX_CHARS + 8)); - let mut last_was_space = true; - for c in s.chars() { - if c.is_whitespace() { - if !last_was_space { - out.push(' '); - last_was_space = true; - } - } else { - out.push(c); - last_was_space = false; - } - } - out.trim().to_string() -} - -fn item_body_title_snippet(body: &str) -> Option { - let s = collapse_whitespace_for_title(body); - if s.is_empty() { - return None; - } - let truncated: String = s.chars().take(ITEM_LINK_TITLE_MAX_CHARS).collect(); - let ellipsis = if s.chars().count() > ITEM_LINK_TITLE_MAX_CHARS { - "…" - } else { - "" - }; - Some(format!("{truncated}{ellipsis}")) -} - -fn garden_href_for_item_ref( - raw_ref: &str, - garden_prefix: &str, -) -> Option<(crate::path_types::ItemId, String)> { - let key = slug_types::canonicalize_item(raw_ref); - let id = crate::path_types::ItemId::parse(&key)?; - let href = if let Some(tail) = id.tilde_tail() { - if tail.is_empty() { - garden_prefix.trim_end_matches('/').to_string() - } else { - format!("{}/{}", garden_prefix.trim_end_matches('/'), tail) - } - } else if id.as_str().starts_with("https://") || id.as_str().starts_with("http://") { - let display = id.display_path(); - let rest = display.strip_prefix("-/").unwrap_or(display.as_str()); - let ext_prefix = format!("{}-", garden_prefix.trim_end_matches('~')); - format!("{ext_prefix}/{rest}") - } else { - return None; - }; - Some((id, href)) -} - -fn push_item_ref_anchor( - out: &mut String, - raw_ref: &str, - garden_prefix: &str, - item_bodies: Option<&HashMap>, -) -> bool { - let Some((id, href)) = garden_href_for_item_ref(raw_ref, garden_prefix) else { - return false; - }; - out.push_str(r#"'); - out.push_str(&escape_html(raw_ref)); - out.push_str(""); - true -} - -/// Replace item refs in raw prose with clickable garden links. -/// -/// When `item_bodies` is set, matching ontology items get a `title` attribute with a truncated -/// body preview for native browser tooltips (forum posts, item pages). -pub(super) fn linkify_slugs_with_prefix( - raw: &str, - garden_prefix: &str, - item_bodies: Option<&HashMap>, -) -> String { - let mut out = String::with_capacity(raw.len() + 64); - for token in crate::dsl::tokenize_prose_item_refs(raw) { - match token { - crate::dsl::ProseToken::Text(text) => out.push_str(&escape_html(&text)), - crate::dsl::ProseToken::ItemRef(raw_ref) => { - if !push_item_ref_anchor(&mut out, &raw_ref, garden_prefix, item_bodies) { - out.push_str(&escape_html(&raw_ref)); - } - } - } - } - out -} - -#[derive(Clone)] -struct EmbedFrame { - src: String, - title: String, - provider_class: &'static str, -} - -fn clean_media_id(s: &str) -> String { - s.chars() - .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_') - .collect() -} - -fn query_param(url: &str, name: &str) -> Option { - let q = url.split_once('?')?.1; - for pair in q.split('&') { - let (k, v) = pair.split_once('=').unwrap_or((pair, "")); - if k == name { - return Some(v.to_string()); - } - } - None -} - -fn spotify_embed_src(url: &str) -> Option { - let rest = url - .strip_prefix("https://") - .or_else(|| url.strip_prefix("http://"))?; - let (host, tail) = rest.split_once('/').unwrap_or((rest, "")); - let host = host.to_lowercase(); - if !(host == "open.spotify.com" || host == "www.open.spotify.com") { - return None; - } - let path = tail - .split('#') - .next() - .unwrap_or(tail) - .split('?') - .next() - .unwrap_or(tail); - let mut segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); - if segs.first().is_some_and(|s| s.starts_with("intl-")) { - segs.remove(0); - } - if segs.len() < 2 { - return None; - } - let kind = segs[0]; - let id = clean_media_id(segs[1]); - if id.is_empty() { - return None; - } - let allowed = matches!(kind, "track" | "album" | "playlist" | "episode" | "show"); - if !allowed { - return None; - } - Some(EmbedFrame { - src: format!("https://open.spotify.com/embed/{kind}/{id}"), - title: format!("Spotify {kind}"), - provider_class: "embed-spotify", - }) -} - -fn youtube_embed_src(url: &str) -> Option { - let rest = url - .strip_prefix("https://") - .or_else(|| url.strip_prefix("http://"))?; - let (host, tail) = rest.split_once('/').unwrap_or((rest, "")); - let host = host.to_lowercase(); - - let video_id = if host == "youtu.be" || host == "www.youtu.be" { - clean_media_id( - tail.split(['?', '#']) - .next() - .unwrap_or(tail) - .trim_matches('/'), - ) - } else if matches!( - host.as_str(), - "youtube.com" | "www.youtube.com" | "m.youtube.com" | "music.youtube.com" - ) { - let path = format!("/{}", tail.split('#').next().unwrap_or(tail)); - if path.starts_with("/watch") { - clean_media_id(&query_param(url, "v")?) - } else if let Some(id) = path.strip_prefix("/shorts/") { - clean_media_id(id.split(['?', '/']).next().unwrap_or(id)) - } else if let Some(id) = path.strip_prefix("/embed/") { - clean_media_id(id.split(['?', '/']).next().unwrap_or(id)) - } else { - String::new() - } - } else { - String::new() - }; - - if video_id.is_empty() { - return None; - } - Some(EmbedFrame { - src: format!("https://www.youtube.com/embed/{video_id}"), - title: "YouTube video".to_string(), - provider_class: "embed-youtube", - }) -} - -fn extract_embed_frames(raw: &str) -> Vec { - let mut out = Vec::new(); - let mut seen = HashSet::new(); - for token in raw.split_whitespace() { - let token = token - .trim_matches(|c: char| c == '(' || c == '[' || c == '{' || c == '"' || c == '\'') - .trim_end_matches(|c: char| ".,;:!?)]}\"'".contains(c)); - if !(token.starts_with("https://") || token.starts_with("http://")) { - continue; - } - let embed = spotify_embed_src(token).or_else(|| youtube_embed_src(token)); - if let Some(frame) = embed { - if seen.insert(frame.src.clone()) { - out.push(frame); - } - } - } - out -} - -pub(super) fn render_linkified_with_embeds_in_scope( - raw: &str, - garden_prefix: &str, - item_bodies: Option<&HashMap>, -) -> Markup { - let embeds = extract_embed_frames(raw); - html! { - pre { (maud::PreEscaped(linkify_slugs_with_prefix(raw, garden_prefix, item_bodies))) } - @if !embeds.is_empty() { - div class="rich-embeds" { - @for e in embeds { - div class=(format!("rich-embed {}", e.provider_class)) { - iframe - src=(e.src) - title=(e.title) - loading="lazy" - allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture; web-share" - referrerpolicy="strict-origin-when-cross-origin" - allowfullscreen {} - } - } - } - } - } -} - -/// Item page / thread body: resolver-specific rich HTML, else linkified `
` + media embeds.
-pub(super) fn render_item_body_in_scope(
-    raw: &str,
-    garden_prefix: &str,
-    item_bodies: Option<&HashMap>,
-) -> Markup {
-    if let Some(m) = crate::resolvers::try_render_resolver_item_body(raw) {
-        return html! {
-            div class="item-body-rich" { (m) }
-        };
-    }
-    render_linkified_with_embeds_in_scope(raw, garden_prefix, item_bodies)
-}
-
-/// CLI strings are embedded in a single-quoted JS literal; they must never need escaping.
-fn assert_cli_panel_cmd_js_single_quote_safe(s: &str) {
-    assert!(
-        !s.contains('\\') && !s.contains('\'') && !s.contains('\n') && !s.contains('\r'),
-        "cli_panel cmd must not contain `\\`, `'`, or newlines (got {s:?})"
-    );
-}
-
-/// Small CLI hint panel: one border and title; each line is hover-highlighted and copies on click.
-pub(super) fn cli_panel>(cmds: &[I]) -> Markup {
-    if cmds.is_empty() {
-        return html! {};
-    }
-    for cmd in cmds {
-        assert_cli_panel_cmd_js_single_quote_safe(cmd.as_ref());
-    }
-    html! {
-        div class="cli-panel" {
-            span class="cli-panel-label muted" { "cli" }
-            div class="cli-panel-cmds" {
-                @for cmd in cmds {
-                    @let s = cmd.as_ref();
-                    button type="button" class="cli-panel-row" title="Copy command" onclick=(format!(
-                        r#"navigator.clipboard.writeText('{}');"#,
-                        s
-                    )) {
-                        code class="cli-panel-cmd" { (s) }
-                    }
-                }
-            }
-        }
-    }
-}
-
-/// Age bucket for recency coloring of thread entries.
-pub(super) fn recency_class(now_ms: i64, ts_ms: i64) -> &'static str {
-    let age_ms = now_ms.saturating_sub(ts_ms);
-    let age_secs = age_ms / 1000;
-    if age_secs < 3600 {
-        "age-fresh" // < 1 hour
-    } else if age_secs < 86400 {
-        "age-recent" // < 1 day
-    } else if age_secs < 86400 * 7 {
-        "age-week" // < 1 week
-    } else {
-        "age-old" // >= 1 week
-    }
-}
-
-#[cfg(test)]
-mod linkify_title_tests {
-    use super::*;
-    use crate::path_types::ItemId;
-    use std::collections::HashMap;
-
-    #[test]
-    fn tilde_link_gets_title_from_item_bodies() {
-        let mut bodies = HashMap::new();
-        let key = ItemId::parse(&slug_types::canonicalize_item("~/foo/bar")).unwrap();
-        bodies.insert(key, "Hello  world\nline".to_string());
-        let html = linkify_slugs_with_prefix("see ~/foo/bar ok", "/r/x/~", Some(&bodies));
-        assert!(html.contains("title=\"Hello world line\""));
-        assert!(html.contains("href=\"/r/x/~/foo/bar\""));
-    }
-
-    #[test]
-    fn raw_url_links_to_public_external_garden_page() {
-        let html = linkify_slugs_with_prefix("see https://example.com/z.", "/~", None);
-        assert!(html.contains(
-            r#"https://example.com/z."#
-        ));
-    }
-
-    #[test]
-    fn dash_ref_links_to_room_external_garden_page_with_title() {
-        let mut bodies = HashMap::new();
-        let key = ItemId::parse(&slug_types::canonicalize_item("-/example.com/z")).unwrap();
-        bodies.insert(key, "External body\npreview".to_string());
-        let html =
-            linkify_slugs_with_prefix("see -/example.com/z", "/r/9ab12cdroom/~", Some(&bodies));
-        assert!(html.contains(r#"href="/r/9ab12cdroom/-/https://example.com/z""#));
-        assert!(html.contains(r#"title="External body preview""#));
-    }
-
-    #[test]
-    fn code_fence_urls_are_not_linkified() {
-        let html = linkify_slugs_with_prefix(
-            "```json\n{\"url\":\"https://example.com/z\"}\n```\nthen https://example.com/a",
-            "/~",
-            None,
-        );
-        assert!(!html.contains(r#"href="/-/https://example.com/z""#));
-        assert!(html.contains(r#"href="/-/https://example.com/a""#));
-    }
-
-    #[test]
-    fn no_title_when_body_missing_or_empty() {
-        let html = linkify_slugs_with_prefix("x ~/a/b y", "/~", Some(&HashMap::new()));
-        assert!(!html.contains(" title="));
-    }
-}
diff --git a/legacy/ranking.rs b/legacy/ranking.rs
deleted file mode 100644
index 34241cf6bf13117b3ad3262761eb7f7b48b54d80..0000000000000000000000000000000000000000
--- a/legacy/ranking.rs
+++ /dev/null
@@ -1,393 +0,0 @@
-tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒  cat server/src/ranking.rs 
-use std::collections::{HashMap, HashSet};
-
-use crate::path_types::ItemId;
-use crate::reducer::GroupState;
-
-#[derive(Debug, Clone)]
-pub struct RankedItem {
-    pub item: ItemId,
-    pub score: f64,
-}
-
-/// Compute connected components over the voted-pairs graph (treated as undirected).
-///
-/// Returns:
-/// - `components`: each component is a sorted list of node indices, excluding isolates.
-/// - `isolates`: sorted list of node indices with degree 0 (no voted pairs).
-pub fn connected_components_from_voted_pairs(
-    n: usize,
-    voted_pairs: impl Iterator,
-) -> (Vec>, Vec) {
-    let mut adj: Vec> = vec![Vec::new(); n];
-    for (a, b) in voted_pairs {
-        if a >= n || b >= n || a == b {
-            continue;
-        }
-        adj[a].push(b);
-        adj[b].push(a);
-    }
-
-    let mut isolates: Vec = (0..n).filter(|&i| adj[i].is_empty()).collect();
-    isolates.sort();
-
-    let mut seen = vec![false; n];
-    for &i in &isolates {
-        seen[i] = true;
-    }
-
-    let mut comps: Vec> = Vec::new();
-    for i in 0..n {
-        if seen[i] {
-            continue;
-        }
-        let mut stack = vec![i];
-        seen[i] = true;
-        let mut comp: Vec = Vec::new();
-        while let Some(x) = stack.pop() {
-            comp.push(x);
-            for &y in &adj[x] {
-                if !seen[y] {
-                    seen[y] = true;
-                    stack.push(y);
-                }
-            }
-        }
-        comp.sort();
-        comps.push(comp);
-    }
-
-    (comps, isolates)
-}
-
-/// Compute rank centrality scores for a GroupState.
-/// This matches the approach in the earlier standalone prototype but avoids dependencies by doing
-/// an O(E) multiply per iteration.
-pub fn compute_group_ranking(group: &mut GroupState, max_iters: usize, tol: f64) {
-    if !group.dirty && !group.cached_scores.is_empty() {
-        return;
-    }
-
-    let n = group.idx_to_item.len();
-    if n == 0 {
-        group.cached_scores = vec![];
-        group.dirty = false;
-        return;
-    }
-    if n == 1 {
-        group.cached_scores = vec![1.0];
-        group.dirty = false;
-        return;
-    }
-
-    let scores = compute_scores_from_edges(
-        n,
-        group.edges.iter().map(|(&k, &w)| (k, w)),
-        max_iters,
-        tol,
-    );
-    group.cached_scores = scores;
-    group.dirty = false;
-}
-
-pub fn ranked_items(group: &mut GroupState, max_iters: usize, tol: f64) -> Vec {
-    compute_group_ranking(group, max_iters, tol);
-    let mut items: Vec = group
-        .idx_to_item
-        .iter()
-        .enumerate()
-        .map(|(i, item)| RankedItem {
-            item: item.clone(),
-            score: *group.cached_scores.get(i).unwrap_or(&0.0),
-        })
-        .collect();
-
-    items.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
-    items
-}
-
-fn compute_scores_from_edges(n: usize, edges: impl Iterator, max_iters: usize, tol: f64) -> Vec {
-    if n == 0 {
-        return vec![];
-    }
-    if n == 1 {
-        return vec![1.0];
-    }
-
-    // Collect raw edges into a map for pairwise normalization.
-    let mut raw: HashMap<(usize, usize), f64> = HashMap::new();
-    for ((src, dst), w) in edges {
-        if src >= n || dst >= n || w <= 0.0 {
-            continue;
-        }
-        *raw.entry((src, dst)).or_insert(0.0) += w;
-    }
-
-    // Pairwise normalization: a_ij = A_ij / (A_ij + A_ji).
-    // This ensures repeated votes on the same pair don't inflate influence
-    // beyond what the ratio implies.
-    let keys: Vec<(usize, usize)> = raw.keys().copied().collect();
-    let mut normalized: HashMap<(usize, usize), f64> = HashMap::new();
-    for (i, j) in keys {
-        if normalized.contains_key(&(i, j)) {
-            continue;
-        }
-        let w_ij = *raw.get(&(i, j)).unwrap_or(&0.0);
-        let w_ji = *raw.get(&(j, i)).unwrap_or(&0.0);
-        let total = w_ij + w_ji;
-        if total <= 0.0 {
-            continue;
-        }
-        normalized.insert((i, j), w_ij / total);
-        if w_ji > 0.0 {
-            normalized.insert((j, i), w_ji / total);
-        }
-    }
-
-    // Rank Centrality (Negahban, Oh, Shah 2012, §3.1):
-    //   P_ij = (1/d_max) * A_ij           for i ≠ j compared
-    //   P_ii = 1 - (1/d_max) * Σ_k A_ik
-    // where d_i is the *degree* (number of distinct neighbors compared) and
-    // d_max = max_i d_i. Using the unweighted degree — not the sum of
-    // pairwise-normalized weights — is what guarantees aperiodicity: it
-    // forces P_ii > 0 for every non-maximum-degree node, and for max-degree
-    // nodes whenever any neighbor weight is below 1 (i.e. not a unanimous
-    // loss). Without this, regular comparison graphs (e.g. a pure star at
-    // ratio 2:1) produce a bipartite chain that oscillates instead of
-    // converging — see issue #146.
-    let mut out_edges: Vec> = vec![Vec::new(); n];
-    let mut neighbors: Vec> = vec![HashSet::new(); n];
-
-    for ((src, dst), w) in &normalized {
-        out_edges[*src].push((*dst, *w));
-        neighbors[*src].insert(*dst);
-        neighbors[*dst].insert(*src);
-    }
-
-    let weight_sum: Vec = out_edges
-        .iter()
-        .map(|es| es.iter().map(|(_, w)| *w).sum())
-        .collect();
-    let d_max = neighbors.iter().map(|s| s.len()).max().unwrap_or(0);
-    if d_max == 0 {
-        return vec![1.0 / n as f64; n];
-    }
-    let d_max_f = d_max as f64;
-
-    let mut scores = vec![1.0 / n as f64; n];
-    let mut next = vec![0.0f64; n];
-
-    for _ in 0..max_iters {
-        next.fill(0.0);
-        for i in 0..n {
-            let stay_prob = (d_max_f - weight_sum[i]) / d_max_f;
-            next[i] += scores[i] * stay_prob;
-
-            if out_edges[i].is_empty() {
-                continue;
-            }
-            for &(dst, w) in &out_edges[i] {
-                next[dst] += scores[i] * (w / d_max_f);
-            }
-        }
-
-        let diff: f64 = scores
-            .iter()
-            .zip(next.iter())
-            .map(|(a, b)| (a - b).abs())
-            .sum();
-
-        scores.clone_from_slice(&next);
-        if diff < tol {
-            break;
-        }
-    }
-
-    let sum: f64 = scores.iter().sum();
-    if sum.is_finite() && sum > 0.0 {
-        for s in &mut scores {
-            *s /= sum;
-        }
-    }
-    scores
-}
-
-/// Rank-centrality within a subset of items (an induced subgraph), using the group's aggregated edges.
-///
-/// `idxs` are indices into `group.idx_to_item`. The returned items use the original item names.
-pub fn ranked_items_subset(group: &GroupState, idxs: &[usize], max_iters: usize, tol: f64) -> Vec {
-    if idxs.is_empty() {
-        return vec![];
-    }
-
-    // Map original idx -> compact idx [0..m)
-    let mut map: HashMap = HashMap::with_capacity(idxs.len());
-    for (j, &i) in idxs.iter().enumerate() {
-        map.insert(i, j);
-    }
-
-    let edges_iter = group.edges.iter().filter_map(|(&(src, dst), &w)| {
-        let s = *map.get(&src)?;
-        let d = *map.get(&dst)?;
-        Some(((s, d), w))
-    });
-
-    let scores = compute_scores_from_edges(idxs.len(), edges_iter, max_iters, tol);
-
-    // Filter out entries where idx_to_item doesn't have the slot (shouldn't happen, but be safe).
-    let mut items: Vec = idxs
-        .iter()
-        .enumerate()
-        .filter_map(|(j, &orig)| {
-            let item = group.idx_to_item.get(orig)?.clone();
-            Some(RankedItem { item, score: *scores.get(j).unwrap_or(&0.0) })
-        })
-        .collect();
-
-    items.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
-    items
-}
-
-pub fn group_summary_scores(
-    group: &mut GroupState,
-    max_iters: usize,
-    tol: f64,
-) -> HashMap {
-    ranked_items(group, max_iters, tol)
-        .into_iter()
-        .map(|r| (r.item, r.score))
-        .collect()
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use crate::reducer::VoteData;
-
-    fn mk_group() -> GroupState {
-        GroupState::new()
-    }
-
-    fn vote(ts: i64, a: &str, b: &str, l: i32, r: i32) -> VoteData {
-        use crate::path_types::ItemId;
-        VoteData {
-            ts,
-            a: ItemId::parse(a).unwrap(),
-            b: ItemId::parse(b).unwrap(),
-            ratio_left: l,
-            ratio_right: r,
-            body: "because".to_string(),
-            principal: "test".to_string(),
-            delegate: Some("00000000-0000-0000-0000-000000000000:test:local/test".to_string()),
-            thread_tag: "untagged".to_string(),
-        }
-    }
-
-    /// Regression for issue #146: pure forward star at default `>` ratio (2:1).
-    /// Under the old (sum-of-weights) divisor every node had P_ii = 0 and the
-    /// chain was bipartite; power iteration oscillated and returned the
-    /// uniform initial distribution after an even number of steps. Using the
-    /// paper's degree-based d_max gives every node a positive self-loop and
-    /// the chain converges to the correct stationary distribution.
-    #[test]
-    fn star_topology_winner_at_top_via_subset() {
-        let mut g = mk_group();
-        g.apply_vote(vote(1, "zebra", "alpha", 2, 1));
-        g.apply_vote(vote(2, "zebra", "beta", 2, 1));
-
-        let mut items: Vec<(usize, String)> = g
-            .idx_to_item
-            .iter()
-            .enumerate()
-            .map(|(i, it)| (i, it.as_str().to_string()))
-            .collect();
-        items.sort_by(|a, b| a.1.cmp(&b.1));
-        let idxs: Vec = items.iter().map(|(i, _)| *i).collect();
-
-        let ranked = ranked_items_subset(&g, &idxs, 10000, 1e-8);
-        for r in &ranked {
-            eprintln!("{}: {}", r.item.as_str(), r.score);
-        }
-        assert_eq!(
-            ranked[0].item.as_str(),
-            "https://slug.social/zebra",
-            "zebra won both votes and should rank #1"
-        );
-    }
-
-    #[test]
-    fn group_ranking_cache_dirty_flow() {
-        let mut g = mk_group();
-        assert!(g.dirty);
-
-        g.apply_vote(vote(1, "a", "b", 3, 1));
-        assert!(g.dirty);
-        assert!(g.cached_scores.is_empty());
-
-        compute_group_ranking(&mut g, 10000, 1e-8);
-        assert!(!g.dirty);
-        assert_eq!(g.cached_scores.len(), g.idx_to_item.len());
-
-        // Recomputing when not dirty should be a no-op.
-        let before = g.cached_scores.clone();
-        compute_group_ranking(&mut g, 10000, 1e-8);
-        assert_eq!(before, g.cached_scores);
-    }
-
-    #[test]
-    fn connected_components_split_disconnected_pairs() {
-        let mut g = mk_group();
-        // Two disconnected edges: (a,b) and (c,d)
-        g.apply_vote(vote(1, "a", "b", 3, 1));
-        g.apply_vote(vote(2, "c", "d", 3, 1));
-
-        let n = g.idx_to_item.len();
-        let (mut comps, isolates) =
-            connected_components_from_voted_pairs(n, g.voted_pairs.iter().copied());
-        assert!(isolates.is_empty());
-        // Order-independent: sort components by their item names for stable assert.
-        comps.sort_by_key(|c| {
-            c.iter()
-                .map(|&i| g.idx_to_item[i].clone())
-                .collect::>()
-        });
-        assert_eq!(comps.len(), 2);
-        let comp0 = comps[0]
-            .iter()
-            .map(|&i| g.idx_to_item[i].as_str())
-            .collect::>();
-        let comp1 = comps[1]
-            .iter()
-            .map(|&i| g.idx_to_item[i].as_str())
-            .collect::>();
-        assert_eq!(comp0, vec!["https://slug.social/a", "https://slug.social/b"]);
-        assert_eq!(comp1, vec!["https://slug.social/c", "https://slug.social/d"]);
-    }
-
-    #[test]
-    fn subset_ranking_ranks_within_component_only() {
-        let mut g = mk_group();
-        g.apply_vote(vote(1, "a", "b", 3, 1)); // a > b
-        g.apply_vote(vote(2, "c", "d", 1, 4)); // d > c
-
-        let (comps, _) =
-            connected_components_from_voted_pairs(g.idx_to_item.len(), g.voted_pairs.iter().copied());
-        assert_eq!(comps.len(), 2);
-
-        // Rank each component and ensure winner is first within that component.
-        for comp in comps {
-            let ranked = ranked_items_subset(&g, &comp, 10000, 1e-8);
-            assert_eq!(ranked.len(), 2);
-            let names = ranked.iter().map(|r| r.item.as_str()).collect::>();
-            if names.contains(&"https://slug.social/a") {
-                assert_eq!(names[0], "https://slug.social/a");
-            } else {
-                assert_eq!(names[0], "https://slug.social/d");
-            }
-        }
-    }
-}
-
-
-tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒  
-
diff --git a/legacy/reddit.rs b/legacy/reddit.rs
deleted file mode 100644
index da27f6f76482dd27d44cad9d7b844972bf042e64..0000000000000000000000000000000000000000
--- a/legacy/reddit.rs
+++ /dev/null
@@ -1,420 +0,0 @@
-use governor::{Quota, RateLimiter, Jitter};
-use nonzero_ext::nonzero;
-use reqwest::{Client, StatusCode};
-use serde::{Deserialize, Serialize};
-use std::sync::Arc;
-use std::time::Duration;
-use anyhow::{Result, Context, anyhow};
-
-/// Reddit API client with built-in rate limiting
-pub struct RedditClient {
-    client: Client,
-    limiter: Arc,
-    user_agent: String,
-}
-
-impl RedditClient {
-    /// Create a new Reddit client with rate limiting
-    /// Reddit API allows 60 requests per minute for OAuth2 authenticated apps
-    /// We'll be conservative and use 50 requests per minute
-    pub fn new() -> Self {
-        // Create rate limiter: 50 requests per minute
-        let quota = Quota::per_minute(nonzero!(50u32));
-        let limiter = Arc::new(RateLimiter::direct(quota));
-        
-        // Create HTTP client with timeout
-        let client = Client::builder()
-            .timeout(Duration::from_secs(30))
-            .build()
-            .expect("Failed to create HTTP client");
-        
-        // Reddit requires a unique user agent
-        let user_agent = format!(
-            "rust:sorter:v{} (by /u/hourLong_arnould)",
-            env!("CARGO_PKG_VERSION")
-        );
-        
-        Self {
-            client,
-            limiter,
-            user_agent,
-        }
-    }
-    
-    /// Fetch hot posts from a subreddit
-    pub async fn get_subreddit_hot(&self, subreddit: &str, limit: usize) -> Result {
-        // Wait for rate limiter
-        self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;
-        
-        let url = format!("https://www.reddit.com/r/{}/hot.json?limit={}", subreddit, limit);
-        
-        let response = self.client
-            .get(&url)
-            .header("User-Agent", &self.user_agent)
-            .send()
-            .await
-            .context("Failed to send request to Reddit")?;
-        
-        self.handle_response(response).await
-    }
-    
-    /// Fetch top posts from a subreddit
-    pub async fn get_subreddit_top(&self, subreddit: &str, limit: usize, time_period: &str) -> Result {
-        self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;
-        
-        let url = format!(
-            "https://www.reddit.com/r/{}/top.json?limit={}&t={}",
-            subreddit, limit, time_period
-        );
-        
-        let response = self.client
-            .get(&url)
-            .header("User-Agent", &self.user_agent)
-            .send()
-            .await
-            .context("Failed to send request to Reddit")?;
-        
-        self.handle_response(response).await
-    }
-    
-    /// Fetch new posts from a subreddit
-    pub async fn get_subreddit_new(&self, subreddit: &str, limit: usize) -> Result {
-        self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;
-        
-        let url = format!("https://www.reddit.com/r/{}/new.json?limit={}", subreddit, limit);
-        
-        let response = self.client
-            .get(&url)
-            .header("User-Agent", &self.user_agent)
-            .send()
-            .await
-            .context("Failed to send request to Reddit")?;
-        
-        self.handle_response(response).await
-    }
-    
-    /// Fetch a specific post and its comments
-    pub async fn get_post(&self, subreddit: &str, post_id: &str) -> Result> {
-        self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;
-        
-        let url = format!(
-            "https://www.reddit.com/r/{}/comments/{}.json",
-            subreddit, post_id
-        );
-        
-        let response = self.client
-            .get(&url)
-            .header("User-Agent", &self.user_agent)
-            .send()
-            .await
-            .context("Failed to send request to Reddit")?;
-        
-        match response.status() {
-            StatusCode::OK => {
-                let listings: Vec = response
-                    .json()
-                    .await
-                    .context("Failed to parse Reddit response")?;
-                Ok(listings)
-            }
-            StatusCode::TOO_MANY_REQUESTS => {
-                Err(anyhow!("Reddit rate limit exceeded. Please try again later."))
-            }
-            StatusCode::NOT_FOUND => {
-                Err(anyhow!("Post not found: r/{}/comments/{}", subreddit, post_id))
-            }
-            status => {
-                Err(anyhow!("Reddit API error: {}", status))
-            }
-        }
-    }
-    
-    /// Fetch user profile (posts and comments)
-    pub async fn get_user_profile(&self, username: &str, content_type: &str, limit: usize) -> Result {
-        self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;
-        
-        let url = format!(
-            "https://www.reddit.com/user/{}/{}.json?limit={}",
-            username, content_type, limit
-        );
-        
-        let response = self.client
-            .get(&url)
-            .header("User-Agent", &self.user_agent)
-            .send()
-            .await
-            .context("Failed to send request to Reddit")?;
-        
-        self.handle_response(response).await
-    }
-    
-    /// Handle Reddit API response with proper error checking
-    async fn handle_response(&self, response: reqwest::Response) -> Result {
-        match response.status() {
-            StatusCode::OK => {
-                let listing: RedditListingResponse = response
-                    .json()
-                    .await
-                    .context("Failed to parse Reddit response")?;
-                Ok(listing)
-            }
-            StatusCode::TOO_MANY_REQUESTS => {
-                Err(anyhow!("Reddit rate limit exceeded. Please try again later."))
-            }
-            StatusCode::NOT_FOUND => {
-                Err(anyhow!("Reddit resource not found"))
-            }
-            StatusCode::FORBIDDEN => {
-                Err(anyhow!("Access forbidden. The subreddit may be private."))
-            }
-            status => {
-                Err(anyhow!("Reddit API error: {}", status))
-            }
-        }
-    }
-}
-
-// Reddit API response types
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct RedditListingResponse {
-    pub kind: String,
-    pub data: RedditListingData,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct RedditListingData {
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub after: Option,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub before: Option,
-    pub children: Vec,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub modhash: Option,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct RedditThing {
-    pub kind: String,
-    pub data: RedditThingData,
-}
-
-/// Reddit's "Thing" data can be either a post, comment, or other types
-/// We use untagged enum to handle different data structures
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(untagged)]
-pub enum RedditThingData {
-    Post(RedditPost),
-    Comment(RedditComment),
-    // For things we don't care about yet (like "more" comments)
-    Other(serde_json::Value),
-}
-
-/// Reddit post data with serde handling all the parsing
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct RedditPost {
-    pub id: String,
-    pub name: String, // Full name (t3_xxx)
-    #[serde(default = "default_untitled")]
-    pub title: String,
-    #[serde(default = "default_deleted_user")]
-    pub author: String,
-    pub subreddit: String,
-    #[serde(default)]
-    pub url: String,
-    #[serde(default)]
-    pub permalink: String,
-    #[serde(default, deserialize_with = "deserialize_selftext")]
-    pub selftext: Option,
-    #[serde(default)]
-    pub score: i64,
-    #[serde(default)]
-    pub num_comments: i64,
-    #[serde(default)]
-    pub created_utc: f64,
-    #[serde(default, deserialize_with = "deserialize_thumbnail")]
-    pub thumbnail: Option,
-    #[serde(default)]
-    pub is_video: bool,
-    #[serde(default)]
-    pub is_self: bool,
-    // Additional useful fields
-    #[serde(default)]
-    pub ups: i64,
-    #[serde(default)]
-    pub downs: i64,
-    #[serde(default)]
-    pub upvote_ratio: f64,
-    #[serde(default)]
-    pub over_18: bool,
-    #[serde(default)]
-    pub spoiler: bool,
-    #[serde(default)]
-    pub stickied: bool,
-    #[serde(default)]
-    pub locked: bool,
-    #[serde(default)]
-    pub distinguished: Option,
-}
-
-/// Reddit comment data
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct RedditComment {
-    pub id: String,
-    pub name: String, // Full name (t1_xxx)
-    #[serde(default = "default_deleted_user")]
-    pub author: String,
-    #[serde(default)]
-    pub body: String,
-    #[serde(default)]
-    pub score: i64,
-    #[serde(default)]
-    pub created_utc: f64,
-    #[serde(default)]
-    pub parent_id: String,
-    #[serde(default)]
-    pub permalink: String,
-    #[serde(default)]
-    pub depth: i32,
-    // Additional useful fields
-    #[serde(default)]
-    pub ups: i64,
-    #[serde(default)]
-    pub downs: i64,
-    #[serde(default)]
-    pub edited: RedditEditStatus,
-    #[serde(default)]
-    pub stickied: bool,
-    #[serde(default)]
-    pub distinguished: Option,
-    #[serde(default)]
-    pub is_submitter: bool,
-    #[serde(default)]
-    pub collapsed: bool,
-    #[serde(default)]
-    pub controversiality: i32,
-}
-
-/// Reddit's edit status - can be false or a timestamp
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(untagged)]
-pub enum RedditEditStatus {
-    NotEdited(bool),
-    EditedAt(f64),
-}
-
-impl Default for RedditEditStatus {
-    fn default() -> Self {
-        RedditEditStatus::NotEdited(false)
-    }
-}
-
-// Helper functions for serde defaults
-fn default_untitled() -> String {
-    "Untitled".to_string()
-}
-
-fn default_deleted_user() -> String {
-    "[deleted]".to_string()
-}
-
-// Custom deserializer for selftext (empty strings should be None)
-fn deserialize_selftext<'de, D>(deserializer: D) -> Result, D::Error>
-where
-    D: serde::Deserializer<'de>,
-{
-    let s: Option = Option::deserialize(deserializer)?;
-    Ok(s.filter(|text| !text.is_empty()))
-}
-
-// Custom deserializer for thumbnail (filter out "self", "default", empty)
-fn deserialize_thumbnail<'de, D>(deserializer: D) -> Result, D::Error>
-where
-    D: serde::Deserializer<'de>,
-{
-    let s: Option = Option::deserialize(deserializer)?;
-    Ok(s.filter(|thumb| {
-        !thumb.is_empty() && thumb != "self" && thumb != "default" && thumb != "nsfw" && thumb != "spoiler"
-    }))
-}
-
-// Helper methods for extracting typed data from Things
-impl RedditThing {
-    /// Try to get this Thing as a Post
-    pub fn as_post(&self) -> Option<&RedditPost> {
-        if self.kind != "t3" {
-            return None;
-        }
-        match &self.data {
-            RedditThingData::Post(post) => Some(post),
-            _ => None,
-        }
-    }
-    
-    /// Try to get this Thing as a Comment
-    pub fn as_comment(&self) -> Option<&RedditComment> {
-        if self.kind != "t1" {
-            return None;
-        }
-        match &self.data {
-            RedditThingData::Comment(comment) => Some(comment),
-            _ => None,
-        }
-    }
-    
-    /// Check if this is a "more comments" indicator
-    pub fn is_more_comments(&self) -> bool {
-        self.kind == "more"
-    }
-}
-
-// Helper methods for working with listing responses
-impl RedditListingResponse {
-    /// Extract all posts from this listing
-    pub fn get_posts(&self) -> Vec<&RedditPost> {
-        self.data.children.iter()
-            .filter_map(|thing| thing.as_post())
-            .collect()
-    }
-    
-    /// Extract all comments from this listing
-    pub fn get_comments(&self) -> Vec<&RedditComment> {
-        self.data.children.iter()
-            .filter_map(|thing| thing.as_comment())
-            .collect()
-    }
-    
-    /// Extract posts as owned values
-    pub fn into_posts(self) -> Vec {
-        self.data.children.into_iter()
-            .filter_map(|thing| {
-                if thing.kind == "t3" {
-                    match thing.data {
-                        RedditThingData::Post(post) => Some(post),
-                        _ => None,
-                    }
-                } else {
-                    None
-                }
-            })
-            .collect()
-    }
-    
-    /// Extract comments as owned values
-    pub fn into_comments(self) -> Vec {
-        self.data.children.into_iter()
-            .filter_map(|thing| {
-                if thing.kind == "t1" {
-                    match thing.data {
-                        RedditThingData::Comment(comment) => Some(comment),
-                        _ => None,
-                    }
-                } else {
-                    None
-                }
-            })
-            .collect()
-    }
-}
-
-
-
diff --git a/legacy/reducer.rs b/legacy/reducer.rs
deleted file mode 100644
index 1ba8030feaf783b066b2e0bba4d2d05659c992f4..0000000000000000000000000000000000000000
--- a/legacy/reducer.rs
+++ /dev/null
@@ -1,845 +0,0 @@
-use std::collections::{HashMap, HashSet, VecDeque};
-
-use serde::{Deserialize, Serialize};
-
-use crate::canonical_path::canonicalize_tag;
-use crate::dsl;
-use crate::events::{Event, Ingest, ThreadCapability};
-use crate::path_types::ItemId;
-
-#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
-pub enum ScopeId {
-    Public,
-    Room(String),
-}
-
-/// Wire `room` field → content scope (`"public"` → [`ScopeId::Public`]).
-pub fn scope_from_room_wire(room: &str) -> ScopeId {
-    let r = room.trim();
-    if r.is_empty() || r == "public" {
-        ScopeId::Public
-    } else {
-        ScopeId::Room(r.to_string())
-    }
-}
-
-/// Parsed vote data (internal representation).
-#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
-pub struct VoteData {
-    pub ts: i64,
-    pub a: ItemId,
-    pub b: ItemId,
-    pub ratio_left: i32,
-    pub ratio_right: i32,
-    pub body: String,
-    pub principal: String,
-    pub delegate: Option,
-    /// Forum channel where this vote was cast (tag only, not room id).
-    pub thread_tag: String,
-}
-
-#[derive(Debug, Clone)]
-pub struct GroupState {
-    pub item_to_idx: HashMap,
-    pub idx_to_item: Vec,
-
-    /// Aggregated directed edge weights: (src_idx, dst_idx) -> weight.
-    pub edges: HashMap<(usize, usize), f64>,
-
-    /// Unordered pairs that have at least one vote recorded between them (i,
-    pub dirty: bool,
-    pub cached_scores: Vec,
-    pub recent_votes: VecDeque,
-}
-
-impl Default for GroupState {
-    fn default() -> Self {
-        Self::new()
-    }
-}
-
-impl GroupState {
-    pub fn new() -> Self {
-        Self {
-            item_to_idx: HashMap::new(),
-            idx_to_item: Vec::new(),
-            edges: HashMap::new(),
-            voted_pairs: HashSet::new(),
-            dirty: true,
-            cached_scores: Vec::new(),
-            recent_votes: VecDeque::with_capacity(200),
-        }
-    }
-
-    fn ensure_item(&mut self, item: &ItemId) -> usize {
-        if let Some(&idx) = self.item_to_idx.get(item) {
-            return idx;
-        }
-        let idx = self.idx_to_item.len();
-        self.idx_to_item.push(item.clone());
-        self.item_to_idx.insert(item.clone(), idx);
-        self.dirty = true;
-        idx
-    }
-
-    /// Public test helper: insert an item into the group without a vote (for unit tests).
-    pub fn ensure_item_pub(&mut self, item: &str) -> usize {
-        if let Some(canon) = ItemId::parse(item) {
-            self.ensure_item(&canon)
-        } else {
-            // Fallback: treat as raw storage string
-            let canon = ItemId::opaque(item.to_string());
-            self.ensure_item(&canon)
-        }
-    }
-
-    fn add_edge_weight(&mut self, src: usize, dst: usize, w: f64) {
-        if w <= 0.0 {
-            return;
-        }
-        *self.edges.entry((src, dst)).or_insert(0.0) += w;
-        self.dirty = true;
-    }
-
-    pub fn apply_vote(&mut self, mut vote: VoteData) {
-        vote.a = ItemId::parse(vote.a.as_str()).unwrap_or_else(|| vote.a.clone());
-        vote.b = ItemId::parse(vote.b.as_str()).unwrap_or_else(|| vote.b.clone());
-        vote.thread_tag = canonicalize_tag(&vote.thread_tag);
-        if vote.ratio_left < 0 {
-            vote.ratio_left = 0;
-        }
-        if vote.ratio_right < 0 {
-            vote.ratio_right = 0;
-        }
-        let a_idx = self.ensure_item(&vote.a);
-        let b_idx = self.ensure_item(&vote.b);
-
-        let (i, j) = if a_idx < b_idx { (a_idx, b_idx) } else { (b_idx, a_idx) };
-        self.voted_pairs.insert((i, j));
-
-        let w_a = vote.ratio_left as f64;
-        let w_b = vote.ratio_right as f64;
-
-        self.add_edge_weight(b_idx, a_idx, w_a);
-        self.add_edge_weight(a_idx, b_idx, w_b);
-
-        self.recent_votes.push_front(vote);
-        while self.recent_votes.len() > 200 {
-            self.recent_votes.pop_back();
-        }
-    }
-}
-
-/// Compact rank-history entry stored per item in the ledger.
-/// `caused_by` is resolved lazily at query time from `ingests_by_id`.
-#[derive(Debug, Clone)]
-pub struct RankHistoryEntry {
-    pub ts: i64,
-    pub scope_rank: usize,
-    pub scope_rank_delta: i32,
-    pub scope_total: usize,
-    pub global_rank: usize,
-    pub global_rank_delta: i32,
-    pub global_total: usize,
-    pub score: f64,
-    pub thread: String,
-    pub post_id: String,
-}
-
-/// Invite link materialized from log events [`crate::events::InviteMinted`] /
-/// [`crate::events::InviteRedeemed`] when those are replayed.
-///
-/// **`RoomMintInvite` today** stores tokens only in [`crate::state::AppState::invites`] (RAM);
-/// they are not appended to the JSONL log, so they disappear on restart. This struct is for
-/// replay and any future persisted-mint path, not for the current ephemeral RPC mint.
-#[derive(Debug, Clone)]
-pub struct ActiveInviteState {
-    pub room_id: String,
-    pub capabilities: HashSet,
-    pub inviter: String,
-    pub uses_remaining: u32,
-    pub expires_ts_ms: Option,
-}
-
-#[derive(Clone, Debug)]
-pub enum RoomTimelineKind {
-    RoomCreated {
-        owner: String,
-        slug: String,
-    },
-    RoomDeleted {
-        deleted_by: String,
-    },
-    GrantAdded {
-        username: String,
-        granted_by: String,
-        capabilities: Vec,
-    },
-    GrantRevoked {
-        username: String,
-        revoked_by: String,
-        capabilities: Vec,
-    },
-}
-
-#[derive(Clone, Debug)]
-pub struct RoomTimelineEntry {
-    pub ts: i64,
-    pub kind: RoomTimelineKind,
-}
-
-#[derive(Debug, Clone)]
-#[derive(Default)]
-pub struct ForumThreadState {
-    pub last_activity_ts: i64,
-    /// Username of the most recent person who bumped this thread.
-    pub last_actor: String,
-}
-
-
-#[derive(Debug, Clone, Default)]
-pub struct ContentState {
-    pub ranking_group: GroupState,
-    pub items: HashSet,
-    pub item_bodies: HashMap,
-    /// Parent [`ItemId`] -> direct children.
-    pub item_children: HashMap>,
-    /// Per-item vote history (most recent first).
-    pub item_votes: HashMap>,
-    /// Per-item ingest references (most recent first).
-    pub item_snippets: HashMap>,
-    /// Item path -> threads that mention or vote on this item.
-    pub item_threads: HashMap>,
-    /// Per-item rank history, oldest first.
-    pub rank_history: HashMap>,
-}
-
-#[derive(Debug, Clone)]
-pub struct ReducerState {
-    pub content: HashMap,
-
-    /// (provider, provider_id) -> username
-    pub users_by_provider: HashMap<(String, String), String>,
-    /// token_id -> (username, salt, token_hash)
-    pub tokens_by_id: HashMap,
-    pub agent_bindings: HashMap,
-
-
-    pub ingests_by_id: HashMap,
-    /// (scope, thread_tag) → ingest ids, newest first.
-    pub ingests_by_scope_thread: HashMap<(ScopeId, String), VecDeque>,
-    /// Private room ids (`shortid/slug`) known from [`RoomCreated`].
-    pub rooms: HashSet,
-    /// (scope, thread_tag) → last activity.
-    pub forum_threads: HashMap<(ScopeId, String), ForumThreadState>,
-    pub actor_last_post_ts: HashMap,
-
-    /// All ingest IDs in chronological order (oldest first). Used by the feed endpoint.
-    pub ingests_ordered: Vec,
-    /// Username → ingest ids in post order (oldest first), for profile pages.
-    pub posts_by_actor: HashMap>,
-    /// Ingest ids removed by author redaction (content and thread body omitted; tombstone in UI).
-    pub redacted_posts: HashSet,
-    /// Redaction event timestamp (ms) per post id.
-    pub post_redact_ts: HashMap,
-    /// room_id → username → capabilities
-    pub grants: HashMap>>,
-    /// room_id → chronological room admin lines (for thread UI).
-    pub room_timeline: HashMap>,
-    /// Invite token → active invite (absent when fully consumed or never minted).
-    pub invites: HashMap,
-}
-
-impl ReducerState {
-    pub fn content_for_scope(&self, scope: &ScopeId) -> Option<&ContentState> {
-        self.content.get(scope)
-    }
-
-    /// 0-based chronological index of `post_id` in `(scope, thread_tag)` (forum routes `/t/tag/N`).
-    pub fn try_thread_post_index_chronological(
-        &self,
-        scope: &ScopeId,
-        thread_tag: &str,
-        post_id: &str,
-    ) -> Option {
-        let tag = canonicalize_tag(thread_tag);
-        self.ingests_by_scope_thread
-            .get(&(scope.clone(), tag))
-            .and_then(|q| q.iter().rev().position(|pid| pid == post_id))
-    }
-
-    pub fn thread_post_index_chronological(
-        &self,
-        scope: &ScopeId,
-        thread_tag: &str,
-        post_id: &str,
-    ) -> usize {
-        self.try_thread_post_index_chronological(scope, thread_tag, post_id)
-            .expect(
-                "post_id must appear in ingests_by_scope_thread for this scope and thread",
-            )
-    }
-
-    /// Ingest ids authored by `actor`, oldest first. Unfiltered; use with access checks per ingest.
-    pub fn posts_by_actor_ids(&self, actor: &str) -> Vec {
-        self.posts_by_actor
-            .get(actor)
-            .map(|q| q.iter().cloned().collect())
-            .unwrap_or_default()
-    }
-
-    /// Same order as [`Self::posts_by_actor_ids`], omitting ingests in scopes the viewer cannot see.
-    pub fn visible_posts_for_actor(&self, actor: &str, viewer: Option<&str>) -> Vec {
-        self.posts_by_actor_ids(actor)
-            .into_iter()
-            .filter(|id| {
-                !self.redacted_posts.contains(id)
-                    && self.ingests_by_id.get(id).is_some_and(|ing| {
-                        let scope = scope_from_room_wire(&ing.room_id);
-                        match &scope {
-                            ScopeId::Public => true,
-                            ScopeId::Room(rid) => {
-                                viewer.is_some_and(|u| self.user_has_cap(rid, u, ThreadCapability::View))
-                            }
-                        }
-                    })
-            })
-            .collect()
-    }
-
-    pub fn user_has_cap(&self, room_id: &str, username: &str, cap: ThreadCapability) -> bool {
-        self.grants
-            .get(room_id)
-            .and_then(|t| t.get(username))
-            .map(|caps| caps.contains(&cap))
-            .unwrap_or(false)
-    }
-
-    /// Invite link is present, not expired, and has uses left.
-    pub fn invite_token_active(&self, token: &str, now_ms: i64) -> Option<&ActiveInviteState> {
-        let inv = self.invites.get(token)?;
-        if inv.uses_remaining == 0 {
-            return None;
-        }
-        if let Some(exp) = inv.expires_ts_ms {
-            if now_ms > exp {
-                return None;
-            }
-        }
-        Some(inv)
-    }
-
-    pub fn content_for_scope_mut(&mut self, scope: ScopeId) -> &mut ContentState {
-        self.content.entry(scope).or_default()
-    }
-
-    pub fn public(&self) -> &ContentState {
-        self.content.get(&ScopeId::Public).expect("public scope missing")
-    }
-
-    /// Register parent→child edges for the full ancestor chain.
-    /// For `a/b/c/d` this creates: `a/b/c→d`, `a/b→a/b/c`, `a→a/b`, `""→a`.
-    /// Stops early when an intermediate is already registered (its ancestors must be too).
-    /// @e2bdefa9-a6fa-4725-b0a2-c0b09d95bb20:claudecode:anthropic/claude-opus-4
-    fn add_child_edge(content: &mut ContentState, item: &ItemId) {
-        let mut child = item.clone();
-        loop {
-            let Some(parent) = child.parent() else { break };
-            let is_new = content
-                .item_children
-                .entry(parent.clone())
-                .or_default()
-                .insert(child);
-            if !is_new { break; }
-            child = parent;
-        }
-    }
-
-    /// Resolve an item path as a first-class [`ItemId`].
-    fn normalize_item(item: &str) -> Option {
-        ItemId::parse(item)
-    }
-
-    /// 1-indexed rank of `item` within its connected component in the parent scope.
-    /// 0 if the item has no votes connecting it to siblings (unranked).
-    fn scope_rank_of(
-        group: &GroupState,
-        item: &ItemId,
-        item_children: &HashMap>,
-    ) -> usize {
-        let scope = match item.parent() {
-            Some(p) => p,
-            None => return 0,
-        };
-        let children = match item_children.get(&scope) {
-            None => return 0,
-            Some(c) => c,
-        };
-        let &item_global_idx = match group.item_to_idx.get(item) {
-            None => return 0,
-            Some(i) => i,
-        };
-        // Map scope children to compact local indices.
-        let sibling_idxs: Vec = children.iter()
-            .filter_map(|c| group.item_to_idx.get(c).copied())
-            .collect();
-        let global_to_local: HashMap = sibling_idxs.iter()
-            .enumerate().map(|(l, &g)| (g, l)).collect();
-        let item_local = match global_to_local.get(&item_global_idx) {
-            None => return 0,
-            Some(&l) => l,
-        };
-        // Connected components within scope.
-        let (comps, _) = crate::ranking::connected_components_from_voted_pairs(
-            sibling_idxs.len(),
-            group.voted_pairs.iter().filter_map(|(a, b)| {
-                Some((global_to_local.get(a).copied()?, global_to_local.get(b).copied()?))
-            }),
-        );
-        // Find the component containing this item.
-        let comp_local = match comps.iter().find(|c| c.contains(&item_local)) {
-            None => return 0,
-            Some(c) => c,
-        };
-        let comp_global: Vec = comp_local.iter()
-            .filter_map(|&l| sibling_idxs.get(l).copied())
-            .collect();
-        let ranked = crate::ranking::ranked_items_subset(group, &comp_global, 10000, 1e-8);
-        ranked.iter().position(|r| &r.item == item).map(|i| i + 1).unwrap_or(0)
-    }
-
-    /// 1-indexed position of `item` in the component-aware global flat list.
-    /// Components sorted largest-first; items ranked within each component.
-    /// 0 if the item is not in the ranking group.
-    fn global_rank_of(group: &GroupState, item: &ItemId) -> usize {
-        if !group.item_to_idx.contains_key(item) {
-            return 0;
-        }
-        let n = group.idx_to_item.len();
-        let (mut comps, _) = crate::ranking::connected_components_from_voted_pairs(
-            n, group.voted_pairs.iter().copied(),
-        );
-        comps.sort_by_key(|b| std::cmp::Reverse(b.len()));
-        let mut pos = 1usize;
-        for comp in &comps {
-            let ranked = crate::ranking::ranked_items_subset(group, comp, 10000, 1e-8);
-            for r in &ranked {
-                if &r.item == item {
-                    return pos;
-                }
-                pos += 1;
-            }
-        }
-        0
-    }
-
-    /// Apply one ingest's DSL effects to `content` (votes, items, snippets, rank history).
-    fn apply_ingest_to_content(content: &mut ContentState, ing: &Ingest) -> Result<(), ()> {
-        let doc = dsl::parse_full(&ing.raw).map_err(|_| ())?;
-        let canonical_thread = canonicalize_tag(&ing.thread_tag);
-
-        let voted_items: Vec = doc
-            .statements
-            .iter()
-            .filter_map(|s| {
-                if let dsl::Stmt::Vote { item1, item2, .. } = s {
-                    Some([item1, item2])
-                } else {
-                    None
-                }
-            })
-            .flat_map(|pair| pair.into_iter())
-            .filter_map(|raw| Self::normalize_item(raw))
-            .collect::>()
-            .into_iter()
-            .collect();
-
-        let principal = ing.principal.clone();
-        let delegate = ing.delegate.clone();
-
-        let before: HashMap = if !voted_items.is_empty() {
-            crate::ranking::compute_group_ranking(&mut content.ranking_group, 10000, 1e-8);
-            voted_items
-                .iter()
-                .map(|it| {
-                    (
-                        it.clone(),
-                        (
-                            Self::scope_rank_of(&content.ranking_group, it, &content.item_children),
-                            Self::global_rank_of(&content.ranking_group, it),
-                        ),
-                    )
-                })
-                .collect()
-        } else {
-            HashMap::new()
-        };
-
-        let mut ingest_items: HashSet = HashSet::new();
-
-        for stmt in doc.statements {
-            match stmt {
-                dsl::Stmt::Item { title, body } => {
-                    let Some(item) = Self::normalize_item(&title) else {
-                        continue;
-                    };
-                    nav!(content.items, set_elem(item.clone()));
-                    ingest_items.insert(item.clone());
-                    Self::add_child_edge(content, &item);
-
-                    if let Some(body_text) = body {
-                        if !body_text.trim().is_empty() {
-                            nav!(content.item_bodies, keypath(item.clone()), setval(body_text));
-                        }
-                    }
-                }
-                dsl::Stmt::Vote {
-                    item1,
-                    item2,
-                    ratio_left,
-                    ratio_right,
-                    explanation,
-                } => {
-                    let Some(item_a) = Self::normalize_item(&item1) else {
-                        continue;
-                    };
-                    let Some(item_b) = Self::normalize_item(&item2) else {
-                        continue;
-                    };
-
-                    let vote = VoteData {
-                        ts: ing.ts,
-                        a: item_a.clone(),
-                        b: item_b.clone(),
-                        ratio_left,
-                        ratio_right,
-                        body: explanation,
-                        principal: principal.clone(),
-                        delegate: delegate.clone(),
-                        thread_tag: canonical_thread.clone(),
-                    };
-
-                    ingest_items.insert(item_a.clone());
-                    ingest_items.insert(item_b.clone());
-
-                    nav!(content.items, set_elem(item_a.clone()));
-                    nav!(content.items, set_elem(item_b.clone()));
-                    Self::add_child_edge(content, &item_a);
-                    Self::add_child_edge(content, &item_b);
-
-                    content.ranking_group.apply_vote(vote.clone());
-
-                    for it in [&item_a, &item_b] {
-                        nav!(content.item_votes, keypath(it.clone()), push_front(vote.clone()));
-                    }
-                }
-                dsl::Stmt::Prose { .. } => {}
-            }
-        }
-
-        for item in ingest_items.iter() {
-            nav!(content.item_snippets, keypath(item.clone()), push_front(ing.id.clone()));
-        }
-
-        for item in ingest_items.iter() {
-            nav!(
-                content.item_threads,
-                keypath(item.clone()),
-                set_elem(canonical_thread.clone())
-            );
-        }
-
-        if !voted_items.is_empty() {
-            crate::ranking::compute_group_ranking(&mut content.ranking_group, 10000, 1e-8);
-            let thread = canonical_thread.clone();
-            for item in &voted_items {
-                let after_scope = Self::scope_rank_of(&content.ranking_group, item, &content.item_children);
-                let after_global = Self::global_rank_of(&content.ranking_group, item);
-                let score = content
-                    .ranking_group
-                    .item_to_idx
-                    .get(item)
-                    .and_then(|&i| content.ranking_group.cached_scores.get(i))
-                    .copied()
-                    .unwrap_or(0.0);
-                let (before_scope, before_global) = before.get(item).copied().unwrap_or((0, 0));
-                let prev = content.rank_history.get(item).and_then(|v| v.last());
-                let scope_delta = if prev.is_none() {
-                    0
-                } else {
-                    after_scope as i32 - before_scope as i32
-                };
-                let global_delta = if prev.is_none() {
-                    0
-                } else {
-                    after_global as i32 - before_global as i32
-                };
-                let scope_total = item
-                    .parent()
-                    .and_then(|p| content.item_children.get(&p))
-                    .map(|s| s.len())
-                    .unwrap_or(0);
-                let global_total = content.ranking_group.idx_to_item.len();
-                content.rank_history.entry(item.clone()).or_default().push(RankHistoryEntry {
-                    ts: ing.ts,
-                    scope_rank: after_scope,
-                    scope_rank_delta: scope_delta,
-                    scope_total,
-                    global_rank: after_global,
-                    global_rank_delta: global_delta,
-                    global_total,
-                    score,
-                    thread: thread.clone(),
-                    post_id: ing.id.clone(),
-                });
-            }
-        }
-
-        Ok(())
-    }
-
-    fn rebuild_scope_content(&mut self, scope: ScopeId) {
-        let mut cs = ContentState::default();
-        for id in &self.ingests_ordered {
-            if self.redacted_posts.contains(id) {
-                continue;
-            }
-            let Some(ing) = self.ingests_by_id.get(id) else {
-                continue;
-            };
-            if scope_from_room_wire(&ing.room_id) != scope {
-                continue;
-            }
-            let _ = Self::apply_ingest_to_content(&mut cs, ing);
-        }
-        self.content.insert(scope, cs);
-        // `ingests_by_scope_thread` is intentionally not rebuilt: tombstoned ids stay in the deque so
-        // per-post URLs and chronological indices remain stable; only projected garden state resets.
-    }
-
-    /// Drop all reducer state keyed by a private room id (forum, garden scope, invites, grants).
-    fn purge_private_room(&mut self, room_id: &str) {
-        let scope = ScopeId::Room(room_id.to_string());
-        self.rooms.remove(room_id);
-        self.grants.remove(room_id);
-        self.room_timeline.remove(room_id);
-        self.invites.retain(|_, inv| inv.room_id != room_id);
-        self.content.remove(&scope);
-        self.forum_threads.retain(|(s, _), _| s != &scope);
-        self.ingests_by_scope_thread.retain(|(s, _), _| s != &scope);
-
-        let mut to_drop: Vec = self
-            .ingests_by_id
-            .iter()
-            .filter(|(_, ing)| ing.room_id.trim() == room_id)
-            .map(|(id, _)| id.clone())
-            .collect();
-        to_drop.sort();
-        to_drop.dedup();
-        for id in to_drop {
-            if let Some(ing) = self.ingests_by_id.remove(&id) {
-                self.posts_by_actor
-                    .entry(ing.principal)
-                    .and_modify(|q| {
-                        q.retain(|x| x != &id);
-                    });
-            }
-            self.ingests_ordered.retain(|x| x != &id);
-            self.redacted_posts.remove(&id);
-            self.post_redact_ts.remove(&id);
-        }
-    }
-
-    pub fn apply_event(&mut self, event: Event) {
-        match event {
-            Event::UserRegistered(ur) => {
-                self.users_by_provider.insert(
-                    (ur.provider.to_lowercase(), ur.provider_id.clone()),
-                    ur.username,
-                );
-            }
-            Event::TokenIssued(ti) => {
-                self.tokens_by_id.insert(
-                    ti.token_id.clone(),
-                    (ti.username, ti.salt.clone(), ti.token_hash.clone()),
-                );
-            }
-            Event::AgentBound(ab) => {
-                if ab.agent.is_empty() {
-                    return;
-                }
-                self.agent_bindings.insert(ab.agent, ab.username);
-            }
-            Event::RoomCreated(rc) => {
-                self.rooms.insert(rc.room_id.clone());
-                self.room_timeline
-                    .entry(rc.room_id.clone())
-                    .or_default()
-                    .push(RoomTimelineEntry {
-                        ts: rc.ts,
-                        kind: RoomTimelineKind::RoomCreated {
-                            owner: rc.owner.clone(),
-                            slug: rc.slug.clone(),
-                        },
-                    });
-            }
-            Event::RoomDeleted(rd) => {
-                let room_id = rd.room_id.clone();
-                if self.rooms.contains(&room_id) {
-                    self.room_timeline
-                        .entry(room_id.clone())
-                        .or_default()
-                        .push(RoomTimelineEntry {
-                            ts: rd.ts,
-                            kind: RoomTimelineKind::RoomDeleted {
-                                deleted_by: rd.deleted_by.clone(),
-                            },
-                        });
-                }
-                self.purge_private_room(&room_id);
-            }
-            Event::Ingest(mut ing) => {
-                ing.thread_tag = canonicalize_tag(&ing.thread_tag);
-                let room_key = ing.room_id.trim().to_string();
-                let scope = scope_from_room_wire(&room_key);
-                let canonical_thread = ing.thread_tag.clone();
-                let scope_thread_key = (scope.clone(), canonical_thread.clone());
-
-                {
-                    let content = self.content_for_scope_mut(scope.clone());
-                    if Self::apply_ingest_to_content(content, &ing).is_err() {
-                        eprintln!(
-                            "WARNING: Skipping malformed ingest event {}: parse failed",
-                            ing.id
-                        );
-                        return;
-                    }
-                }
-
-                self.ingests_by_id.insert(ing.id.clone(), ing.clone());
-
-                let ft = self.forum_threads.entry(scope_thread_key.clone()).or_default();
-                let prev_ts = ft.last_activity_ts;
-                if ing.ts > prev_ts {
-                    ft.last_activity_ts = ing.ts;
-                    ft.last_actor = ing.principal.clone();
-                }
-
-                nav!(self.ingests_by_scope_thread, keypath(scope_thread_key), push_front(ing.id.clone()));
-
-                nav!(self.ingests_ordered, push_back(ing.id.clone()));
-
-                nav!(self.posts_by_actor, keypath(ing.principal.clone()), push_back(ing.id.clone()));
-
-                nav!(self.actor_last_post_ts, keypath(ing.principal.clone()), setval(ing.ts));
-            }
-            Event::PostRedacted(pr) => {
-                self.redacted_posts.insert(pr.post_id.clone());
-                self.post_redact_ts.insert(pr.post_id.clone(), pr.ts);
-                let Some(ing) = self.ingests_by_id.get(&pr.post_id).cloned() else {
-                    return;
-                };
-                let scope = scope_from_room_wire(ing.room_id.trim());
-                // Rebuilds garden projection only; `ingests_by_scope_thread` is left as-is — see `rebuild_scope_content`.
-                self.rebuild_scope_content(scope);
-            }
-            Event::GrantAdded(ga) => {
-                let room_id = ga.room_id.clone();
-                let caps = self.grants
-                    .entry(ga.room_id)
-                    .or_default()
-                    .entry(ga.username.clone())
-                    .or_default();
-                for cap in ga.capabilities.iter().copied() {
-                    caps.insert(cap);
-                }
-                self.room_timeline
-                    .entry(room_id)
-                    .or_default()
-                    .push(RoomTimelineEntry {
-                        ts: ga.ts,
-                        kind: RoomTimelineKind::GrantAdded {
-                            username: ga.username.clone(),
-                            granted_by: ga.granted_by.clone(),
-                            capabilities: ga.capabilities.clone(),
-                        },
-                    });
-            }
-            Event::GrantRevoked(gr) => {
-                let room_id = gr.room_id.clone();
-                if let Some(room_grants) = self.grants.get_mut(&gr.room_id) {
-                    let username = gr.username.clone();
-                    if let Some(caps) = room_grants.get_mut(&username) {
-                        for cap in &gr.capabilities {
-                            caps.remove(cap);
-                        }
-                        if caps.is_empty() {
-                            room_grants.remove(&username);
-                        }
-                    }
-                    if room_grants.is_empty() {
-                        self.grants.remove(&gr.room_id);
-                    }
-                }
-                self.room_timeline
-                    .entry(room_id)
-                    .or_default()
-                    .push(RoomTimelineEntry {
-                        ts: gr.ts,
-                        kind: RoomTimelineKind::GrantRevoked {
-                            username: gr.username.clone(),
-                            revoked_by: gr.revoked_by.clone(),
-                            capabilities: gr.capabilities.clone(),
-                        },
-                    });
-            }
-            Event::InviteMinted(im) => {
-                self.invites.insert(
-                    im.token.clone(),
-                    ActiveInviteState {
-                        room_id: im.room_id.clone(),
-                        capabilities: im.capabilities.iter().copied().collect(),
-                        inviter: im.inviter.clone(),
-                        uses_remaining: im.max_uses,
-                        expires_ts_ms: im.expires_ts_ms,
-                    },
-                );
-            }
-            Event::InviteRedeemed(ir) => {
-                if let Some(inv) = self.invites.get_mut(&ir.token) {
-                    inv.uses_remaining = inv.uses_remaining.saturating_sub(1);
-                    if inv.uses_remaining == 0 {
-                        self.invites.remove(&ir.token);
-                    }
-                }
-            }
-        }
-    }
-}
-
-impl Default for ReducerState {
-    fn default() -> Self {
-        let mut content = HashMap::new();
-        content.insert(ScopeId::Public, ContentState::default());
-        Self {
-            content,
-            users_by_provider: HashMap::new(),
-            tokens_by_id: HashMap::new(),
-            agent_bindings: HashMap::new(),
-            ingests_by_id: HashMap::new(),
-            ingests_by_scope_thread: HashMap::new(),
-            rooms: HashSet::new(),
-            forum_threads: HashMap::new(),
-            actor_last_post_ts: HashMap::new(),
-            ingests_ordered: Vec::new(),
-            posts_by_actor: HashMap::new(),
-            redacted_posts: HashSet::new(),
-            post_redact_ts: HashMap::new(),
-            grants: HashMap::new(),
-            room_timeline: HashMap::new(),
-            invites: HashMap::new(),
-        }
-    }
-}
-
diff --git a/legacy/ui_action.rs b/legacy/ui_action.rs
deleted file mode 100644
index 5e4131dd346a6f80bfe091a9b0cf7902721bc47f..0000000000000000000000000000000000000000
--- a/legacy/ui_action.rs
+++ /dev/null
@@ -1,270 +0,0 @@
-//! Browser-only UI commands: JSON in hidden `__rpc__` plus hole fill ([`crate::form_template`]).
-//! Not part of [`slug_types::RpcCommand`] (CLI / JSON API).
-
-use crate::form_template::fill_template_from_form;
-use serde::{Deserialize, Serialize};
-use serde_json::Value;
-use std::collections::HashMap;
-use thiserror::Error;
-
-/// Form field name for the compact JSON template (possibly with `{"$form":"…"}` holes).
-pub const UI_RPC_FIELD: &str = "__rpc__";
-
-fn default_ui_form_action() -> String {
-    "/ui".to_string()
-}
-
-/// HTML form / fetch `POST /ui` payload after template fill and deserialization.
-#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
-#[serde(tag = "action", rename_all = "snake_case")]
-pub enum HtmlUiAction {
-    /// Forum ingest via `POST /ui`.
-    PostIngest {
-        room: String,
-        thread_tag: String,
-        text: String,
-        #[serde(default)]
-        error_target: Option,
-        #[serde(default)]
-        form_id: Option,
-    },
-    /// DSL check / validation via `POST /ui`.
-    CheckIngest {
-        room: String,
-        thread_tag: String,
-        text: String,
-        #[serde(default)]
-        error_target: Option,
-        #[serde(default)]
-        form_id: Option,
-    },
-    /// Post a pairwise vote from `/vote` (browser compose).
-    VoteComparePost {
-        room: String,
-        thread_tag: String,
-        left_item: String,
-        right_item: String,
-        /// From form fields (string); parsed server-side.
-        ratio_left: String,
-        ratio_right: String,
-        explanation: String,
-        /// Same-origin path after successful post (JS redirect).
-        next: String,
-        /// Pool parent item path, if the vote was initiated from a pool URL.
-        #[serde(default)]
-        pool: Option,
-        #[serde(default = "default_ui_form_action")]
-        form_action: String,
-    },
-    /// Set or clear the garden HUD pin cookie (`slug_garden_pin`). Response is **`303` + `Set-Cookie`** when submitted as a full-navigation form (`data-navigate="full"`), matching `POST /theme`.
-    SetGardenPin {
-        #[serde(default)]
-        clear: bool,
-        #[serde(default)]
-        room_wire: String,
-        #[serde(default)]
-        item_storage: Option,
-        next: String,
-        /// Must equal the form `action` (usually `/ui`). Used to reject forged requests that POST to another path.
-        #[serde(default = "default_ui_form_action")]
-        form_action: String,
-    },
-    /// Resolve children or siblings for an external garden item.
-    ResolveExternal {
-        room_wire: String,
-        item_storage: String,
-        mode: String,
-        next: String,
-        #[serde(default = "default_ui_form_action")]
-        form_action: String,
-    },
-    /// Author redacts own post via `POST /ui`.
-    RedactPost { post_id: String },
-    /// Morph `#room-members-section` — members list open or collapsed (server-rendered).
-    SetRoomMembersExpanded {
-        room_wire: String,
-        #[serde(default)]
-        expanded: bool,
-    },
-    /// Delete the private room (Manage only); redirects to `/` on success.
-    DeleteRoom { room: String },
-    /// Morph `#new-thread-ui-slot` inner — compose open or collapsed (`room_wire: "public"` for home).
-    SetNewThreadComposeExpanded {
-        room_wire: String,
-        #[serde(default)]
-        expanded: bool,
-    },
-    /// Replace a truncated post card with the full body (same thread index).
-    ExpandPostFull {
-        room: String,
-        thread_tag: String,
-        post_index: usize,
-    },
-    /// Expand a redacted/tombstone post to show stripped body (author view).
-    ExpandRedactedPost {
-        room: String,
-        thread_tag: String,
-        post_index: usize,
-    },
-    /// Collapse an expanded redacted post back to the tombstone card.
-    CollapseRedactedPost {
-        room: String,
-        thread_tag: String,
-        post_index: usize,
-    },
-    /// Copy full thread text (CLI `forum show` format) to the clipboard.
-    CopyThread {
-        room: String,
-        thread_tag: String,
-        copy_btn_id: String,
-    },
-}
-
-#[derive(Debug, Error)]
-pub enum HtmlUiParseError {
-    #[error("missing __rpc__ field")]
-    MissingRpc,
-    #[error("invalid template json: {0}")]
-    Template(serde_json::Error),
-    #[error("invalid ui action: {0}")]
-    Action(serde_json::Error),
-}
-
-/// Parse `__rpc__` JSON, apply `$form` holes from the rest of the form map, deserialize.
-pub fn parse_html_ui_from_form(
-    form: &HashMap,
-) -> Result {
-    let template = form.get(UI_RPC_FIELD).ok_or(HtmlUiParseError::MissingRpc)?;
-    let mut hole_map = form.clone();
-    hole_map.remove(UI_RPC_FIELD);
-    let v: Value =
-        fill_template_from_form(template, &hole_map).map_err(HtmlUiParseError::Template)?;
-    serde_json::from_value(v).map_err(HtmlUiParseError::Action)
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[test]
-    fn round_trip_post_ingest_with_holes() {
-        let template = serde_json::json!({
-            "action": "post_ingest",
-            "room": "public",
-            "thread_tag": {"$form": "thread_tag"},
-            "text": {"$form": "text"},
-            "error_target": "e",
-            "form_id": "f",
-        });
-        let mut form = HashMap::new();
-        form.insert(
-            UI_RPC_FIELD.to_string(),
-            serde_json::to_string(&template).unwrap(),
-        );
-        form.insert("thread_tag".into(), "x".into());
-        form.insert("text".into(), "body".into());
-
-        let a = parse_html_ui_from_form(&form).unwrap();
-        assert_eq!(
-            a,
-            HtmlUiAction::PostIngest {
-                room: "public".into(),
-                thread_tag: "x".into(),
-                text: "body".into(),
-                error_target: Some("e".into()),
-                form_id: Some("f".into()),
-            }
-        );
-    }
-
-    #[test]
-    fn expand_post_full_round_trip() {
-        let template = serde_json::json!({
-            "action": "expand_post_full",
-            "room": "public",
-            "thread_tag": "demo",
-            "post_index": 3,
-        });
-        let mut form = HashMap::new();
-        form.insert(
-            UI_RPC_FIELD.to_string(),
-            serde_json::to_string(&template).unwrap(),
-        );
-        let a = parse_html_ui_from_form(&form).unwrap();
-        assert_eq!(
-            a,
-            HtmlUiAction::ExpandPostFull {
-                room: "public".into(),
-                thread_tag: "demo".into(),
-                post_index: 3,
-            }
-        );
-    }
-
-    #[test]
-    fn set_room_members_expanded_defaults_false() {
-        let template = serde_json::json!({
-            "action": "set_room_members_expanded",
-            "room_wire": "ab/cd",
-        });
-        let mut form = HashMap::new();
-        form.insert(
-            UI_RPC_FIELD.to_string(),
-            serde_json::to_string(&template).unwrap(),
-        );
-        let a = parse_html_ui_from_form(&form).unwrap();
-        assert_eq!(
-            a,
-            HtmlUiAction::SetRoomMembersExpanded {
-                room_wire: "ab/cd".into(),
-                expanded: false,
-            }
-        );
-    }
-
-    #[test]
-    fn copy_thread_round_trip() {
-        let template = serde_json::json!({
-            "action": "copy_thread",
-            "room": "public",
-            "thread_tag": "demo",
-            "copy_btn_id": "thread-copy-top",
-        });
-        let mut form = HashMap::new();
-        form.insert(
-            UI_RPC_FIELD.to_string(),
-            serde_json::to_string(&template).unwrap(),
-        );
-        let a = parse_html_ui_from_form(&form).unwrap();
-        assert_eq!(
-            a,
-            HtmlUiAction::CopyThread {
-                room: "public".into(),
-                thread_tag: "demo".into(),
-                copy_btn_id: "thread-copy-top".into(),
-            }
-        );
-    }
-
-    #[test]
-    fn set_new_thread_compose_expanded_true() {
-        let template = serde_json::json!({
-            "action": "set_new_thread_compose_expanded",
-            "room_wire": "ab/cd",
-            "expanded": true,
-        });
-        let mut form = HashMap::new();
-        form.insert(
-            UI_RPC_FIELD.to_string(),
-            serde_json::to_string(&template).unwrap(),
-        );
-        let a = parse_html_ui_from_form(&form).unwrap();
-        assert_eq!(
-            a,
-            HtmlUiAction::SetNewThreadComposeExpanded {
-                room_wire: "ab/cd".into(),
-                expanded: true,
-            }
-        );
-    }
-}
diff --git a/legacy/views.rs b/legacy/views.rs
deleted file mode 100644
index d4f0ffc49475f014698b4da0de6f476884430813..0000000000000000000000000000000000000000
--- a/legacy/views.rs
+++ /dev/null
@@ -1,63 +0,0 @@
-use std::{
-    collections::HashMap,
-    sync::{Arc, Mutex},
-};
-use tokio::sync::mpsc;
-
-type CountMap = Arc>>;
-
-#[derive(Clone)]
-pub struct ViewStore {
-    counts: CountMap,
-    flush_tx: mpsc::Sender<()>,
-}
-
-impl ViewStore {
-    pub fn new(json_path: &str) -> Self {
-        // Load existing counts from disk on startup (best-effort)
-        let initial: HashMap = std::fs::read_to_string(json_path)
-            .ok()
-            .and_then(|s| serde_json::from_str(&s).ok())
-            .unwrap_or_default();
-
-        let counts: CountMap = Arc::new(Mutex::new(initial));
-        let (flush_tx, mut flush_rx) = mpsc::channel::<()>(64);
-        let path = json_path.to_string();
-
-        let counts_for_writer = counts.clone();
-        tokio::spawn(async move {
-            while flush_rx.recv().await.is_some() {
-                while flush_rx.try_recv().is_ok() {}
-
-                let snapshot: HashMap = {
-                    counts_for_writer.lock().unwrap().clone()
-                };
-
-                let path = path.clone();
-                let _ = tokio::task::spawn_blocking(move || {
-                    if let Ok(json) = serde_json::to_string(&snapshot) {
-                        let tmp = format!("{path}.tmp");
-                        if std::fs::write(&tmp, &json).is_ok() {
-                            let _ = std::fs::rename(&tmp, &path);
-                        }
-                    }
-                })
-                .await;
-            }
-        });
-
-        Self { counts, flush_tx }
-    }
-
-    pub fn increment(&self, path: String) {
-        {
-            let mut map = self.counts.lock().unwrap();
-            *map.entry(path).or_insert(0) += 1;
-        }
-        let _ = self.flush_tx.try_send(());
-    }
-
-    pub fn get_views(&self, path: &str) -> u64 {
-        self.counts.lock().unwrap().get(path).copied().unwrap_or(0)
-    }
-}
diff --git a/legacy/vote.rs b/legacy/vote.rs
deleted file mode 100644
index d0ec78cd675eae284d056fb3b8eaf5cc853d6263..0000000000000000000000000000000000000000
--- a/legacy/vote.rs
+++ /dev/null
@@ -1,580 +0,0 @@
-use axum::{
-    extract::{Path, Query, State},
-    http::{HeaderMap, StatusCode, Uri},
-    response::{Html, IntoResponse},
-};
-use axum_extra::extract::cookie::CookieJar;
-use maud::html;
-use serde::Deserialize;
-use serde_json::json;
-use std::collections::{HashMap, HashSet};
-
-use crate::{
-    api::optional_principal,
-    canonical_path::canonicalize_tag,
-    form_template::template_json_compact,
-    html::{
-        forum::ThreadNav,
-        layout_full_bleed_chromeless,
-        ratio_pct, render_item_body_in_scope,
-        theme_from_jar, theme_next_from_uri,
-        user_can_post_room,
-        ui_action::UI_RPC_FIELD,
-        JsBuilder,
-    },
-    middleware::canonical_view_url,
-    path_types::ItemId,
-    reducer::{ContentState, ScopeId},
-    scope_rank::suggest_next_pair_in_pool,
-    state::AppState,
-};
-
-use super::{
-    access::{content_for_garden_view, room_not_found_page, room_scope_has_garden_content, user_can_view_room},
-    item::{item_display_path, login_href_with_next},
-};
-
-fn pick_autothread_for_vote_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> String {
-    let cands: HashSet = content
-        .item_threads
-        .get(a)
-        .into_iter()
-        .chain(content.item_threads.get(b))
-        .flat_map(|s| s.iter().cloned())
-        .collect();
-    if cands.is_empty() {
-        return "vote".to_string();
-    }
-    let mut v: Vec = cands.into_iter().collect();
-    v.sort();
-    canonicalize_tag(&v[0])
-}
-
-/// Canonical unordered pair: lexicographic by storage string (stable edge identity).
-pub(super) fn canonical_edge_items(a: &ItemId, b: &ItemId) -> (ItemId, ItemId) {
-    let ac = a.clone().normalized_storage();
-    let bc = b.clone().normalized_storage();
-    if ac.as_str() <= bc.as_str() {
-        (ac, bc)
-    } else {
-        (bc, ac)
-    }
-}
-
-/// All votes whose endpoints are exactly this unordered pair (unsorted).
-pub(super) fn edge_vote_entries_for_pair(
-    content: &ContentState,
-    a: &ItemId,
-    b: &ItemId,
-) -> Vec {
-    let (lo, hi) = canonical_edge_items(a, b);
-    let lo_s = lo.as_str();
-    let hi_s = hi.as_str();
-    content
-        .item_votes
-        .get(&lo)
-        .into_iter()
-        .flat_map(|q| q.iter())
-        .filter(|v| {
-            (v.a.as_str() == lo_s && v.b.as_str() == hi_s)
-                || (v.a.as_str() == hi_s && v.b.as_str() == lo_s)
-        })
-        .cloned()
-        .collect()
-}
-
-pub(super) fn ratios_for_compare_page(
-    v: &crate::reducer::VoteData,
-    page_left: &ItemId,
-    page_right: &ItemId,
-) -> (i32, i32) {
-    let pl = page_left.as_str();
-    let pr = page_right.as_str();
-    match (v.a.as_str(), v.b.as_str()) {
-        (a, b) if a == pl && b == pr => (v.ratio_left, v.ratio_right),
-        (a, b) if a == pr && b == pl => (v.ratio_right, v.ratio_left),
-        _ => (v.ratio_left, v.ratio_right),
-    }
-}
-
-fn left_share_normalized(ratio_left: i32, ratio_right: i32) -> f64 {
-    let l = ratio_left.max(0) as f64;
-    let r = ratio_right.max(0) as f64;
-    let sum = l + r;
-    if sum <= 0.0 {
-        0.5
-    } else {
-        l / sum
-    }
-}
-
-/// Stronger preference for **`page_left` first**; ties **newer first**.
-pub(super) fn sort_votes_for_compare_display(
-    mut votes: Vec,
-    page_left: &ItemId,
-    page_right: &ItemId,
-) -> Vec {
-    votes.sort_by(|va, vb| {
-        let (ratio_left_a, ratio_right_a) = ratios_for_compare_page(va, page_left, page_right);
-        let (ratio_left_b, ratio_right_b) = ratios_for_compare_page(vb, page_left, page_right);
-        let sa = left_share_normalized(ratio_left_a, ratio_right_a);
-        let sb = left_share_normalized(ratio_left_b, ratio_right_b);
-        match sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal) {
-            std::cmp::Ordering::Equal => vb.ts.cmp(&va.ts),
-            o => o,
-        }
-    });
-    votes
-}
-
-/// Number of vote ingests recorded for this unordered pair in `content` (same scope as ranking).
-pub(super) fn edge_vote_count_for_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> usize {
-    let (lo, hi) = canonical_edge_items(a, b);
-    let lo_s = lo.as_str();
-    let hi_s = hi.as_str();
-    content
-        .item_votes
-        .get(&lo)
-        .into_iter()
-        .flat_map(|q| q.iter())
-        .filter(|v| {
-            (v.a.as_str() == lo_s && v.b.as_str() == hi_s)
-                || (v.a.as_str() == hi_s && v.b.as_str() == lo_s)
-        })
-        .count()
-}
-
-fn vote_thread_tags_for_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> Vec {
-    let set: HashSet = content
-        .item_threads
-        .get(a)
-        .into_iter()
-        .chain(content.item_threads.get(b))
-        .flat_map(|s| s.iter().cloned())
-        .collect();
-    let mut v: Vec = set.into_iter().collect();
-    v.sort();
-    v.into_iter().map(|t| canonicalize_tag(&t)).collect()
-}
-
-fn vote_edge_history_markup(content: &ContentState, left: &ItemId, right: &ItemId) -> maud::Markup {
-    let votes = edge_vote_entries_for_pair(content, left, right);
-    let votes = sort_votes_for_compare_display(votes, left, right);
-    let legend_left = item_display_path(left.as_str());
-    let legend_right = item_display_path(right.as_str());
-    html! {
-        @if votes.is_empty() {
-            p class="muted vote-edge-empty" { "no votes on this pair in this scope yet" }
-        } @else {
-            h3 class="vote-edge-history-title" {
-                "votes on this edge"
-                span class="vote-edge-history-axis muted" { " · " (legend_left) " : " (legend_right) }
-            }
-            ul class="vote-edge-history" {
-                @for v in &votes {
-                    @let (r_left, r_right) = ratios_for_compare_page(v, left, right);
-                    @let pct = ratio_pct(r_left, r_right);
-                    @let row_tip = format!(
-                        "{}:{} counts toward {} (left of bar) vs {} (right of bar); #{} · @{}",
-                        r_left,
-                        r_right,
-                        legend_left,
-                        legend_right,
-                        v.thread_tag,
-                        v.principal,
-                    );
-                    li class="vote-edge-history-row" title=(row_tip) {
-                        div class="vote-edge-meta" {
-                            span class="vote-edge-ratio" { (format!("{}:{}", r_left, r_right)) }
-                            span class="muted" { " · #" (v.thread_tag) " · @" (v.principal) }
-                        }
-                        div class="ratio-bar vote-edge-bar" aria-hidden="true" {
-                            div class="ratio-left" style={(format!("width: {:.3}%;", pct))} {}
-                            div class="ratio-right" style={(format!("width: {:.3}%;", 100.0 - pct))} {}
-                        }
-                        @if !v.body.trim().is_empty() {
-                            div class="vote-edge-reason muted" { (v.body.trim()) }
-                        }
-                    }
-                }
-            }
-        }
-    }
-}
-
-/// After a successful vote post: refresh edge history (no in-page preview card).
-#[allow(clippy::too_many_arguments)]
-pub(crate) async fn vote_compare_post_success_js(
-    state: &AppState,
-    nav: &ThreadNav,
-    _room_wire: &str,
-    _thread_tag: &str,
-    left: &ItemId,
-    right: &ItemId,
-    pool: Option<&ItemId>,
-    _post_id: &str,
-    _post_idx: Option,
-) -> String {
-    let reduced = state.reduced.read().await;
-    let content = content_for_garden_view(&reduced, &nav.scope());
-    let edge_history = vote_edge_history_markup(content, left, right);
-    let next_pair = suggest_next_vote_pair(content, left, right, pool);
-    let nav_markup = vote_compare_nav_markup(nav, next_pair.as_ref(), pool);
-    drop(reduced);
-    JsBuilder::new()
-        .morph_inner_selector("#vote-edge-history-region", edge_history)
-        .morph_selector(".vote-compare-nav", nav_markup)
-        .build()
-}
-
-pub(super) fn vote_compare_href(
-    nav: &ThreadNav,
-    left: &ItemId,
-    right: &ItemId,
-    thread_override: Option<&str>,
-    pool: Option<&ItemId>,
-) -> String {
-    let left_dp = left.display_path();
-    let right_dp = right.display_path();
-    let left_q = urlencoding::encode(&left_dp);
-    let right_q = urlencoding::encode(&right_dp);
-    let mut base = format!(
-        "{}/vote?left={}&right={}",
-        nav.room_path_prefix_for_vote_compare(),
-        left_q,
-        right_q
-    );
-    if let Some(t) = thread_override.filter(|s| !s.is_empty()) {
-        base = format!("{}&thread={}", base, urlencoding::encode(t));
-    }
-    if let Some(p) = pool {
-        let pool_dp = p.display_path();
-        base = format!("{}&pool={}", base, urlencoding::encode(&pool_dp));
-    }
-    base
-}
-
-pub(super) fn vote_pool_href(nav: &ThreadNav, pool_item_str: &str) -> String {
-    let display = ItemId::parse(pool_item_str)
-        .map(|i| i.display_path())
-        .unwrap_or_else(|| pool_item_str.to_string());
-    format!(
-        "{}/vote?pool={}",
-        nav.room_path_prefix_for_vote_compare(),
-        urlencoding::encode(&display)
-    )
-}
-
-fn vote_compare_nav_markup(
-    nav: &ThreadNav,
-    next_pair: Option<&(ItemId, ItemId)>,
-    pool: Option<&ItemId>,
-) -> maud::Markup {
-    let next_pair_href = next_pair.map(|(nl, nr)| vote_compare_href(nav, nl, nr, None, pool));
-    html! {
-        div class="vote-compare-nav" {
-            @if let Some(href) = &next_pair_href {
-                a class="vote-compare-next" data-testid="vote-next-pair" href=(href) { "next pair" }
-            } @else {
-                span class="vote-compare-next is-disabled" { "no next pair" }
-            }
-        }
-    }
-}
-
-pub(super) fn suggest_next_vote_pair(
-    content: &ContentState,
-    current_left: &ItemId,
-    current_right: &ItemId,
-    pool_parent: Option<&ItemId>,
-) -> Option<(ItemId, ItemId)> {
-    let pool: Vec = if let Some(parent) = pool_parent {
-        content
-            .item_children
-            .get(parent)
-            .map(|s| s.iter().cloned().collect())
-            .unwrap_or_default()
-    } else if current_left.parent().as_ref().map(|p| p.as_str())
-        == current_right.parent().as_ref().map(|p| p.as_str())
-    {
-        current_left
-            .parent()
-            .and_then(|parent| {
-                content
-                    .item_children
-                    .get(&parent.normalized_storage())
-                    .cloned()
-            })
-            .map(|children| children.into_iter().collect())
-            .unwrap_or_default()
-    } else {
-        Vec::new()
-    };
-    if pool.len() < 2 {
-        return None;
-    }
-    suggest_next_pair_in_pool(
-        &content.ranking_group,
-        &pool,
-        Some((current_left, current_right)),
-    )
-}
-
-pub(super) fn vote_compare_item_card(
-    nav: &ThreadNav,
-    item: &ItemId,
-    body: Option<&String>,
-    side_class: &str,
-    item_bodies: Option<&HashMap>,
-) -> maud::Markup {
-    html! {
-        div class=(format!("vote-compare-side {side_class}")) {
-            a class=(format!("vote-compare-item {side_class}")) href=(nav.garden_item_href(item)) {
-                code { (item_display_path(item.as_str())) }
-            }
-            @if let Some(body) = body.filter(|b| !b.trim().is_empty()) {
-                div class="vote-compare-item-body" {
-                    (render_item_body_in_scope(
-                        body,
-                        nav.garden_root_url(),
-                        item_bodies,
-                    ))
-                }
-            } @else {
-                p class="muted vote-compare-item-body-empty" { "no body yet" }
-            }
-        }
-    }
-}
-#[derive(Debug, Deserialize)]
-pub struct VoteCompareQuery {
-    #[serde(default)]
-    pub left: Option,
-    #[serde(default)]
-    pub right: Option,
-    #[serde(default)]
-    pub thread: Option,
-    #[serde(default)]
-    pub pool: Option,
-}
-
-/// Public pairwise vote UI — `/vote?left=&right=&thread=`.
-pub async fn vote_compare_page(
-    State(state): State,
-    Query(q): Query,
-    headers: HeaderMap,
-    jar: CookieJar,
-    uri: Uri,
-) -> impl IntoResponse {
-    let nav = ThreadNav::public();
-    vote_compare_inner(state, q, nav, headers, jar, uri).await
-}
-
-pub async fn room_vote_compare_page(
-    State(state): State,
-    Path(room_key): Path,
-    Query(q): Query,
-    headers: HeaderMap,
-    jar: CookieJar,
-    uri: Uri,
-) -> impl IntoResponse {
-    let Some(room_id) = slug_types::room_id_from_route_segment(&room_key) else {
-        return (StatusCode::NOT_FOUND, "bad room path").into_response();
-    };
-    let Some(nav) = ThreadNav::from_room_id(&room_id) else {
-        return (StatusCode::NOT_FOUND, "bad room path").into_response();
-    };
-    let reduced = state.reduced.read().await;
-    let user = optional_principal(&headers, &jar, &reduced);
-    if !user_can_view_room(&reduced, &room_id, user.as_deref()) {
-        drop(reduced);
-        return room_not_found_page(&jar, &uri).into_response();
-    }
-    if !room_scope_has_garden_content(&reduced, &nav) {
-        drop(reduced);
-        return room_not_found_page(&jar, &uri).into_response();
-    }
-    drop(reduced);
-    vote_compare_inner(state, q, nav, headers, jar, uri).await
-}
-
-async fn vote_compare_inner(
-    state: AppState,
-    q: VoteCompareQuery,
-    nav: ThreadNav,
-    headers: HeaderMap,
-    jar: CookieJar,
-    uri: Uri,
-) -> axum::response::Response {
-    let pool_id: Option = match q.pool.as_deref() {
-        Some(p) => match ItemId::parse(p.trim()) {
-            Some(i) => Some(i.normalized_storage()),
-            None => return (StatusCode::BAD_REQUEST, "bad pool item").into_response(),
-        },
-        None => None,
-    };
-
-    let (left, right) = match (q.left.as_deref(), q.right.as_deref()) {
-        (Some(l), Some(r)) => {
-            let left = match ItemId::parse(l.trim()) {
-                Some(i) => i.normalized_storage(),
-                None => return (StatusCode::NOT_FOUND, "bad left item").into_response(),
-            };
-            let right = match ItemId::parse(r.trim()) {
-                Some(i) => i.normalized_storage(),
-                None => return (StatusCode::NOT_FOUND, "bad right item").into_response(),
-            };
-            if left == right {
-                return (StatusCode::BAD_REQUEST, "items must differ").into_response();
-            }
-            (left, right)
-        }
-        (None, None) => {
-            let Some(pool) = pool_id.as_ref() else {
-                return (StatusCode::BAD_REQUEST, "provide left+right or pool").into_response();
-            };
-            let reduced = state.reduced.read().await;
-            let content = content_for_garden_view(&reduced, &nav.scope());
-            let children: Vec = content
-                .item_children
-                .get(pool)
-                .map(|s| s.iter().cloned().collect())
-                .unwrap_or_default();
-            if children.len() < 2 {
-                drop(reduced);
-                return (StatusCode::BAD_REQUEST, "pool has fewer than 2 children to compare").into_response();
-            }
-            let pair = suggest_next_pair_in_pool(&content.ranking_group, &children, None);
-            drop(reduced);
-            match pair {
-                Some(p) => p,
-                None => return (StatusCode::BAD_REQUEST, "no pairs available in pool").into_response(),
-            }
-        }
-        _ => return (StatusCode::BAD_REQUEST, "provide both left and right, or just pool").into_response(),
-    };
-
-    let reduced = state.reduced.read().await;
-    let content = content_for_garden_view(&reduced, &nav.scope());
-    let viewer = optional_principal(&headers, &jar, &reduced);
-    let can_post = match &nav.scope() {
-        ScopeId::Public => viewer.is_some(),
-        ScopeId::Room(rid) => viewer
-            .as_ref()
-            .map(|u| user_can_post_room(&reduced, rid, u))
-            .unwrap_or(false),
-    };
-    let auto_thread = q
-        .thread
-        .as_ref()
-        .map(|t| canonicalize_tag(t))
-        .filter(|t| !t.is_empty())
-        .unwrap_or_else(|| pick_autothread_for_vote_pair(content, &left, &right));
-    let thread_tags = vote_thread_tags_for_pair(content, &left, &right);
-    let edge_history = vote_edge_history_markup(content, &left, &right);
-    let left_body = content.item_bodies.get(&left).cloned();
-    let right_body = content.item_bodies.get(&right).cloned();
-    let item_bodies_for_cards = content.item_bodies.clone();
-    let next_pair = suggest_next_vote_pair(content, &left, &right, pool_id.as_ref());
-    drop(reduced);
-
-    let title = format!(
-        "vote — {} vs {}",
-        item_display_path(left.as_str()),
-        item_display_path(right.as_str())
-    );
-    let next_path = uri
-        .path_and_query()
-        .map(|pq| pq.as_str().to_string())
-        .unwrap_or_else(|| "/vote".into());
-
-    let rpc_json = template_json_compact(&json!({
-        "action": "vote_compare_post",
-        "room": nav.room_wire,
-        "thread_tag": {"$form": "thread_tag"},
-        "left_item": left.as_str(),
-        "right_item": right.as_str(),
-        "ratio_left": {"$form": "ratio_left"},
-        "ratio_right": {"$form": "ratio_right"},
-        "explanation": {"$form": "explanation"},
-        "next": next_path,
-        "pool": pool_id.as_ref().map(|p| p.as_str()),
-        "form_action": "/ui",
-    }))
-    .expect("vote compare rpc json");
-
-    let body = html! {
-    section class="vote-compare-shell" {
-        h2 { "compare" }
-        div class="vote-compare-pair" {
-            (vote_compare_item_card(
-                &nav,
-                &left,
-                left_body.as_ref(),
-                "vote-compare-left",
-                Some(&item_bodies_for_cards),
-            ))
-            span class="vote-compare-vs" { "vs" }
-            (vote_compare_item_card(
-                &nav,
-                &right,
-                right_body.as_ref(),
-                "vote-compare-right",
-                Some(&item_bodies_for_cards),
-            ))
-        }
-        (vote_compare_nav_markup(&nav, next_pair.as_ref(), pool_id.as_ref()))
-        div id="vote-edge-history-region" {
-            (edge_history)
-        }
-        @if can_post {
-            form id="vote-compare-form" method="POST" action="/ui" {
-                input type="hidden" name=(UI_RPC_FIELD) value=(rpc_json);
-                div class="vote-thread-picker" {
-                    label class="vote-thread-picker-label" { "thread" }
-                    select id="vote-thread-select" name="thread_tag" aria-label="Thread to post vote into" {
-                        @if thread_tags.is_empty() {
-                            option value="vote" selected { "#vote" }
-                        }
-                        @for t in &thread_tags {
-                            @if *t == auto_thread {
-                                option value=(t) selected { "#" (t) }
-                            } @else {
-                                option value=(t) { "#" (t) }
-                            }
-                        }
-                    }
-                }
-                input type="hidden" name="ratio_left" id="vote-ratio-left" value="50";
-                input type="hidden" name="ratio_right" id="vote-ratio-right" value="50";
-                label class="vote-compare-slider-label" {
-                    span id="vote-slider-left-label" { (item_display_path(left.as_str())) }
-                    input type="range" id="vote-preference-slider" min="0" max="100" value="50"
-                        aria-valuemin="0" aria-valuemax="100";
-                    span id="vote-slider-right-label" { (item_display_path(right.as_str())) }
-                }
-                label class="vote-explain-label" { "reason (required)" }
-                textarea name="explanation" id="vote-explain" rows="5" placeholder="why this split?" required {}
-                div id="vote-compare-errors" {}
-                p { button type="submit" { "post vote" } }
-            }
-        } @else {
-            p class="muted" { a href=(login_href_with_next(&next_path)) { "log in" } " to post this vote." }
-        }
-    }
-    };
-
-    let url_key = canonical_view_url(&uri);
-    let view_count = state.views.get_views(&url_key);
-
-    let page = layout_full_bleed_chromeless(
-        &title,
-        "view-ontology view-ontology-light view-vote-compare view-vote-compare-fullscreen",
-        body,
-        Some(view_count),
-        theme_from_jar(&jar),
-        &theme_next_from_uri(&uri),
-    );
-    Html(page.into_string()).into_response()
-}
-