{"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[c3cbcaa7] refactor\n\nSide A — unified diff (full patch):\ndiff --git a/server/src/api/auth.rs b/server/src/api/auth.rs\nindex b3631b06153d52f88348fef927e7a024b7b85ad6..cd556eb89e9dd8203eba6c8969ffd144db5d329d 100644\n--- a/server/src/api/auth.rs\n+++ b/server/src/api/auth.rs\n@@ -71,6 +71,24 @@ pub fn optional_principal(headers: &HeaderMap, jar: &CookieJar, reduced: &Reduce\n verify_token(reduced, c.value()).ok()\n }\n \n+/// Browser session: principal + bearer token string (same shape as CLI session cookie).\n+#[derive(Debug, Clone)]\n+pub struct WebSession {\n+ pub username: String,\n+ pub bearer: String,\n+}\n+\n+/// Resolve username and bearer together for `POST /ui` dispatch (one read of headers + jar).\n+pub fn resolve_web_session(headers: &HeaderMap, jar: &CookieJar, reduced: &ReducerState) -> Option {\n+ let username = optional_principal(headers, jar, reduced)?;\n+ let bearer = headers\n+ .get(header::AUTHORIZATION)\n+ .and_then(|v| v.to_str().ok())\n+ .and_then(|s| s.strip_prefix(\"Bearer \").map(|t| t.trim().to_string()))\n+ .or_else(|| jar.get(SLUG_SESSION_COOKIE).map(|c| c.value().to_string()))?;\n+ Some(WebSession { username, bearer })\n+}\n+\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)\ndiff --git a/server/src/api/mod.rs b/server/src/api/mod.rs\nindex a986f706ea4b261cbaf004c02b4cf84184b41371..4e223a7460997c464706dca850281447bd754ed5 100644\n--- a/server/src/api/mod.rs\n+++ b/server/src/api/mod.rs\n@@ -4,7 +4,6 @@ mod rpc;\n mod stream;\n mod validate;\n mod ui_html;\n-mod web_post;\n \n pub use auth::{\n get_join_invite,\n@@ -19,7 +18,9 @@ pub use auth::{\n get_web_login,\n get_logout,\n optional_principal,\n+ resolve_web_session,\n session_cookie_header_value,\n+ WebSession,\n SLUG_SESSION_COOKIE,\n };\n \n@@ -35,7 +36,6 @@ pub use stream::{get_html_stream, get_stream};\n pub use validate::{normalize_room_and_thread, validate_ingest_document, ValidatedIngest};\n \n pub use ui_html::post_ui_html;\n-pub use web_post::{check_web_ingest, post_web_ingest, post_web_redact};\n \n #[cfg(test)]\n mod tests {\ndiff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs\nindex 2b40a72059981d558768f73d189b991f3448c257..497f8fdd222a8ec7c3d76b0695e0352e973ca3ba 100644\n--- a/server/src/api/ui_html.rs\n+++ b/server/src/api/ui_html.rs\n@@ -1,25 +1,29 @@\n-//! Single `POST /ui` entry for browser [`crate::html::ui_action::HtmlUiAction`] (JSON in `__rpc__` + holes).\n+//! Single `POST /ui` entry: parse `__rpc__` → [`HtmlUiAction`], resolve [`WebSession`] once, dispatch.\n \n use axum::{\n- body::Body,\n+ body,\n extract::State,\n- http::{header, HeaderMap, StatusCode},\n+ http::{header, HeaderMap, HeaderValue, StatusCode},\n response::{IntoResponse, Response},\n Form,\n };\n use axum_extra::extract::cookie::CookieJar;\n+use slug_types::{RpcBatch, RpcBatchResponse, RpcCommand, RpcResult};\n use std::collections::HashMap;\n \n use crate::{\n api::{\n- auth::optional_principal,\n- web_post::{run_check_web_ingest, run_post_web_ingest, run_post_web_redact, WebPostForm, WebRedactForm},\n+ auth::{resolve_web_session, WebSession},\n+ handle_rpc_batch,\n+ rpc::{rpc_post_redact, rpc_post_with_bearer},\n },\n+ canonical_path::canonicalize_tag,\n html::{\n fragment_public_new_thread_form, fragment_room_new_thread_form, login_to_post_hint_markup,\n- parse_html_ui_from_form, user_can_post_room, user_can_view_room, HtmlUiAction, JsBuilder,\n- ThreadNav,\n+ parse_html_ui_from_form, thread_feed_html, thread_feed_html_for_room, thread_feed_region_markup,\n+ user_can_post_room, user_can_view_room, HtmlUiAction, JsBuilder, ThreadNav,\n },\n+ reducer::{scope_from_room_wire, ScopeId},\n state::AppState,\n };\n \n@@ -34,6 +38,19 @@ pub async fn post_ui_html(\n Err(e) => return ui_js_warn(&e.to_string()).into_response(),\n };\n \n+ let reduced = state.reduced.read().await;\n+ let session = resolve_web_session(&headers, &jar, &reduced);\n+ drop(reduced);\n+\n+ dispatch_ui_action(&state, session.as_ref(), action).await\n+}\n+\n+/// All UI command logic: HTTP extractors stop above; this only sees [`AppState`], session, and [`HtmlUiAction`].\n+async fn dispatch_ui_action(\n+ state: &AppState,\n+ session: Option<&WebSession>,\n+ action: HtmlUiAction,\n+) -> Response {\n match action {\n HtmlUiAction::PostIngest {\n room,\n@@ -42,47 +59,87 @@ pub async fn post_ui_html(\n error_target,\n form_id,\n } => {\n- run_post_web_ingest(\n- &state,\n- &headers,\n- &jar,\n- WebPostForm {\n- room,\n- thread_tag,\n- text,\n- error_target,\n- form_id,\n- },\n- )\n- .await\n+ let Some(session) = session else {\n+ return js_redirect(\"/login\").into_response();\n+ };\n+ let room = room.trim().to_string();\n+ let thread_tag = thread_tag.trim().to_string();\n+ if text.trim().is_empty() {\n+ return form_js_error(\n+ error_target.as_ref(),\n+ \"empty post\",\n+ \"Write something in the text area (DSL / prose).\",\n+ )\n+ .into_response();\n+ }\n+ match rpc_post_with_bearer(state, &session.bearer, room.clone(), thread_tag.clone(), text).await {\n+ Ok(RpcResult::PostOk { .. }) => {\n+ post_success_response(\n+ state,\n+ &room,\n+ &thread_tag,\n+ error_target.as_ref(),\n+ form_id.as_ref(),\n+ Some(session.username.as_str()),\n+ )\n+ .await\n+ .into_response()\n+ }\n+ Ok(_) => form_js_error(\n+ error_target.as_ref(),\n+ \"unexpected response\",\n+ \"Post did not return PostOk.\",\n+ )\n+ .into_response(),\n+ Err((msg, hint)) => form_js_error(error_target.as_ref(), &msg, hint.as_deref().unwrap_or(\"\")).into_response(),\n+ }\n }\n HtmlUiAction::CheckIngest {\n room,\n thread_tag,\n text,\n error_target,\n- form_id,\n+ form_id: _,\n } => {\n- run_check_web_ingest(\n- &state,\n- &headers,\n- &jar,\n- WebPostForm {\n- room,\n- thread_tag,\n- text,\n- error_target,\n- form_id,\n- },\n- )\n- .await\n+ let Some(session) = session else {\n+ return js_redirect(\"/login\").into_response();\n+ };\n+ let room = room.trim().to_string();\n+ let thread_tag = canonicalize_tag(&thread_tag);\n+ if thread_tag.is_empty() {\n+ return form_js_error(\n+ error_target.as_ref(),\n+ \"missing thread tag\",\n+ \"Set a thread tag before posting.\",\n+ )\n+ .into_response();\n+ }\n+ if text.trim().is_empty() {\n+ return js_clear_errors(&form_error_target(error_target.as_ref())).into_response();\n+ }\n+ match rpc_check_with_bearer(state, &session.bearer, room, text.clone()).await {\n+ Ok(RpcResult::CheckOk { .. }) => js_clear_errors(&form_error_target(error_target.as_ref())).into_response(),\n+ Ok(_) => form_js_error(error_target.as_ref(), \"unexpected response\", \"Check did not return CheckOk.\").into_response(),\n+ Err((msg, hint)) => form_js_error(error_target.as_ref(), &msg, hint.as_deref().unwrap_or(\"\")).into_response(),\n+ }\n }\n HtmlUiAction::RedactPost { post_id } => {\n- run_post_web_redact(&state, &headers, &jar, WebRedactForm { post_id }).await\n+ let Some(session) = session else {\n+ return js_redirect(\"/login\").into_response();\n+ };\n+ let h = headers_from_bearer(&session.bearer);\n+ match rpc_post_redact(state, &h, post_id).await {\n+ Ok(RpcResult::RedactPostOk {}) => redact_success_response(state).await.into_response(),\n+ Ok(_) => (StatusCode::BAD_REQUEST, \"unexpected response\").into_response(),\n+ Err((msg, hint)) => {\n+ let detail = hint.as_deref().unwrap_or(\"\");\n+ js_error(\"#errors\", &msg, detail).into_response()\n+ }\n+ }\n }\n HtmlUiAction::ExpandPublicNewThreadForm => {\n let reduced = state.reduced.read().await;\n- let user = optional_principal(&headers, &jar, &reduced);\n+ let user = session.map(|s| s.username.as_str());\n drop(reduced);\n let markup = if user.is_some() {\n fragment_public_new_thread_form(true)\n@@ -99,18 +156,18 @@ pub async fn post_ui_html(\n return ui_js_warn(\"missing room\").into_response();\n }\n let reduced = state.reduced.read().await;\n- let user = optional_principal(&headers, &jar, &reduced);\n+ let user = session.map(|s| s.username.as_str());\n if !reduced.rooms.contains(&room_wire) {\n drop(reduced);\n return ui_js_warn(\"room not found\").into_response();\n }\n- if !user_can_view_room(&reduced, &room_wire, user.as_deref()) {\n+ if !user_can_view_room(&reduced, &room_wire, user) {\n drop(reduced);\n return ui_js_warn(\"forbidden\").into_response();\n }\n- let can_post = user\n+ let can_post = session\n .as_ref()\n- .map(|u| user_can_post_room(&reduced, &room_wire, u))\n+ .map(|s| user_can_post_room(&reduced, &room_wire, &s.username))\n .unwrap_or(false);\n drop(reduced);\n let Some(nav) = ThreadNav::from_room_id(&room_wire) else {\n@@ -128,12 +185,195 @@ pub async fn post_ui_html(\n }\n }\n \n+fn headers_from_bearer(bearer: &str) -> HeaderMap {\n+ let mut headers = HeaderMap::new();\n+ if let Ok(hv) = HeaderValue::from_str(&format!(\"Bearer {bearer}\")) {\n+ headers.insert(header::AUTHORIZATION, hv);\n+ }\n+ headers\n+}\n+\n+fn post_redirect_location(room: &str, thread_tag: &str) -> String {\n+ let tag = canonicalize_tag(thread_tag);\n+ if room.trim() == \"public\" {\n+ format!(\"/t/{tag}\")\n+ } else {\n+ let room = room.trim();\n+ let Some((a, b)) = room.split_once('/') else {\n+ return \"/\".to_string();\n+ };\n+ format!(\"/r/{a}/{b}/t/{tag}\")\n+ }\n+}\n+\n+fn js_quote(s: &str) -> String {\n+ serde_json::to_string(s).expect(\"js string escaping must succeed\")\n+}\n+\n+fn js_redirect(to: &str) -> Response {\n+ let js = format!(\"window.location = {};\", js_quote(to));\n+ Response::builder()\n+ .status(StatusCode::OK)\n+ .header(header::CONTENT_TYPE, \"text/javascript; charset=utf-8\")\n+ .body(axum::body::Body::from(js))\n+ .unwrap()\n+}\n+\n+fn js_error(error_target: &str, title: &str, detail: &str) -> Response {\n+ let markup = maud::html! {\n+ div id=(error_target.trim_start_matches('#')) {\n+ p class=\"auth-error\" { (title) }\n+ @if !detail.is_empty() {\n+ pre class=\"muted\" { (detail) }\n+ }\n+ }\n+ };\n+ JsBuilder::new()\n+ .morph_selector(error_target, markup)\n+ .into_response()\n+}\n+\n+fn form_error_target(error_target: Option<&String>) -> String {\n+ error_target\n+ .map(|s| s.as_str())\n+ .filter(|s| !s.trim().is_empty())\n+ .map(|s| {\n+ if s.starts_with('#') {\n+ s.to_string()\n+ } else {\n+ format!(\"#{s}\")\n+ }\n+ })\n+ .unwrap_or_else(|| \"#errors\".to_string())\n+}\n+\n+fn form_js_error(error_target: Option<&String>, title: &str, detail: &str) -> Response {\n+ js_error(&form_error_target(error_target), title, detail)\n+}\n+\n+fn js_clear_errors(error_target: &str) -> Response {\n+ let markup = maud::html! {\n+ div id=(error_target.trim_start_matches('#')) {}\n+ };\n+ JsBuilder::new()\n+ .morph_selector(error_target, markup)\n+ .into_response()\n+}\n+\n+fn empty_error_markup(error_target: &str) -> maud::Markup {\n+ maud::html! {\n+ div id=(error_target.trim_start_matches('#')) {}\n+ }\n+}\n+\n+async fn rpc_check_with_bearer(\n+ state: &AppState,\n+ bearer_token: &str,\n+ room: String,\n+ text: String,\n+) -> Result)> {\n+ let mut headers = HeaderMap::new();\n+ let hv = HeaderValue::from_str(&format!(\"Bearer {bearer_token}\"))\n+ .map_err(|_| (\"invalid session token\".into(), None))?;\n+ headers.insert(header::AUTHORIZATION, hv);\n+\n+ let response = handle_rpc_batch(\n+ State(state.clone()),\n+ headers,\n+ axum::Json(RpcBatch(vec![RpcCommand::Check { room, text }])),\n+ )\n+ .await\n+ .into_response();\n+\n+ let status = response.status();\n+ if !status.is_success() {\n+ return Err((format!(\"rpc check http {}\", status), None));\n+ }\n+\n+ let body = body::to_bytes(response.into_body(), usize::MAX)\n+ .await\n+ .map_err(|e| (e.to_string(), None))?;\n+ let parsed: RpcBatchResponse =\n+ serde_json::from_slice(&body).map_err(|e| (e.to_string(), None))?;\n+ let line = parsed\n+ .results\n+ .into_iter()\n+ .next()\n+ .ok_or_else(|| (\"empty rpc check response\".to_string(), None))?;\n+ if line.ok {\n+ line.result\n+ .ok_or_else(|| (\"missing rpc check result\".to_string(), None))\n+ } else {\n+ Err((\n+ line.error.unwrap_or_else(|| \"check failed\".to_string()),\n+ line.hint,\n+ ))\n+ }\n+}\n+\n+async fn post_success_response(\n+ state: &AppState,\n+ room: &str,\n+ thread_tag: &str,\n+ error_target: Option<&String>,\n+ form_id: Option<&String>,\n+ viewer: Option<&str>,\n+) -> Response {\n+ let error_target = form_error_target(error_target);\n+ let room = room.trim().to_string();\n+ let thread_tag = canonicalize_tag(thread_tag);\n+ let thread_location = post_redirect_location(&room, &thread_tag);\n+ let form_id = form_id.map(|s| s.as_str()).unwrap_or(\"\");\n+ let scope = scope_from_room_wire(&room);\n+ let feed_markup = match &scope {\n+ ScopeId::Public => thread_feed_html(state).await,\n+ ScopeId::Room(_) => thread_feed_html_for_room(state, &room).await,\n+ };\n+ let thread_markup = thread_feed_region_markup(\n+ state,\n+ match &scope {\n+ ScopeId::Public => None,\n+ ScopeId::Room(_) => Some(room.as_str()),\n+ },\n+ &thread_tag,\n+ viewer.as_deref(),\n+ )\n+ .await;\n+ let feed_selector = match &scope {\n+ ScopeId::Public => \"#thread-feed\",\n+ ScopeId::Room(_) => \"#room-thread-feed\",\n+ };\n+\n+ let builder = JsBuilder::new()\n+ .morph_selector(&error_target, empty_error_markup(&error_target))\n+ .morph_selector(feed_selector, feed_markup)\n+ .if_current_path_matches(&thread_location, |builder| {\n+ let builder = builder.morph_selector(\"#thread-feed-region\", thread_markup);\n+ let builder = if !form_id.trim().is_empty() {\n+ builder.qs(&format!(\"#{form_id}\")).reset()\n+ } else {\n+ builder\n+ };\n+ builder\n+ })\n+ .if_current_path_not_matches(&thread_location, |builder| builder.redirect(&thread_location));\n+\n+ builder.into_response()\n+}\n+\n+async fn redact_success_response(state: &AppState) -> Response {\n+ let feed_markup = thread_feed_html(state).await;\n+ JsBuilder::new()\n+ .morph_selector(\"#thread-feed\", feed_markup)\n+ .into_response()\n+}\n+\n fn ui_js_warn(msg: &str) -> Response {\n use crate::html::js_string_literal;\n let js = format!(\"console.warn({});\", js_string_literal(msg));\n Response::builder()\n .status(StatusCode::OK)\n .header(header::CONTENT_TYPE, \"text/javascript; charset=utf-8\")\n- .body(Body::from(js))\n+ .body(axum::body::Body::from(js))\n .unwrap()\n }\ndiff --git a/server/src/api/web_post.rs b/server/src/api/web_post.rs\ndeleted file mode 100644\nindex 265025f41ff1548d05b2d2d5d84202245388053f..0000000000000000000000000000000000000000\n--- a/server/src/api/web_post.rs\n+++ /dev/null\n@@ -1,362 +0,0 @@\n-use axum::{\n- body,\n- extract::State,\n- http::{header, HeaderMap, HeaderValue, StatusCode},\n- response::{IntoResponse, Response},\n- Form,\n-};\n-use axum_extra::extract::cookie::CookieJar;\n-use serde::Deserialize;\n-use slug_types::{RpcBatch, RpcBatchResponse, RpcCommand, RpcResult};\n-\n-use crate::{\n- api::{\n- auth::{optional_principal, SLUG_SESSION_COOKIE},\n- handle_rpc_batch,\n- rpc::{rpc_post_redact, rpc_post_with_bearer},\n- },\n- canonical_path::canonicalize_tag,\n- html::{thread_feed_html, thread_feed_html_for_room, thread_feed_region_markup, JsBuilder},\n- reducer::{scope_from_room_wire, ScopeId},\n- state::AppState,\n-};\n-\n-#[derive(Debug, Deserialize)]\n-pub struct WebPostForm {\n- pub room: String,\n- pub thread_tag: String,\n- pub text: String,\n- #[serde(default)]\n- pub error_target: Option,\n- #[serde(default)]\n- pub form_id: Option,\n-}\n-\n-#[derive(Debug, Deserialize)]\n-pub struct WebRedactForm {\n- pub post_id: String,\n-}\n-\n-fn post_redirect_location(room: &str, thread_tag: &str) -> String {\n- let tag = canonicalize_tag(thread_tag);\n- if room.trim() == \"public\" {\n- format!(\"/t/{tag}\")\n- } else {\n- let room = room.trim();\n- let Some((a, b)) = room.split_once('/') else {\n- return \"/\".to_string();\n- };\n- format!(\"/r/{a}/{b}/t/{tag}\")\n- }\n-}\n-\n-fn js_quote(s: &str) -> String {\n- serde_json::to_string(s).expect(\"js string escaping must succeed\")\n-}\n-\n-fn js_redirect(to: &str) -> Response {\n- let js = format!(\"window.location = {};\", js_quote(to));\n- Response::builder()\n- .status(StatusCode::OK)\n- .header(header::CONTENT_TYPE, \"text/javascript; charset=utf-8\")\n- .body(axum::body::Body::from(js))\n- .unwrap()\n-}\n-\n-fn js_error(error_target: &str, title: &str, detail: &str) -> Response {\n- let markup = maud::html! {\n- div id=(error_target.trim_start_matches('#')) {\n- p class=\"auth-error\" { (title) }\n- @if !detail.is_empty() {\n- pre class=\"muted\" { (detail) }\n- }\n- }\n- };\n- JsBuilder::new()\n- .morph_selector(error_target, markup)\n- .into_response()\n-}\n-\n-fn form_error_target(form: &WebPostForm) -> String {\n- form.error_target\n- .as_deref()\n- .filter(|s| !s.trim().is_empty())\n- .map(|s| {\n- if s.starts_with('#') {\n- s.to_string()\n- } else {\n- format!(\"#{s}\")\n- }\n- })\n- .unwrap_or_else(|| \"#errors\".to_string())\n-}\n-\n-fn form_js_error(form: &WebPostForm, title: &str, detail: &str) -> Response {\n- js_error(&form_error_target(form), title, detail)\n-}\n-\n-fn js_clear_errors(error_target: &str) -> Response {\n- let markup = maud::html! {\n- div id=(error_target.trim_start_matches('#')) {}\n- };\n- JsBuilder::new()\n- .morph_selector(error_target, markup)\n- .into_response()\n-}\n-\n-fn empty_error_markup(error_target: &str) -> maud::Markup {\n- maud::html! {\n- div id=(error_target.trim_start_matches('#')) {}\n- }\n-}\n-\n-async fn rpc_check_with_bearer(\n- state: &AppState,\n- bearer_token: &str,\n- room: String,\n- text: String,\n-) -> Result)> {\n- let mut headers = HeaderMap::new();\n- let hv = HeaderValue::from_str(&format!(\"Bearer {bearer_token}\"))\n- .map_err(|_| (\"invalid session token\".into(), None))?;\n- headers.insert(header::AUTHORIZATION, hv);\n-\n- let response = handle_rpc_batch(\n- State(state.clone()),\n- headers,\n- axum::Json(RpcBatch(vec![RpcCommand::Check { room, text }])),\n- )\n- .await\n- .into_response();\n-\n- let status = response.status();\n- if !status.is_success() {\n- return Err((format!(\"rpc check http {}\", status), None));\n- }\n-\n- let body = body::to_bytes(response.into_body(), usize::MAX)\n- .await\n- .map_err(|e| (e.to_string(), None))?;\n- let parsed: RpcBatchResponse =\n- serde_json::from_slice(&body).map_err(|e| (e.to_string(), None))?;\n- let line = parsed\n- .results\n- .into_iter()\n- .next()\n- .ok_or_else(|| (\"empty rpc check response\".to_string(), None))?;\n- if line.ok {\n- line.result\n- .ok_or_else(|| (\"missing rpc check result\".to_string(), None))\n- } else {\n- Err((\n- line.error.unwrap_or_else(|| \"check failed\".to_string()),\n- line.hint,\n- ))\n- }\n-}\n-\n-fn post_success_response<'a>(\n- state: &'a AppState,\n- form: &'a WebPostForm,\n- headers: &'a HeaderMap,\n- jar: &'a CookieJar,\n-) -> impl std::future::Future + 'a {\n- async move {\n- let error_target = form_error_target(form);\n- let room = form.room.trim().to_string();\n- let thread_tag = canonicalize_tag(&form.thread_tag);\n- let thread_location = post_redirect_location(&room, &thread_tag);\n- let form_id = form.form_id.as_deref().unwrap_or_default();\n- let scope = scope_from_room_wire(&room);\n- let viewer = {\n- let reduced = state.reduced.read().await;\n- optional_principal(headers, jar, &reduced)\n- };\n- let feed_markup = match &scope {\n- ScopeId::Public => thread_feed_html(state).await,\n- ScopeId::Room(_) => thread_feed_html_for_room(state, &room).await,\n- };\n- let thread_markup = thread_feed_region_markup(\n- state,\n- match &scope {\n- ScopeId::Public => None,\n- ScopeId::Room(_) => Some(room.as_str()),\n- },\n- &thread_tag,\n- viewer.as_deref(),\n- )\n- .await;\n- let feed_selector = match &scope {\n- ScopeId::Public => \"#thread-feed\",\n- ScopeId::Room(_) => \"#room-thread-feed\",\n- };\n-\n- let builder = JsBuilder::new()\n- .morph_selector(&error_target, empty_error_markup(&error_target))\n- .morph_selector(feed_selector, feed_markup)\n- .if_current_path_matches(&thread_location, |builder| {\n- let builder = builder.morph_selector(\"#thread-feed-region\", thread_markup);\n- let builder = if !form_id.trim().is_empty() {\n- builder.qs(&format!(\"#{form_id}\")).reset()\n- } else {\n- builder\n- };\n- builder\n- })\n- .if_current_path_not_matches(&thread_location, |builder| builder.redirect(&thread_location));\n-\n- builder.into_response()\n- }\n-}\n-\n-async fn redact_success_response(state: &AppState) -> Response {\n- let feed_markup = thread_feed_html(state).await;\n- JsBuilder::new()\n- .morph_selector(\"#thread-feed\", feed_markup)\n- .into_response()\n-}\n-\n-pub async fn post_web_redact(\n- State(state): State,\n- headers: HeaderMap,\n- jar: CookieJar,\n- Form(form): Form,\n-) -> impl IntoResponse {\n- run_post_web_redact(&state, &headers, &jar, form).await\n-}\n-\n-/// Shared with [`crate::api::ui_html::post_ui_html`].\n-pub(crate) async fn run_post_web_redact(\n- state: &AppState,\n- headers: &HeaderMap,\n- jar: &CookieJar,\n- form: WebRedactForm,\n-) -> Response {\n- let reduced = state.reduced.read().await;\n- let Some(_username) = optional_principal(headers, jar, &reduced) else {\n- drop(reduced);\n- return js_redirect(\"/login\").into_response();\n- };\n- drop(reduced);\n-\n- let bearer = headers\n- .get(axum::http::header::AUTHORIZATION)\n- .and_then(|v| v.to_str().ok())\n- .and_then(|s| s.strip_prefix(\"Bearer \").map(|t| t.trim().to_string()))\n- .or_else(|| jar.get(SLUG_SESSION_COOKIE).map(|c| c.value().to_string()));\n-\n- let Some(_bearer) = bearer else {\n- return js_redirect(\"/login\").into_response();\n- };\n-\n- match rpc_post_redact(state, headers, form.post_id).await {\n- Ok(RpcResult::RedactPostOk {}) => redact_success_response(state).await.into_response(),\n- Ok(_) => (StatusCode::BAD_REQUEST, \"unexpected response\").into_response(),\n- Err((msg, hint)) => {\n- let detail = hint.as_deref().unwrap_or(\"\");\n- js_error(\"#errors\", &msg, detail).into_response()\n- }\n- }\n-}\n-\n-pub async fn post_web_ingest(\n- State(state): State,\n- headers: HeaderMap,\n- jar: CookieJar,\n- Form(form): Form,\n-) -> impl IntoResponse {\n- run_post_web_ingest(&state, &headers, &jar, form).await\n-}\n-\n-/// Shared with [`crate::api::ui_html::post_ui_html`] (`POST /ui`).\n-pub(crate) async fn run_post_web_ingest(\n- state: &AppState,\n- headers: &HeaderMap,\n- jar: &CookieJar,\n- form: WebPostForm,\n-) -> Response {\n- let reduced = state.reduced.read().await;\n- let Some(_username) = optional_principal(headers, jar, &reduced) else {\n- drop(reduced);\n- return js_redirect(\"/login\").into_response();\n- };\n-\n- let bearer = headers\n- .get(axum::http::header::AUTHORIZATION)\n- .and_then(|v| v.to_str().ok())\n- .and_then(|s| s.strip_prefix(\"Bearer \").map(|t| t.trim().to_string()))\n- .or_else(|| jar.get(SLUG_SESSION_COOKIE).map(|c| c.value().to_string()));\n-\n- drop(reduced);\n-\n- let Some(bearer) = bearer else {\n- return js_redirect(\"/login\").into_response();\n- };\n-\n- let room = form.room.trim().to_string();\n- let thread_tag = form.thread_tag.trim().to_string();\n- let text = form.text.clone();\n-\n- if text.trim().is_empty() {\n- return form_js_error(&form, \"empty post\", \"Write something in the text area (DSL / prose).\")\n- .into_response();\n- }\n-\n- match rpc_post_with_bearer(state, &bearer, room.clone(), thread_tag.clone(), text).await {\n- Ok(RpcResult::PostOk { .. }) => post_success_response(state, &form, headers, jar)\n- .await\n- .into_response(),\n- Ok(_) => form_js_error(&form, \"unexpected response\", \"Post did not return PostOk.\").into_response(),\n- Err((msg, hint)) => form_js_error(&form, &msg, hint.as_deref().unwrap_or(\"\")).into_response(),\n- }\n-}\n-\n-pub async fn check_web_ingest(\n- State(state): State,\n- headers: HeaderMap,\n- jar: CookieJar,\n- Form(form): Form,\n-) -> impl IntoResponse {\n- run_check_web_ingest(&state, &headers, &jar, form).await\n-}\n-\n-/// Shared with [`crate::api::ui_html::post_ui_html`] (`POST /ui`).\n-pub(crate) async fn run_check_web_ingest(\n- state: &AppState,\n- headers: &HeaderMap,\n- jar: &CookieJar,\n- form: WebPostForm,\n-) -> Response {\n- let reduced = state.reduced.read().await;\n- let Some(_username) = optional_principal(headers, jar, &reduced) else {\n- drop(reduced);\n- return js_redirect(\"/login\").into_response();\n- };\n-\n- let bearer = headers\n- .get(axum::http::header::AUTHORIZATION)\n- .and_then(|v| v.to_str().ok())\n- .and_then(|s| s.strip_prefix(\"Bearer \").map(|t| t.trim().to_string()))\n- .or_else(|| jar.get(SLUG_SESSION_COOKIE).map(|c| c.value().to_string()));\n- drop(reduced);\n-\n- let Some(bearer) = bearer else {\n- return js_redirect(\"/login\").into_response();\n- };\n-\n- let room = form.room.trim().to_string();\n- let thread_tag = canonicalize_tag(&form.thread_tag);\n- if thread_tag.is_empty() {\n- return form_js_error(&form, \"missing thread tag\", \"Set a thread tag before posting.\").into_response();\n- }\n-\n- if form.text.trim().is_empty() {\n- return js_clear_errors(&form_error_target(&form)).into_response();\n- }\n-\n- match rpc_check_with_bearer(state, &bearer, room, form.text.clone()).await {\n- Ok(RpcResult::CheckOk { .. }) => js_clear_errors(&form_error_target(&form)).into_response(),\n- Ok(_) => form_js_error(&form, \"unexpected response\", \"Check did not return CheckOk.\").into_response(),\n- Err((msg, hint)) => form_js_error(&form, &msg, hint.as_deref().unwrap_or(\"\")).into_response(),\n- }\n-}\ndiff --git a/server/src/html/forum.rs b/server/src/html/forum.rs\nindex b1c037f3dd93c7bb96d6624cab6019228432c501..a173e7aea7a81c7cfab5f2f6a5549e0db560fb2b 100644\n--- a/server/src/html/forum.rs\n+++ b/server/src/html/forum.rs\n@@ -17,6 +17,7 @@ use crate::{\n state::AppState,\n timeago,\n };\n+use serde_json::json;\n \n use super::ui_action::{HtmlUiAction, UI_RPC_FIELD};\n \n@@ -182,8 +183,8 @@ fn post_header_row(\n div class=\"ingest-header-row\" {\n (meta)\n @if show_delete {\n- form class=\"post-delete-form\" method=\"POST\" action=\"/post/redact\" {\n- input type=\"hidden\" name=\"post_id\" value=(ing.id);\n+ form class=\"post-delete-form\" method=\"POST\" action=\"/ui\" {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::RedactPost { post_id: ing.id.clone() }).unwrap());\n button type=\"submit\" class=\"post-delete-btn\" { \"delete\" }\n }\n }\n@@ -427,13 +428,28 @@ fn compose_form(nav: &ThreadNav, thread_tag: &str, show: bool) -> Markup {\n if !show {\n return html! {};\n }\n+ let rpc_post = template_json_compact(&json!({\n+ \"action\": \"post_ingest\",\n+ \"room\": nav.room_wire,\n+ \"thread_tag\": thread_tag,\n+ \"text\": {\"$form\": \"text\"},\n+ \"error_target\": \"thread-compose-errors\",\n+ \"form_id\": \"thread-compose-form\",\n+ }))\n+ .unwrap();\n+ let rpc_check = template_json_compact(&json!({\n+ \"action\": \"check_ingest\",\n+ \"room\": nav.room_wire,\n+ \"thread_tag\": thread_tag,\n+ \"text\": {\"$form\": \"text\"},\n+ \"error_target\": \"thread-compose-errors\",\n+ \"form_id\": \"thread-compose-form\",\n+ }))\n+ .unwrap();\n html! {\n section class=\"compose\" id=\"thread-compose\" {\n- form id=\"thread-compose-form\" method=\"POST\" action=\"/post\" data-check-action=\"/post/check\" {\n- input type=\"hidden\" name=\"room\" value=(nav.room_wire.clone());\n- input type=\"hidden\" name=\"thread_tag\" value=(thread_tag);\n- input type=\"hidden\" name=\"error_target\" value=\"thread-compose-errors\";\n- input type=\"hidden\" name=\"form_id\" value=\"thread-compose-form\";\n+ form id=\"thread-compose-form\" method=\"POST\" action=\"/ui\" data-check-action=\"/ui\" data-check-rpc=(rpc_check) {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(rpc_post);\n textarea name=\"text\" rows=\"5\" cols=\"80\" placeholder=\"prose or ~/items and votes…\" {}\n p {\n button type=\"submit\" { \"post\" }\n@@ -463,10 +479,22 @@ fn new_thread_form_public(show: bool) -> Markup {\n h3 { \"new public thread\" }\n p class=\"muted\" { \"Set thread tag and body. Example: start with a title line or use the CLI-shaped DSL.\" }\n div id=\"public-new-thread-errors\" {}\n- form id=\"public-new-thread-form\" method=\"POST\" action=\"/post\" data-check-action=\"/post/check\" {\n- input type=\"hidden\" name=\"room\" value=\"public\";\n- input type=\"hidden\" name=\"error_target\" value=\"public-new-thread-errors\";\n- input type=\"hidden\" name=\"form_id\" value=\"public-new-thread-form\";\n+ form id=\"public-new-thread-form\" method=\"POST\" action=\"/ui\" data-check-action=\"/ui\" data-check-rpc=(template_json_compact(&json!({\n+ \"action\": \"check_ingest\",\n+ \"room\": \"public\",\n+ \"thread_tag\": {\"$form\": \"thread_tag\"},\n+ \"text\": {\"$form\": \"text\"},\n+ \"error_target\": \"public-new-thread-errors\",\n+ \"form_id\": \"public-new-thread-form\",\n+ })).unwrap()) {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(template_json_compact(&json!({\n+ \"action\": \"post_ingest\",\n+ \"room\": \"public\",\n+ \"thread_tag\": {\"$form\": \"thread_tag\"},\n+ \"text\": {\"$form\": \"text\"},\n+ \"error_target\": \"public-new-thread-errors\",\n+ \"form_id\": \"public-new-thread-form\",\n+ })).unwrap());\n label for=\"new-thread-tag\" { \"thread tag\" }\n input type=\"text\" id=\"new-thread-tag\" name=\"thread_tag\" pattern=\"[a-z0-9_\\\\-]{1,64}\" placeholder=\"my-topic\";\n label for=\"new-thread-text\" { \"text\" }\n@@ -946,10 +974,22 @@ fn new_thread_form_for_room(nav: &ThreadNav, show: bool) -> Markup {\n section class=\"compose\" id=\"room-new-thread-compose\" hidden {\n h3 { \"new thread in this room\" }\n div id=\"room-new-thread-errors\" {}\n- form id=\"room-new-thread-form\" method=\"POST\" action=\"/post\" data-check-action=\"/post/check\" {\n- input type=\"hidden\" name=\"room\" value=(nav.room_wire.clone());\n- input type=\"hidden\" name=\"error_target\" value=\"room-new-thread-errors\";\n- input type=\"hidden\" name=\"form_id\" value=\"room-new-thread-form\";\n+ form id=\"room-new-thread-form\" method=\"POST\" action=\"/ui\" data-check-action=\"/ui\" data-check-rpc=(template_json_compact(&json!({\n+ \"action\": \"check_ingest\",\n+ \"room\": nav.room_wire,\n+ \"thread_tag\": {\"$form\": \"thread_tag\"},\n+ \"text\": {\"$form\": \"text\"},\n+ \"error_target\": \"room-new-thread-errors\",\n+ \"form_id\": \"room-new-thread-form\",\n+ })).unwrap()) {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(template_json_compact(&json!({\n+ \"action\": \"post_ingest\",\n+ \"room\": nav.room_wire,\n+ \"thread_tag\": {\"$form\": \"thread_tag\"},\n+ \"text\": {\"$form\": \"text\"},\n+ \"error_target\": \"room-new-thread-errors\",\n+ \"form_id\": \"room-new-thread-form\",\n+ })).unwrap());\n label for=\"room-new-tag\" { \"thread tag\" }\n input type=\"text\" id=\"room-new-tag\" name=\"thread_tag\" pattern=\"[a-z0-9_\\\\-]{1,64}\" required;\n textarea name=\"text\" rows=\"4\" placeholder=\"First post body…\" required {}\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex a4fd2dd60ac283f7eb9b2e64522501b21d1e27e9..63f723804be511af80b5c13e792109754b606fce 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -373,9 +373,14 @@ script { (maud::PreEscaped(r#\"\n async function runFormCheck(form) {\n const action = form.getAttribute('data-check-action');\n if (!action) return;\n+ const fd = new URLSearchParams(new FormData(form));\n+ const checkRpc = form.getAttribute('data-check-rpc');\n+ if (checkRpc) {\n+ fd.set('__rpc__', checkRpc);\n+ }\n const resp = await fetch(action, {\n method: 'POST',\n- body: new URLSearchParams(new FormData(form)),\n+ body: fd,\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n credentials: 'same-origin',\n });\ndiff --git a/server/src/html/ui_action.rs b/server/src/html/ui_action.rs\nindex 3bc69ecbb7e16a59d2d393b2d59f9cd3fedb12b1..73180737db8ba8d16fb7b42b790e44d2d7b796f5 100644\n--- a/server/src/html/ui_action.rs\n+++ b/server/src/html/ui_action.rs\n@@ -14,7 +14,7 @@ pub const UI_RPC_FIELD: &str = \"__rpc__\";\n #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\n #[serde(tag = \"action\", rename_all = \"snake_case\")]\n pub enum HtmlUiAction {\n- /// Same semantics as `POST /post` (forum ingest).\n+ /// Forum ingest via `POST /ui`.\n PostIngest {\n room: String,\n thread_tag: String,\n@@ -24,7 +24,7 @@ pub enum HtmlUiAction {\n #[serde(default)]\n form_id: Option,\n },\n- /// Same as `POST /post/check`.\n+ /// DSL check / validation via `POST /ui`.\n CheckIngest {\n room: String,\n thread_tag: String,\n@@ -34,7 +34,7 @@ pub enum HtmlUiAction {\n #[serde(default)]\n form_id: Option,\n },\n- /// Same as `POST /post/redact`.\n+ /// Author redacts own post via `POST /ui`.\n RedactPost {\n post_id: String,\n },\ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nindex 5f5b6b0f01b29350c7415e5801ec5caa74f0b452..c28c2335d23080671e71155ea3fb023f9b8d98c4 100644\n--- a/server/src/lib.rs\n+++ b/server/src/lib.rs\n@@ -31,9 +31,6 @@ pub fn create_app(state: AppState) -> Router {\n .route(\"/\", get(crate::html::home))\n .route(\"/login\", get(api::get_web_login))\n .route(\"/logout\", get(api::get_logout))\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(\"/ui\", post(api::post_ui_html))\n .route(\"/theme\", post(crate::html::post_theme))\n .route(\"/sse\", get(api::get_html_stream))\n@@ -64,6 +61,7 @@ pub fn create_app(state: AppState) -> Router {\n )\n .route(\"/t/:tag/:index\", get(crate::html::thread_post_view))\n .route(\"/t/:tag\", get(crate::html::thread_view))\n+ // TODO DELETE THESE AND USE __RPC__ PATTERN\n .route(\n \"/r/:room_short/:room_slug/t/:thread_tag/:index/expand\",\n get(crate::html::room_thread_post_expand),\ndiff --git a/server/tests/integration.rs b/server/tests/integration.rs\nindex d86de2dd446516807a3c8a607aa88787d1b136b0..831722430d6b41308e3f03d485cd62f887ace799 100644\n--- a/server/tests/integration.rs\n+++ b/server/tests/integration.rs\n@@ -21,6 +21,30 @@ fn test_bearer() -> String {\n format!(\"slug_{token_id}_{secret}\")\n }\n \n+/// Compact JSON for `POST /ui` (`HtmlUiAction::PostIngest`).\n+fn ui_post_ingest_rpc(room: &str, thread_tag: &str, text: &str) -> String {\n+ serde_json::json!({\n+ \"action\": \"post_ingest\",\n+ \"room\": room,\n+ \"thread_tag\": thread_tag,\n+ \"text\": text,\n+ })\n+ .to_string()\n+}\n+\n+/// Compact JSON for `POST /ui` (`HtmlUiAction::CheckIngest`).\n+fn ui_check_ingest_rpc(room: &str, thread_tag: &str, text: &str, error_target: &str) -> String {\n+ serde_json::json!({\n+ \"action\": \"check_ingest\",\n+ \"room\": room,\n+ \"thread_tag\": thread_tag,\n+ \"text\": text,\n+ \"error_target\": error_target,\n+ \"form_id\": \"thread-compose-form\",\n+ })\n+ .to_string()\n+}\n+\n /// `commands` is a JSON array of RPC commands (`RpcBatch` is a transparent `Vec`).\n async fn rpc_batch(\n client: &reqwest::Client,\n@@ -343,14 +367,11 @@ async fn test_private_room_thread_urls_use_t_segment() {\n .to_string();\n let (room_short, room_slug) = room_id.split_once('/').unwrap();\n \n+ let rpc = ui_post_ingest_rpc(&room_id, \"main-thread\", \"private post via web\");\n let post = client\n- .post(format!(\"http://{addr}/post\"))\n+ .post(format!(\"http://{addr}/ui\"))\n .header(\"Authorization\", format!(\"Bearer {bearer}\"))\n- .form(&[\n- (\"room\", room_id.as_str()),\n- (\"thread_tag\", \"main-thread\"),\n- (\"text\", \"private post via web\"),\n- ])\n+ .form(&[(\"__rpc__\", rpc.as_str())])\n .send()\n .await\n .unwrap();\n@@ -404,14 +425,15 @@ async fn test_private_room_post_links_use_private_garden_routes() {\n .to_string();\n let (room_short, room_slug) = room_id.split_once('/').unwrap();\n \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+ );\n let post = client\n- .post(format!(\"http://{addr}/post\"))\n+ .post(format!(\"http://{addr}/ui\"))\n .header(\"Authorization\", format!(\"Bearer {bearer}\"))\n- .form(&[\n- (\"room\", room_id.as_str()),\n- (\"thread_tag\", \"garden-thread\"),\n- (\"text\", \"~/secret/item {classified}\\n~/secret/other {other body}\\n~/secret/item 3:1 ~/secret/other {because}\\n\"),\n- ])\n+ .form(&[(\"__rpc__\", rpc.as_str())])\n .send()\n .await\n .unwrap();\n@@ -449,15 +471,11 @@ async fn test_post_check_returns_targeted_js_error_for_missing_thread_tag() {\n .unwrap();\n let bearer = test_bearer();\n \n+ let rpc = ui_check_ingest_rpc(\"public\", \"\", \"hello\", \"thread-compose-errors\");\n let resp = client\n- .post(format!(\"http://{addr}/post/check\"))\n+ .post(format!(\"http://{addr}/ui\"))\n .header(\"Authorization\", format!(\"Bearer {bearer}\"))\n- .form(&[\n- (\"room\", \"public\"),\n- (\"thread_tag\", \"\"),\n- (\"text\", \"hello\"),\n- (\"error_target\", \"thread-compose-errors\"),\n- ])\n+ .form(&[(\"__rpc__\", rpc.as_str())])\n .send()\n .await\n .unwrap();\n@@ -553,14 +571,11 @@ async fn test_sse_stream_emits_evalable_js_after_post() {\n .unwrap();\n assert!(sse_resp.status().is_success());\n \n+ let rpc = ui_post_ingest_rpc(&room_id, \"live-thread\", \"hello over sse\");\n let _post = client\n- .post(format!(\"http://{addr}/post\"))\n+ .post(format!(\"http://{addr}/ui\"))\n .header(\"Authorization\", format!(\"Bearer {bearer}\"))\n- .form(&[\n- (\"room\", room_id.as_str()),\n- (\"thread_tag\", \"live-thread\"),\n- (\"text\", \"hello over sse\"),\n- ])\n+ .form(&[(\"__rpc__\", rpc.as_str())])\n .send()\n .await\n .unwrap();\ndiff --git a/test/walkthrough_fixture.clj b/test/walkthrough_fixture.clj\nindex ec7914464b1a0389e40c5be53af59cf8ed82b09e..4b814822029c913e38151ca53848d1c64fb58373 100644\n--- a/test/walkthrough_fixture.clj\n+++ b/test/walkthrough_fixture.clj\n@@ -52,22 +52,28 @@\n \"~/secret/item {classified}\\n\"\n \"~/secret/other {secondary}\\n\"\n \"~/secret/item 3:1 ~/secret/other {because}\\n\")\n+ rpc (json/generate-string\n+ {:action \"post_ingest\"\n+ :room room-id\n+ :thread_tag \"walkthrough-thread\"\n+ :text wall-text})\n post-resp (oauth/http-post-form\n- (str base-url \"/post\")\n- {:room room-id\n- :thread_tag \"walkthrough-thread\"\n- :text wall-text}\n+ (str base-url \"/ui\")\n+ {:__rpc__ rpc}\n :headers {\"Authorization\" (str \"Bearer \" alice-token)})]\n (assert! (= 200 (:status post-resp)) \"seed post must succeed\"))\n (let [bob-reply (str \"Bob here — reply with another long block so the thread has multiple cards.\\n\\n\"\n \"Paragraph two: repeating slugs ~/secret/item and ~/secret/other for cross-post link styling. \"\n \"If everything wraps cleanly, monospace pre + serif body (in craft theme) should still feel readable.\\n\\n\"\n \"More overflow: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX\\n\")\n+ bob-rpc (json/generate-string\n+ {:action \"post_ingest\"\n+ :room room-id\n+ :thread_tag \"walkthrough-thread\"\n+ :text bob-reply})\n bob-post (oauth/http-post-form\n- (str base-url \"/post\")\n- {:room room-id\n- :thread_tag \"walkthrough-thread\"\n- :text bob-reply}\n+ (str base-url \"/ui\")\n+ {:__rpc__ bob-rpc}\n :headers {\"Authorization\" (str \"Bearer \" bob-token)})]\n (assert! (= 200 (:status bob-post)) \"seed reply post must succeed\"))\n {:users {:alice {:token alice-token}\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[36e92cf2] Fix /vote/compare page: attribution, fallback, swap button, and route rename.\n\n- Pass WEB_BROWSER_AGENT as delegate_opt out-of-band (not in DSL body text)\n- Error instead of falling back to all items when sibling pool < 2\n- Remove pointless \"swap sides\" button\n- Rename route /vote/compare → /vote\n\nCo-Authored-By: Claude Sonnet 4.6 \n\nSide B — unified diff (full patch):\ndiff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs\nindex 0b7070a2b7289e5c40778c8d062bd4150285a8df..8e3d8e34922ddaaa89ea699edd99402bd40fdb1b 100644\n--- a/server/src/api/rpc.rs\n+++ b/server/src/api/rpc.rs\n@@ -281,6 +281,7 @@ pub async fn rpc_post_with_bearer(\n bearer_token: &str,\n room: String,\n thread_tag: String,\n+ delegate_opt: Option,\n text: String,\n ) -> Result {\n use axum::http::{header, HeaderMap, HeaderValue};\n@@ -288,7 +289,7 @@ pub async fn rpc_post_with_bearer(\n let hv = HeaderValue::from_str(&format!(\"Bearer {bearer_token}\"))\n .map_err(|_| (\"invalid session token\".into(), None))?;\n headers.insert(header::AUTHORIZATION, hv);\n- rpc_post(state, &headers, room, thread_tag, None, text, false).await\n+ rpc_post(state, &headers, room, thread_tag, delegate_opt, text, false).await\n }\n \n async fn rpc_check(\ndiff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs\nindex a13a94767d8905adc09e31effff838e166781b6c..5faa642451d69555cb391974beb0ad22c9355c8c 100644\n--- a/server/src/api/ui_html.rs\n+++ b/server/src/api/ui_html.rs\n@@ -118,6 +118,7 @@ async fn dispatch_ui_action(\n &session.bearer,\n room.clone(),\n thread_tag.clone(),\n+ None,\n text,\n )\n .await\n@@ -246,8 +247,7 @@ async fn dispatch_ui_action(\n }\n \n let text = format!(\n- \"@{}\\n{{\\n{}\\n}}\\n{} {}:{} {}\\n\",\n- crate::api::auth::WEB_BROWSER_AGENT,\n+ \"{{\\n{}\\n}}\\n{} {}:{} {}\\n\",\n exp,\n left_id.as_str(),\n rl,\n@@ -260,6 +260,7 @@ async fn dispatch_ui_action(\n &session.bearer,\n room.clone(),\n thread_tag.clone(),\n+ Some(crate::api::auth::WEB_BROWSER_AGENT.to_string()),\n text,\n )\n .await\ndiff --git a/server/src/html/forum/nav.rs b/server/src/html/forum/nav.rs\nindex d33dc50f0b89a5ed7730c3bca204a67f6309388f..ae67f550a4bdf0bbeff58384c29a715015b64bd5 100644\n--- a/server/src/html/forum/nav.rs\n+++ b/server/src/html/forum/nav.rs\n@@ -85,7 +85,7 @@ impl ThreadNav {\n format!(\"{}/{}/{}\", self.thread_path_prefix, tag, idx)\n }\n \n- /// Empty for public; `/r/:seg` for room — prefix for routes like `/vote/compare`.\n+ /// Empty for public; `/r/:seg` for room — prefix for routes like `/vote`.\n pub(crate) fn room_path_prefix_for_vote_compare(&self) -> String {\n match &self.scope {\n ScopeId::Public => String::new(),\ndiff --git a/server/src/html/garden/vote.rs b/server/src/html/garden/vote.rs\nindex bdb4008c29ba49d7dc97b1cd2c32d9cdfce1ca04..1d7fc8aa7436bfa9c1186dfd940a8d751ab7e088 100644\n--- a/server/src/html/garden/vote.rs\n+++ b/server/src/html/garden/vote.rs\n@@ -208,7 +208,7 @@ pub(crate) async fn vote_compare_post_success_js(\n state: &AppState,\n nav: &ThreadNav,\n _room_wire: &str,\n- thread_tag: &str,\n+ _thread_tag: &str,\n left: &ItemId,\n right: &ItemId,\n _post_id: &str,\n@@ -218,8 +218,7 @@ pub(crate) async fn vote_compare_post_success_js(\n let content = content_for_garden_view(&reduced, &nav.scope());\n let edge_history = vote_edge_history_markup(content, left, right);\n let next_pair = suggest_next_vote_pair(content, left, right);\n- let nav_markup =\n- vote_compare_nav_markup(nav, next_pair.as_ref(), left, right, Some(thread_tag));\n+ let nav_markup = vote_compare_nav_markup(nav, next_pair.as_ref());\n drop(reduced);\n JsBuilder::new()\n .morph_inner_selector(\"#vote-edge-history-region\", edge_history)\n@@ -236,7 +235,7 @@ pub(super) fn vote_compare_href(\n let left_q = urlencoding::encode(left.as_str());\n let right_q = urlencoding::encode(right.as_str());\n let base = format!(\n- \"{}/vote/compare?left={}&right={}\",\n+ \"{}/vote?left={}&right={}\",\n nav.room_path_prefix_for_vote_compare(),\n left_q,\n right_q\n@@ -251,12 +250,8 @@ pub(super) fn vote_compare_href(\n fn vote_compare_nav_markup(\n nav: &ThreadNav,\n next_pair: Option<&(ItemId, ItemId)>,\n- left: &ItemId,\n- right: &ItemId,\n- thread_override: Option<&str>,\n ) -> maud::Markup {\n let next_pair_href = next_pair.map(|(nl, nr)| vote_compare_href(nav, nl, nr, None));\n- let swap_pair_href = vote_compare_href(nav, right, left, thread_override);\n html! {\n div class=\"vote-compare-nav\" {\n @if let Some(href) = &next_pair_href {\n@@ -264,7 +259,6 @@ fn vote_compare_nav_markup(\n } @else {\n span class=\"vote-compare-next is-disabled\" { \"no next pair\" }\n }\n- a class=\"vote-compare-next\" href=(swap_pair_href) { \"swap sides\" }\n }\n }\n }\n@@ -274,7 +268,7 @@ pub(super) fn suggest_next_vote_pair(\n current_left: &ItemId,\n current_right: &ItemId,\n ) -> Option<(ItemId, ItemId)> {\n- let mut pool: Vec = if current_left.parent().as_ref().map(|p| p.as_str())\n+ let pool: Vec = if current_left.parent().as_ref().map(|p| p.as_str())\n == current_right.parent().as_ref().map(|p| p.as_str())\n {\n current_left\n@@ -291,7 +285,7 @@ pub(super) fn suggest_next_vote_pair(\n Vec::new()\n };\n if pool.len() < 2 {\n- pool = content.items.iter().cloned().collect();\n+ return None;\n }\n suggest_next_pair_in_pool(\n &content.ranking_group,\n@@ -334,7 +328,7 @@ pub struct VoteCompareQuery {\n pub thread: Option,\n }\n \n-/// Public pairwise vote UI — `/vote/compare?left=&right=&thread=`.\n+/// Public pairwise vote UI — `/vote?left=&right=&thread=`.\n pub async fn vote_compare_page(\n State(state): State,\n Query(q): Query,\n@@ -426,7 +420,7 @@ async fn vote_compare_inner(\n let next_path = uri\n .path_and_query()\n .map(|pq| pq.as_str().to_string())\n- .unwrap_or_else(|| \"/vote/compare\".into());\n+ .unwrap_or_else(|| \"/vote\".into());\n \n let rpc_json = template_json_compact(&json!({\n \"action\": \"vote_compare_post\",\n@@ -462,7 +456,7 @@ async fn vote_compare_inner(\n Some(&item_bodies_for_cards),\n ))\n }\n- (vote_compare_nav_markup(&nav, next_pair.as_ref(), &left, &right, q.thread.as_deref()))\n+ (vote_compare_nav_markup(&nav, next_pair.as_ref()))\n div id=\"vote-edge-history-region\" {\n (edge_history)\n }\ndiff --git a/server/src/html/ui_action.rs b/server/src/html/ui_action.rs\nindex c7b643c8dde37dd1e3001f5ff716fe4f79d24c5e..dd5294202569454acf9f41b458718700bbf96f39 100644\n--- a/server/src/html/ui_action.rs\n+++ b/server/src/html/ui_action.rs\n@@ -38,7 +38,7 @@ pub enum HtmlUiAction {\n #[serde(default)]\n form_id: Option,\n },\n- /// Post a pairwise vote from `/vote/compare` (browser compose).\n+ /// Post a pairwise vote from `/vote` (browser compose).\n VoteComparePost {\n room: String,\n thread_tag: String,\ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nindex ad8e31099c807fb5844acb16cd5086a2f19327a7..e102e73ec167fa35dcc22d4ec8c6f40202982429 100644\n--- a/server/src/lib.rs\n+++ b/server/src/lib.rs\n@@ -82,9 +82,9 @@ pub fn create_app(state: AppState) -> Router {\n .route(\"/u/:username\", get(crate::html::user_profile_page))\n .route(\"/try\", get(crate::html::editor_page))\n .route(\"/try/check\", post(crate::html::editor_check))\n- .route(\"/vote/compare\", get(crate::html::vote_compare_page))\n+ .route(\"/vote\", get(crate::html::vote_compare_page))\n .route(\n- \"/r/:room_key/vote/compare\",\n+ \"/r/:room_key/vote\",\n get(crate::html::room_vote_compare_page),\n )\n .route(\"/~\", get(crate::html::garden_index))\ndiff --git a/server/tests/integration_html.rs b/server/tests/integration_html.rs\nindex a25a456c7cafd74a69e65e0c2e8ed2bfb2e27498..b3e31376737b2d5aabb739f408984a1f5ae0c974 100644\n--- a/server/tests/integration_html.rs\n+++ b/server/tests/integration_html.rs\n@@ -52,12 +52,12 @@ async fn test_view_counts_increment_and_display() {\n .normalized_storage()\n .to_storage_string();\n let vote_q_right_first = format!(\n- \"/vote/compare?right={}&left={}\",\n+ \"/vote?right={}&left={}\",\n urlencoding::encode(&right),\n urlencoding::encode(&left)\n );\n let vote_q_left_first = format!(\n- \"/vote/compare?left={}&right={}\",\n+ \"/vote?left={}&right={}\",\n urlencoding::encode(&left),\n urlencoding::encode(&right)\n );\n@@ -148,7 +148,7 @@ https://github.com/ghvotehi/a/issues/10 {\\n\\\n .normalized_storage()\n .to_storage_string();\n let q = format!(\n- \"/vote/compare?left={}&right={}\",\n+ \"/vote?left={}&right={}\",\n urlencoding::encode(&left),\n urlencoding::encode(&right)\n );\ndiff --git a/server/tests/integration_ui.rs b/server/tests/integration_ui.rs\nindex 091afd922d28f48ab36aa9b9084aa825036d1f74..23db7b5672418d5bb0ab7529e05ef1f89e59062a 100644\n--- a/server/tests/integration_ui.rs\n+++ b/server/tests/integration_ui.rs\n@@ -110,7 +110,7 @@ async fn test_choose_username_carries_redirect_next() {\n .expect(\"pending session must exist\");\n pending.provider = Some(\"google\".to_string());\n pending.provider_id = Some(\"google-user-redirect\".to_string());\n- pending.redirect_next = Some(\"/vote/compare?left=%7E%2Fa&right=%7E%2Fb\".to_string());\n+ pending.redirect_next = Some(\"/vote?left=%7E%2Fa&right=%7E%2Fb\".to_string());\n }\n \n let choose = client\n@@ -122,7 +122,7 @@ async fn test_choose_username_carries_redirect_next() {\n \n assert_eq!(choose.status(), reqwest::StatusCode::OK);\n let body = choose.text().await.unwrap();\n- assert!(body.contains(\"window.location = \\\"/vote/compare?left=%7E%2Fa&right=%7E%2Fb\\\"\"));\n+ assert!(body.contains(\"window.location = \\\"/vote?left=%7E%2Fa&right=%7E%2Fb\\\"\"));\n }\n \n #[tokio::test]\ndiff --git a/test/browser_vote_compare.clj b/test/browser_vote_compare.clj\nindex 7cd1e467fdd8a4480df1be2afd78adb45b9cdc6d..a98e144756421e1fa5064db6a9fc0dd8b08471c7 100644\n--- a/test/browser_vote_compare.clj\n+++ b/test/browser_vote_compare.clj\n@@ -66,7 +66,7 @@\n :headers {\"Authorization\" (str \"Bearer \" alice-token)})\n post-json (json/parse-string (:body post-resp) false)\n _ (is (true? (get-in post-json [\"results\" 0 \"ok\"])) \"seed items + edge vote via rpc\")\n- cmp-url (str base-url \"/vote/compare?left=\" (enc left-url) \"&right=\" (enc third-url))]\n+ cmp-url (str base-url \"/vote?left=\" (enc left-url) \"&right=\" (enc third-url))]\n (core/with-playwright [pw]\n (core/with-browser [browser (core/launch-chromium pw {:headless true :channel \"chrome\"})]\n (core/with-context [ctx (core/new-context browser)]\n","role":"user"}],"model":"openai/gpt-chat-latest"}