{"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[78964dd7] nice\n\nSide A — unified diff (full patch):\ndiff --git a/server/src/fetch/html.rs b/server/src/fetch/html.rs\nindex 9b6c2d0964640a157bca2a6a64cb0820238d8e4f..3e0309ff1c2ac1b17923922d2340ba6710e0f9d1 100644\n--- a/server/src/fetch/html.rs\n+++ b/server/src/fetch/html.rs\n@@ -11,6 +11,9 @@ use crate::{\n };\n \n fn entity_panel(node: &NodeState) -> Markup {\n+ if let Some(markup) = crate::render::reddit::entity_markup(node) {\n+ return markup;\n+ }\n html! {\n @if let Some(data) = &node.data {\n div id=\"entity-panel\" class=\"entity-card\" {\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex e88cc43ddc9d8100f7994be6f5960ec4d8f22c55..3bf9fc7e92e50beed24e2c25106a77421038c90b 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -153,16 +153,21 @@ pub fn breadcrumb_path(item: &ItemId) -> Markup {\n }\n }\n \n-fn rank_list(label: &str, items: &[RankedItem], start_rank: usize) -> Markup {\n+fn rank_list(label: &str, items: &[RankedItem], start_rank: usize, tree: &GlobalTree) -> Markup {\n html! {\n @if !items.is_empty() {\n h3 class=\"rank-heading muted small\" { (label) }\n ol class=\"rank-list\" {\n @for (i, r) in items.iter().enumerate() {\n- li {\n+ @let href = item_href(&r.item);\n+ li class=(if crate::render::reddit::is_reddit_post(&r.item) { \"reddit-post-row\" } else { \"\" }) {\n span class=\"rank-num\" { (start_rank + i) \". \" }\n- a href=(item_href(&r.item)) {\n- strong { (display_label(&r.item)) }\n+ @if let Some(row) = crate::render::reddit::child_row_markup(tree, &r.item, &href) {\n+ (row)\n+ } @else {\n+ a href=(href) {\n+ strong { (display_label(&r.item)) }\n+ }\n }\n span class=\"muted\" {\n \" — \"\n@@ -196,9 +201,14 @@ fn unranked_list(label: &str, items: &[ItemId], tree: &GlobalTree) -> Markup {\n h3 class=\"rank-heading muted small\" { (label) }\n ul class=\"rank-list unranked\" {\n @for it in items {\n- li {\n- a href=(item_href(it)) {\n- strong { (child_label(tree, it)) }\n+ @let href = item_href(it);\n+ li class=(if crate::render::reddit::is_reddit_post(it) { \"reddit-post-row\" } else { \"\" }) {\n+ @if let Some(row) = crate::render::reddit::child_row_markup(tree, it, &href) {\n+ (row)\n+ } @else {\n+ a href=(href) {\n+ strong { (child_label(tree, it)) }\n+ }\n }\n }\n }\n@@ -253,7 +263,7 @@ pub fn ranking_panel(item: &ItemId, node: &NodeState, tree: &GlobalTree) -> Mark\n } @else {\n @for (gi, ranked) in ranked_groups.iter().enumerate() {\n @let label = if multi { format!(\"Ranking group {}\", gi + 1) } else { \"Ranking\".to_string() };\n- (rank_list(&label, ranked, 1))\n+ (rank_list(&label, ranked, 1, tree))\n }\n (unranked_list(\"Unranked\", &unranked, tree))\n }\ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nindex 0677363e0ed21d244bfa065f7ec728549ddd9e50..da5f3ebecec1794b05a2a69cc78379551b2ad769 100644\n--- a/server/src/lib.rs\n+++ b/server/src/lib.rs\n@@ -8,6 +8,7 @@ pub mod parser;\n pub mod path_types;\n pub mod ranking;\n pub mod reddit;\n+pub mod render;\n pub mod reducer;\n pub mod journal;\n pub mod state;\ndiff --git a/server/src/reddit.rs b/server/src/reddit.rs\nindex 69d979bc7e4a1cb078f70114dc539bdc1986b574..1168ec2afc77c092514eec91b513bac301cbe225 100644\n--- a/server/src/reddit.rs\n+++ b/server/src/reddit.rs\n@@ -608,6 +608,8 @@ fn parse_subreddit_about(v: &Value) -> Option {\n author: None,\n body_html,\n thumb_url,\n+ image_url: None,\n+ link_url: None,\n })\n }\n \n@@ -635,15 +637,64 @@ fn parse_post_listing(v: &Value) -> Option {\n .and_then(|t| t.as_str())\n .filter(|s| s.starts_with(\"http\"))\n .map(|s| s.to_string());\n+ let image_url = reddit_post_image_url(child);\n+ let link_url = reddit_post_link_url(child);\n \n Some(crate::reducer::EntityData {\n title,\n author,\n body_html,\n thumb_url,\n+ image_url,\n+ link_url,\n })\n }\n \n+fn reddit_post_link_url(data: &Value) -> Option {\n+ for key in [\"url_overridden_by_dest\", \"url\"] {\n+ if let Some(u) = data.get(key).and_then(|v| v.as_str()) {\n+ if u.starts_with(\"http\") {\n+ return Some(u.to_string());\n+ }\n+ }\n+ }\n+ None\n+}\n+\n+/// Full-size still for post detail: direct image `url`, else Reddit preview source.\n+fn reddit_post_image_url(data: &Value) -> Option {\n+ for key in [\"url\", \"url_overridden_by_dest\"] {\n+ if let Some(u) = data.get(key).and_then(|v| v.as_str()) {\n+ if reddit_direct_image_url(u) {\n+ return Some(u.to_string());\n+ }\n+ }\n+ }\n+ reddit_preview_source_url(data)\n+}\n+\n+fn reddit_preview_source_url(data: &Value) -> Option {\n+ data.pointer(\"/preview/images/0/source/url\")\n+ .and_then(|v| v.as_str())\n+ .filter(|s| s.starts_with(\"http\"))\n+ .map(str::to_string)\n+}\n+\n+fn reddit_direct_image_url(url: &str) -> bool {\n+ let u = url.to_ascii_lowercase();\n+ if u.contains(\"redgifs.com\") {\n+ return false;\n+ }\n+ u.contains(\"i.redd.it\")\n+ || u.contains(\"preview.redd.it\")\n+ || u.contains(\"external-preview.redd.it\")\n+ || u.ends_with(\".jpg\")\n+ || u.ends_with(\".jpeg\")\n+ || u.ends_with(\".png\")\n+ || u.ends_with(\".gif\")\n+ || u.ends_with(\".webp\")\n+}\n+\n #[cfg(test)]\n mod tests {\n use super::*;\n@@ -672,4 +723,20 @@ mod tests {\n .unwrap();\n assert_eq!(entity.title, \"The Rust Programming Language\");\n }\n+\n+ #[test]\n+ fn parse_post_listing_extracts_thumb_and_full_preview() {\n+ let json = include_str!(\"../../test/fixtures/reddit/post_preview.json\");\n+ let v: Value = serde_json::from_str(json).unwrap();\n+ let id = ItemId::parse(\"reddit.com/r/nsfw/comments/1tpy6a1/angel_eyes\").unwrap();\n+ let entity = entity_view_from_payload(&id, &v).unwrap();\n+ assert_eq!(entity.title, \"Angel Eyes\");\n+ assert!(entity.thumb_url.as_ref().unwrap().contains(\"width=140\"));\n+ assert!(entity.image_url.as_ref().unwrap().contains(\"auto=webp\"));\n+ assert!(!entity.image_url.as_ref().unwrap().contains(\"redgifs\"));\n+ assert_eq!(\n+ entity.link_url.as_deref(),\n+ Some(\"http://v3.redgifs.com/watch/impossibleprestigioushedgehog\")\n+ );\n+ }\n }\ndiff --git a/server/src/reducer.rs b/server/src/reducer.rs\nindex 4e42d0369dab50bb2f8ca664aa69b628292f6c07..336e78b77d3ab59361b89af5c9868e13da4ac962 100644\n--- a/server/src/reducer.rs\n+++ b/server/src/reducer.rs\n@@ -122,7 +122,12 @@ pub struct EntityData {\n pub title: String,\n pub author: Option,\n pub body_html: Option,\n+ /// Small preview (subreddit listing / child rows).\n pub thumb_url: Option,\n+ /// Full-size still image for the post detail view.\n+ pub image_url: Option,\n+ /// Outbound link for link/video posts (`url` / `url_overridden_by_dest`).\n+ pub link_url: Option,\n }\n \n /// One node in the fractal tree: entity + ranked children.\ndiff --git a/server/src/render/mod.rs b/server/src/render/mod.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..c2c41404abe01c671139658fcccdeced5be388e4\n--- /dev/null\n+++ b/server/src/render/mod.rs\n@@ -0,0 +1,3 @@\n+//! Domain-specific HTML fragments for imported entities.\n+\n+pub mod reddit;\ndiff --git a/server/src/render/reddit.rs b/server/src/render/reddit.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..4a18de93cf57d8395ded3caa373a708f39b3f43f\n--- /dev/null\n+++ b/server/src/render/reddit.rs\n@@ -0,0 +1,64 @@\n+//! Reddit post cards: thumbnail in child lists, full image on the post page.\n+\n+use maud::{html, Markup};\n+\n+use crate::{\n+ path_types::ItemId,\n+ reducer::{EntityData, GlobalTree, NodeState},\n+};\n+\n+pub fn is_reddit_post(id: &ItemId) -> bool {\n+ id.as_str().starts_with(\"reddit.com/\") && id.as_str().contains(\"/comments/\")\n+}\n+\n+/// Post detail card (`#entity-panel`).\n+pub fn entity_markup(node: &NodeState) -> Option {\n+ if !is_reddit_post(&node.id) {\n+ return None;\n+ }\n+ let data = node.data.as_ref()?;\n+ Some(post_entity_card(data))\n+}\n+\n+/// One row in a parent ranking list (thumbnail + title).\n+pub fn child_row_markup(tree: &GlobalTree, id: &ItemId, href: &str) -> Option {\n+ if !is_reddit_post(id) {\n+ return None;\n+ }\n+ let data = tree.get(id)?.data.as_ref()?;\n+ Some(html! {\n+ @if let Some(thumb) = &data.thumb_url {\n+ a class=\"reddit-post-thumb-link\" href=(href) {\n+ img class=\"reddit-post-thumb\" src=(thumb) alt=\"\" loading=\"lazy\";\n+ }\n+ }\n+ a href=(href) {\n+ strong { (data.title) }\n+ }\n+ })\n+}\n+\n+fn post_entity_card(data: &EntityData) -> Markup {\n+ let image = data.image_url.as_ref().or(data.thumb_url.as_ref());\n+ html! {\n+ div id=\"entity-panel\" class=\"entity-card reddit-post\" {\n+ h2 { (data.title) }\n+ @if let Some(author) = &data.author {\n+ p class=\"muted small\" { \"by \" (author) }\n+ }\n+ @if let Some(url) = &data.link_url {\n+ p class=\"reddit-post-url muted small\" {\n+ a href=(url) rel=\"noopener noreferrer\" { (url) }\n+ }\n+ }\n+ @if let Some(src) = image {\n+ figure class=\"reddit-post-figure\" {\n+ img class=\"reddit-post-image\" src=(src) alt=\"\" loading=\"lazy\";\n+ }\n+ }\n+ @if let Some(body) = &data.body_html {\n+ div class=\"entity-body\" { (maud::PreEscaped(body)) }\n+ }\n+ }\n+ }\n+}\ndiff --git a/server/static/sorter.css b/server/static/sorter.css\nindex 1f9f5158414902d43a380dd899232c70d8cf5d63..021918f8e778b84e4b3eac8559fab7d223a3d490 100644\n--- a/server/static/sorter.css\n+++ b/server/static/sorter.css\n@@ -171,6 +171,41 @@ code {\n margin-top: 0;\n }\n \n+.reddit-post-row {\n+ display: flex;\n+ align-items: center;\n+ gap: 0.6rem;\n+}\n+\n+.reddit-post-thumb-link {\n+ flex-shrink: 0;\n+ line-height: 0;\n+}\n+\n+.reddit-post-thumb {\n+ width: 64px;\n+ height: 64px;\n+ object-fit: cover;\n+ border-radius: 4px;\n+ border: 1px solid var(--border);\n+}\n+\n+.reddit-post-url a {\n+ word-break: break-all;\n+}\n+\n+.reddit-post-figure {\n+ margin: 0.75rem 0;\n+}\n+\n+.reddit-post-image {\n+ display: block;\n+ max-width: 100%;\n+ height: auto;\n+ border-radius: 6px;\n+ border: 1px solid var(--border);\n+}\n+\n .scope-name {\n color: var(--accent);\n font-weight: 600;\ndiff --git a/test/fixtures/reddit/post_preview.json b/test/fixtures/reddit/post_preview.json\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..c6ed31ae397dc0f7b6e07e5e010e617df78d9c18\n--- /dev/null\n+++ b/test/fixtures/reddit/post_preview.json\n@@ -0,0 +1,22 @@\n+{\n+ \"kind\": \"t3\",\n+ \"data\": {\n+ \"title\": \"Angel Eyes\",\n+ \"permalink\": \"/r/nsfw/comments/1tpy6a1/angel_eyes/\",\n+ \"author\": \"alice\",\n+ \"thumbnail\": \"https://external-preview.redd.it/Kb6Nf5Q4RAucat-2RFcYMeQb2d6vgYNo5EPlf6kGLbs.jpeg?width=140&height=140&auto=webp&s=d7238240ebf191b954ef5f42b0cf61b67aecec88\",\n+ \"url\": \"http://v3.redgifs.com/watch/impossibleprestigioushedgehog\",\n+ \"post_hint\": \"rich:video\",\n+ \"preview\": {\n+ \"images\": [\n+ {\n+ \"source\": {\n+ \"url\": \"https://external-preview.redd.it/Kb6Nf5Q4RAucat-2RFcYMeQb2d6vgYNo5EPlf6kGLbs.jpeg?auto=webp&s=ccdabee3b81e46ef2d7cec072f6604a09f61dc9a\",\n+ \"width\": 480,\n+ \"height\": 480\n+ }\n+ }\n+ ]\n+ }\n+ }\n+}\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[c3cbcaa7] refactor\n\nSide B — 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","role":"user"}],"model":"~x-ai/grok-latest"}