{"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[d6da7856] Resurrect page view counts (ViewStore + middleware + layout) (#138)\n\n* Wire ViewStore through AppState, view-count middleware, and HTML.\n\n- Add views module to lib, ViewStore on AppState (create_app_state and tests).\n- Implement canonical_view_url and GET view_count_middleware with path filters.\n- Layer middleware before with_state; add url crate for query canonicalization.\n- Pass canonical-key view counts into garden, forum, search, and try layouts.\n- Replace stub integration test with real view counter assertions.\n\nCo-authored-by: tommy \n\n* Test vote/compare query canonicalization instead of search.\n\nVote compare uses chromeless layout without view badge; assert the shared\nViewStore count for permuted left/right query order via AppState.\n\nCo-authored-by: tommy \n\n* Browser test: stop waiting for removed vote-compare shell.\n\nAssert vote compare via body.view-vote-compare and compare heading instead\nof .vote-compare-shell, which was intentionally removed from the HTML.\n\nCo-authored-by: tommy \n\n* Drop vote-compare preview morph and browser assertion.\n\nvote_compare_post_success_js now only refreshes #vote-edge-history-region.\nRemove preview wrap markup, browser test wait on #vote-compare-preview, and\nunused forum re-export of ingest_entry_markup.\n\nCo-authored-by: tommy \n\n---------\n\nCo-authored-by: Cursor Agent \n\nSide A — unified diff (full patch):\ndiff --git a/Cargo.lock b/Cargo.lock\nindex ad7e4fe6d4ba2f2b033916194c1ef1ed873f1d46..bf8153d9c723af97122c9ffdd4a7cfe82e853bb6 100644\n--- a/Cargo.lock\n+++ b/Cargo.lock\n@@ -1805,6 +1805,7 @@ dependencies = [\n \"tower-http 0.5.2\",\n \"tracing\",\n \"tracing-subscriber\",\n+ \"url\",\n \"urlencoding\",\n \"uuid\",\n ]\ndiff --git a/server/Cargo.toml b/server/Cargo.toml\nindex 6d85b8cce5701482367d4be43287394e26f5896f..0d9cdcd21d94751babdf769e118d2c89d6a009d8 100644\n--- a/server/Cargo.toml\n+++ b/server/Cargo.toml\n@@ -28,6 +28,7 @@ slug-types = { path = \"../types\" }\n async-trait = \"0.1\"\n base64 = \"0.22\"\n postcard = { version = \"1\", features = [\"use-std\"] }\n+url = \"2\"\n urlencoding = \"2\"\n \n [dev-dependencies]\ndiff --git a/server/src/html/editor.rs b/server/src/html/editor.rs\nindex a24e638802c8bb38699b39fdb0c53c22183a4a49..8ff58326eb75806eeec3937f088fa977c7f3886e 100644\n--- a/server/src/html/editor.rs\n+++ b/server/src/html/editor.rs\n@@ -9,8 +9,9 @@ use maud::{html, Markup};\n use serde::Deserialize;\n \n use crate::{\n- api::{validate_ingest_document, resolve_item},\n+ api::{resolve_item, validate_ingest_document},\n html::JsBuilder,\n+ middleware::canonical_view_url,\n reducer::ScopeId,\n state::AppState,\n };\n@@ -25,7 +26,10 @@ fn bc_try() -> Markup {\n }\n \n /// The interactive editor page — `/try`.\n-pub async fn editor_page(jar: CookieJar, uri: Uri) -> impl IntoResponse {\n+pub async fn editor_page(State(state): State, jar: CookieJar, uri: Uri) -> impl IntoResponse {\n+ let url_key = canonical_view_url(&uri);\n+ let view_count = state.views.get_views(&url_key);\n+\n let page = layout(\n \"try — slug.social\",\n \"view-thread\",\n@@ -41,7 +45,7 @@ pub async fn editor_page(jar: CookieJar, uri: Uri) -> impl IntoResponse {\n div id=\"editor-results\" {}\n }\n },\n- None,\n+ Some(view_count),\n theme_from_jar(&jar),\n &theme_next_from_uri(&uri),\n None,\ndiff --git a/server/src/html/forum/feed.rs b/server/src/html/forum/feed.rs\nindex 63f196e0df251678bd649da97107e35a741f97fa..70321dd2a75e563282f7612f2b2fa6d57b9175d9 100644\n--- a/server/src/html/forum/feed.rs\n+++ b/server/src/html/forum/feed.rs\n@@ -8,6 +8,7 @@ use maud::{html, Markup};\n \n use crate::api::optional_principal;\n use crate::canonical_path::canonicalize_tag;\n+use crate::middleware::canonical_view_url;\n use crate::reducer::{ReducerState, ScopeId};\n use crate::state::AppState;\n use crate::timeago;\n@@ -218,6 +219,9 @@ pub async fn home(\n let strip = auth_strip(&headers, &jar, &reduced_read);\n drop(reduced_read);\n \n+ let url_key = canonical_view_url(&uri);\n+ let view_count = state.views.get_views(&url_key);\n+\n let page = layout(\n \"slug.social\",\n \"view-thread\",\n@@ -251,7 +255,7 @@ pub async fn home(\n (render_thread_feed(Some(&nav), \"thread-feed\", &public_rows, now))\n (cli_panel(&[\"npx slugsocial public forum list\"]))\n },\n- None,\n+ Some(view_count),\n theme_from_jar(&jar),\n &theme_next_from_uri(&uri),\n None,\ndiff --git a/server/src/html/forum/mod.rs b/server/src/html/forum/mod.rs\nindex 53809e94e3b107a1ddb937c2e45325fc6b8706c6..bd60703924eee5b0e5a7f69c0a2dc526442512a2 100644\n--- a/server/src/html/forum/mod.rs\n+++ b/server/src/html/forum/mod.rs\n@@ -20,7 +20,6 @@ pub use profile::user_profile_page;\n pub use views::{room_page, room_thread_view, thread_view};\n \n pub(crate) use access::{user_can_post_room, user_can_view_room};\n-pub(crate) use ingest::ingest_entry_markup;\n pub(crate) use new_thread::{fragment_new_thread_slot, login_to_post_hint_markup};\n pub(crate) use room_members::room_members_section_markup;\n pub(crate) use thread_morph::{\ndiff --git a/server/src/html/forum/post_single.rs b/server/src/html/forum/post_single.rs\nindex 6e36e80d076ddd8ca521c1450cbf8cb7ba6a28fe..91866b2cc3dea98d0c3847f54379245eca67894c 100644\n--- a/server/src/html/forum/post_single.rs\n+++ b/server/src/html/forum/post_single.rs\n@@ -8,6 +8,7 @@ use maud::html;\n \n use crate::api::optional_principal;\n use crate::canonical_path::canonicalize_tag;\n+use crate::middleware::canonical_view_url;\n use crate::reducer::ScopeId;\n use crate::state::AppState;\n \n@@ -70,6 +71,9 @@ async fn thread_post_view_inner(\n }\n };\n \n+ let url_key = canonical_view_url(&uri);\n+ let view_count = state.views.get_views(&url_key);\n+\n let page = layout(\n &format!(\"#{tag} / post #{index}\"),\n \"view-thread\",\n@@ -87,7 +91,7 @@ async fn thread_post_view_inner(\n p class=\"muted\" { \"post not found\" }\n }\n },\n- None,\n+ Some(view_count),\n theme_from_jar(&jar),\n &theme_next_from_uri(&uri),\n None,\ndiff --git a/server/src/html/forum/profile.rs b/server/src/html/forum/profile.rs\nindex ebdc41fdd44e97a10a49ecd1ac5150ae390ec319..94701f8e9e293b64b889291c2145f97c1078c1c4 100644\n--- a/server/src/html/forum/profile.rs\n+++ b/server/src/html/forum/profile.rs\n@@ -9,6 +9,7 @@ use maud::html;\n use crate::api::optional_principal;\n use crate::canonical_path::canonicalize_tag;\n use crate::identity::parse_username;\n+use crate::middleware::canonical_view_url;\n use crate::state::AppState;\n \n use super::ingest::{thread_nav_for_ingest, thread_post_index_in_scope};\n@@ -74,6 +75,9 @@ pub async fn user_profile_page(\n };\n \n let now = now_ms();\n+ let url_key = canonical_view_url(&uri);\n+ let view_count = state.views.get_views(&url_key);\n+\n let page = layout(\n &format!(\"@{canon}\"),\n \"view-thread\",\n@@ -108,7 +112,7 @@ pub async fn user_profile_page(\n }\n (cli_panel(&[format!(\"npx slugsocial public forum list\")]))\n },\n- None,\n+ Some(view_count),\n theme_from_jar(&jar),\n &theme_next_from_uri(&uri),\n None,\ndiff --git a/server/src/html/forum/views.rs b/server/src/html/forum/views.rs\nindex 34c47a2a6838a0ec99415e326cbd78f2cc988bd7..b363f0d7c8af3d941e4f402c1f1b87100bb0d394 100644\n--- a/server/src/html/forum/views.rs\n+++ b/server/src/html/forum/views.rs\n@@ -11,6 +11,7 @@ use serde_json::json;\n use crate::api::optional_principal;\n use crate::canonical_path::canonicalize_tag;\n use crate::form_template::template_json_compact;\n+use crate::middleware::canonical_view_url;\n use crate::reducer::ScopeId;\n use crate::state::AppState;\n \n@@ -141,6 +142,9 @@ async fn thread_view_inner(\n ScopeId::Room(r) => format!(\"npx slugsocial private {r} forum show {tag}\"),\n };\n \n+ let url_key = canonical_view_url(&uri);\n+ let view_count = state.views.get_views(&url_key);\n+\n let body = html! {\n (strip)\n nav class=\"breadcrumb\" { (bc) }\n@@ -166,7 +170,7 @@ async fn thread_view_inner(\n &format!(\"#{tag}\"),\n \"view-thread\",\n body,\n- None,\n+ Some(view_count),\n theme_from_jar(&jar),\n &theme_next_from_uri(&uri),\n None,\n@@ -276,6 +280,9 @@ pub async fn room_page(\n let audit_cli = format!(\"npx slugsocial private {room_id} audit\");\n drop(reduced);\n \n+ let url_key = canonical_view_url(&uri);\n+ let view_count = state.views.get_views(&url_key);\n+\n let slug_display = room_id\n .split_once('/')\n .map(|(_, slug)| slug)\n@@ -296,7 +303,7 @@ pub async fn room_page(\n }\n (cli_panel(&[forum_cli, garden_cli, audit_cli]))\n },\n- None,\n+ Some(view_count),\n theme_from_jar(&jar),\n &theme_next_from_uri(&uri),\n None,\ndiff --git a/server/src/html/garden.rs b/server/src/html/garden.rs\nindex 2d5fcd903fe8f56800ad2c5564b704bd25f9b18d..9e08307e636ce7984766168db1187f84b8635201 100644\n--- a/server/src/html/garden.rs\n+++ b/server/src/html/garden.rs\n@@ -14,11 +14,12 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD as B64_ENGINE\n use crate::{\n api::optional_principal,\n canonical_path::{canonicalize_item, canonicalize_tag},\n+ middleware::canonical_view_url,\n events::ThreadCapability,\n form_template::template_json_compact,\n html::{JsBuilder, ui_action::UI_RPC_FIELD, user_can_post_room},\n path_types::ItemId,\n- reducer::{ContentState, ReducerState, ScopeId, scope_from_room_wire},\n+ reducer::{ContentState, ReducerState, ScopeId},\n scope_rank::{ChildrenRankings, build_children_rankings},\n state::AppState,\n timeago,\n@@ -28,7 +29,7 @@ use super::{\n bc_path, bc_path_external, bc_segment,\n breadcrumb_path::{ExternalOntologyPath, OntologyPath},\n cli_panel,\n- forum::{ThreadNav, ingest_entry_markup},\n+ forum::ThreadNav,\n layout, layout_full_bleed_chromeless, now_ms, ratio_pct, render_linkified_with_embeds_in_scope,\n theme_from_jar, theme_next_from_uri,\n };\n@@ -224,39 +225,24 @@ fn vote_edge_history_markup(content: &ContentState, left: &ItemId, right: &ItemI\n }\n }\n \n-/// After a successful vote post: morph the new card into `#vote-compare-preview` and refresh edge history.\n+/// After a successful vote post: refresh edge history (no in-page preview card).\n pub(crate) async fn vote_compare_post_success_js(\n state: &AppState,\n nav: &ThreadNav,\n- room_wire: &str,\n- thread_tag: &str,\n+ _room_wire: &str,\n+ _thread_tag: &str,\n left: &ItemId,\n right: &ItemId,\n- post_id: &str,\n- post_idx: Option,\n+ _post_id: &str,\n+ _post_idx: Option,\n ) -> String {\n let reduced = state.reduced.read().await;\n- let scope = scope_from_room_wire(room_wire);\n- let Some(ing) = reduced.ingests_by_id.get(post_id).cloned() else {\n- drop(reduced);\n- return \"console.warn('vote compare: new post not found');\".to_string();\n- };\n- let idx = match post_idx {\n- Some(i) => i,\n- None => reduced\n- .try_thread_post_index_chronological(&scope, thread_tag, post_id)\n- .unwrap_or(0),\n- };\n- let viewer = None::<&str>;\n- let now = now_ms();\n let content = content_for_garden_view(&reduced, &nav.scope());\n let edge_history = vote_edge_history_markup(content, left, right);\n- let card = ingest_entry_markup(nav, thread_tag, idx, &ing, viewer, now, &reduced);\n drop(reduced);\n- let mut b = JsBuilder::new();\n- b = b.morph_inner_selector(\"#vote-compare-preview\", card);\n- b = b.morph_inner_selector(\"#vote-edge-history-region\", edge_history);\n- b.build()\n+ JsBuilder::new()\n+ .morph_inner_selector(\"#vote-edge-history-region\", edge_history)\n+ .build()\n }\n \n fn item_display_path(item: &str) -> String {\n@@ -515,6 +501,9 @@ pub async fn garden_index(\n build_children_rankings(reduced.public(), &ItemId::ontology_root())\n };\n \n+ let url_key = canonical_view_url(&uri);\n+ let view_count = state.views.get_views(&url_key);\n+\n let page = layout(\n \"~/\",\n \"view-ontology view-ontology-light\",\n@@ -556,7 +545,7 @@ pub async fn garden_index(\n }\n (cli_panel(&[\"npx slugsocial garden tree\"]))\n },\n- None,\n+ Some(view_count),\n theme_from_jar(&jar),\n &theme_next_from_uri(&uri),\n Some(\"public\"),\n@@ -597,6 +586,9 @@ pub async fn external_garden_index(\n build_children_rankings(reduced.public(), &parent)\n };\n \n+ let url_key = canonical_view_url(&uri);\n+ let view_count = state.views.get_views(&url_key);\n+\n let page = layout(\n \"-/\",\n \"view-ontology view-ontology-light\",\n@@ -637,7 +629,7 @@ pub async fn external_garden_index(\n }\n }\n },\n- None,\n+ Some(view_count),\n theme_from_jar(&jar),\n &theme_next_from_uri(&uri),\n Some(\"public\"),\n@@ -727,6 +719,9 @@ pub async fn room_external_garden_index(\n build_children_rankings(content_for_garden_view(&reduced, &nav.scope()), &parent);\n drop(reduced);\n \n+ let url_key = canonical_view_url(&uri);\n+ let view_count = state.views.get_views(&url_key);\n+\n let page = layout(\n \"-/\",\n \"view-ontology view-ontology-light\",\n@@ -766,7 +761,7 @@ pub async fn room_external_garden_index(\n }\n }\n },\n- None,\n+ Some(view_count),\n theme_from_jar(&jar),\n &theme_next_from_uri(&uri),\n Some(nav.room_wire.as_str()),\n@@ -1080,6 +1075,9 @@ async fn render_scope_view(\n .filter(|s| !s.is_empty())\n .unwrap_or_else(|| \"/\".to_string());\n \n+ let url_key = canonical_view_url(&uri);\n+ let view_count = state.views.get_views(&url_key);\n+\n let page = layout(\n &item_display_path(&model.item),\n \"view-ontology view-ontology-light\",\n@@ -1227,7 +1225,7 @@ async fn render_scope_view(\n };\n (cli_panel(std::slice::from_ref(&cli)))\n },\n- None,\n+ Some(view_count),\n theme_from_jar(&jar),\n &theme_next_from_uri(&uri),\n Some(&garden_room),\ndiff --git a/server/src/html/search.rs b/server/src/html/search.rs\nindex 04fd6f27854138f668689df3b250d7240645d1ac..35d3604ab8ee50327af5b0acf1a86e646633cf1f 100644\n--- a/server/src/html/search.rs\n+++ b/server/src/html/search.rs\n@@ -10,6 +10,7 @@ use serde::Deserialize;\n use crate::{\n api::optional_principal,\n events::ThreadCapability,\n+ middleware::canonical_view_url,\n reducer::{ReducerState, ScopeId},\n state::AppState,\n timeago,\n@@ -407,6 +408,9 @@ pub async fn search_page(\n SearchResults { items: vec![], threads: vec![], posts: vec![] }\n };\n \n+ let url_key = canonical_view_url(&uri);\n+ let view_count = state.views.get_views(&url_key);\n+\n let page = layout(\n \"search \\u{2014} slug.social\",\n \"view-thread\",\n@@ -420,7 +424,7 @@ pub async fn search_page(\n (render_search_results(&results, &query))\n (cli_panel(&[\"npx slugsocial search \"]))\n },\n- None,\n+ Some(view_count),\n theme_from_jar(&jar),\n &theme_next_from_uri(&uri),\n None,\ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nindex fc95fc5a845fde9718195e275f2b228f68d913a6..b902094653cf9319efd14e67b91436e269362441 100644\n--- a/server/src/lib.rs\n+++ b/server/src/lib.rs\n@@ -16,6 +16,7 @@ pub mod reducer;\n pub mod scope_rank;\n pub mod state;\n pub mod timeago;\n+pub mod views;\n pub mod write_cmd;\n \n use std::collections::HashMap;\n@@ -37,6 +38,8 @@ pub fn create_app_state(cfg: AppConfig) -> AppState {\n let (stream_tx, _) = broadcast::channel(64);\n let (js_tx, _) = broadcast::channel(64);\n let (write_tx, write_rx) = mpsc::channel::(256);\n+ let views_path = format!(\"{}/views.json\", cfg.data_dir);\n+ let views = crate::views::ViewStore::new(&views_path);\n let state = AppState {\n cfg: Arc::new(cfg),\n event_log: Arc::new(event_log),\n@@ -46,6 +49,7 @@ pub fn create_app_state(cfg: AppConfig) -> AppState {\n stream_tx,\n js_tx,\n write_tx,\n+ views,\n };\n tokio::spawn(crate::api::write_actor::writer_actor(write_rx, state.clone()));\n state\n@@ -118,6 +122,10 @@ pub fn create_app(state: AppState) -> Router {\n .route(\"/api/v0/pending-session/:id\", get(api::get_pending_session))\n .route(\"/api/v0/whoami\", get(api::get_whoami))\n .route(\"/api/v0/rpc\", post(api::handle_rpc_batch))\n+ .layer(axum::middleware::from_fn_with_state(\n+ state.clone(),\n+ crate::middleware::view_count_middleware,\n+ ))\n .with_state(state)\n .layer(TraceLayer::new_for_http())\n }\ndiff --git a/server/src/middleware.rs b/server/src/middleware.rs\nindex 47751d0da2cf44df44b3e7c94e2a1063b29bd6d6..458bd65054bb1baa70691b06ec44ae71b72b47c5 100644\n--- a/server/src/middleware.rs\n+++ b/server/src/middleware.rs\n@@ -1,12 +1,47 @@\n use axum::{\n- extract::Request,\n+ extract::{Request, State},\n middleware::Next,\n response::Response,\n };\n \n+use crate::state::AppState;\n+\n+pub fn canonical_view_url(uri: &axum::http::Uri) -> String {\n+ let path = uri.path();\n+ if let Some(query) = uri.query() {\n+ let mut pairs: Vec<_> = url::form_urlencoded::parse(query.as_bytes()).into_owned().collect();\n+ if pairs.is_empty() {\n+ return path.to_string();\n+ }\n+ pairs.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));\n+\n+ let new_query = url::form_urlencoded::Serializer::new(String::new())\n+ .extend_pairs(pairs)\n+ .finish();\n+ format!(\"{path}?{new_query}\")\n+ } else {\n+ path.to_string()\n+ }\n+}\n+\n pub async fn view_count_middleware(\n+ State(state): State,\n req: Request,\n next: Next,\n ) -> Response {\n+ if req.method() == axum::http::Method::GET {\n+ let path = req.uri().path();\n+\n+ if !path.starts_with(\"/static\")\n+ && !path.starts_with(\"/api\")\n+ && !path.starts_with(\"/sse\")\n+ && !path.starts_with(\"/auth\")\n+ && path != \"/healthz\"\n+ && path != \"/ui\"\n+ {\n+ let url_key = canonical_view_url(req.uri());\n+ state.views.increment(url_key);\n+ }\n+ }\n next.run(req).await\n }\ndiff --git a/server/src/state.rs b/server/src/state.rs\nindex 8fc39914d64ace53a544c5eb85bc61447db60305..13831e868031edc19fda0f347376e0b17e8b99b7 100644\n--- a/server/src/state.rs\n+++ b/server/src/state.rs\n@@ -67,6 +67,7 @@ pub struct AppState {\n pub js_tx: broadcast::Sender,\n /// All durable writes and reducer mutations are serialized through this channel.\n pub write_tx: mpsc::Sender,\n+ pub views: crate::views::ViewStore,\n }\n \n impl AppState {\n@@ -81,6 +82,8 @@ impl AppState {\n let event_log = EventLog::new(cfg.event_log_path.clone());\n let (stream_tx, _) = broadcast::channel(64);\n let (js_tx, _) = broadcast::channel(64);\n+ let views_path = format!(\"{}/views.json\", cfg.data_dir);\n+ let views = crate::views::ViewStore::new(&views_path);\n Self {\n cfg: Arc::new(cfg),\n event_log: Arc::new(event_log),\n@@ -90,6 +93,7 @@ impl AppState {\n stream_tx,\n js_tx,\n write_tx,\n+ views,\n }\n }\n }\ndiff --git a/server/tests/integration.rs b/server/tests/integration.rs\nindex b34ba69a2115cad9875afce17743ad27f3838959..bd10fd2a6ef06ac1a23de790a54ad5aa0065a472 100644\n--- a/server/tests/integration.rs\n+++ b/server/tests/integration.rs\n@@ -1,8 +1,10 @@\n+use axum::http::Uri;\n use sha2::{Digest, Sha256};\n-use slug_types::room_route_segment;\n+use slug_types::{room_route_segment, ItemId};\n use slugsocial_server::{\n event_log::EventLog,\n events::{Event, TokenIssued, UserRegistered},\n+ middleware::canonical_view_url,\n spawn_writer_actor_for_test,\n state::{AppConfig, AppState},\n };\n@@ -1368,7 +1370,69 @@ async fn test_search_page_and_results() {\n \n #[tokio::test]\n async fn test_view_counts_increment_and_display() {\n- // HTML view counters are offline during the auth-v3 refactor.\n+ let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await;\n+ let client = reqwest::Client::new();\n+ let url = format!(\"http://{addr}/~\");\n+\n+ let r1 = client.get(&url).send().await.unwrap();\n+ assert!(r1.status().is_success());\n+ let body1 = r1.text().await.unwrap();\n+ assert!(\n+ body1.contains(\"1 views\"),\n+ \"expected first GET to show 1 views, body snippet: {}\",\n+ &body1.chars().take(500).collect::()\n+ );\n+\n+ let r2 = client.get(&url).send().await.unwrap();\n+ assert!(r2.status().is_success());\n+ let body2 = r2.text().await.unwrap();\n+ assert!(\n+ body2.contains(\"2 views\"),\n+ \"expected second GET to show 2 views\"\n+ );\n+\n+ // Vote compare uses permuted `left` / `right`; middleware canonicalizes query order.\n+ let left = ItemId::parse(\"~/vc-l\")\n+ .unwrap()\n+ .normalized_storage()\n+ .to_storage_string();\n+ let right = ItemId::parse(\"~/vc-r\")\n+ .unwrap()\n+ .normalized_storage()\n+ .to_storage_string();\n+ let vote_q_right_first = format!(\n+ \"/vote/compare?right={}&left={}\",\n+ urlencoding::encode(&right),\n+ urlencoding::encode(&left)\n+ );\n+ let vote_q_left_first = format!(\n+ \"/vote/compare?left={}&right={}\",\n+ urlencoding::encode(&left),\n+ urlencoding::encode(&right)\n+ );\n+ let vote_key_uri: Uri = format!(\"http://127.0.0.1{vote_q_left_first}\")\n+ .parse()\n+ .unwrap();\n+ let vote_key = canonical_view_url(&vote_key_uri);\n+\n+ let v1 = client\n+ .get(format!(\"http://{addr}{vote_q_right_first}\"))\n+ .send()\n+ .await\n+ .unwrap();\n+ assert!(v1.status().is_success(), \"vote compare GET 1: {}\", v1.status());\n+ let v2 = client\n+ .get(format!(\"http://{addr}{vote_q_left_first}\"))\n+ .send()\n+ .await\n+ .unwrap();\n+ assert!(v2.status().is_success(), \"vote compare GET 2: {}\", v2.status());\n+\n+ assert_eq!(\n+ state.views.get_views(&vote_key),\n+ 2,\n+ \"permuted vote/compare URLs should share one ViewStore key ({vote_key:?})\"\n+ );\n }\n \n #[tokio::test]\ndiff --git a/test/browser_vote_compare.clj b/test/browser_vote_compare.clj\nindex 78fc40c160c84478ab8ee0d953b9ed878b4c712f..839482d12963f130a2063f2212a6b922b2d229e1 100644\n--- a/test/browser_vote_compare.clj\n+++ b/test/browser_vote_compare.clj\n@@ -71,13 +71,12 @@\n (page/navigate pg (str base-url \"/login\"))\n (is (wait-for-text pg \"body\" \"@alice\" 15000) \"alice session after login\")\n (page/navigate pg cmp-url)\n- (is (wait-for-text pg \".vote-compare-shell\" \"compare\" 15000) \"vote compare shell\")\n+ ;; No .vote-compare-shell wrapper — wait on stable vote-compare UI instead.\n+ (is (wait-for-text pg \"body.view-vote-compare\" \"compare\" 15000) \"vote compare page\")\n (is (wait-for-text pg \"ul.vote-edge-history\" \"seed edge vote\" 15000)\n \"edge history lists canonical-order vote\")\n (locator/fill (page/locator pg \"#vote-explain\") \"because playwright says so\")\n (locator/click (page/locator pg \"#vote-compare-form button[type=submit]\"))\n- (is (wait-for-text pg \"#vote-compare-preview\" \"because playwright\" 20000)\n- \"preview region morphs new post card\")\n (is (wait-for-text pg \"ul.vote-edge-history\" \"because playwright\" 20000)\n \"new vote appears in edge history after morph\"))))))\n \n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[d88719a1] refactor(html): unify public and room new-thread forms\n\n- Single DOM contract: #new-thread-ui-slot, #new-thread-compose, #new-thread-form,\n #new-thread-tag; shared check_ingest + post_ingest error wiring for all scopes.\n- Replace ExpandPublic/ExpandRoom/SetRoomNewThreadComposeExpanded with\n ExpandNewThreadForm { room_wire } and SetNewThreadComposeExpanded { room_wire, expanded };\n room_wire \"public\" covers the home toolbar flow.\n- SSE refresh resets #new-thread-compose form for any room_key.\n- Browser test selectors updated for the unified ids.\n\nMade-with: Cursor\n\nSide B — unified diff (full patch):\ndiff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs\nindex 03f4f7efe08df3ec160e50d08652bde7d6a9c4d2..422b520f527f64571f86f0ee3cb57edcbc21c0c3 100644\n--- a/server/src/api/rpc.rs\n+++ b/server/src/api/rpc.rs\n@@ -63,11 +63,7 @@ async fn broadcast_web_refresh(state: &AppState, room_key: &str, thread_id: &str\n crate::html::thread_feed_region_markup(state, Some(room_key), thread_id, None).await;\n \n let builder = JsBuilder::new().morph_selector(&format!(\"#{feed_id}\"), feed_markup);\n- let builder = if room_key == \"public\" {\n- builder.qs(\"#public-new-thread-compose form\").reset()\n- } else {\n- builder.qs(\"#room-new-thread-compose form\").reset()\n- };\n+ let builder = builder.qs(\"#new-thread-compose form\").reset();\n let builder = builder.if_current_path_matches(&thread_url, |builder| {\n builder.morph_selector(\"#thread-feed-region\", thread_feed_markup)\n });\ndiff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs\nindex 6ff9f1319e91f61bf8546eb65637c20f3a58483c..e979053ff1ba8c0e2bf55add0a32b7de11cf1e56 100644\n--- a/server/src/api/ui_html.rs\n+++ b/server/src/api/ui_html.rs\n@@ -19,7 +19,7 @@ use crate::{\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+ fragment_new_thread_slot, login_to_post_hint_markup,\n parse_html_ui_from_form, room_members_section_markup, thread_feed_html,\n thread_feed_html_for_room, thread_feed_region_markup, thread_ui_collapse_redacted_post,\n thread_ui_expand_post_full, thread_ui_expand_redacted_post, ui_js_warn, user_can_post_room,\n@@ -139,24 +139,24 @@ async fn dispatch_ui_action(\n }\n }\n }\n- HtmlUiAction::ExpandPublicNewThreadForm => {\n- let reduced = state.reduced.read().await;\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- } else {\n- login_to_post_hint_markup()\n- };\n- JsBuilder::new()\n- .morph_inner_selector(\"#public-new-thread-ui-slot\", markup)\n- .into_response()\n- }\n- HtmlUiAction::ExpandRoomNewThreadForm { room_wire } => {\n+ HtmlUiAction::ExpandNewThreadForm { room_wire } => {\n let room_wire = room_wire.trim().to_string();\n if room_wire.is_empty() {\n return ui_js_warn(\"missing room\").into_response();\n }\n+ if room_wire == \"public\" {\n+ let reduced = state.reduced.read().await;\n+ let user = session.map(|s| s.username.as_str());\n+ drop(reduced);\n+ let markup = if user.is_some() {\n+ fragment_new_thread_slot(&ThreadNav::public(), true, false)\n+ } else {\n+ login_to_post_hint_markup()\n+ };\n+ return JsBuilder::new()\n+ .morph_inner_selector(\"#new-thread-ui-slot\", markup)\n+ .into_response();\n+ }\n let reduced = state.reduced.read().await;\n let user = session.map(|s| s.username.as_str());\n if !reduced.rooms.contains(&room_wire) {\n@@ -176,12 +176,12 @@ async fn dispatch_ui_action(\n return ui_js_warn(\"bad room\").into_response();\n };\n let markup = if can_post {\n- fragment_room_new_thread_form(&nav, true, false)\n+ fragment_new_thread_slot(&nav, true, false)\n } else {\n login_to_post_hint_markup()\n };\n JsBuilder::new()\n- .morph_inner_selector(\"#room-new-thread-ui-slot\", markup)\n+ .morph_inner_selector(\"#new-thread-ui-slot\", markup)\n .into_response()\n }\n HtmlUiAction::SetRoomMembersExpanded { room_wire, expanded } => {\n@@ -205,11 +205,28 @@ async fn dispatch_ui_action(\n .morph_selector(\"#room-members-section\", markup)\n .into_response()\n }\n- HtmlUiAction::SetRoomNewThreadComposeExpanded { room_wire, expanded } => {\n+ HtmlUiAction::SetNewThreadComposeExpanded { room_wire, expanded } => {\n let room_wire = room_wire.trim().to_string();\n if room_wire.is_empty() {\n return ui_js_warn(\"missing room\").into_response();\n }\n+ if room_wire == \"public\" {\n+ let reduced = state.reduced.read().await;\n+ let _user = session.map(|s| s.username.as_str());\n+ drop(reduced);\n+ let can_post = session.is_some();\n+ let nav = ThreadNav::public();\n+ let markup = if can_post {\n+ fragment_new_thread_slot(&nav, true, expanded)\n+ } else {\n+ login_to_post_hint_markup()\n+ };\n+ let mut b = JsBuilder::new().morph_inner_selector(\"#new-thread-ui-slot\", markup);\n+ if expanded && can_post {\n+ b = b.focus_selector(\"#new-thread-tag\");\n+ }\n+ return b.into_response();\n+ }\n let reduced = state.reduced.read().await;\n let user = session.map(|s| s.username.as_str());\n if !reduced.rooms.contains(&room_wire) {\n@@ -229,13 +246,13 @@ async fn dispatch_ui_action(\n return ui_js_warn(\"bad room\").into_response();\n };\n let markup = if can_post {\n- fragment_room_new_thread_form(&nav, true, expanded)\n+ fragment_new_thread_slot(&nav, true, expanded)\n } else {\n login_to_post_hint_markup()\n };\n- let mut b = JsBuilder::new().morph_inner_selector(\"#room-new-thread-ui-slot\", markup);\n+ let mut b = JsBuilder::new().morph_inner_selector(\"#new-thread-ui-slot\", markup);\n if expanded && can_post {\n- b = b.focus_selector(\"#room-new-tag\");\n+ b = b.focus_selector(\"#new-thread-tag\");\n }\n b.into_response()\n }\ndiff --git a/server/src/html/forum/feed.rs b/server/src/html/forum/feed.rs\nindex 30b95ff6e85245a82199c96e41484d03c24989dd..1b4ae7baa3ad4757b74172d7b67f3f2b33d1075d 100644\n--- a/server/src/html/forum/feed.rs\n+++ b/server/src/html/forum/feed.rs\n@@ -245,11 +245,13 @@ pub async fn home(\n p class=\"muted\" { \"dark = time-ordered · light = vote-ranked\" }\n div class=\"thread-feed-toolbar\" {\n form method=\"POST\" action=\"/ui\" {\n- input type=\"hidden\" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::ExpandPublicNewThreadForm).expect(\"static json\"));\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::ExpandNewThreadForm {\n+ room_wire: \"public\".into(),\n+ }).expect(\"static json\"));\n button type=\"submit\" class=\"section-add-btn\" { \"+\" }\n }\n }\n- div id=\"public-new-thread-ui-slot\" {}\n+ div id=\"new-thread-ui-slot\" {}\n (render_thread_feed(Some(&nav), \"thread-feed\", &public_rows, now))\n (cli_panel(&[\"npx slugsocial public forum list\"]))\n },\ndiff --git a/server/src/html/forum/mod.rs b/server/src/html/forum/mod.rs\nindex 8fbf3b71f41fafb474999ebb3d622e6bd5b5f313..bd60703924eee5b0e5a7f69c0a2dc526442512a2 100644\n--- a/server/src/html/forum/mod.rs\n+++ b/server/src/html/forum/mod.rs\n@@ -20,9 +20,7 @@ pub use profile::user_profile_page;\n pub use views::{room_page, room_thread_view, thread_view};\n \n pub(crate) use access::{user_can_post_room, user_can_view_room};\n-pub(crate) use new_thread::{\n- fragment_public_new_thread_form, fragment_room_new_thread_form, login_to_post_hint_markup,\n-};\n+pub(crate) use new_thread::{fragment_new_thread_slot, login_to_post_hint_markup};\n pub(crate) use room_members::room_members_section_markup;\n pub(crate) use thread_morph::{\n thread_ui_collapse_redacted_post, thread_ui_expand_post_full, thread_ui_expand_redacted_post,\ndiff --git a/server/src/html/forum/new_thread.rs b/server/src/html/forum/new_thread.rs\nindex b2d24b8ab3ec7eee0edd16bed8ee6605e49bf408..4dd2654f953a7db6ee9b25c348f99906da93a94a 100644\n--- a/server/src/html/forum/new_thread.rs\n+++ b/server/src/html/forum/new_thread.rs\n@@ -5,120 +5,48 @@ use serde_json::json;\n \n use super::nav::ThreadNav;\n \n-struct NewThreadIds {\n- compose_section_id: &'static str,\n- errors_id: &'static str,\n- form_id: &'static str,\n- tag_input_id: &'static str,\n- text_input_id: Option<&'static str>,\n-}\n-\n-const PUBLIC_IDS: NewThreadIds = NewThreadIds {\n- compose_section_id: \"public-new-thread-compose\",\n- errors_id: \"public-new-thread-errors\",\n- form_id: \"public-new-thread-form\",\n- tag_input_id: \"new-thread-tag\",\n- text_input_id: Some(\"new-thread-text\"),\n-};\n-\n-const ROOM_IDS: NewThreadIds = NewThreadIds {\n- compose_section_id: \"room-new-thread-compose\",\n- errors_id: \"room-new-thread-errors\",\n- form_id: \"room-new-thread-form\",\n- tag_input_id: \"room-new-tag\",\n- text_input_id: None,\n-};\n-\n-#[derive(Clone, Copy)]\n-enum NewThreadComposeKind {\n- /// Home page: no client-side check RPC; post template omits `error_target` / `form_id`.\n- Public,\n- /// Room page: `check_ingest` + error targets on post (matches thread compose).\n- Room,\n-}\n-\n-/// Shared `
` for creating a thread + first post.\n-fn new_thread_compose_section(room_wire: &str, ids: &NewThreadIds, kind: NewThreadComposeKind) -> Markup {\n- let client_check = matches!(kind, NewThreadComposeKind::Room);\n- let (tag_placeholder, text_placeholder, submit_label) = match kind {\n- NewThreadComposeKind::Public => (\n- \"thread-title-slug-here\",\n- \"Hello threadgoers!! Behold my new thread!\",\n- \"create thread / make first post\",\n- ),\n- NewThreadComposeKind::Room => (\n- \"thread-topic-slug-here\",\n- \"First post body…\",\n- \"post\",\n- ),\n- };\n+/// Stable ids shared by public home and private room “new thread” UI (`#new-thread-ui-slot`).\n+const COMPOSE_SECTION_ID: &str = \"new-thread-compose\";\n+const ERRORS_ID: &str = \"new-thread-errors\";\n+const FORM_ID: &str = \"new-thread-form\";\n+const TAG_INPUT_ID: &str = \"new-thread-tag\";\n \n+/// Shared `
`: check + post with `error_target` / `form_id` (same as thread reply compose).\n+fn new_thread_compose_section(room_wire: &str) -> Markup {\n html! {\n- section class=\"compose\" id=(ids.compose_section_id) {\n- div id=(ids.errors_id) {}\n- @if client_check {\n- form id=(ids.form_id) method=\"POST\" action=\"/ui\" data-check-action=\"/ui\" data-check-rpc=(template_json_compact(&json!({\n- \"action\": \"check_ingest\",\n+ section class=\"compose\" id=(COMPOSE_SECTION_ID) {\n+ div id=(ERRORS_ID) {}\n+ form id=(FORM_ID) method=\"POST\" action=\"/ui\" data-check-action=\"/ui\" data-check-rpc=(template_json_compact(&json!({\n+ \"action\": \"check_ingest\",\n+ \"room\": room_wire,\n+ \"thread_tag\": {\"$form\": \"thread_tag\"},\n+ \"text\": {\"$form\": \"text\"},\n+ \"error_target\": ERRORS_ID,\n+ \"form_id\": FORM_ID,\n+ })).unwrap()) {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(template_json_compact(&json!({\n+ \"action\": \"post_ingest\",\n \"room\": room_wire,\n \"thread_tag\": {\"$form\": \"thread_tag\"},\n \"text\": {\"$form\": \"text\"},\n- \"error_target\": ids.errors_id,\n- \"form_id\": ids.form_id,\n- })).unwrap()) {\n- input type=\"hidden\" name=(UI_RPC_FIELD) value=(template_json_compact(&json!({\n- \"action\": \"post_ingest\",\n- \"room\": room_wire,\n- \"thread_tag\": {\"$form\": \"thread_tag\"},\n- \"text\": {\"$form\": \"text\"},\n- \"error_target\": ids.errors_id,\n- \"form_id\": ids.form_id,\n- })).unwrap());\n- input type=\"text\" id=(ids.tag_input_id) name=\"thread_tag\" pattern=\"[a-z0-9_\\\\-]{1,64}\" required placeholder=(tag_placeholder);\n- @if let Some(tid) = ids.text_input_id {\n- textarea id=(tid) name=\"text\" rows=\"4\" placeholder=(text_placeholder) required {}\n- } @else {\n- textarea name=\"text\" rows=\"4\" placeholder=(text_placeholder) required {}\n- }\n- p { button type=\"submit\" { (submit_label) } }\n- }\n- } @else {\n- form id=(ids.form_id) method=\"POST\" action=\"/ui\" {\n- input type=\"hidden\" name=(UI_RPC_FIELD) value=(template_json_compact(&json!({\n- \"action\": \"post_ingest\",\n- \"room\": room_wire,\n- \"thread_tag\": {\"$form\": \"thread_tag\"},\n- \"text\": {\"$form\": \"text\"},\n- })).unwrap());\n- input type=\"text\" id=(ids.tag_input_id) name=\"thread_tag\" pattern=\"[a-z0-9_\\\\-]{1,64}\" placeholder=(tag_placeholder);\n- @if let Some(tid) = ids.text_input_id {\n- textarea id=(tid) name=\"text\" rows=\"4\" placeholder=(text_placeholder) {}\n- } @else {\n- textarea name=\"text\" rows=\"4\" placeholder=(text_placeholder) {}\n- }\n- p { button type=\"submit\" { (submit_label) } }\n- }\n+ \"error_target\": ERRORS_ID,\n+ \"form_id\": FORM_ID,\n+ })).unwrap());\n+ input type=\"text\" id=(TAG_INPUT_ID) name=\"thread_tag\" pattern=\"[a-z0-9_\\\\-]{1,64}\" required placeholder=\"thread-topic-slug-here\";\n+ textarea name=\"text\" rows=\"4\" placeholder=\"First post body…\" required {}\n+ p { button type=\"submit\" { \"create thread / post\" } }\n }\n }\n }\n }\n \n-fn new_thread_form_public(show: bool) -> Markup {\n- if !show {\n- return html! {};\n- }\n- new_thread_compose_section(\"public\", &PUBLIC_IDS, NewThreadComposeKind::Public)\n-}\n-\n-fn new_thread_form_for_room(nav: &ThreadNav, show: bool, compose_expanded: bool) -> Markup {\n- if !show {\n- return html! {};\n- }\n- // Single root for Idiomorph when morphing `#room-new-thread-ui-slot` (expanded has form + section).\n+/// Inner fragment morphed into `#new-thread-ui-slot` (Idiomorph replaces children; outer `id` stays).\n+fn new_thread_slot_inner(nav: &ThreadNav, compose_expanded: bool) -> Markup {\n html! {\n- div class=\"room-new-thread-slot-inner\" {\n+ div class=\"new-thread-slot-inner\" {\n @if compose_expanded {\n form method=\"POST\" action=\"/ui\" {\n- input type=\"hidden\" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::SetRoomNewThreadComposeExpanded {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::SetNewThreadComposeExpanded {\n room_wire: nav.room_wire.clone(),\n expanded: false,\n }).expect(\"static json\"));\n@@ -126,10 +54,10 @@ fn new_thread_form_for_room(nav: &ThreadNav, show: bool, compose_expanded: bool)\n \"-\"\n }\n }\n- (new_thread_compose_section(&nav.room_wire, &ROOM_IDS, NewThreadComposeKind::Room))\n+ (new_thread_compose_section(&nav.room_wire))\n } @else {\n form method=\"POST\" action=\"/ui\" {\n- input type=\"hidden\" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::SetRoomNewThreadComposeExpanded {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::SetNewThreadComposeExpanded {\n room_wire: nav.room_wire.clone(),\n expanded: true,\n }).expect(\"static json\"));\n@@ -142,16 +70,19 @@ fn new_thread_form_for_room(nav: &ThreadNav, show: bool, compose_expanded: bool)\n }\n }\n \n-pub(crate) fn login_to_post_hint_markup() -> Markup {\n- html! {\n- p class=\"muted\" { \"log in to post\" }\n+pub(crate) fn new_thread_slot_markup(nav: &ThreadNav, show: bool, compose_expanded: bool) -> Markup {\n+ if !show {\n+ return html! {};\n }\n+ new_thread_slot_inner(nav, compose_expanded)\n }\n \n-pub(crate) fn fragment_public_new_thread_form(show: bool) -> Markup {\n- new_thread_form_public(show)\n+pub(crate) fn fragment_new_thread_slot(nav: &ThreadNav, show: bool, compose_expanded: bool) -> Markup {\n+ new_thread_slot_markup(nav, show, compose_expanded)\n }\n \n-pub(crate) fn fragment_room_new_thread_form(nav: &ThreadNav, show: bool, compose_expanded: bool) -> Markup {\n- new_thread_form_for_room(nav, show, compose_expanded)\n+pub(crate) fn login_to_post_hint_markup() -> Markup {\n+ html! {\n+ p class=\"muted\" { \"log in to post\" }\n+ }\n }\ndiff --git a/server/src/html/forum/views.rs b/server/src/html/forum/views.rs\nindex be08491b893f5960723e77c7f7fb9d416100f0fb..f9c425634d1dfa2cbbc2e22c3c745717a6486173 100644\n--- a/server/src/html/forum/views.rs\n+++ b/server/src/html/forum/views.rs\n@@ -19,7 +19,7 @@ use super::access::user_can_view_room;\n use super::feed::{collect_thread_rows_for_scope, render_thread_feed};\n use super::ingest::ingest_entry_markup;\n use super::nav::ThreadNav;\n-use super::new_thread::fragment_room_new_thread_form;\n+use super::new_thread::fragment_new_thread_slot;\n use super::page::{auth_strip, bc_room};\n use super::paginator::{render_thread_paginator, PAGE_SIZE};\n use super::room_members::room_members_section_markup;\n@@ -276,8 +276,8 @@ pub async fn room_page(\n h3 { \"threads\" }\n (render_thread_feed(Some(&nav), \"room-thread-feed\", &rows, now))\n @if show_new {\n- div id=\"room-new-thread-ui-slot\" {\n- (fragment_room_new_thread_form(&nav, true, false))\n+ div id=\"new-thread-ui-slot\" {\n+ (fragment_new_thread_slot(&nav, true, false))\n }\n }\n (cli_panel(&[forum_cli, garden_cli, audit_cli]))\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex 6617781a2e8e86c2e2693788ea7cd0eb0e3659a2..e142240c95c8e92c22ab631df98268253cf0cbdf 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -27,9 +27,9 @@ pub use forum::{\n };\n \n pub(crate) use forum::{\n- fragment_public_new_thread_form, fragment_room_new_thread_form, login_to_post_hint_markup,\n- room_members_section_markup, thread_ui_collapse_redacted_post, thread_ui_expand_post_full,\n- thread_ui_expand_redacted_post, user_can_post_room, user_can_view_room,\n+ fragment_new_thread_slot, login_to_post_hint_markup, room_members_section_markup,\n+ thread_ui_collapse_redacted_post, thread_ui_expand_post_full, thread_ui_expand_redacted_post,\n+ user_can_post_room, user_can_view_room,\n };\n pub use garden::{garden_index, ontology_path, room_garden_index, room_ontology_path};\n pub use search::{search_page, search_results_fragment};\n@@ -183,7 +183,7 @@ impl JsBuilder {\n )\n }\n \n- /// Morph **children** of `selector` so the outer element (e.g. `#room-new-thread-ui-slot`) keeps its `id`.\n+ /// Morph **children** of `selector` so the outer element (e.g. `#new-thread-ui-slot`) keeps its `id`.\n pub(crate) fn morph_inner_selector(self, selector: &str, markup: Markup) -> Self {\n self.qs(selector).morph_inner(markup)\n }\ndiff --git a/server/src/html/ui_action.rs b/server/src/html/ui_action.rs\nindex 0c4d33e58ad2bff9661bf70ec994ac30f7ca3efe..5031ebfeb928f28e471f23210b8c644654adb7c7 100644\n--- a/server/src/html/ui_action.rs\n+++ b/server/src/html/ui_action.rs\n@@ -38,10 +38,9 @@ pub enum HtmlUiAction {\n RedactPost {\n post_id: String,\n },\n- /// Morph `#public-new-thread-ui-slot` to the new-thread form (or login hint).\n- ExpandPublicNewThreadForm,\n- /// Morph `#room-new-thread-ui-slot` for the given room wire id.\n- ExpandRoomNewThreadForm {\n+ /// Morph `#new-thread-ui-slot` inner to the collapsed compose toggle (or login hint).\n+ /// Use `room_wire: \"public\"` for the public forum home; otherwise a private room id (`short/slug`).\n+ ExpandNewThreadForm {\n room_wire: String,\n },\n /// Morph `#room-members-section` — members list open or collapsed (server-rendered).\n@@ -50,8 +49,8 @@ pub enum HtmlUiAction {\n #[serde(default)]\n expanded: bool,\n },\n- /// Morph `#room-new-thread-ui-slot` — compose body open or collapsed (server-rendered).\n- SetRoomNewThreadComposeExpanded {\n+ /// Morph `#new-thread-ui-slot` inner — compose open or collapsed (`room_wire: \"public\"` for home).\n+ SetNewThreadComposeExpanded {\n room_wire: String,\n #[serde(default)]\n expanded: bool,\n@@ -133,15 +132,23 @@ mod tests {\n }\n \n #[test]\n- fn expand_public_unit_variant() {\n- let template = serde_json::json!({ \"action\": \"expand_public_new_thread_form\" });\n+ fn expand_new_thread_form_public() {\n+ let template = serde_json::json!({\n+ \"action\": \"expand_new_thread_form\",\n+ \"room_wire\": \"public\",\n+ });\n let mut form = HashMap::new();\n form.insert(\n UI_RPC_FIELD.to_string(),\n serde_json::to_string(&template).unwrap(),\n );\n let a = parse_html_ui_from_form(&form).unwrap();\n- assert_eq!(a, HtmlUiAction::ExpandPublicNewThreadForm);\n+ assert_eq!(\n+ a,\n+ HtmlUiAction::ExpandNewThreadForm {\n+ room_wire: \"public\".into(),\n+ }\n+ );\n }\n \n #[test]\n@@ -190,9 +197,9 @@ mod tests {\n }\n \n #[test]\n- fn set_room_new_thread_compose_expanded_true() {\n+ fn set_new_thread_compose_expanded_true() {\n let template = serde_json::json!({\n- \"action\": \"set_room_new_thread_compose_expanded\",\n+ \"action\": \"set_new_thread_compose_expanded\",\n \"room_wire\": \"ab/cd\",\n \"expanded\": true,\n });\n@@ -204,7 +211,7 @@ mod tests {\n let a = parse_html_ui_from_form(&form).unwrap();\n assert_eq!(\n a,\n- HtmlUiAction::SetRoomNewThreadComposeExpanded {\n+ HtmlUiAction::SetNewThreadComposeExpanded {\n room_wire: \"ab/cd\".into(),\n expanded: true,\n }\ndiff --git a/test/browser_sse.clj b/test/browser_sse.clj\nindex 3ac71a2986cbb71ed2c513f50cd5ef3f2582c945..8bb064dbc2dc0feb6caaa67f520a461645958277 100644\n--- a/test/browser_sse.clj\n+++ b/test/browser_sse.clj\n@@ -80,19 +80,17 @@\n room-url (str base-url \"/r/\" room-short \"/\" room-slug)\n thread-url (str room-url \"/t/sse-thread\")]\n ;; Object under test: slug_ui.js intercepts POST /ui, evals JS, morphs\n- ;; #room-new-thread-ui-slot (expand compose), then post_ingest redirects to thread.\n+ ;; #new-thread-ui-slot (expand compose), then post_ingest redirects to thread.\n (page/navigate alice-pg room-url)\n (page/wait-for-load-state alice-pg :load)\n- (is (wait-for-text alice-pg \"#room-new-thread-ui-slot\"\n- \"new thread in this room\" 30000)\n+ (is (wait-for-text alice-pg \"#new-thread-ui-slot\" \"+\" 30000)\n \"collapsed new-thread control in slot (page + slug_ui.js)\")\n- (locator/click (page/locator alice-pg \"#room-new-thread-ui-slot button.form-toggle\"))\n- (is (wait-for-text alice-pg \"#room-new-thread-ui-slot\"\n- \"hide new thread form\" 30000)\n+ (locator/click (page/locator alice-pg \"#new-thread-ui-slot button.form-toggle\"))\n+ (is (wait-for-text alice-pg \"#new-thread-compose\" \"create thread / post\" 30000)\n \"compose expanded via POST /ui morph (slug_ui.js)\")\n- (locator/fill (page/locator alice-pg \"#room-new-tag\") \"sse-thread\")\n- (locator/fill (page/locator alice-pg \"#room-new-thread-compose textarea\") \"seed thread\")\n- (locator/click (page/locator alice-pg \"#room-new-thread-form button[type='submit']\"))\n+ (locator/fill (page/locator alice-pg \"#new-thread-tag\") \"sse-thread\")\n+ (locator/fill (page/locator alice-pg \"#new-thread-compose textarea\") \"seed thread\")\n+ (locator/click (page/locator alice-pg \"#new-thread-form button[type='submit']\"))\n (page/wait-for-url alice-pg thread-url {:timeout 90000.0})\n (page/wait-for-load-state alice-pg :load)\n (is (wait-for-text alice-pg \"#thread-feed-region\" \"seed thread\" 45000)\n","role":"user"}],"model":"~anthropic/claude-sonnet-latest"}