{"messages":[{"content":"You are a constitutional council ranking individual git commits for ownership allocation.\n\nCompare these two commits. Decide which contributed more lasting value to the project.\n\nJudge substance, not spectacle:\n- Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise.\n- Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one.\n- Do not favor a side merely because its patch is longer or noisier.\n- Weight what the change does for the project, not the contributor's name.\n\nReturn ONLY a JSON object: {\"winner\": \"A\" or \"B\", \"ratio\": \"N:M\", \"explanation\": \"...\"}\nThe explanation must cite concrete differences in the patches (1-3 sentences).\n\nSide A — contributor: tommy-mor\nSide A — commit message:\n[2fe70b0e] themes\n\nSide A — unified diff (full patch):\ndiff --git a/server/src/api/auth.rs b/server/src/api/auth.rs\nindex 559db65de14c6157690ffbf18eca0cf65b0a5202..b3631b06153d52f88348fef927e7a024b7b85ad6 100644\n--- a/server/src/api/auth.rs\n+++ b/server/src/api/auth.rs\n@@ -1,7 +1,7 @@\n use axum::{\n body::Body,\n extract::{Path, Query, State},\n- http::{header, HeaderMap, HeaderValue, StatusCode},\n+ http::{header, HeaderMap, HeaderValue, StatusCode, Uri},\n response::{IntoResponse, Redirect, Response},\n Form, Json,\n };\n@@ -17,7 +17,7 @@ use crate::{\n events::{Event, GrantAdded, TokenIssued, UserRegistered},\n html::{\n auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment,\n- choose_username_page, JsBuilder,\n+ choose_username_page, theme_cookie_header_from_jar, theme_from_jar, theme_next_from_uri, JsBuilder,\n },\n identity::{parse_agent, parse_username},\n reducer::ReducerState,\n@@ -48,15 +48,17 @@ fn js_form_error_fragment(session: &str, error: &str) -> Response {\n .into_response()\n }\n \n-fn js_signed_in_fragment(bearer: &str) -> Response {\n+fn js_signed_in_fragment(bearer: &str, jar: &CookieJar) -> Response {\n let mut response = JsBuilder::new()\n .id(\"choose-username-form\")\n .morph_inner(auth_signed_in_fragment())\n .redirect(\"/auth/complete\")\n .into_response();\n- response\n- .headers_mut()\n- .insert(header::SET_COOKIE, session_cookie_header_value(bearer));\n+ let headers = response.headers_mut();\n+ headers.append(header::SET_COOKIE, session_cookie_header_value(bearer));\n+ if let Some(theme) = theme_cookie_header_from_jar(jar) {\n+ headers.append(header::SET_COOKIE, theme);\n+ }\n response\n }\n \n@@ -69,13 +71,18 @@ pub fn optional_principal(headers: &HeaderMap, jar: &CookieJar, reduced: &Reduce\n verify_token(reduced, c.value()).ok()\n }\n \n-fn redirect_with_session_cookie(public_url: &str, path_and_query: &str, bearer: &str) -> Response {\n- Response::builder()\n+fn redirect_with_session_cookie(public_url: &str, path_and_query: &str, bearer: &str, jar: &CookieJar) -> Response {\n+ let mut res = Response::builder()\n .status(StatusCode::TEMPORARY_REDIRECT)\n .header(header::LOCATION, format!(\"{public_url}{path_and_query}\"))\n- .header(header::SET_COOKIE, session_cookie_header_value(bearer))\n .body(Body::empty())\n- .unwrap()\n+ .unwrap();\n+ let headers = res.headers_mut();\n+ headers.append(header::SET_COOKIE, session_cookie_header_value(bearer));\n+ if let Some(theme) = theme_cookie_header_from_jar(jar) {\n+ headers.append(header::SET_COOKIE, theme);\n+ }\n+ res\n }\n \n async fn apply_invite_redemption(state: &AppState, invite_token: &str, grantee_username: &str) -> Result<(), String> {\n@@ -286,7 +293,11 @@ pub struct AuthCallbackQuery {\n pub state: String,\n }\n \n-pub async fn get_auth_callback(Query(q): Query, State(state): State) -> impl IntoResponse {\n+pub async fn get_auth_callback(\n+ Query(q): Query,\n+ State(state): State,\n+ jar: CookieJar,\n+) -> impl IntoResponse {\n let sessions = pending_sessions(&state);\n {\n let sessions_read = sessions.read().await;\n@@ -365,7 +376,7 @@ pub async fn get_auth_callback(Query(q): Query, State(state):\n }\n let cookie_bearer = bearer.clone();\n s.complete = Some((username, bearer));\n- return redirect_with_session_cookie(&public_url, \"/\", &cookie_bearer).into_response();\n+ return redirect_with_session_cookie(&public_url, \"/\", &cookie_bearer, &jar).into_response();\n }\n }\n \n@@ -378,14 +389,20 @@ pub struct ChooseUsernameQuery {\n pub error: Option,\n }\n \n-pub async fn get_choose_username(Query(q): Query, State(state): State) -> impl IntoResponse {\n+pub async fn get_choose_username(\n+ Query(q): Query,\n+ State(state): State,\n+ jar: CookieJar,\n+ uri: Uri,\n+) -> impl IntoResponse {\n let sessions = pending_sessions(&state);\n let sessions_read = sessions.read().await;\n if !sessions_read.contains_key(&q.session) {\n return api_error(StatusCode::NOT_FOUND, \"unknown session\", None).into_response();\n }\n drop(sessions_read);\n- choose_username_page(&q.session, q.error.as_deref()).into_response()\n+ let next = theme_next_from_uri(&uri);\n+ choose_username_page(&q.session, q.error.as_deref(), theme_from_jar(&jar), &next).into_response()\n }\n \n #[derive(Debug, Deserialize)]\n@@ -396,6 +413,7 @@ pub struct ChooseUsernameForm {\n \n pub async fn post_choose_username(\n State(state): State,\n+ jar: CookieJar,\n Form(form): Form,\n ) -> impl IntoResponse {\n let canon_user = match parse_username(&form.username) {\n@@ -477,7 +495,7 @@ pub async fn post_choose_username(\n s.complete = Some((canon_user.clone(), bearer.clone()));\n }\n \n- js_signed_in_fragment(&bearer).into_response()\n+ js_signed_in_fragment(&bearer, &jar).into_response()\n }\n \n /// Start a browser-only OAuth flow (no CLI polling). Sets session cookie on success.\n@@ -569,8 +587,9 @@ pub async fn get_pending_session(\n .into_response()\n }\n \n-pub async fn get_auth_complete() -> impl IntoResponse {\n- auth_complete_page()\n+pub async fn get_auth_complete(jar: CookieJar, uri: Uri) -> impl IntoResponse {\n+ let next = theme_next_from_uri(&uri);\n+ auth_complete_page(theme_from_jar(&jar), &next).into_response()\n }\n \n pub async fn get_whoami(State(state): State, headers: HeaderMap) -> impl IntoResponse {\ndiff --git a/server/src/api/helpers.rs b/server/src/api/helpers.rs\nindex 81e2a55fa3abb8609b4099f91a989480336e11eb..9b71491e9f9efc44a2a4beba09be8f64bd2ff2ee 100644\n--- a/server/src/api/helpers.rs\n+++ b/server/src/api/helpers.rs\n@@ -39,6 +39,55 @@ pub fn item_path_for_api(item: &str) -> String {\n }\n }\n \n+/// Same as [`item_path_for_api`], but for private rooms ontology items are prefixed with\n+/// `/r/{short}/{slug}` so the URL matches the web app (`/r/…/~/…` routes).\n+pub fn item_path_for_api_in_room(item: &str, room_wire: &str) -> String {\n+ let room = room_wire.trim();\n+ if room.is_empty() || room == \"public\" {\n+ return item_path_for_api(item);\n+ }\n+ let Some((short, slug)) = room.split_once('/') else {\n+ return item_path_for_api(item);\n+ };\n+ if short.is_empty() || slug.is_empty() {\n+ return item_path_for_api(item);\n+ }\n+ let Some(c) = CanonicalItemUrl::parse(item) else {\n+ return item_path_for_api(item);\n+ };\n+ let root = CanonicalItemUrl::ontology_root();\n+ let item_norm = c.as_str().trim_end_matches('/');\n+ let root_norm = root.as_str().trim_end_matches('/');\n+ if let Some(tail) = c.tilde_tail() {\n+ return if tail.is_empty() {\n+ format!(\"https://slug.social/r/{short}/{slug}/~\")\n+ } else {\n+ format!(\"https://slug.social/r/{short}/{slug}/~/{}\", tail)\n+ };\n+ }\n+ if item_norm == root_norm {\n+ return format!(\"https://slug.social/r/{short}/{slug}/~\");\n+ }\n+ item_path_for_api(item)\n+}\n+\n+/// Absolute thread URL for forum JSON (`/t/…` vs `/r/…/t/…`).\n+pub fn forum_thread_web_url(room_wire: &str, thread_tag: &str) -> String {\n+ let room = room_wire.trim();\n+ let tag = thread_tag.trim().trim_start_matches('#');\n+ if room.is_empty() || room == \"public\" {\n+ format!(\"https://slug.social/t/{tag}\")\n+ } else if let Some((short, slug)) = room.split_once('/') {\n+ if short.is_empty() || slug.is_empty() {\n+ format!(\"https://slug.social/t/{tag}\")\n+ } else {\n+ format!(\"https://slug.social/r/{short}/{slug}/t/{tag}\")\n+ }\n+ } else {\n+ format!(\"https://slug.social/t/{tag}\")\n+ }\n+}\n+\n /// Resolve an item path as a first-class canonical path.\n pub fn resolve_item(item: &str) -> Result {\n let canonical = canonicalize_item(item);\n@@ -188,3 +237,52 @@ pub fn vote_touches_path(a: &str, b: &str, parent_canon: &str) -> bool {\n let under = |item: &str| item == parent_canon || item.starts_with(&format!(\"{}/\", parent_canon));\n under(a) || under(b)\n }\n+\n+#[cfg(test)]\n+mod wire_url_tests {\n+ use super::{forum_thread_web_url, item_path_for_api_in_room};\n+\n+ #[test]\n+ fn public_room_unchanged() {\n+ let u = \"https://slug.social/~/a/b\";\n+ assert_eq!(item_path_for_api_in_room(u, \"public\"), u);\n+ }\n+\n+ #[test]\n+ fn private_room_prefixes_ontology() {\n+ assert_eq!(\n+ item_path_for_api_in_room(\"https://slug.social/~/topic/x\", \"9ab12cd/my-room\"),\n+ \"https://slug.social/r/9ab12cd/my-room/~/topic/x\"\n+ );\n+ }\n+\n+ #[test]\n+ fn private_room_ontology_root() {\n+ assert_eq!(\n+ item_path_for_api_in_room(\"https://slug.social/~\", \"9ab12cd/my-room\"),\n+ \"https://slug.social/r/9ab12cd/my-room/~\"\n+ );\n+ assert_eq!(\n+ item_path_for_api_in_room(\"https://slug.social/~/\", \"9ab12cd/my-room\"),\n+ \"https://slug.social/r/9ab12cd/my-room/~\"\n+ );\n+ }\n+\n+ #[test]\n+ fn external_url_untouched_in_private_room() {\n+ let u = \"https://example.com/z\";\n+ assert_eq!(item_path_for_api_in_room(u, \"9ab12cd/my-room\"), u);\n+ }\n+\n+ #[test]\n+ fn forum_web_public_vs_room() {\n+ assert_eq!(\n+ forum_thread_web_url(\"public\", \"debate\"),\n+ \"https://slug.social/t/debate\"\n+ );\n+ assert_eq!(\n+ forum_thread_web_url(\"9ab12cd/my-room\", \"#debate\"),\n+ \"https://slug.social/r/9ab12cd/my-room/t/debate\"\n+ );\n+ }\n+}\ndiff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs\nindex 31f5fcfb4eaf4df0a9cbac532dd3dedfe3611810..5b91f5836625eedbb1cd9423168046e3fb576c17 100644\n--- a/server/src/api/rpc.rs\n+++ b/server/src/api/rpc.rs\n@@ -27,8 +27,9 @@ use crate::{\n \n use super::auth::verify_bearer_principal;\n use super::helpers::{\n- compute_connectivity_stats, is_pair_voted, item_path_for_api, now_ms, paginate_rankings,\n- parse_parent_specs, pick_random_distinct, resolve_item, vote_touches_path,\n+ compute_connectivity_stats, forum_thread_web_url, is_pair_voted, item_path_for_api,\n+ item_path_for_api_in_room, now_ms, paginate_rankings, parse_parent_specs, pick_random_distinct,\n+ resolve_item, vote_touches_path,\n };\n use super::validate::{normalize_room_and_thread, validate_ingest_document};\n \n@@ -148,6 +149,7 @@ fn compute_scope_rank_changes(\n parent: &str,\n before: &crate::scope_rank::ChildrenRankings,\n after: &crate::scope_rank::ChildrenRankings,\n+ room_wire: &str,\n ) -> Option {\n fn build_positions(rankings: &crate::scope_rank::ChildrenRankings) -> HashMap> {\n let mut map = HashMap::new();\n@@ -182,7 +184,7 @@ fn compute_scope_rank_changes(\n };\n if changed {\n changes.push(RankChange {\n- item: item_path_for_api(&item),\n+ item: item_path_for_api_in_room(&item, room_wire),\n before: b,\n after: a,\n });\n@@ -204,7 +206,7 @@ fn compute_scope_rank_changes(\n parent: if parent.is_empty() {\n \"/\".to_string()\n } else {\n- item_path_for_api(parent)\n+ item_path_for_api_in_room(parent, room_wire)\n },\n changes,\n })\n@@ -256,6 +258,7 @@ fn build_rank_response_for_content(\n offset: usize,\n limit: Option,\n want_percent: bool,\n+ room_wire: &str,\n ) -> Result {\n let parent_owned = parent.map(|s| s.to_string());\n let specs = parse_parent_specs(parent_owned.as_ref());\n@@ -299,7 +302,7 @@ fn build_rank_response_for_content(\n .ranked\n .into_iter()\n .map(|r| RankRow {\n- item: item_path_for_api(r.item.as_str()),\n+ item: item_path_for_api_in_room(r.item.as_str(), room_wire),\n percent: if want_percent {\n Some((r.score / max_score) * 100.0)\n } else {\n@@ -315,7 +318,7 @@ fn build_rank_response_for_content(\n let prefixed_unranked: Vec = rankings\n .unranked_items\n .into_iter()\n- .map(|s| item_path_for_api(s.as_str()))\n+ .map(|s| item_path_for_api_in_room(s.as_str(), room_wire))\n .collect();\n \n let (components, unranked_items) = if offset > 0 || limit.is_some() {\n@@ -522,7 +525,7 @@ async fn rpc_post(\n .filter_map(|p| {\n let before = pre_rankings.get(p)?;\n let after = crate::scope_rank::build_children_rankings(content, p);\n- compute_scope_rank_changes(p.as_str(), before, &after)\n+ compute_scope_rank_changes(p.as_str(), before, &after, &room_key)\n })\n .collect();\n if v.is_empty() { None } else { Some(v) }\n@@ -530,14 +533,28 @@ async fn rpc_post(\n None\n };\n \n+ let (pair_hint, rank_hint, web_url) = if room_key == \"public\" {\n+ (\n+ \"npx slugsocial public garden pair\".to_string(),\n+ \"npx slugsocial public garden rank\".to_string(),\n+ forum_thread_web_url(\"public\", &thread_id),\n+ )\n+ } else {\n+ (\n+ format!(\"npx slugsocial private {room_key} garden pair\"),\n+ format!(\"npx slugsocial private {room_key} garden rank\"),\n+ forum_thread_web_url(&room_key, &thread_id),\n+ )\n+ };\n+\n Ok(RpcResult::PostOk {\n events_appended,\n ranking_changes,\n threads: vec![format!(\"#{}\", thread_id)],\n next: NextMoves {\n- pair: \"npx slugsocial public garden pair\".to_string(),\n- rank: \"npx slugsocial public garden rank\".to_string(),\n- web: format!(\"https://slug.social/t/{}\", thread_id),\n+ pair: pair_hint,\n+ rank: rank_hint,\n+ web: web_url,\n },\n })\n }\n@@ -609,7 +626,7 @@ async fn rpc_check(\n raw: v.raw_text.clone(),\n principal,\n delegate,\n- room_id: room_key,\n+ room_id: room_key.clone(),\n thread_tag: thread_id.clone(),\n });\n \n@@ -647,7 +664,7 @@ async fn rpc_check(\n .ranked\n .into_iter()\n .map(|r| RankRow {\n- item: item_path_for_api(r.item.as_str()),\n+ item: item_path_for_api_in_room(r.item.as_str(), &room_key),\n score: r.score,\n percent: None,\n })\n@@ -655,25 +672,35 @@ async fn rpc_check(\n })\n .collect();\n CheckScopeRanking {\n- parent: item_path_for_api(parent.as_str()),\n+ parent: item_path_for_api_in_room(parent.as_str(), &room_key),\n components,\n unranked_items: scoped\n .unranked_items\n .into_iter()\n- .map(|it| item_path_for_api(it.as_str()))\n+ .map(|it| item_path_for_api_in_room(it.as_str(), &room_key))\n .collect(),\n }\n })\n .collect();\n \n+ let check_next = if room_key == \"public\" {\n+ vec![\n+ \"npx slugsocial public forum post --delegate \".to_string(),\n+ \"npx slugsocial public forum list\".to_string(),\n+ forum_thread_web_url(\"public\", &thread_id),\n+ ]\n+ } else {\n+ vec![\n+ format!(\"npx slugsocial private {room_key} forum post --delegate \"),\n+ format!(\"npx slugsocial private {room_key} forum list\"),\n+ forum_thread_web_url(&room_key, &thread_id),\n+ ]\n+ };\n+\n Ok(RpcResult::CheckOk {\n rankings,\n threads: vec![format!(\"#{}\", thread_id)],\n- next: vec![\n- \"npx slugsocial public forum post --delegate \".to_string(),\n- \"npx slugsocial public forum list\".to_string(),\n- format!(\"https://slug.social/t/{}\", thread_id),\n- ],\n+ next: check_next,\n })\n }\n \n@@ -690,7 +717,7 @@ fn rpc_list_forum_threads(reduced: &ReducerState, room: &str) -> ThreadsResponse\n .map(|((_, tag), ts)| ThreadSummary {\n thread: format!(\"#{tag}\"),\n last_activity_ts: ts.last_activity_ts,\n- web: format!(\"https://slug.social/t/{}\", tag),\n+ web: forum_thread_web_url(room, tag),\n })\n .collect();\n out.sort_by(|a, b| b.last_activity_ts.cmp(&a.last_activity_ts));\n@@ -1031,8 +1058,8 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re\n .collect();\n let cs = compute_connectivity_stats(&content.ranking_group, &pool);\n Ok(RpcResult::Pair(PairResponse {\n- left: item_path_for_api(&left),\n- right: item_path_for_api(&right),\n+ left: item_path_for_api_in_room(&left, &room),\n+ right: item_path_for_api_in_room(&right, &room),\n left_body: lb,\n right_body: rb,\n threads: th,\n@@ -1088,6 +1115,7 @@ pub async fn handle_rpc_batch(\n offset.unwrap_or(0),\n limit,\n percent.unwrap_or(false),\n+ &room,\n ) {\n Ok(r) => line_ok(RpcResult::GardenRank(r)),\n Err((e, h)) => line_err(e, h),\n@@ -1109,7 +1137,7 @@ pub async fn handle_rpc_batch(\n if !content.items.contains(&item) {\n line_err(\n \"item not found\",\n- Some(format!(\"{} does not exist\", item_path_for_api(&item_str))),\n+ Some(format!(\"{} does not exist\", item_path_for_api_in_room(&item_str, &room))),\n )\n } else {\n const MAX_ITEM_BODY: usize = 10_000;\n@@ -1132,7 +1160,7 @@ pub async fn handle_rpc_batch(\n .map(|s| s.iter().cloned().collect())\n .unwrap_or_default();\n line_ok(RpcResult::GardenItem(ItemResponse {\n- item: item_str,\n+ item: item_path_for_api_in_room(&item_str, &room),\n body,\n truncated,\n body_len,\n@@ -1525,7 +1553,11 @@ pub async fn handle_rpc_batch(\n let range = (top - bot).max(1e-12);\n for r in items {\n let pct = want_percent.then(|| ((r.score - bot) / range * 100.0).clamp(0.0, 100.0));\n- ranked.push(RankRow { item: item_path_for_api(r.item.as_str()), score: r.score, percent: pct });\n+ ranked.push(RankRow {\n+ item: item_path_for_api_in_room(r.item.as_str(), &room),\n+ score: r.score,\n+ percent: pct,\n+ });\n }\n }\n \n@@ -1542,7 +1574,7 @@ pub async fn handle_rpc_batch(\n let page: Vec = ranked\n .into_iter()\n .chain(unranked.into_iter().map(|it| RankRow {\n- item: item_path_for_api(&it),\n+ item: item_path_for_api_in_room(&it, &room),\n score: 0.0,\n percent: want_percent.then_some(0.0),\n }))\n@@ -1586,7 +1618,7 @@ pub async fn handle_rpc_batch(\n if !content.items.contains(&item) {\n line_err(\n \"item not found\",\n- Some(format!(\"{} does not exist\", item_path_for_api(&item_str))),\n+ Some(format!(\"{} does not exist\", item_path_for_api_in_room(&item_str, &room))),\n )\n } else {\n let votes: Vec = content\n@@ -1597,8 +1629,8 @@ pub async fn handle_rpc_batch(\n .take(limit)\n .map(|v| VoteRow {\n ts: v.ts,\n- a: v.a.as_str().to_string(),\n- b: v.b.as_str().to_string(),\n+ a: item_path_for_api_in_room(v.a.as_str(), &room),\n+ b: item_path_for_api_in_room(v.b.as_str(), &room),\n ratio: format!(\"{}:{}\", v.ratio_left, v.ratio_right),\n actor: Some(v.principal.clone()),\n body: v.body.clone(),\n@@ -1608,7 +1640,7 @@ pub async fn handle_rpc_batch(\n })\n .unwrap_or_default();\n line_ok(RpcResult::Matchup(MatchupResponse {\n- item: item_path_for_api(&item_str),\n+ item: item_path_for_api_in_room(&item_str, &room),\n votes,\n }))\n }\n@@ -1635,8 +1667,8 @@ pub async fn handle_rpc_batch(\n if a == item_str || b == item_str {\n Some(VoteRow {\n ts: e.ts,\n- a: item_path_for_api(&a),\n- b: item_path_for_api(&b),\n+ a: item_path_for_api_in_room(&a, &room),\n+ b: item_path_for_api_in_room(&b, &room),\n ratio: format!(\"{}:{}\", ratio_left, ratio_right),\n actor: reduced.ingests_by_id.get(&e.post_id).map(|ing| ing.principal.clone()),\n body: explanation,\n@@ -1672,7 +1704,7 @@ pub async fn handle_rpc_batch(\n }\n }).collect();\n line_ok(RpcResult::RankHistory(RankHistoryResponse {\n- item: item_path_for_api(&item_str),\n+ item: item_path_for_api_in_room(&item_str, &room),\n history,\n }))\n }\n@@ -1691,6 +1723,10 @@ pub async fn handle_rpc_batch(\n .map(|p| p.as_str().to_string())\n .collect();\n paths.sort();\n+ let paths: Vec = paths\n+ .into_iter()\n+ .map(|p| item_path_for_api_in_room(&p, &room))\n+ .collect();\n line_ok(RpcResult::Leaves(LeavesResponse { paths }))\n }\n },\n@@ -1707,10 +1743,21 @@ pub async fn handle_rpc_batch(\n let mut v: Vec = roots.iter()\n .map(|path| {\n let children = content.item_children.get(path.as_str()).map(|s| s.len()).unwrap_or(0);\n+ let path_label = CanonicalItemUrl::parse(path.as_str())\n+ .and_then(|c| {\n+ c.tilde_tail().map(|t| {\n+ if t.is_empty() {\n+ \"~/\".to_string()\n+ } else {\n+ format!(\"~/{}\", t)\n+ }\n+ })\n+ })\n+ .unwrap_or_else(|| path.to_string());\n PathSummary {\n- path: format!(\"~/{}\", path),\n+ path: path_label,\n children,\n- web: format!(\"https://slug.social/~/{}\", path),\n+ web: item_path_for_api_in_room(path.as_str(), &room),\n }\n }).collect();\n v.sort_by(|a, b| a.path.cmp(&b.path));\n@@ -1743,8 +1790,8 @@ pub async fn handle_rpc_batch(\n .take(limit)\n .map(|v| VoteRow {\n ts: v.ts,\n- a: v.a.as_str().to_string(),\n- b: v.b.as_str().to_string(),\n+ a: item_path_for_api_in_room(v.a.as_str(), &room),\n+ b: item_path_for_api_in_room(v.b.as_str(), &room),\n ratio: format!(\"{}:{}\", v.ratio_left, v.ratio_right),\n actor: Some(v.principal.clone()),\n body: v.body.clone(),\ndiff --git a/server/src/html/auth.rs b/server/src/html/auth.rs\nindex d57b1c725ce3e06b1d904882b9388c3da48d85cb..5df77c76b89ed2bb11d936e1dbb804d50a0e79a1 100644\n--- a/server/src/html/auth.rs\n+++ b/server/src/html/auth.rs\n@@ -23,7 +23,7 @@ fn form_inner(session: &str, error: Option<&str>) -> Markup {\n }\n }\n \n-pub fn choose_username_page(session: &str, error: Option<&str>) -> Markup {\n+pub fn choose_username_page(session: &str, error: Option<&str>, theme: &str, theme_next: &str) -> Markup {\n let body = html! {\n nav.breadcrumb {\n a href=\"/\" { \"slug.social\" }\n@@ -36,7 +36,7 @@ pub fn choose_username_page(session: &str, error: Option<&str>) -> Markup {\n (form_inner(session, error))\n }\n };\n- super::layout(\"join — slug.social\", \"view-auth\", body, None)\n+ super::layout(\"join — slug.social\", \"view-auth\", body, None, theme, theme_next)\n }\n \n /// Fragment returned to the poem JS on error — replaces the form's innerHTML.\n@@ -52,7 +52,7 @@ pub fn auth_signed_in_fragment() -> Markup {\n }\n }\n \n-pub fn auth_complete_page() -> Markup {\n+pub fn auth_complete_page(theme: &str, theme_next: &str) -> Markup {\n let body = html! {\n nav.breadcrumb {\n a href=\"/\" { \"slug.social\" }\n@@ -63,5 +63,5 @@ pub fn auth_complete_page() -> Markup {\n p { \"Return to your terminal — your agent is polling and will collect your token automatically.\" }\n p.auth-hint { \"You can close this tab.\" }\n };\n- super::layout(\"signed in — slug.social\", \"view-auth\", body, None)\n+ super::layout(\"signed in — slug.social\", \"view-auth\", body, None, theme, theme_next)\n }\ndiff --git a/server/src/html/editor.rs b/server/src/html/editor.rs\nindex 07f6ab3b778f07f60765fa3f39060ebc5f72bc1a..ecdd226b1b17d5f11d1b79f99add5759ef918281 100644\n--- a/server/src/html/editor.rs\n+++ b/server/src/html/editor.rs\n@@ -1,8 +1,10 @@\n use axum::{\n extract::State,\n+ http::Uri,\n response::{Html, IntoResponse},\n Form,\n };\n+use axum_extra::extract::cookie::CookieJar;\n use maud::{html, Markup};\n use serde::Deserialize;\n \n@@ -13,7 +15,7 @@ use crate::{\n state::AppState,\n };\n \n-use super::{bc_segment, layout};\n+use super::{bc_segment, layout, theme_from_jar, theme_next_from_uri};\n \n fn bc_try() -> Markup {\n html! {\n@@ -23,7 +25,7 @@ fn bc_try() -> Markup {\n }\n \n /// The interactive editor page — `/try`.\n-pub async fn editor_page() -> impl IntoResponse {\n+pub async fn editor_page(jar: CookieJar, uri: Uri) -> impl IntoResponse {\n let page = layout(\n \"try — slug.social\",\n \"view-thread\",\n@@ -68,6 +70,8 @@ pub async fn editor_page() -> impl IntoResponse {\n \"#)) }\n },\n None,\n+ theme_from_jar(&jar),\n+ &theme_next_from_uri(&uri),\n );\n Html(page.into_string())\n }\ndiff --git a/server/src/html/forum.rs b/server/src/html/forum.rs\nindex 5c5f9e2ba0e5cb1d661c113117988ec80c01deca..368b1016eb43d83e9ff39464ff6164fa9a8b558a 100644\n--- a/server/src/html/forum.rs\n+++ b/server/src/html/forum.rs\n@@ -1,6 +1,6 @@\n use axum::{\n extract::{Path, Query, State},\n- http::{HeaderMap, StatusCode},\n+ http::{HeaderMap, StatusCode, Uri},\n response::{Html, IntoResponse},\n };\n use axum_extra::extract::cookie::CookieJar;\n@@ -19,7 +19,7 @@ use crate::{\n \n use super::{\n bc_segment, bc_threads, cli_panel, layout, now_ms, profile_href, recency_class,\n- render_linkified_with_embeds_in_scope, JsBuilder,\n+ render_linkified_with_embeds_in_scope, theme_from_jar, theme_next_from_uri, JsBuilder,\n };\n \n #[derive(Clone)]\n@@ -577,6 +577,7 @@ pub async fn home(\n State(state): State,\n headers: HeaderMap,\n jar: CookieJar,\n+ uri: Uri,\n ) -> impl IntoResponse {\n let now = now_ms();\n let reduced = state.reduced.read().await;\n@@ -624,6 +625,8 @@ pub async fn home(\n (cli_panel(\"npx slugsocial public forum list\"))\n },\n None,\n+ theme_from_jar(&jar),\n+ &theme_next_from_uri(&uri),\n );\n Html(page.into_string())\n }\n@@ -679,6 +682,7 @@ async fn thread_view_inner(\n nav: ThreadNav,\n headers: HeaderMap,\n jar: CookieJar,\n+ uri: Uri,\n ) -> impl IntoResponse {\n let tag = canonicalize_tag(&tag);\n let scope = nav.scope();\n@@ -778,6 +782,8 @@ async fn thread_view_inner(\n (cli_panel(&cli))\n },\n None,\n+ theme_from_jar(&jar),\n+ &theme_next_from_uri(&uri),\n );\n Html(page.into_string()).into_response()\n }\n@@ -789,8 +795,9 @@ pub async fn thread_view(\n Query(q): Query,\n headers: HeaderMap,\n jar: CookieJar,\n+ uri: Uri,\n ) -> impl IntoResponse {\n- thread_view_inner(state, tag, q, ThreadNav::public(), headers, jar).await\n+ thread_view_inner(state, tag, q, ThreadNav::public(), headers, jar, uri).await\n }\n \n /// Room thread — `/r/:short/:slug/t/:tag`\n@@ -800,31 +807,39 @@ pub async fn room_thread_view(\n Query(q): Query,\n headers: HeaderMap,\n jar: CookieJar,\n+ uri: Uri,\n ) -> impl IntoResponse {\n let room_id = format!(\"{room_short}/{room_slug}\");\n let reduced = state.reduced.read().await;\n let user = optional_principal(&headers, &jar, &reduced);\n if !user_can_view_room(&reduced, &room_id, user.as_deref()) {\n drop(reduced);\n- return room_not_found_page().into_response();\n+ return room_not_found_page(&jar, &uri).into_response();\n }\n drop(reduced);\n let Some(nav) = ThreadNav::from_room_id(&room_id) else {\n return (StatusCode::NOT_FOUND, \"bad room path\").into_response();\n };\n- thread_view_inner(state, tag, q, nav, headers, jar)\n+ thread_view_inner(state, tag, q, nav, headers, jar, uri)\n .await\n .into_response()\n }\n \n-fn room_not_found_page() -> impl IntoResponse {\n+fn room_not_found_page(jar: &CookieJar, uri: &Uri) -> impl IntoResponse {\n let body = html! {\n nav class=\"breadcrumb\" { a href=\"/\" { \"slug.social\" } }\n h1 { \"not found\" }\n p { \"The requested page could not be found.\" }\n p { a href=\"/\" { \"home\" } }\n };\n- let page = layout(\"not found — slug.social\", \"view-thread\", body, None);\n+ let page = layout(\n+ \"not found — slug.social\",\n+ \"view-thread\",\n+ body,\n+ None,\n+ theme_from_jar(jar),\n+ &theme_next_from_uri(uri),\n+ );\n (StatusCode::NOT_FOUND, Html(page.into_string()))\n }\n \n@@ -834,6 +849,7 @@ pub async fn room_page(\n Path((room_short, room_slug)): Path<(String, String)>,\n headers: HeaderMap,\n jar: CookieJar,\n+ uri: Uri,\n ) -> impl IntoResponse {\n let room_id = format!(\"{room_short}/{room_slug}\");\n let now = now_ms();\n@@ -845,7 +861,7 @@ pub async fn room_page(\n let user = optional_principal(&headers, &jar, &reduced);\n if !user_can_view_room(&reduced, &room_id, user.as_deref()) {\n drop(reduced);\n- return room_not_found_page().into_response();\n+ return room_not_found_page(&jar, &uri).into_response();\n }\n let scope = ScopeId::Room(room_id.clone());\n let mut rows = collect_thread_rows_for_scope(&reduced, &scope, now);\n@@ -902,6 +918,8 @@ pub async fn room_page(\n (cli_panel(&audit_cli))\n },\n None,\n+ theme_from_jar(&jar),\n+ &theme_next_from_uri(&uri),\n );\n Html(page.into_string()).into_response()\n }\n@@ -943,6 +961,8 @@ async fn thread_post_view_inner(\n index_str: String,\n nav: ThreadNav,\n viewer: Option,\n+ jar: CookieJar,\n+ uri: Uri,\n ) -> impl IntoResponse {\n let tag = canonicalize_tag(&tag);\n let index: usize = index_str.parse().unwrap_or(0);\n@@ -987,6 +1007,8 @@ async fn thread_post_view_inner(\n }\n },\n None,\n+ theme_from_jar(&jar),\n+ &theme_next_from_uri(&uri),\n );\n Html(page.into_string()).into_response()\n }\n@@ -996,11 +1018,12 @@ pub async fn thread_post_view(\n Path((tag, index_str)): Path<(String, String)>,\n headers: HeaderMap,\n jar: CookieJar,\n+ uri: Uri,\n ) -> impl IntoResponse {\n let reduced = state.reduced.read().await;\n let viewer = optional_principal(&headers, &jar, &reduced);\n drop(reduced);\n- thread_post_view_inner(state, tag, index_str, ThreadNav::public(), viewer)\n+ thread_post_view_inner(state, tag, index_str, ThreadNav::public(), viewer, jar, uri)\n .await\n }\n \n@@ -1009,19 +1032,20 @@ pub async fn room_thread_post_view(\n Path((room_short, room_slug, tag, index_str)): Path<(String, String, String, String)>,\n headers: HeaderMap,\n jar: CookieJar,\n+ uri: Uri,\n ) -> impl IntoResponse {\n let room_id = format!(\"{room_short}/{room_slug}\");\n let reduced = state.reduced.read().await;\n let user = optional_principal(&headers, &jar, &reduced);\n if !user_can_view_room(&reduced, &room_id, user.as_deref()) {\n drop(reduced);\n- return room_not_found_page().into_response();\n+ return room_not_found_page(&jar, &uri).into_response();\n }\n drop(reduced);\n let Some(nav) = ThreadNav::from_room_id(&room_id) else {\n return (StatusCode::NOT_FOUND, \"bad room path\").into_response();\n };\n- thread_post_view_inner(state, tag, index_str, nav, user)\n+ thread_post_view_inner(state, tag, index_str, nav, user, jar, uri)\n .await\n .into_response()\n }\n@@ -1193,6 +1217,7 @@ pub async fn user_profile_page(\n Path(username): Path,\n headers: HeaderMap,\n jar: CookieJar,\n+ uri: Uri,\n ) -> impl IntoResponse {\n let canon = match parse_username(&username) {\n Ok(u) => u,\n@@ -1270,6 +1295,8 @@ pub async fn user_profile_page(\n (cli_panel(&format!(\"npx slugsocial public forum list\")))\n },\n None,\n+ theme_from_jar(&jar),\n+ &theme_next_from_uri(&uri),\n );\n Html(page.into_string()).into_response()\n }\n@@ -1292,13 +1319,14 @@ pub async fn room_thread_post_expand(\n Path((room_short, room_slug, tag, index_str)): Path<(String, String, String, String)>,\n headers: HeaderMap,\n jar: CookieJar,\n+ uri: Uri,\n ) -> impl IntoResponse {\n let room_id = format!(\"{room_short}/{room_slug}\");\n let reduced = state.reduced.read().await;\n let user = optional_principal(&headers, &jar, &reduced);\n if !user_can_view_room(&reduced, &room_id, user.as_deref()) {\n drop(reduced);\n- return room_not_found_page().into_response();\n+ return room_not_found_page(&jar, &uri).into_response();\n }\n let Some(nav) = ThreadNav::from_room_id(&room_id) else {\n drop(reduced);\n@@ -1328,13 +1356,14 @@ pub async fn room_thread_post_expand_deleted(\n Path((room_short, room_slug, tag, index_str)): Path<(String, String, String, String)>,\n headers: HeaderMap,\n jar: CookieJar,\n+ uri: Uri,\n ) -> impl IntoResponse {\n let room_id = format!(\"{room_short}/{room_slug}\");\n let reduced = state.reduced.read().await;\n let user = optional_principal(&headers, &jar, &reduced);\n if !user_can_view_room(&reduced, &room_id, user.as_deref()) {\n drop(reduced);\n- return room_not_found_page().into_response();\n+ return room_not_found_page(&jar, &uri).into_response();\n }\n let Some(nav) = ThreadNav::from_room_id(&room_id) else {\n drop(reduced);\n@@ -1364,13 +1393,14 @@ pub async fn room_thread_post_collapse_deleted(\n Path((room_short, room_slug, tag, index_str)): Path<(String, String, String, String)>,\n headers: HeaderMap,\n jar: CookieJar,\n+ uri: Uri,\n ) -> impl IntoResponse {\n let room_id = format!(\"{room_short}/{room_slug}\");\n let reduced = state.reduced.read().await;\n let user = optional_principal(&headers, &jar, &reduced);\n if !user_can_view_room(&reduced, &room_id, user.as_deref()) {\n drop(reduced);\n- return room_not_found_page().into_response();\n+ return room_not_found_page(&jar, &uri).into_response();\n }\n let Some(nav) = ThreadNav::from_room_id(&room_id) else {\n drop(reduced);\ndiff --git a/server/src/html/garden.rs b/server/src/html/garden.rs\nindex e08f068ae04f5a2542ec2c43c240f931f87146bf..9b4789a79c3e293cbfc5f033a0eac8650320d94d 100644\n--- a/server/src/html/garden.rs\n+++ b/server/src/html/garden.rs\n@@ -1,6 +1,6 @@\n use axum::{\n extract::{Path, State},\n- http::{HeaderMap, StatusCode},\n+ http::{HeaderMap, StatusCode, Uri},\n response::{Html, IntoResponse},\n };\n use axum_extra::extract::cookie::CookieJar;\n@@ -20,7 +20,8 @@ use crate::{\n \n use super::{\n bc_path, bc_segment, cli_panel, layout, now_ms, ratio_pct,\n- render_linkified_with_embeds_in_scope, breadcrumb_path::OntologyPath,\n+ render_linkified_with_embeds_in_scope, theme_from_jar, theme_next_from_uri,\n+ breadcrumb_path::OntologyPath,\n forum::ThreadNav,\n };\n \n@@ -59,14 +60,21 @@ fn scoped_bc_path(path: &OntologyPath, nav: &ThreadNav) -> maud::Markup {\n }\n }\n \n-fn room_not_found_page() -> impl IntoResponse {\n+fn room_not_found_page(jar: &CookieJar, uri: &Uri) -> impl IntoResponse {\n let body = html! {\n nav class=\"breadcrumb\" { a href=\"/\" { \"slug.social\" } }\n h1 { \"not found\" }\n p { \"The requested page could not be found.\" }\n p { a href=\"/\" { \"home\" } }\n };\n- let page = layout(\"not found — slug.social\", \"view-thread\", body, None);\n+ let page = layout(\n+ \"not found — slug.social\",\n+ \"view-thread\",\n+ body,\n+ None,\n+ theme_from_jar(jar),\n+ &theme_next_from_uri(uri),\n+ );\n (StatusCode::NOT_FOUND, Html(page.into_string()))\n }\n \n@@ -81,7 +89,11 @@ fn user_can_view_room(reduced: &ReducerState, room_id: &str, username: Option<&s\n }\n \n /// Ontology index — root-level paths. Private (UUID) roots are excluded.\n-pub async fn garden_index(State(state): State) -> impl IntoResponse {\n+pub async fn garden_index(\n+ State(state): State,\n+ jar: CookieJar,\n+ uri: Uri,\n+) -> impl IntoResponse {\n let nav = ThreadNav::public();\n let child_rankings = {\n let reduced = state.reduced.read().await;\n@@ -130,6 +142,8 @@ pub async fn garden_index(State(state): State) -> impl IntoResponse {\n (cli_panel(\"npx slugsocial garden tree\"))\n },\n None,\n+ theme_from_jar(&jar),\n+ &theme_next_from_uri(&uri),\n );\n Html(page.into_string())\n }\n@@ -138,9 +152,11 @@ pub async fn garden_index(State(state): State) -> impl IntoResponse {\n pub async fn ontology_path(\n State(state): State,\n Path(path): Path,\n+ jar: CookieJar,\n+ uri: Uri,\n ) -> impl IntoResponse {\n let path = OntologyPath::from_input(&path);\n- render_scope_view(state, path, ThreadNav::public()).await\n+ render_scope_view(state, path, ThreadNav::public(), jar, uri).await\n }\n \n pub async fn room_garden_index(\n@@ -148,19 +164,20 @@ pub async fn room_garden_index(\n Path((room_short, room_slug)): Path<(String, String)>,\n headers: HeaderMap,\n jar: CookieJar,\n+ uri: Uri,\n ) -> impl IntoResponse {\n let room_id = format!(\"{room_short}/{room_slug}\");\n let reduced = state.reduced.read().await;\n let user = optional_principal(&headers, &jar, &reduced);\n if !user_can_view_room(&reduced, &room_id, user.as_deref()) {\n drop(reduced);\n- return room_not_found_page().into_response();\n+ return room_not_found_page(&jar, &uri).into_response();\n }\n drop(reduced);\n let Some(nav) = ThreadNav::from_room_id(&room_id) else {\n return (StatusCode::NOT_FOUND, \"bad room path\").into_response();\n };\n- render_scope_view(state, OntologyPath::root(), nav).await\n+ render_scope_view(state, OntologyPath::root(), nav, jar, uri).await\n }\n \n pub async fn room_ontology_path(\n@@ -168,20 +185,21 @@ pub async fn room_ontology_path(\n Path((room_short, room_slug, path)): Path<(String, String, String)>,\n headers: HeaderMap,\n jar: CookieJar,\n+ uri: Uri,\n ) -> impl IntoResponse {\n let room_id = format!(\"{room_short}/{room_slug}\");\n let reduced = state.reduced.read().await;\n let user = optional_principal(&headers, &jar, &reduced);\n if !user_can_view_room(&reduced, &room_id, user.as_deref()) {\n drop(reduced);\n- return room_not_found_page().into_response();\n+ return room_not_found_page(&jar, &uri).into_response();\n }\n drop(reduced);\n let Some(nav) = ThreadNav::from_room_id(&room_id) else {\n return (StatusCode::NOT_FOUND, \"bad room path\").into_response();\n };\n let path = OntologyPath::from_input(&path);\n- render_scope_view(state, path, nav).await\n+ render_scope_view(state, path, nav, jar, uri).await\n }\n \n #[derive(Debug, Clone)]\n@@ -375,6 +393,8 @@ async fn render_scope_view(\n state: AppState,\n path: OntologyPath,\n nav: ThreadNav,\n+ jar: CookieJar,\n+ uri: Uri,\n ) -> axum::response::Response {\n let scope = nav.scope();\n let model = {\n@@ -525,6 +545,8 @@ async fn render_scope_view(\n (cli_panel(&cli))\n },\n None,\n+ theme_from_jar(&jar),\n+ &theme_next_from_uri(&uri),\n );\n \n Html(page.into_string()).into_response()\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex a8fd73433d120b07351dd027faf0b25f8f101321..53041a0cf0b30e6f20749c92ecc15a4e9ac56e09 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -1,10 +1,13 @@\n use axum::{\n body::Body,\n extract::Path,\n- http::{header, StatusCode},\n+ http::{header, HeaderValue, StatusCode, Uri},\n response::{IntoResponse, Response},\n+ Form,\n };\n+use axum_extra::extract::cookie::CookieJar;\n use maud::{html, Markup, DOCTYPE};\n+use serde::Deserialize;\n use std::collections::HashSet;\n \n mod auth;\n@@ -33,6 +36,75 @@ pub(crate) fn profile_href(username: &str) -> String {\n format!(\"/u/{username}\")\n }\n \n+/// Cookie name for UI theme (must match document.cookie migration in [`layout`]).\n+pub const SLUG_THEME_COOKIE: &str = \"slug-theme\";\n+\n+/// Normalize a requested theme id to a known stylesheet key.\n+pub fn normalize_theme(raw: &str) -> &'static str {\n+ match raw {\n+ \"retro\" => \"retro\",\n+ \"retro_craft\" => \"retro_craft\",\n+ _ => \"default\",\n+ }\n+}\n+\n+/// Resolved theme for rendering and cookie re-issue.\n+pub fn theme_from_jar(jar: &CookieJar) -> &'static str {\n+ jar.get(SLUG_THEME_COOKIE)\n+ .map(|c| normalize_theme(c.value()))\n+ .unwrap_or(\"default\")\n+}\n+\n+/// `Path` + optional `?query` for round-tripping after `POST /theme`.\n+pub fn theme_next_from_uri(uri: &Uri) -> String {\n+ uri.path_and_query()\n+ .map(|pq| pq.as_str().to_string())\n+ .filter(|s| !s.is_empty())\n+ .unwrap_or_else(|| \"/\".to_string())\n+}\n+\n+/// `Set-Cookie` for a validated theme (ASCII cookie value).\n+pub fn theme_cookie_header_value(theme: &str) -> HeaderValue {\n+ let t = normalize_theme(theme);\n+ let s = format!(\"{SLUG_THEME_COOKIE}={t}; Path=/; SameSite=Lax; Max-Age=31536000\");\n+ HeaderValue::from_str(&s).expect(\"theme cookie must be ASCII\")\n+}\n+\n+/// Re-issue theme cookie on responses that also set `slug_session`, so login does not drop theme.\n+pub fn theme_cookie_header_from_jar(jar: &CookieJar) -> Option {\n+ let c = jar.get(SLUG_THEME_COOKIE)?;\n+ Some(theme_cookie_header_value(c.value()))\n+}\n+\n+fn sanitize_theme_next(next: Option<&str>) -> String {\n+ let s = next.unwrap_or(\"/\").trim();\n+ if s.starts_with('/') && !s.starts_with(\"//\") && s.len() < 8192 {\n+ s.to_string()\n+ } else {\n+ \"/\".to_string()\n+ }\n+}\n+\n+#[derive(Debug, Deserialize)]\n+pub struct ThemeForm {\n+ theme: String,\n+ next: Option,\n+}\n+\n+/// `POST /theme` — set theme cookie and redirect back (full navigation, no fetch).\n+pub async fn post_theme(Form(form): Form) -> impl IntoResponse {\n+ let theme = normalize_theme(&form.theme);\n+ let next = sanitize_theme_next(form.next.as_deref());\n+ let loc = HeaderValue::try_from(next.as_str())\n+ .unwrap_or_else(|_| HeaderValue::from_static(\"/\"));\n+ Response::builder()\n+ .status(StatusCode::SEE_OTHER)\n+ .header(header::LOCATION, loc)\n+ .header(header::SET_COOKIE, theme_cookie_header_value(theme))\n+ .body(Body::empty())\n+ .expect(\"theme redirect response\")\n+}\n+\n // Embed CSS files at compile time\n const THEME_DEFAULT_CSS: &str = include_str!(\"../../static/theme_default.css\");\n const THEME_RETRO_CSS: &str = include_str!(\"../../static/theme_retro.css\");\n@@ -173,7 +245,9 @@ impl JsQueryBuilder {\n }\n }\n \n-pub(super) fn layout(title: &str, view: &str, body: Markup, views: Option) -> Markup {\n+pub(super) fn layout(title: &str, view: &str, body: Markup, views: Option, theme: &str, theme_next: &str) -> Markup {\n+ let theme = normalize_theme(theme);\n+ let css_href = format!(\"/static/theme_{theme}.css\");\n html! {\n (DOCTYPE)\n html {\n@@ -181,7 +255,20 @@ pub(super) fn layout(title: &str, view: &str, body: Markup, views: Option)\n meta charset=\"utf-8\";\n meta name=\"viewport\" content=\"width=device-width, initial-scale=1\";\n title { (title) }\n- link rel=\"stylesheet\" href=\"/static/theme_default.css\" id=\"theme-stylesheet\";\n+ script { (maud::PreEscaped(r#\"\n+(function() {\n+ try {\n+ var ls = localStorage.getItem('slug-theme');\n+ if (!ls) return;\n+ var m = document.cookie.match(/(?:^|;\\s*)slug-theme=([^;]+)/);\n+ var c = m ? decodeURIComponent(m[1].replace(/\\+/g, ' ')) : '';\n+ if (ls === c) { localStorage.removeItem('slug-theme'); return; }\n+ document.cookie = 'slug-theme=' + encodeURIComponent(ls) + '; Path=/; SameSite=Lax; Max-Age=31536000';\n+ location.reload();\n+ } catch (e) {}\n+})();\n+\"#)) }\n+ link rel=\"stylesheet\" href=(css_href) id=\"theme-stylesheet\";\n script src=\"https://unpkg.com/idiomorph@0.3.0/dist/idiomorph.min.js\" {}\n }\n body class=(view) {\n@@ -196,32 +283,21 @@ pub(super) fn layout(title: &str, view: &str, body: Markup, views: Option)\n input type=\"range\" id=\"spread-slider\" min=\"0\" max=\"1\" step=\"0.05\" value=\"1\";\n }\n a id=\"search-btn\" href=\"/search\" { \"search\" }\n- div id=\"theme-switcher\" { \"theme\" }\n+ form id=\"slug-theme-form\" method=\"post\" action=\"/theme\" {\n+ input type=\"hidden\" name=\"next\" value=(theme_next);\n+ select id=\"theme-select\" name=\"theme\" onchange=\"this.form.submit()\" aria-label=\"Theme\" {\n+ @for (val, label) in [(\"default\", \"default\"), (\"retro\", \"retro\"), (\"retro_craft\", \"craft\")] {\n+ @if theme == val {\n+ option value=(val) selected { (label) }\n+ } @else {\n+ option value=(val) { (label) }\n+ }\n+ }\n+ }\n+ }\n }\n script { (maud::PreEscaped(r#\"\n (function() {\n- // Theme switching\n- const themes = ['default', 'retro', 'retro_craft'];\n- const themeLabel = { default: 'default', retro: 'retro', retro_craft: 'craft' };\n- const storedTheme = localStorage.getItem('slug-theme') || 'default';\n- const switcher = document.getElementById('theme-switcher');\n- const stylesheet = document.getElementById('theme-stylesheet');\n-\n- function setTheme(name) {\n- stylesheet.href = `/static/theme_${name}.css`;\n- localStorage.setItem('slug-theme', name);\n- switcher.textContent = themeLabel[name] || name;\n- setTimeout(() => setSpread(parseFloat(slider.value)), 50);\n- }\n-\n- setTheme(storedTheme);\n-\n- switcher.addEventListener('click', function() {\n- const current = themes.indexOf(localStorage.getItem('slug-theme') || 'default');\n- const next = (current + 1) % themes.length;\n- setTheme(themes[next]);\n- });\n-\n // Spread control\n const slider = document.getElementById('spread-slider');\n const storedSpread = localStorage.getItem('slug-spread');\n@@ -245,6 +321,7 @@ script { (maud::PreEscaped(r#\"\n const f = e.target;\n if (!f || f.tagName !== 'FORM') return;\n if ((f.method || 'get').toLowerCase() !== 'post') return;\n+ if (f.id === 'slug-theme-form') return;\n e.preventDefault();\n const resp = await fetch(f.action, {\n method: 'POST',\ndiff --git a/server/src/html/search.rs b/server/src/html/search.rs\nindex 0871ddb7a87e3153e2cb6c1f827b46de2c9f0f3b..28ceaa53c3fcac6777311535e95fb771b19438f5 100644\n--- a/server/src/html/search.rs\n+++ b/server/src/html/search.rs\n@@ -1,6 +1,6 @@\n use axum::{\n extract::{Query, State},\n- http::HeaderMap,\n+ http::{HeaderMap, Uri},\n response::{Html, IntoResponse},\n };\n use axum_extra::extract::cookie::CookieJar;\n@@ -15,7 +15,7 @@ use crate::{\n timeago,\n };\n \n-use super::{authorship_address, bc_segment, cli_panel, layout, now_ms};\n+use super::{authorship_address, bc_segment, cli_panel, layout, now_ms, theme_from_jar, theme_next_from_uri};\n \n /// Escape HTML special chars for safe injection.\n fn escape_html(s: &str) -> String {\n@@ -396,6 +396,7 @@ pub async fn search_page(\n Query(params): Query,\n headers: HeaderMap,\n jar: CookieJar,\n+ uri: Uri,\n ) -> impl IntoResponse {\n let query = params.q.clone().unwrap_or_default();\n let results = if query.len() >= 2 {\n@@ -420,6 +421,8 @@ pub async fn search_page(\n (cli_panel(\"npx slugsocial search \"))\n },\n None,\n+ theme_from_jar(&jar),\n+ &theme_next_from_uri(&uri),\n );\n Html(page.into_string())\n }\ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nindex 43b63293c2a62b9700948767c618cb0614ddf6d9..b9d4b79791ba8dd3a3c26ff777943a2548092de5 100644\n--- a/server/src/lib.rs\n+++ b/server/src/lib.rs\n@@ -33,6 +33,7 @@ pub fn create_app(state: AppState) -> Router {\n .route(\"/post\", post(api::post_web_ingest))\n .route(\"/post/redact\", post(api::post_web_redact))\n .route(\"/post/check\", post(api::check_web_ingest))\n+ .route(\"/theme\", post(crate::html::post_theme))\n .route(\"/sse\", get(api::get_html_stream))\n .route(\"/stream\", get(api::get_stream))\n .route(\"/search\", get(crate::html::search_page))\ndiff --git a/server/src/path_types.rs b/server/src/path_types.rs\nindex 997597f6659c1ba2092bc5348e393bf7a8fa7ae0..cad58cac7da948f92d6ea2a5587d917ab21d57ea 100644\n--- a/server/src/path_types.rs\n+++ b/server/src/path_types.rs\n@@ -8,6 +8,12 @@\n //!\n //! This module adds lightweight newtypes so code can be explicit about what it\n //! expects without changing core storage formats.\n+//!\n+//! **Storage vs wire:** [`CanonicalItemUrl`] values are shared across scopes\n+//! (`https://slug.social/~/…`); which [`crate::reducer::ContentState`] they live in\n+//! is determined by scope, not by embedding the room id in the string. For JSON/RPC\n+//! and browser links in a private room, use [`crate::api::helpers::item_path_for_api_in_room`]\n+//! so ontology items become `https://slug.social/r/{short}/{slug}/~/…`.\n \n use std::borrow::Borrow;\n use std::fmt;\ndiff --git a/server/static/theme_default.css b/server/static/theme_default.css\nindex 2b06b12f3e77c9ed305272fe3ab47a8ad08504df..5e6304bcc36caed8aeda913fd241fc8fb9cf6d25 100644\n--- a/server/static/theme_default.css\n+++ b/server/static/theme_default.css\n@@ -678,7 +678,10 @@ details > summary::-webkit-details-marker { display: none; }\n gap: 6px;\n }\n #spread-slider { accent-color: var(--ui); width: 80px; }\n-#theme-switcher {\n+#slug-theme-form {\n+ display: contents;\n+}\n+#theme-select {\n background: var(--g4);\n border: var(--bv) solid;\n border-color: var(--hi) var(--lo) var(--lo) var(--hi);\n@@ -688,8 +691,8 @@ details > summary::-webkit-details-marker { display: none; }\n padding: 2px 10px;\n user-select: none;\n }\n-#theme-switcher:hover { color: var(--signal); }\n-#theme-switcher:active {\n+#theme-select:hover { color: var(--signal); }\n+#theme-select:active {\n border-color: var(--lo) var(--hi) var(--hi) var(--lo);\n transform: translate(1px, 1px);\n }\ndiff --git a/server/static/theme_retro_craft.css b/server/static/theme_retro_craft.css\nindex 508222443a3bb2f16d3034a07af53a7df37a0314..3fde7e2b77fa4e4f46418927eb0fb0e5cd195c75 100644\n--- a/server/static/theme_retro_craft.css\n+++ b/server/static/theme_retro_craft.css\n@@ -35,7 +35,6 @@ h1, h2, h3 {\n font-weight: 600;\n letter-spacing: 0.14em;\n margin: 1.5rem 0 0.5rem;\n- text-transform: uppercase;\n }\n \n a {\n@@ -354,7 +353,10 @@ div.cli-panel {\n accent-color: var(--accent);\n width: 72px;\n }\n-#theme-switcher,\n+#slug-theme-form {\n+ display: contents;\n+}\n+#theme-select,\n #search-btn,\n a#src-link {\n border: 1px solid var(--line);\n@@ -365,7 +367,7 @@ a#src-link {\n padding: 0.25rem 0.6rem;\n text-decoration: none;\n }\n-#theme-switcher:hover,\n+#theme-select:hover,\n #search-btn:hover,\n a#src-link:hover {\n border-color: var(--accent);\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[6bda2635] Use title-first items and explanation-first votes (#135)\n\n* Require block-first sorter DSL statements\n\nCo-authored-by: tommy \n\n* Use title-first items with explanation-first votes\n\nCo-authored-by: tommy \n\n* Update garden vote test DSL fixtures\n\nCo-authored-by: tommy \n\n* Update browser vote DSL payloads\n\nCo-authored-by: tommy \n\n---------\n\nCo-authored-by: Cursor Agent \n\nSide B — unified diff (full patch):\ndiff --git a/cli/DSL.txt b/cli/DSL.txt\nindex 18f69ef25f01583fddf9fc90e077bb2c3fa72bb6..c9b12bf0edaf73ad252f0a202b7fe61e311df50a 100644\n--- a/cli/DSL.txt\n+++ b/cli/DSL.txt\n@@ -18,9 +18,20 @@ Blank lines are preserved to maintain paragraph structure.\n ~/item/a {itembody}\n ~/python { A high-level scripting language }\n \n-~/item/a > ~/python { Item A is better because of X. }\n-~/python 3:1 ~/go { Python's ecosystem is much richer than Go's. }\n-https://example.com/lang = ~/go { They are equally good in this context. }\n+{\n+Item A is better because of X.\n+}\n+~/item/a > ~/python\n+\n+{\n+Python's ecosystem is much richer than Go's.\n+}\n+~/python 3:1 ~/go\n+\n+{\n+They are equally good in this context.\n+}\n+https://example.com/lang = ~/go\n ```\n \n SYNTAX RULES\n@@ -35,7 +46,7 @@ Starts a thread. Tag allows alphanumeric, `-`, `_`, and `/`. Subtitle max 100 ch\n ~/ { description }\n ```\n Defines an ontology item (garden layer). Paths can be nested (e.g. `~/languages/python`).\n-A leading `/` alone is **not** allowed in the DSL — use `~/` only. Descriptions (bodies) are wrapped in `{}`. Can be adjacent (e.g. `~/arrived{ready}`).\n+A leading `/` alone is **not** allowed in the DSL — use `~/` only. Descriptions (bodies) are wrapped in `{}` and follow the item path.\n \n ```sorter\n https://example.com/item { description }\n@@ -46,7 +57,10 @@ Canonicalization rules for URLs:\n - `~/` and `https://slug.social/~/` map to the same local item path.\n \n ```sorter\n- { required explanation }\n+{\n+required explanation\n+}\n+ \n ```\n Compares two items. The explanation is REQUIRED.\n Comparisons:\n@@ -65,7 +79,8 @@ When writing bodies or explanations, you can use braces `{}` and code blocks wit\n 3. Single braces: { ... }\n \n ```sorter\n-~/code { Here is a block: ```def foo(): return {\"a\": 1}``` }\n+{ Here is a block: ```def foo(): return {\"a\": 1}``` }\n+~/code\n ```\n \n STYLE\ndiff --git a/cli/GUIDE.sorter b/cli/GUIDE.sorter\nindex 7ea1c2649cb4a323b46f0bf389025079a9c9ab43..86accba72ecea547215d947fb6552e0ead25687c 100644\n--- a/cli/GUIDE.sorter\n+++ b/cli/GUIDE.sorter\n@@ -83,7 +83,8 @@ Item definitions (attaches a description to an item):\n ~/thread/item { description }\n \n Comparisons:\n- ~/thread/item-a 3:1 ~/thread/item-b { reasoning }\n+ { reasoning }\n+ ~/thread/item-a 3:1 ~/thread/item-b\n \n Ratio formats:\n 3:1 left is 3x better than right\n@@ -95,8 +96,7 @@ Shorthand:\n < means 1:2 (right is better)\n = means 1:1 (equal)\n \n-Bodies can attach without whitespace:\n- ~/thread/item{Description here}\n+Item bodies follow the item path. Vote explanations come first; the comparison is the verdict line.\n }\n \n You can write any prose in your posts. These won't be part of the garden but only the thread.\n@@ -165,7 +165,8 @@ npx slugsocial public forum post languages --delegate '7a3b9c2d-1234-5678-90ab-c\n \n ~/languages/python { A high-level language focused on readability. }\n ~/languages/rust { A systems language focused on safety and performance. }\n-~/languages/python 2:1 ~/languages/rust { Python has simpler syntax for beginners - fewer symbols, explicit over implicit. Rust's borrow checker adds cognitive load even for simple programs. Both are readable once learned, but Python's learning curve is gentler. }\n+{ Python has simpler syntax for beginners - fewer symbols, explicit over implicit. Rust's borrow checker adds cognitive load even for simple programs. Both are readable once learned, but Python's learning curve is gentler. }\n+~/languages/python 2:1 ~/languages/rust\n EOF\n \n # See current ranking\ndiff --git a/ideas/single-thread.md b/ideas/single-thread.md\nindex 86230fe6119c31045c21dbfcb12311b323c02fa8..0efb7199524cc3b308b1922c03e3a99193e4586c 100644\n--- a/ideas/single-thread.md\n+++ b/ideas/single-thread.md\n@@ -15,7 +15,8 @@ Previously a `.sorter` document could scatter `#tags` throughout:\n ~/languages/rust {A systems language.}\n #tools\n ~/tools/cargo {Rust's build system.}\n-~/languages/rust 2:1 ~/tools/cargo {Rust is more foundational than its tooling.}\n+{Rust is more foundational than its tooling.}\n+~/languages/rust 2:1 ~/tools/cargo\n ```\n \n The system would fan the ingest into both `#languages` and `#tools` — the same\ndiff --git a/server/src/api/mod.rs b/server/src/api/mod.rs\nindex 9f3c1cc21c2ae157c024a447980a97e190c8f066..920e967b47852ea82fa61b84c457ae3582dd9800 100644\n--- a/server/src/api/mod.rs\n+++ b/server/src/api/mod.rs\n@@ -72,16 +72,16 @@ mod tests {\n apply_ingest(\n &mut reduced,\n 1,\n- \"~/t/a {a}\\n~/t/b {b}\\n~/t/a 2:1 ~/t/b {because}\\n\",\n+ \"~/t/a {a}\\n~/t/b {b}\\n{because}\\n~/t/a 2:1 ~/t/b\\n\",\n );\n- let text = \"~/t/a 1:1 ~/t/b {equal}\\n\";\n+ let text = \"{equal}\\n~/t/a 1:1 ~/t/b\\n\";\n validate_ingest_document(&reduced, text, &crate::reducer::ScopeId::Public).unwrap();\n }\n \n #[test]\n fn validate_ingest_document_rejects_vote_on_undefined_item() {\n let reduced = ReducerState::default();\n- let text = \"~/t/a {x}\\n~/t/b 1:1 ~/t/missing {why}\\n\";\n+ let text = \"~/t/a {x}\\n{why}\\n~/t/b 1:1 ~/t/missing\\n\";\n let err = validate_ingest_document(&reduced, text, &crate::reducer::ScopeId::Public).unwrap_err();\n assert_eq!(err.0, StatusCode::BAD_REQUEST);\n assert!(err.1.contains(\"undefined item\"));\ndiff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs\nindex 696e7b3605e2e68aee0351116c494c2958506add..7cbda3876451687aa7a55547fc06bbe86ac9d260 100644\n--- a/server/src/api/ui_html.rs\n+++ b/server/src/api/ui_html.rs\n@@ -222,13 +222,13 @@ async fn dispatch_ui_action(\n }\n \n let text = format!(\n- \"@{}\\n{} {}:{} {} {{\\n{}\\n}}\\n\",\n+ \"@{}\\n{{\\n{}\\n}}\\n{} {}:{} {}\\n\",\n crate::api::auth::WEB_BROWSER_AGENT,\n+ exp,\n left_id.as_str(),\n rl,\n rr,\n- right_id.as_str(),\n- exp\n+ right_id.as_str()\n );\n \n match rpc_post_with_bearer(state, &session.bearer, room.clone(), thread_tag.clone(), text).await {\ndiff --git a/server/src/dsl.rs b/server/src/dsl.rs\nindex 7962342bd023b3b5061a684d84d4ab164134b111..def8b497c71567016a522648b9630d7038163e41 100644\n--- a/server/src/dsl.rs\n+++ b/server/src/dsl.rs\n@@ -11,16 +11,21 @@ pub struct Document {\n /// A single statement in the DSL (or prose when using `parse_full`).\n #[derive(Debug, Clone, PartialEq, Eq)]\n pub enum Stmt {\n- Item { title: String, body: Option },\n+ Item {\n+ title: String,\n+ body: Option,\n+ },\n Vote {\n item1: String,\n item2: String,\n ratio_left: i32,\n ratio_right: i32,\n- /// Required non-empty explanation (from trailing `{ ... }`).\n+ /// Required non-empty explanation (from leading `{ ... }`).\n explanation: String,\n },\n- Prose { text: String },\n+ Prose {\n+ text: String,\n+ },\n }\n \n #[derive(Debug, thiserror::Error)]\n@@ -391,71 +396,46 @@ fn parse_comparison_at(s: &str, i: usize) -> Option<((i32, i32), usize)> {\n Some(((left, right), j))\n }\n \n-fn parse_item_statement(stripped: &str, masker: &BlockMasker) -> Result {\n- // item: (\"~/\" | \"https://...\" | \"http://...\") item_ref body?\n- // vote: same for both operands.\n- //\n- // Important: body token can be adjacent to the item name (no whitespace),\n- // e.g. \"~/arrived{...}\" -> \"~/arrived__BLOCK_x__\".\n- let s = stripped;\n- let bytes = s.as_bytes();\n- if bytes.is_empty() {\n- return Err(DslError::Parse(\"missing item statement\".to_string()));\n+fn parse_block_prefixed_statement(\n+ block_token: &str,\n+ tail: &str,\n+ masker: &BlockMasker,\n+) -> Result {\n+ // vote: block item_ref comparison item_ref\n+ let s = tail.trim_start();\n+ if s.is_empty() {\n+ return Err(DslError::Parse(\n+ \"missing vote statement after leading explanation block\".to_string(),\n+ ));\n }\n \n let (item1, j) =\n parse_item_name_at(s, 0).ok_or_else(|| DslError::Parse(\"invalid item name\".to_string()))?;\n+ let explanation = masker.extract_body(block_token);\n+ let mut i = skip_ws(s, j);\n \n- // Either we have:\n- // - immediate/whitespace block token => Item\n- // - comparison => Vote\n- // - whitespace then block token => Item\n- // - whitespace then comparison => Vote\n- let i = skip_ws(s, j);\n-\n- // If next is end or a block token => Item.\n if i >= s.len() {\n- return Ok(Stmt::Item {\n- title: item1,\n- body: None,\n- });\n- }\n- if let Some((tok, end)) = parse_block_token_at(s, i) {\n- let body = masker.extract_body(&tok);\n- let tail = s[end..].trim();\n- if !tail.is_empty() {\n- return Err(DslError::Parse(\"extra tokens after item\".to_string()));\n- }\n- return Ok(Stmt::Item {\n- title: item1,\n- body: Some(body),\n- });\n+ return Err(DslError::Parse(\n+ \"leading `{ ... }` blocks are vote explanations; item bodies belong after item paths\"\n+ .to_string(),\n+ ));\n }\n \n- // Otherwise parse comparison then \"/item2\" then REQUIRED body.\n- let ((ratio_left, ratio_right), mut k) = parse_comparison_at(s, i)\n+ let ((ratio_left, ratio_right), k) = parse_comparison_at(s, i)\n .ok_or_else(|| DslError::Parse(format!(\"invalid comparison near: {}\", &s[i..])))?;\n if ratio_left == 0 && ratio_right == 0 {\n return Err(DslError::Parse(\n \"vote ratio 0:0 is invalid; use 1:1 for a tie or omit the vote\".to_string(),\n ));\n }\n- k = skip_ws(s, k);\n- let (item2, mut m) = parse_item_name_at(s, k)\n+ i = skip_ws(s, k);\n+ let (item2, m) = parse_item_name_at(s, i)\n .ok_or_else(|| DslError::Parse(\"invalid rhs item name\".to_string()))?;\n- m = skip_ws(s, m);\n-\n- let Some((tok, end)) = parse_block_token_at(s, m) else {\n- return Err(DslError::Parse(\n- \"missing vote explanation (add a trailing `{ ... }`)\".to_string(),\n- ));\n- };\n- let explanation = masker.extract_body(&tok);\n+ i = skip_ws(s, m);\n if explanation.trim().is_empty() {\n return Err(DslError::Parse(\"empty vote explanation\".to_string()));\n }\n- m = end;\n- let tail = s[m..].trim();\n+ let tail = s[i..].trim();\n if !tail.is_empty() {\n return Err(DslError::Parse(\"extra tokens after vote\".to_string()));\n }\n@@ -469,6 +449,35 @@ fn parse_item_statement(stripped: &str, masker: &BlockMasker) -> Result Result {\n+ let (item1, j) =\n+ parse_item_name_at(stripped, 0).ok_or_else(|| DslError::Parse(\"invalid item name\".to_string()))?;\n+ let i = skip_ws(stripped, j);\n+\n+ if i >= stripped.len() {\n+ return Ok(Stmt::Item {\n+ title: item1,\n+ body: None,\n+ });\n+ }\n+\n+ if let Some((tok, end)) = parse_block_token_at(stripped, i) {\n+ let body = masker.extract_body(&tok);\n+ let tail = stripped[end..].trim();\n+ if !tail.is_empty() {\n+ return Err(DslError::Parse(\"extra tokens after item\".to_string()));\n+ }\n+ return Ok(Stmt::Item {\n+ title: item1,\n+ body: Some(body),\n+ });\n+ }\n+\n+ Err(DslError::Parse(\n+ \"vote explanations must start with a `{ ... }` block before the comparison\".to_string(),\n+ ))\n+}\n+\n fn parse_line(masked_line: &str, masker: &BlockMasker) -> Result, DslError> {\n let stripped = masked_line.trim_start();\n if stripped.is_empty() {\n@@ -477,27 +486,28 @@ fn parse_line(masked_line: &str, masker: &BlockMasker) -> Result, DslE\n let first = stripped.chars().next().unwrap();\n match first {\n '#' => Err(DslError::Parse(\"not a DSL line\".to_string())),\n- ':' => Err(DslError::Parse(\n- \"leading ':' is not supported\".to_string(),\n- )),\n+ ':' => Err(DslError::Parse(\"leading ':' is not supported\".to_string())),\n '@' => Err(DslError::Parse(\"not a DSL line\".to_string())),\n+ '_' => {\n+ let Some((tok, end)) = parse_block_token_at(stripped, 0) else {\n+ return Err(DslError::Parse(\"not a DSL line\".to_string()));\n+ };\n+ parse_block_prefixed_statement(&tok, &stripped[end..], masker).map(|stmt| vec![stmt])\n+ }\n '/' => Err(DslError::Parse(\n- \"item paths must use `~/` (e.g. `~/languages/python`), not a leading `/`\"\n- .to_string(),\n+ \"item paths must use `~/` (e.g. `~/languages/python`), not a leading `/`\".to_string(),\n )),\n- '~' => {\n- Ok(vec![parse_item_statement(stripped, masker)?])\n- }\n+ '~' => Ok(vec![parse_item_definition_statement(stripped, masker)?]),\n 'h' => {\n if stripped.starts_with(\"https://\") || stripped.starts_with(\"http://\") {\n- Ok(vec![parse_item_statement(stripped, masker)?])\n+ Ok(vec![parse_item_definition_statement(stripped, masker)?])\n } else {\n Err(DslError::Parse(\"not a DSL line\".to_string()))\n }\n }\n '-' => {\n if stripped.starts_with(\"-/\") {\n- Ok(vec![parse_item_statement(stripped, masker)?])\n+ Ok(vec![parse_item_definition_statement(stripped, masker)?])\n } else {\n Err(DslError::Parse(\"not a DSL line\".to_string()))\n }\n@@ -516,6 +526,7 @@ pub fn parse_full(text: &str) -> Result {\n let (masker, masked) = mask_all(BlockMasker::new(), text);\n let mut statements: Vec = Vec::new();\n let mut prose_buffer: Vec<&str> = Vec::new();\n+ let mut pending_block: Option = None;\n \n let flush_prose = |buf: &mut Vec<&str>, out: &mut Vec, masker: &BlockMasker| {\n if buf.is_empty() {\n@@ -529,11 +540,29 @@ pub fn parse_full(text: &str) -> Result {\n \n for line in masked.split('\\n') {\n let stripped = line.trim_start();\n+ if let Some(tok) = pending_block.as_ref() {\n+ if stripped.is_empty() {\n+ continue;\n+ }\n+ if stripped.starts_with(\"-/\")\n+ || stripped.starts_with(\"~/\")\n+ || stripped.starts_with(\"https://\")\n+ || stripped.starts_with(\"http://\")\n+ {\n+ statements.push(parse_block_prefixed_statement(tok, stripped, &masker)?);\n+ pending_block = None;\n+ continue;\n+ }\n+ return Err(DslError::Parse(\n+ \"expected vote statement after leading explanation block\".to_string(),\n+ ));\n+ }\n+\n if !stripped.is_empty()\n && (stripped.starts_with(\"-/\")\n || {\n let c = stripped.chars().next().unwrap();\n- \":/!~\".contains(c)\n+ \":/!~_\".contains(c)\n }\n || stripped.starts_with(\"https://\")\n || stripped.starts_with(\"http://\"))\n@@ -541,6 +570,13 @@ pub fn parse_full(text: &str) -> Result {\n // Flush prose buffer first\n flush_prose(&mut prose_buffer, &mut statements, &masker);\n \n+ if let Some((tok, end)) = parse_block_token_at(stripped, 0) {\n+ if stripped[end..].trim().is_empty() {\n+ pending_block = Some(tok);\n+ continue;\n+ }\n+ }\n+\n // Parse DSL line; DSL statements are not prose, so errors should propagate.\n statements.extend(parse_line(line, &masker)?);\n } else {\n@@ -548,6 +584,12 @@ pub fn parse_full(text: &str) -> Result {\n }\n }\n \n+ if pending_block.is_some() {\n+ return Err(DslError::Parse(\n+ \"missing vote statement after leading explanation block\".to_string(),\n+ ));\n+ }\n+\n // Final flush\n flush_prose(&mut prose_buffer, &mut statements, &masker);\n \n@@ -592,7 +634,7 @@ mod tests {\n \n #[test]\n fn parse_vote_ratio_and_symbols() {\n- let d1 = parse_full(\"~/a 3:1 ~/b {because}\").unwrap();\n+ let d1 = parse_full(\"{because}\\n~/a 3:1 ~/b\").unwrap();\n assert_eq!(\n d1.statements,\n vec![Stmt::Vote {\n@@ -604,7 +646,7 @@ mod tests {\n }]\n );\n \n- let d2 = parse_full(\"~/a > ~/b {because}\").unwrap();\n+ let d2 = parse_full(\"{because}\\n~/a > ~/b\").unwrap();\n assert_eq!(\n d2.statements,\n vec![Stmt::Vote {\n@@ -616,7 +658,7 @@ mod tests {\n }]\n );\n \n- let d3 = parse_full(\"~/a = ~/b {because}\").unwrap();\n+ let d3 = parse_full(\"{because}\\n~/a = ~/b\").unwrap();\n assert_eq!(\n d3.statements,\n vec![Stmt::Vote {\n@@ -631,7 +673,7 @@ mod tests {\n \n #[test]\n fn parse_vote_rejects_zero_zero_ratio() {\n- let err = parse_full(\"~/a 0:0 ~/b {tie placeholder}\").unwrap_err();\n+ let err = parse_full(\"{tie placeholder}\\n~/a 0:0 ~/b\").unwrap_err();\n let msg = match err {\n DslError::Parse(m) => m,\n };\n@@ -667,8 +709,8 @@ mod tests {\n }\n \n #[test]\n- fn parse_vote_with_attached_body_without_space() {\n- let input = \"~/a 2:1 ~/b{because}\";\n+ fn parse_vote_with_attached_explanation_without_space() {\n+ let input = \"{because}~/a 2:1 ~/b\";\n let doc = parse_full(input).unwrap();\n assert_eq!(\n doc.statements,\n@@ -697,7 +739,7 @@ mod tests {\n \n #[test]\n fn parse_nested_path_vote() {\n- let input = \"~/whitepaper/a 3:1 ~/whitepaper/b { because }\";\n+ let input = \"{ because }\\n~/whitepaper/a 3:1 ~/whitepaper/b\";\n let doc = parse_full(input).unwrap();\n assert_eq!(\n doc.statements,\n@@ -718,7 +760,10 @@ mod tests {\n let result = parse_full(input);\n assert!(result.is_err(), \"expected parse error for {input:?}\");\n assert!(\n- result.unwrap_err().to_string().contains(\"leading ':' is not supported\"),\n+ result\n+ .unwrap_err()\n+ .to_string()\n+ .contains(\"leading ':' is not supported\"),\n \"wrong error for {input:?}\"\n );\n }\n@@ -741,7 +786,35 @@ mod tests {\n let result = parse_full(input);\n assert!(result.is_err(), \"vote without explanation should fail\");\n let err_msg = result.unwrap_err().to_string();\n- assert!(err_msg.contains(\"missing vote explanation\"), \"error: {}\", err_msg);\n+ assert!(\n+ err_msg.contains(\"vote explanations must start\"),\n+ \"error: {}\",\n+ err_msg\n+ );\n+ }\n+\n+ #[test]\n+ fn parse_full_rejects_legacy_trailing_explanation_vote() {\n+ let result = parse_full(\"~/a 2:1 ~/b {because}\");\n+ assert!(result.is_err(), \"legacy vote syntax should fail\");\n+ let err_msg = result.unwrap_err().to_string();\n+ assert!(\n+ err_msg.contains(\"vote explanations must start\"),\n+ \"error: {}\",\n+ err_msg\n+ );\n+ }\n+\n+ #[test]\n+ fn parse_full_rejects_block_first_item_body() {\n+ let result = parse_full(\"{body}\\n~/a\");\n+ assert!(result.is_err(), \"block-first item body syntax should fail\");\n+ let err_msg = result.unwrap_err().to_string();\n+ assert!(\n+ err_msg.contains(\"item bodies belong after item paths\"),\n+ \"error: {}\",\n+ err_msg\n+ );\n }\n \n #[test]\n@@ -771,7 +844,7 @@ mod tests {\n \n #[test]\n fn parse_url_vote_statement() {\n- let input = \"https://slug.social/~/music/a 3:1 https://slug.social/~/music/b { because }\";\n+ let input = \"{ because }\\nhttps://slug.social/~/music/a 3:1 https://slug.social/~/music/b\";\n let doc = parse_full(input).unwrap();\n assert_eq!(\n doc.statements,\n@@ -785,5 +858,3 @@ mod tests {\n );\n }\n }\n-\n-\ndiff --git a/server/src/html/editor.rs b/server/src/html/editor.rs\nindex 8ff58326eb75806eeec3937f088fa977c7f3886e..216b144a4acef09d0addddd1528e5323433ac832 100644\n--- a/server/src/html/editor.rs\n+++ b/server/src/html/editor.rs\n@@ -39,7 +39,7 @@ pub async fn editor_page(State(state): State, jar: CookieJar, uri: Uri\n p class=\"muted\" { \"write DSL, see what happens. nothing is saved.\" }\n div class=\"editor-container\" {\n textarea id=\"editor-input\" rows=\"12\" cols=\"80\"\n- placeholder=\"your-uuid:rig:provider/model\\n#your-thread\\n\\n~/path/item-a { description }\\n~/path/item-b { description }\\n\\n~/path/item-a 3:1 ~/path/item-b { reasoning }\"\n+ placeholder=\"your-uuid:rig:provider/model\\n#your-thread\\n\\n{ description }\\n~/path/item-a\\n{ description }\\n~/path/item-b\\n\\n{ reasoning }\\n~/path/item-a 3:1 ~/path/item-b\"\n autocomplete=\"off\" autofocus {}\n div id=\"editor-status\" class=\"muted\" { \"type to check…\" }\n div id=\"editor-results\" {}\ndiff --git a/server/src/html/garden.rs b/server/src/html/garden.rs\nindex 9e08307e636ce7984766168db1187f84b8635201..86b4c184724910bb464682b9025d19ce6cd2050d 100644\n--- a/server/src/html/garden.rs\n+++ b/server/src/html/garden.rs\n@@ -1454,8 +1454,8 @@ mod tests {\n ~/topic {root}\\n\\\n ~/topic/a {alpha}\\n\\\n ~/topic/b {beta}\\n\\\n- ~/topic/a 1:9 ~/topic/b {weak for a}\\n\\\n- ~/topic/a 8:2 ~/topic/b {strong for a}\\n\",\n+ {weak for a}\\n ~/topic/a 1:9 ~/topic/b\\n\\\n+ {strong for a}\\n ~/topic/a 8:2 ~/topic/b\\n\",\n );\n let content = content_for_garden_view(&reduced, &ScopeId::Public);\n let page_left = ItemId::parse(\"~/topic/a\").unwrap().normalized_storage();\n@@ -1483,8 +1483,10 @@ mod tests {\n ~/topic {root}\\n\\\n ~/topic/a {alpha}\\n\\\n ~/topic/b {beta}\\n\\\n- ~/topic/a 3:2 ~/topic/b {first vote}\\n\\\n- ~/topic/b 2:3 ~/topic/a {second vote}\\n\",\n+ {first vote}\\n\\\n+ ~/topic/a 3:2 ~/topic/b\\n\\\n+ {second vote}\\n\\\n+ ~/topic/b 2:3 ~/topic/a\\n\",\n );\n let content = content_for_garden_view(&reduced, &ScopeId::Public);\n let a = ItemId::parse(\"~/topic/a\").unwrap().normalized_storage();\n@@ -1525,7 +1527,7 @@ mod tests {\n ~/topic/a {alpha}\\n\\\n ~/topic/b {beta}\\n\\\n ~/topic/c {gamma}\\n\\\n- ~/topic/a 2:1 ~/topic/b {a beats b}\\n\",\n+ {a beats b}\\n ~/topic/a 2:1 ~/topic/b\\n\",\n );\n \n let model = build_item_page_view_model(&reduced, &ScopeId::Public, \"~/topic/a\");\n@@ -1545,7 +1547,7 @@ mod tests {\n ~/topic {root}\\n\\\n ~/topic/a {alpha}\\n\\\n ~/topic/b {beta}\\n\\\n- ~/topic/a 3:1 ~/topic/b {a beats b}\\n\\\n+ {a beats b}\\n ~/topic/a 3:1 ~/topic/b\\n\\\n ~/topic/kid1 {k1}\\n\\\n ~/topic/kid2 {k2}\\n\\\n ~/topic/kid1/leaf {leaf}\\n\",\n@@ -1616,7 +1618,7 @@ mod tests {\n 1,\n \"9ab12cd/my-room\",\n \"@00000000-0000-0000-0000-000000000000:test:local/test\\n\\\n- ~/a {a}\\n~/b {b}\\n~/a 2:1 ~/b {because}\\n\",\n+ ~/a {a}\\n~/b {b}\\n{because}\\n~/a 2:1 ~/b\\n\",\n );\n use crate::path_types::ItemId;\n let root = ItemId::ontology_root();\ndiff --git a/server/tests/basic.rs b/server/tests/basic.rs\nindex 4a4faa73e2f0338fc541237104d99ea05b3e599a..2f17ce91091745ce1b5ebd1575248565df3b3514 100644\n--- a/server/tests/basic.rs\n+++ b/server/tests/basic.rs\n@@ -31,7 +31,7 @@ fn ingest_event(ts: i64, raw: &str) -> Event {\n \n fn vote_doc(tag: &str, a: &str, b: &str, left: i32, right: i32) -> String {\n format!(\n- \"~/{tag}/{a} {{body a}}\\n~/{tag}/{b} {{body b}}\\n~/{tag}/{a} {left}:{right} ~/{tag}/{b} {{because test}}\\n\"\n+ \"~/{tag}/{a} {{body a}}\\n~/{tag}/{b} {{body b}}\\n{{because test}}\\n~/{tag}/{a} {left}:{right} ~/{tag}/{b}\\n\"\n )\n }\n \n@@ -47,7 +47,7 @@ fn reducer_external_namespace_ranking() {\n \"@00000000-0000-0000-0000-000000000000:test:local/test\\n\\\n -/github.com/iss/1 { one }\\n\\\n -/github.com/iss/2 { two }\\n\\\n- -/github.com/iss/1 2:1 -/github.com/iss/2 { because }\\n\",\n+ { because }\\n -/github.com/iss/1 2:1 -/github.com/iss/2\\n\",\n ));\n \n let content = state.public();\n@@ -77,9 +77,9 @@ fn reducer_and_ranking_linear_chain() {\n let mut state = ReducerState::default();\n \n // First ingest: define items + vote a > b.\n- state.apply_event(ingest_event(1, \"~/t/a {a}\\n~/t/b {b}\\n~/t/a 3:1 ~/t/b {because}\\n\"));\n+ state.apply_event(ingest_event(1, \"~/t/a {a}\\n~/t/b {b}\\n{because}\\n~/t/a 3:1 ~/t/b\\n\"));\n // Second ingest: define c + vote b > c.\n- state.apply_event(ingest_event(2, \"~/t/c {c}\\n~/t/b 3:1 ~/t/c {because}\\n\"));\n+ state.apply_event(ingest_event(2, \"~/t/c {c}\\n{because}\\n~/t/b 3:1 ~/t/c\\n\"));\n \n let mut group = state.public().ranking_group.clone();\n let ranked = ranked_items(&mut group, 20000, 1e-9);\n@@ -96,15 +96,15 @@ fn reducer_canonicalizes_identifiers() {\n // Mix of formats across ingests (case + sigils).\n state.apply_event(ingest_event(\n 1,\n- \"~/Tag/Item-A {x}\\n~/Tag/Item-B {y}\\n~/Tag/Item-A 2:1 ~/Tag/Item-B {because}\\n\",\n+ \"~/Tag/Item-A {x}\\n~/Tag/Item-B {y}\\n{because}\\n~/Tag/Item-A 2:1 ~/Tag/Item-B\\n\",\n ));\n state.apply_event(ingest_event(\n 2,\n- \"~/TAG/ITEM-A 2:1 ~/TAG/ITEM-B {because}\\n\",\n+ \"{because}\\n~/TAG/ITEM-A 2:1 ~/TAG/ITEM-B\\n\",\n ));\n state.apply_event(ingest_event(\n 3,\n- \"~/tag/item-a 2:1 ~/tag/item-b {because}\\n\",\n+ \"{because}\\n~/tag/item-a 2:1 ~/tag/item-b\\n\",\n ));\n \n assert_eq!(state.public().ranking_group.idx_to_item.len(), 2); // Should dedupe to 2 items\n@@ -135,7 +135,7 @@ fn reducer_handles_item_and_body_from_ingest() {\n fn reducer_indexes_item_threads_and_vote_thread() {\n let mut state = ReducerState::default();\n // Thread routing is metadata (ingest.thread_tag), not parsed from raw.\n- let mut ev = match ingest_event(1, \"~/sorts/insertion { O(n^2) }\\n~/sorts/mergesort { O(n log n) }\\n~/sorts/insertion 3:1 ~/sorts/mergesort { simpler for small n }\\n\") {\n+ let mut ev = match ingest_event(1, \"~/sorts/insertion { O(n^2) }\\n~/sorts/mergesort { O(n log n) }\\n{ simpler for small n }\\n~/sorts/insertion 3:1 ~/sorts/mergesort\\n\") {\n Event::Ingest(i) => i,\n _ => unreachable!(),\n };\n@@ -175,11 +175,11 @@ fn reducer_clamps_score_bounds() {\n let mut state = ReducerState::default();\n state.apply_event(ingest_event(\n 1,\n- \"@00000000-0000-0000-0000-000000000000:test:local/test\\n~/t/a {a}\\n~/t/b {b}\\n~/t/a 1000:1 ~/t/b {huge}\\n\",\n+ \"@00000000-0000-0000-0000-000000000000:test:local/test\\n~/t/a {a}\\n~/t/b {b}\\n{huge}\\n~/t/a 1000:1 ~/t/b\\n\",\n ));\n state.apply_event(ingest_event(\n 2,\n- \"@00000000-0000-0000-0000-000000000000:test:local/test\\n~/t/a 1:1000 ~/t/b {huge}\\n\",\n+ \"@00000000-0000-0000-0000-000000000000:test:local/test\\n{huge}\\n~/t/a 1:1000 ~/t/b\\n\",\n ));\n \n assert_eq!(state.public().ranking_group.idx_to_item.len(), 2); // Should still work, scores clamped internally\n@@ -195,15 +195,15 @@ fn ranking_cycle_is_nearly_equal() {\n let mut state = ReducerState::default();\n state.apply_event(ingest_event(\n 1,\n- \"@00000000-0000-0000-0000-000000000000:test:local/test\\n~/rps/rock {r}\\n~/rps/scissors {s}\\n~/rps/rock 3:1 ~/rps/scissors {because}\\n\",\n+ \"@00000000-0000-0000-0000-000000000000:test:local/test\\n~/rps/rock {r}\\n~/rps/scissors {s}\\n{because}\\n~/rps/rock 3:1 ~/rps/scissors\\n\",\n ));\n state.apply_event(ingest_event(\n 2,\n- \"@00000000-0000-0000-0000-000000000000:test:local/test\\n~/rps/paper {p}\\n~/rps/scissors 3:1 ~/rps/paper {because}\\n\",\n+ \"@00000000-0000-0000-0000-000000000000:test:local/test\\n~/rps/paper {p}\\n{because}\\n~/rps/scissors 3:1 ~/rps/paper\\n\",\n ));\n state.apply_event(ingest_event(\n 3,\n- \"@00000000-0000-0000-0000-000000000000:test:local/test\\n~/rps/paper 3:1 ~/rps/rock {because}\\n\",\n+ \"@00000000-0000-0000-0000-000000000000:test:local/test\\n{because}\\n~/rps/paper 3:1 ~/rps/rock\\n\",\n ));\n \n let mut group = state.public().ranking_group.clone();\n@@ -233,7 +233,7 @@ fn ranking_dominant_item_wins() {\n let mut state = ReducerState::default();\n state.apply_event(ingest_event(\n 1,\n- \"@00000000-0000-0000-0000-000000000000:test:local/test\\n~/t/champion {c}\\n~/t/b {b}\\n~/t/c {c}\\n~/t/d {d}\\n~/t/champion 10:1 ~/t/b {because}\\n~/t/champion 10:1 ~/t/c {because}\\n~/t/champion 10:1 ~/t/d {because}\\n~/t/b 2:1 ~/t/c {because}\\n~/t/c 2:1 ~/t/d {because}\\n\",\n+ \"@00000000-0000-0000-0000-000000000000:test:local/test\\n~/t/champion {c}\\n~/t/b {b}\\n~/t/c {c}\\n~/t/d {d}\\n{because}\\n~/t/champion 10:1 ~/t/b\\n{because}\\n~/t/champion 10:1 ~/t/c\\n{because}\\n~/t/champion 10:1 ~/t/d\\n{because}\\n~/t/b 2:1 ~/t/c\\n{because}\\n~/t/c 2:1 ~/t/d\\n\",\n ));\n \n let mut group = state.public().ranking_group.clone();\n@@ -248,7 +248,7 @@ fn ranking_neutral_votes_produce_equal_scores() {\n let mut state = ReducerState::default();\n state.apply_event(ingest_event(\n 1,\n- \"@00000000-0000-0000-0000-000000000000:test:local/test\\n~/t/a {a}\\n~/t/b {b}\\n~/t/c {c}\\n~/t/a 1:1 ~/t/b {neutral}\\n~/t/b 1:1 ~/t/c {neutral}\\n~/t/c 1:1 ~/t/a {neutral}\\n\",\n+ \"@00000000-0000-0000-0000-000000000000:test:local/test\\n~/t/a {a}\\n~/t/b {b}\\n~/t/c {c}\\n{neutral}\\n~/t/a 1:1 ~/t/b\\n{neutral}\\n~/t/b 1:1 ~/t/c\\n{neutral}\\n~/t/c 1:1 ~/t/a\\n\",\n ));\n \n let mut group = state.public().ranking_group.clone();\n@@ -301,8 +301,8 @@ async fn event_log_append_and_load() {\n let log = EventLog::new(log_path);\n \n let events = vec![\n- ingest_event(1, \"~/a {x}\\n~/b {y}\\n~/a 2:1 ~/b {because}\\n\"),\n- ingest_event(2, \"~/b 3:1 ~/c {because}\\n\"),\n+ ingest_event(1, \"~/a {x}\\n~/b {y}\\n{because}\\n~/a 2:1 ~/b\\n\"),\n+ ingest_event(2, \"{because}\\n~/b 3:1 ~/c\\n\"),\n ];\n \n for ev in &events {\n@@ -323,7 +323,7 @@ async fn event_log_handles_corrupt_lines() {\n let log = EventLog::new(&log_path);\n \n // Write valid events using the log itself, then manually corrupt one line.\n- log.append(&ingest_event(1, \"~/a {x}\\n~/b {y}\\n~/a 2:1 ~/b {because}\\n\"))\n+ log.append(&ingest_event(1, \"~/a {x}\\n~/b {y}\\n{because}\\n~/a 2:1 ~/b\\n\"))\n .await\n .unwrap();\n \n@@ -332,7 +332,7 @@ async fn event_log_handles_corrupt_lines() {\n let mut f = fs::OpenOptions::new().append(true).open(&log_path).unwrap();\n writeln!(f, \"not json at all\").unwrap();\n \n- log.append(&ingest_event(2, \"~/b 3:1 ~/c {because}\\n\"))\n+ log.append(&ingest_event(2, \"{because}\\n~/b 3:1 ~/c\\n\"))\n .await\n .unwrap();\n \n@@ -351,7 +351,7 @@ async fn event_log_creates_parent_dirs() {\n let log_path = tmp.path().join(\"subdir\").join(\"nested\").join(\"events.jsonl\");\n let log = EventLog::new(&log_path);\n \n- log.append(&ingest_event(1, \"~/a {x}\\n~/b {y}\\n~/a 2:1 ~/b {because}\\n\"))\n+ log.append(&ingest_event(1, \"~/a {x}\\n~/b {y}\\n{because}\\n~/a 2:1 ~/b\\n\"))\n .await\n .unwrap();\n assert!(log_path.exists());\n@@ -379,7 +379,7 @@ async fn full_workflow_reducer_and_ranking() {\n \n state.apply_event(ingest_event(\n 1,\n- \"~/langs/rust {Systems language}\\n~/langs/go {Simple concurrency}\\n~/langs/rust 3:1 ~/langs/go {because}\\n\",\n+ \"~/langs/rust {Systems language}\\n~/langs/go {Simple concurrency}\\n{because}\\n~/langs/rust 3:1 ~/langs/go\\n\",\n ));\n \n let mut group = state.public().ranking_group.clone();\n@@ -452,21 +452,21 @@ fn ranking_repeated_votes_normalized() {\n let mut state_once = ReducerState::default();\n state_once.apply_event(ingest_event(\n 1,\n- \"~/norm/a {a}\\n~/norm/b {b}\\n~/norm/a 3:1 ~/norm/b {vote}\\n\",\n+ \"~/norm/a {a}\\n~/norm/b {b}\\n{vote}\\n~/norm/a 3:1 ~/norm/b\\n\",\n ));\n \n let mut state_many = ReducerState::default();\n state_many.apply_event(ingest_event(\n 1,\n- \"~/norm/a {a}\\n~/norm/b {b}\\n~/norm/a 3:1 ~/norm/b {vote1}\\n\",\n+ \"~/norm/a {a}\\n~/norm/b {b}\\n{vote1}\\n~/norm/a 3:1 ~/norm/b\\n\",\n ));\n state_many.apply_event(ingest_event(\n 2,\n- \"~/norm/a 3:1 ~/norm/b {vote2}\\n\",\n+ \"{vote2}\\n~/norm/a 3:1 ~/norm/b\\n\",\n ));\n state_many.apply_event(ingest_event(\n 3,\n- \"~/norm/a 3:1 ~/norm/b {vote3}\\n\",\n+ \"{vote3}\\n~/norm/a 3:1 ~/norm/b\\n\",\n ));\n \n let mut group_once = state_once.public().ranking_group.clone();\n@@ -508,7 +508,7 @@ fn reducer_malformed_ingest_is_skipped_no_panic() {\n #[test]\n fn dsl_parse_rejects_zero_zero_vote_ratio() {\n let err = slugsocial_server::dsl::parse_full(\n- \"~/t/a {a}\\n~/t/b {b}\\n~/t/a 0:0 ~/t/b {zero}\\n\",\n+ \"~/t/a {a}\\n~/t/b {b}\\n{zero}\\n~/t/a 0:0 ~/t/b\\n\",\n )\n .expect_err(\"0:0 vote must be rejected by the parser\");\n let msg = match err {\n@@ -522,7 +522,7 @@ fn dsl_parse_rejects_zero_zero_vote_ratio() {\n let mut state = ReducerState::default();\n state.apply_event(ingest_event(\n 1,\n- \"~/t/a {a}\\n~/t/b {b}\\n~/t/a 0:0 ~/t/b {zero}\\n\",\n+ \"~/t/a {a}\\n~/t/b {b}\\n{zero}\\n~/t/a 0:0 ~/t/b\\n\",\n ));\n let content = state.public();\n assert!(\n@@ -620,7 +620,7 @@ fn ranking_convergence_tolerance_triggers_early_exit() {\n let mut state = ReducerState::default();\n state.apply_event(ingest_event(\n 1,\n- \"~/t/a {a}\\n~/t/b {b}\\n~/t/a 3:1 ~/t/b {because}\\n\",\n+ \"~/t/a {a}\\n~/t/b {b}\\n{because}\\n~/t/a 3:1 ~/t/b\\n\",\n ));\n let mut group = state.public().ranking_group.clone();\n // Very tight tolerance but huge max_iters — should still converge fast\n@@ -742,7 +742,7 @@ fn test_thread_timestamp_bump() {\n #[test]\n fn test_thread_id_is_used_for_votes_and_indexes() {\n let mut state = ReducerState::default();\n- let mut ev = match ingest_event(1, \"~/t/a {a}\\n~/t/b {b}\\n~/t/a 2:1 ~/t/b {reason}\\n\") {\n+ let mut ev = match ingest_event(1, \"~/t/a {a}\\n~/t/b {b}\\n{reason}\\n~/t/a 2:1 ~/t/b\\n\") {\n Event::Ingest(i) => i,\n _ => unreachable!(),\n };\n@@ -770,7 +770,7 @@ fn test_rank_history_created_for_voted_items() {\n let mut state = ReducerState::default();\n state.apply_event(ingest_event(\n 1,\n- \"~/t/a {a}\\n~/t/b {b}\\n~/t/a 3:1 ~/t/b {reason}\\n\",\n+ \"~/t/a {a}\\n~/t/b {b}\\n{reason}\\n~/t/a 3:1 ~/t/b\\n\",\n ));\n assert!(\n state.public().rank_history.contains_key(&item_id(\"https://slug.social/~/t/a\")),\n@@ -800,7 +800,7 @@ fn test_rank_history_first_entry_delta_zero() {\n let mut state = ReducerState::default();\n state.apply_event(ingest_event(\n 1,\n- \"~/t/a {a}\\n~/t/b {b}\\n~/t/a 3:1 ~/t/b {reason}\\n\",\n+ \"~/t/a {a}\\n~/t/b {b}\\n{reason}\\n~/t/a 3:1 ~/t/b\\n\",\n ));\n let history_a = state.public().rank_history.get(&item_id(\"https://slug.social/~/t/a\")).unwrap();\n assert_eq!(history_a.len(), 1);\ndiff --git a/server/tests/dsl_fixtures.rs b/server/tests/dsl_fixtures.rs\nindex 2446da86447508b36b7a7dfb112b35cc5a4cc1e6..62f3fa14adc8203562338bf75f9310b99bacbb43 100644\n--- a/server/tests/dsl_fixtures.rs\n+++ b/server/tests/dsl_fixtures.rs\n@@ -28,7 +28,7 @@ fn parses_tutorial_fixture_with_prose() {\n \n #[test]\n fn parses_big_book_fixture_with_attached_bodies() {\n- // This doc heavily uses the \"~/name{...}\" style with no whitespace.\n+ // This doc heavily uses the \"{...}\\n~/name\" style with no whitespace.\n let doc = dsl::parse_full(BIG_BOOK).expect(\"parse_full should succeed\");\n \n let mut items = 0usize;\n@@ -55,7 +55,7 @@ fn parses_big_book_fixture_with_attached_bodies() {\n \n #[test]\n fn parses_external_dash_vote_line() {\n- let doc = dsl::parse_full(\"-/domain.com/a 2:1 -/domain.com/b { reason }\").unwrap();\n+ let doc = dsl::parse_full(\"{ reason }\\n-/domain.com/a 2:1 -/domain.com/b\").unwrap();\n assert_eq!(\n doc.statements,\n vec![dsl::Stmt::Vote {\ndiff --git a/server/tests/fixtures/big-book.sorter b/server/tests/fixtures/big-book.sorter\nindex 7b3ce871d4f56210d48ecbf6f1ca33371d89eda9..3472f18c6d2e7fdd608b5936d34301480498cf40 100644\n--- a/server/tests/fixtures/big-book.sorter\n+++ b/server/tests/fixtures/big-book.sorter\n@@ -1,10 +1,10 @@\n #Big-Book\n \n-~/big-book/arrived{\n+~/big-book/arrived {\n I had arrived.\n }\n \n-~/big-book/how-it-works{\n+~/big-book/how-it-works {\n \n Rarely have we seen a person fail who has thoroughly followed our path.\n Those who do not recover are people who cannot or will not completely give\n@@ -58,7 +58,7 @@ lives. (b) That probably no human power could have relieved our alcoholism.\n }\n \n \n-~/big-book/run-the-show{\n+~/big-book/run-the-show {\n The first requirement is that we be convinced that any life run on\n self-will can hardly be a success. On that basis we are almost always in\n collision with something or somebody, even though our motives are good.\n@@ -94,7 +94,7 @@ all and is locked up. Whatever our protestations, are not most of us\n concerned with ourselves, our resentments, or our self-pity?\n }\n \n-~/big-book/god-director{\n+~/big-book/god-director {\n \n This is the how and why of it. First of all, we had to quit playing God. It\n didn’t work. Next, we decided that hereafter in this drama of life, God was\n@@ -126,7 +126,7 @@ were ready; that we could at last abandon ourselves utterly to Him.\n \n }\n \n-~/big-book/dubious-luxury{\n+~/big-book/dubious-luxury {\n \n But with the alcoholic, whose hope is the maintenance and growth of a\n spiritual experience, this business of resentment is infinitely grave. We\n@@ -139,7 +139,7 @@ poison.\n \n }\n \n-~/big-book/sick-mans-prayer{\n+~/big-book/sick-mans-prayer {\n \n This was our course: We realized that the people who wronged us were\n perhaps spiritually sick. HOW IT WORKS 67 Though we did not like their\n@@ -151,7 +151,7 @@ him? God save me from being angry. Thy will be done.’’\n \n }\n \n-~/big-book/fear-calamity-serenity{\n+~/big-book/fear-calamity-serenity {\n \n Notice that the word “fear’’ is bracketed alongside the difficulties with\n Mr. Brown, Mrs. Jones, the employer, and the wife. This short word somehow\ndiff --git a/server/tests/fixtures/tutorial.sorter b/server/tests/fixtures/tutorial.sorter\nindex d8228c3a2986fbeb08817ebdedd4f61cfa6ee724..60b4dc92d5212703df65e70949f7251cd5a85c3d 100644\n--- a/server/tests/fixtures/tutorial.sorter\n+++ b/server/tests/fixtures/tutorial.sorter\n@@ -15,9 +15,9 @@ subsequent items will be catalogued in the index under this context.\n \n We are in a tag, now let's declare an item.\n \n-~/alphabet/a\n+~/alphabet/a {the letter a}\n \n-~/alphabet/b\n+~/alphabet/b {the letter b}\n \n The prefix `~/` denotes, when the first characters on a line, the beginning\n of the title of an ontology item (the garden layer).\n@@ -25,7 +25,8 @@ Now we have two discrete, addressable items. Imagine two dots floating on a\n 2d plane.\n Now let's draw a line segment between the two dots.\n \n-~/alphabet/a 2:1 ~/alphabet/b {a edges out b in this ranking}\n+{a edges out b in this ranking}\n+~/alphabet/a 2:1 ~/alphabet/b\n \n \n We have asserted that ~/alphabet/a wins over ~/alphabet/b, winning 66% of that matchup.\n@@ -35,12 +36,13 @@ From this assertion we derive the ranking:\n 2) ~/alphabet/b\n \n If we were to send this email right now, we could view the ranking at\n-https://slug.social/~/alphabet\n+the URL https://slug.social/~/alphabet\n Easy enough.\n \n-~/alphabet/c\n+~/alphabet/c {the letter c}\n \n-~/alphabet/a 3:1 ~/alphabet/c {a strongly preferred to c here}\n+{a strongly preferred to c here}\n+~/alphabet/a 3:1 ~/alphabet/c\n Now the ranking is\n \n 1) ~/alphabet/a\n@@ -102,20 +104,24 @@ Sorter is proprietary, trying to become a venture scale business.\n Now we have six dots swimming around. Lets order them.\n \n truth\n-~/sorter-properties/collective = ~/sorter-properties/asynchronous {roughly equally important}\n-~/sorter-properties/asynchronous = ~/sorter-properties/precise {roughly equally important}\n-~/sorter-properties/precise = ~/sorter-properties/transitive {roughly equally important}\n+{roughly equally important}\n+~/sorter-properties/collective = ~/sorter-properties/asynchronous\n+{roughly equally important}\n+~/sorter-properties/asynchronous = ~/sorter-properties/precise\n+{roughly equally important}\n+~/sorter-properties/precise = ~/sorter-properties/transitive\n \n All equally, absolutely true.\n \n-~/sorter-properties/asynchronous 10:1 ~/sorter-properties/new {\n+{\n Sorter is certainly asynchronous.\n There have been many explorations of the core sorter idea. Pairwise\n comparisons have been studied for decades. Conjoint analysis. HotOrNot.\n Sorter is just one iteration of the human desire to rank things collectively\n }\n+~/sorter-properties/asynchronous 10:1 ~/sorter-properties/new\n \n-~/sorter-properties/new 100:1 ~/sorter-properties/proprietary {\n+{\n There are some new things about sorter. The backwards compatible with prose\n syntax.\n The synthesis of email-first, rank-centrality, and social platform. So\n@@ -124,6 +130,7 @@ But sorter is absolutely not ~/sorter-properties/proprietary. Sorter is open sou\n github.com/sortersocial/index\n Therefore, ~/sorter-properties/proprietary is banished 100:1 to the false end of the spectrum.\n }\n+~/sorter-properties/new 100:1 ~/sorter-properties/proprietary\n \n In sorter, there are no binaries. Everything is a spectrum, including\n truth.\n@@ -131,11 +138,17 @@ truth.\n The most important thing, in my mind, about sorter is that it’s collective.\n \n important\n-~/sorter-properties/collective 100:20 ~/sorter-properties/asynchronous { sorter would still be cool if it was\n-live }\n-~/sorter-properties/collective 100:90 ~/sorter-properties/precise { precise thinking is good too }\n-~/sorter-properties/collective 100:90 ~/sorter-properties/transitive { transitivity enables collectivity }\n-~/sorter-properties/collective 100:10 ~/sorter-properties/new { other iterations of sorter were good too }\n+{\n+sorter would still be cool if it was\n+live\n+}\n+~/sorter-properties/collective 100:20 ~/sorter-properties/asynchronous\n+{ precise thinking is good too }\n+~/sorter-properties/collective 100:90 ~/sorter-properties/precise\n+{ transitivity enables collectivity }\n+~/sorter-properties/collective 100:90 ~/sorter-properties/transitive\n+{ other iterations of sorter were good too }\n+~/sorter-properties/collective 100:10 ~/sorter-properties/new\n \n \n Any other person could add to or vote on #sorter-properties, this is just\ndiff --git a/server/tests/integration.rs b/server/tests/integration.rs\nindex bd10fd2a6ef06ac1a23de790a54ad5aa0065a472..e96d11c820c3bbaa8bb55ad11636397ef225e55a 100644\n--- a/server/tests/integration.rs\n+++ b/server/tests/integration.rs\n@@ -673,7 +673,7 @@ async fn test_private_room_post_links_use_private_garden_routes() {\n let rpc = ui_post_ingest_rpc(\n &room_id,\n \"garden-thread\",\n- \"~/secret/item {classified}\\n~/secret/other {other body}\\n~/secret/item 3:1 ~/secret/other {because}\\n\",\n+ \"~/secret/item {classified}\\n~/secret/other {other body}\\n{because}\\n~/secret/item 3:1 ~/secret/other\\n\",\n );\n let post = client\n .post(format!(\"http://{addr}/ui\"))\n@@ -734,7 +734,7 @@ async fn test_private_room_garden_root_lists_top_level_tilde_children() {\n let rpc = ui_post_ingest_rpc(\n &room_id,\n \"ing\",\n- \"~/test1 {wow}\\n~/test2 {wow2}\\n~/test1 2:1 ~/test2 {because}\\n\",\n+ \"~/test1 {wow}\\n~/test2 {wow2}\\n{because}\\n~/test1 2:1 ~/test2\\n\",\n );\n let post = client\n .post(format!(\"http://{addr}/ui\"))\n@@ -1003,7 +1003,7 @@ async fn test_post_redact_removes_garden_and_marks_thread() {\n \"room\": \"public\",\n \"thread_tag\": \"redact-test\",\n \"delegate\": \"00000000-0000-0000-0000-000000000000:test:local/test\",\n- \"text\": \"~/del-a {a}\\n~/del-b {b}\\n~/del-a 2:1 ~/del-b {vote line}\\n\",\n+ \"text\": \"~/del-a {a}\\n~/del-b {b}\\n{vote line}\\n~/del-a 2:1 ~/del-b\\n\",\n \"return_rank_diff\": false\n }\n }]),\n@@ -1123,7 +1123,7 @@ async fn test_vote_endpoint() {\n \"room\": \"public\",\n \"thread_tag\": \"cli\",\n \"delegate\": \"00000000-0000-0000-0000-000000000000:test:local/test\",\n- \"text\": \"~/clap {cli parser}\\n~/argh {cli parser}\\n~/clap 3:1 ~/argh {because clap is more full-featured}\\n\",\n+ \"text\": \"~/clap {cli parser}\\n~/argh {cli parser}\\n{because clap is more full-featured}\\n~/clap 3:1 ~/argh\\n\",\n \"return_rank_diff\": true\n }\n }]);\n@@ -1144,7 +1144,7 @@ async fn test_rank_endpoint() {\n \"room\": \"public\",\n \"thread_tag\": \"langs\",\n \"delegate\": \"00000000-0000-0000-0000-000000000000:test:local/test\",\n- \"text\": \"~/rust {systems}\\n~/go {concurrency}\\n~/rust 3:1 ~/go {because i prefer rust for systems work}\\n\",\n+ \"text\": \"~/rust {systems}\\n~/go {concurrency}\\n{because i prefer rust for systems work}\\n~/rust 3:1 ~/go\\n\",\n \"return_rank_diff\": false\n }\n }]);\n@@ -1178,7 +1178,7 @@ async fn test_check_endpoint_does_not_commit() {\n let check_batch = serde_json::json!([{\n \"Check\": {\n \"room\": \"public\",\n- \"text\": \"~/a {x}\\n~/b {y}\\n~/a 2:1 ~/b {because}\\n\",\n+ \"text\": \"~/a {x}\\n~/b {y}\\n{because}\\n~/a 2:1 ~/b\\n\",\n }\n }]);\n let resp_body = rpc_batch(&client, addr, None, check_batch).await;\n@@ -1217,7 +1217,7 @@ async fn test_garden_item_pair_matchup_include_threads() {\n \"room\": \"public\",\n \"thread_tag\": \"sorting-hat\",\n \"delegate\": \"00000000-0000-0000-0000-000000000000:test:local/test\",\n- \"text\": \"~/sorts/insertion { O(n^2) }\\n~/sorts/mergesort { O(n log n) }\\n~/sorts/insertion 3:1 ~/sorts/mergesort { simpler for small n }\\n\",\n+ \"text\": \"~/sorts/insertion { O(n^2) }\\n~/sorts/mergesort { O(n log n) }\\n{ simpler for small n }\\n~/sorts/insertion 3:1 ~/sorts/mergesort\\n\",\n \"return_rank_diff\": false\n }\n }]);\n@@ -1473,7 +1473,7 @@ async fn test_rank_history() {\n \n ingest(\n \"00000000-0000-0000-0000-000000000001:rig:test/model\",\n- \"~/hist/rust { systems }\\n~/hist/python { scripting }\\n~/hist/go { concurrency }\\n~/hist/rust 3:1 ~/hist/python { ownership over gc }\\n~/hist/rust 2:1 ~/hist/go { performance over simplicity }\\n\",\n+ \"~/hist/rust { systems }\\n~/hist/python { scripting }\\n~/hist/go { concurrency }\\n{ ownership over gc }\\n~/hist/rust 3:1 ~/hist/python\\n{ performance over simplicity }\\n~/hist/rust 2:1 ~/hist/go\\n\",\n )\n .await;\n \n@@ -1506,7 +1506,7 @@ async fn test_rank_history() {\n \n ingest(\n \"00000000-0000-0000-0000-000000000002:rig:test/model\",\n- \"~/hist/python 3:1 ~/hist/go { dynamic typing is worth it }\\n\",\n+ \"{ dynamic typing is worth it }\\n~/hist/python 3:1 ~/hist/go\\n\",\n )\n .await;\n \n@@ -1571,7 +1571,7 @@ async fn pair_returns_connectivity_stats() {\n \"room\": \"public\",\n \"thread_tag\": \"connectivity-test\",\n \"delegate\": \"00000000-0000-0000-0000-000000000001:testrig:test/model\",\n- \"text\": \"~/conn/a { item a }\\n~/conn/b { item b }\\n~/conn/c { item c }\\n~/conn/d { item d }\\n~/conn/a 3:1 ~/conn/b { a is better }\\n\",\n+ \"text\": \"~/conn/a { item a }\\n~/conn/b { item b }\\n~/conn/c { item c }\\n~/conn/d { item d }\\n{ a is better }\\n~/conn/a 3:1 ~/conn/b\\n\",\n \"return_rank_diff\": false\n }\n }]);\n@@ -1605,7 +1605,7 @@ async fn pair_returns_connectivity_stats() {\n \"room\": \"public\",\n \"thread_tag\": \"connectivity-test\",\n \"delegate\": \"00000000-0000-0000-0000-000000000001:testrig:test/model\",\n- \"text\": \"~/conn/c 2:1 ~/conn/a { c beats a }\\n\",\n+ \"text\": \"{ c beats a }\\n~/conn/c 2:1 ~/conn/a\\n\",\n \"return_rank_diff\": false\n }\n }]);\ndiff --git a/test/browser_garden_pin.clj b/test/browser_garden_pin.clj\nindex 98c086d5647207f3ac72dbb9cbc13488c030eef1..d4fa9ed60b45e872ec60e29307cfa61bcd9dd6b2 100644\n--- a/test/browser_garden_pin.clj\n+++ b/test/browser_garden_pin.clj\n@@ -60,7 +60,8 @@\n raw (str \"# \" thread-tag \"\\n\\n\"\n \"~/gp-pin-a {alpha}\\n\"\n \"~/gp-pin-b {beta}\\n\"\n- \"~/gp-pin-a 2:1 ~/gp-pin-b {pin test vote}\\n\")\n+ \"{pin test vote}\\n\"\n+ \"~/gp-pin-a 2:1 ~/gp-pin-b\\n\")\n post-resp (oauth/http-post-json\n (str base-url \"/api/v0/rpc\")\n [{\"Post\" {\"room\" \"public\"\ndiff --git a/test/browser_post_redact.clj b/test/browser_post_redact.clj\nindex f8f5fad7c0579f57e68744647465b9dcce1d4802..bf1af0230bc7c8228f947087d4f22d51d5189a5d 100644\n--- a/test/browser_post_redact.clj\n+++ b/test/browser_post_redact.clj\n@@ -60,7 +60,7 @@\n raw (str \"# \" thread-tag \"\\n\\n\"\n \"~/tomb-a {tomb item a}\\n\"\n \"~/tomb-b {tomb item b}\\n\"\n- \"~/tomb-a 2:1 ~/tomb-b {browser redact vote}\\n\")\n+ \"{browser redact vote}\\n~/tomb-a 2:1 ~/tomb-b\\n\")\n post-resp (oauth/http-post-json\n (str base-url \"/api/v0/rpc\")\n [{\"Post\" {\"room\" \"public\"\ndiff --git a/test/browser_public_garden.clj b/test/browser_public_garden.clj\nindex 7bea08e1cf2c8f5b1a94b0640a46e9ba5607b19b..5dd49f1f70c870e25a2a9f5348f0bdb85647e897 100644\n--- a/test/browser_public_garden.clj\n+++ b/test/browser_public_garden.clj\n@@ -51,7 +51,7 @@\n \"~/br-pub-a {alpha}\\n\"\n \"~/br-pub-b {beta}\\n\"\n \"~/br-pub-c {gamma}\\n\"\n- \"~/br-pub-a 2:1 ~/br-pub-b {browser regression vote}\\n\")\n+ \"{browser regression vote}\\n~/br-pub-a 2:1 ~/br-pub-b\\n\")\n post-resp (oauth/http-post-json\n (str base-url \"/api/v0/rpc\")\n [{\"Post\" {\"room\" \"public\"\ndiff --git a/test/browser_redact_thread_index.clj b/test/browser_redact_thread_index.clj\nindex 88a86fb9844fcf78df85e59f21f2e37f27df96a5..39958a85d4de10f284166f4561bc6ada2332b304 100644\n--- a/test/browser_redact_thread_index.clj\n+++ b/test/browser_redact_thread_index.clj\n@@ -63,11 +63,11 @@\n text-first (str \"# \" thread-tag \"\\n\\n\"\n \"~/rix/a {alpha}\\n\"\n \"~/rix/b {beta}\\n\"\n- \"~/rix/a 2:1 ~/rix/b {browser redact thread idx post one}\\n\")\n+ \"{browser redact thread idx post one}\\n~/rix/a 2:1 ~/rix/b\\n\")\n text-second (str \"# \" thread-tag \"\\n\\n\"\n \"~/rix/c {gamma}\\n\"\n \"~/rix/d {delta}\\n\"\n- \"~/rix/c 2:1 ~/rix/d {browser redact thread idx post two}\\n\")\n+ \"{browser redact thread idx post two}\\n~/rix/c 2:1 ~/rix/d\\n\")\n _ (is (true? (get-in (json/parse-string\n (:body (oauth/http-post-json\n (str base-url \"/api/v0/rpc\")\ndiff --git a/test/browser_vote_compare.clj b/test/browser_vote_compare.clj\nindex 839482d12963f130a2063f2212a6b922b2d229e1..f97a4a45e89c388f093694a1bf296f674bcb377a 100644\n--- a/test/browser_vote_compare.clj\n+++ b/test/browser_vote_compare.clj\n@@ -53,7 +53,8 @@\n raw (str \"# \" thread-tag \"\\n\\n\"\n \"~/gp-vote-a {one}\\n\"\n \"~/gp-vote-b {two}\\n\"\n- \"~/gp-vote-a 1:1 ~/gp-vote-b {seed edge vote}\\n\")\n+ \"{seed edge vote}\\n\"\n+ \"~/gp-vote-a 1:1 ~/gp-vote-b\\n\")\n post-resp (oauth/http-post-json\n (str base-url \"/api/v0/rpc\")\n [{\"Post\" {\"room\" \"public\"\ndiff --git a/test/grants.clj b/test/grants.clj\nindex 12846c6259c2c8f0395668d49155ee22c8abfcd5..055e0a94a7fd90d58f234f643ae8998956ef9939 100644\n--- a/test/grants.clj\n+++ b/test/grants.clj\n@@ -125,14 +125,15 @@\n (println \"\\nalice posts items + vote to private room…\")\n (is (rpc-line-ok? (:parsed (ingest! base-url alice-token room-id \"main\"\n \"00000000-0000-0000-0000-000000000001:test:local/dev\"\n- \"~/fruits/apple { A crisp red apple. }\\n~/fruits/banana { A yellow banana. }\\n~/fruits/apple > ~/fruits/banana { apples are better }\")))\n+ \"~/fruits/apple { A crisp red apple. }\\n~/fruits/banana { A yellow banana. }\\n{ apples are better }\\n~/fruits/apple > ~/fruits/banana\")))\n \"alice vote in private room succeeds\")\n \n ;; Bob (View + Post, no Vote) tries to vote.\n (println \"\\nbob (no Vote) tries to vote…\")\n (is (not (rpc-line-ok? (:parsed (ingest! base-url bob-token room-id \"main\"\n \"00000000-0000-0000-0000-000000000002:test:local/dev\"\n- \"~/fruits/apple > ~/fruits/banana { bob's take }\"))))\n+ \"{ bob's take }\n+~/fruits/apple > ~/fruits/banana\"))))\n \"bob without Vote gets RPC failure\")\n \n ;; Alice grants bob Vote.\n@@ -144,7 +145,8 @@\n (println \"\\nbob (View + Post + Vote) votes…\")\n (is (rpc-line-ok? (:parsed (ingest! base-url bob-token room-id \"main\"\n \"00000000-0000-0000-0000-000000000002:test:local/dev\"\n- \"~/fruits/apple > ~/fruits/banana { bob's take }\")))\n+ \"{ bob's take }\n+~/fruits/apple > ~/fruits/banana\")))\n \"bob with Vote succeeds\"))\n \n (finally\ndiff --git a/test/integration.clj b/test/integration.clj\nindex 81261c7f2e0487666ef849f523fe91b2c005e6cd..5530bf9f4533f7f548e47599b5fe33f9be567309 100644\n--- a/test/integration.clj\n+++ b/test/integration.clj\n@@ -39,19 +39,23 @@\n \"#integration-test\"\n \"\"\n \"~/languages/python { General-purpose, dynamically typed }\"\n- \"~/languages/rust { Systems language with ownership model }\"\n- \"~/languages/go { Compiled, garbage-collected, simple concurrency }\"\n+ \"~/languages/rust { Systems language with ownership model }\"\n+ \"~/languages/go { Compiled, garbage-collected, simple concurrency }\"\n \"\"\n- \"~/languages/rust 3:1 ~/languages/python { Rust has stronger type safety }\"\n- \"~/languages/rust 2:1 ~/languages/go { Ownership beats GC for systems work }\"\n- \"~/languages/python 2:1 ~/languages/go { Python's ecosystem is broader }\"]))\n+ \"{ Rust has stronger type safety }\n+~/languages/rust 3:1 ~/languages/python\"\n+ \"{ Ownership beats GC for systems work }\n+~/languages/rust 2:1 ~/languages/go\"\n+ \"{ Python's ecosystem is broader }\n+~/languages/python 2:1 ~/languages/go\"]))\n \n (def sorter-doc-2\n (str/join \"\\n\"\n [actor-2\n \"#integration-test\"\n \"\"\n- \"~/languages/go 2:1 ~/languages/python { Go deploys as a single binary, simpler ops }\"]))\n+ \"{ Go deploys as a single binary, simpler ops }\n+~/languages/go 2:1 ~/languages/python\"]))\n \n (def external-sorter-doc\n (str/join \"\\n\"\n@@ -61,7 +65,8 @@\n \"-/github.com/iss/1 { issue one }\"\n \"-/github.com/iss/2 { issue two }\"\n \"\"\n- \"-/github.com/iss/1 2:1 -/github.com/iss/2 { triage order }\"]))\n+ \"{ triage order }\n+-/github.com/iss/1 2:1 -/github.com/iss/2\"]))\n \n (def check-doc-disconnected\n (str/join \"\\n\"\n@@ -73,8 +78,10 @@\n \"~/disc/c { c }\"\n \"~/disc/d { d }\"\n \"\"\n- \"~/disc/a 2:1 ~/disc/b { first component }\"\n- \"~/disc/c 2:1 ~/disc/d { second component }\"]))\n+ \"{ first component }\n+~/disc/a 2:1 ~/disc/b\"\n+ \"{ second component }\n+~/disc/c 2:1 ~/disc/d\"]))\n \n ;; ---------------------------------------------------------------------------\n ;; main flow (linear integration)\n@@ -291,8 +298,10 @@\n (bind two-vote-doc (str/join \"\\n\"\n [\"@00000000-0000-0000-0000-000000000003:integration:local/test\"\n \"#integration-test\"\n- \"~/languages/rust 4:1 ~/languages/python { type safety }\"\n- \"~/languages/rust 3:1 ~/languages/go { zero-cost abstractions }\"]))\n+ \"{ type safety }\n+~/languages/rust 4:1 ~/languages/python\"\n+ \"{ zero-cost abstractions }\n+~/languages/rust 3:1 ~/languages/go\"]))\n (bind hist-ingest (common/run-cli cli-bin base-url [\"public\" \"forum\" \"post\" \"integration-test\" \"--json\" \"--delegate\" \"00000000-0000-0000-0000-000000000000:cli:local/dev\"] :input two-vote-doc :extra-env token-env))\n (is (zero? (:exit hist-ingest))\n (str \"two-vote ingest exits 0 (err: \" (:err hist-ingest) \")\"))\ndiff --git a/test/walkthrough_fixture.clj b/test/walkthrough_fixture.clj\nindex f7eb1cacca0317129e9baf68fa4f4e33356ad70f..fb7f4ee8c996e7b4ffb55e225f73f3266feda220 100644\n--- a/test/walkthrough_fixture.clj\n+++ b/test/walkthrough_fixture.clj\n@@ -49,7 +49,7 @@\n \"capabilities\" [\"view\" \"post\" \"vote\" \"add_item\"]}}]))\n (let [wall-text (str \"Walkthrough seed — multi-paragraph stress test.\\n\\n\"\n \"Second paragraph: the garden holds ~/secret/item and ~/secret/other. \"\n- \"Votes like ~/secret/item 3:1 ~/secret/other {because} should still parse.\\n\\n\"\n+ \"Votes like {because}\\\\n~/secret/item 3:1 ~/secret/other should parse.\\n\\n\"\n \"Third block: lorem-style filler so wrapping and vertical rhythm are obvious. \"\n \"We want enough prose that the thread view scrolls and pre blocks show overflow \"\n \"behavior (long lines, slug links, embed URLs) without looking like toy data.\\n\\n\"\n@@ -58,7 +58,7 @@\n \"https://open.spotify.com/track/4iV5W9uYEdYUVa79Axb7U9 https://www.youtube.com/watch?v=dQw4w9WgXcQ\\n\\n\"\n \"~/secret/item {classified}\\n\"\n \"~/secret/other {secondary}\\n\"\n- \"~/secret/item 3:1 ~/secret/other {because}\\n\")\n+ \"{because}\\n~/secret/item 3:1 ~/secret/other\\n\")\n rpc (json/generate-string\n {:action \"post_ingest\"\n :room room-id\n","role":"user"}],"model":"~x-ai/grok-latest"}