{"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[6b6eb0c3] Auth form: poem JS morphs form innerHTML on response; no redirect\n\n- post_choose_username returns HTML fragments instead of redirects:\n success → auth_signed_in_fragment (\"you're signed in — return to your agent\")\n error → choose_username_error_fragment (form re-rendered with error inline)\n- Poem JS now reads response body; if non-empty, morphs form innerHTML with it\n (existing ingest forms return empty body, so they're unaffected)\n- auth.rs: keep full layout() with poem JS — revert to single layout\n- auth-success CSS class added to both themes\n\nCo-Authored-By: Claude Sonnet 4.6 (1M context) \n\nSide A — unified diff (full patch):\ndiff --git a/server/src/api/auth.rs b/server/src/api/auth.rs\nindex c1194f79fd89fb47fe6b425b494a0ffa667abb2d..cb0faa29b834931e2c2b2f5c174c875e2e2e9346 100644\n--- a/server/src/api/auth.rs\n+++ b/server/src/api/auth.rs\n@@ -16,7 +16,7 @@ use crate::{\n canonicalize_username, validate_agent_format, validate_username,\n Event, TokenIssued, UserRegistered,\n },\n- html::{auth_complete_page, choose_username_page},\n+ html::{auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment, choose_username_page},\n state::{AppState, PendingSession},\n };\n \n@@ -274,8 +274,6 @@ pub async fn post_choose_username(\n return api_error(StatusCode::BAD_REQUEST, \"invalid agent format\", Some(msg)).into_response();\n }\n \n- let public_url = std::env::var(\"SLUG_PUBLIC_URL\").unwrap_or_else(|_| \"http://127.0.0.1:8080\".to_string());\n-\n let reduced_arc = state.reduced.clone();\n let reduced = reduced_arc.read().await;\n let provider_key = (provider.to_lowercase(), provider_id.clone());\n@@ -284,11 +282,7 @@ pub async fn post_choose_username(\n }\n if reduced.users_by_provider.values().any(|u| u == &canonicalize_username(&form.username)) {\n drop(reduced);\n- return Redirect::to(&format!(\n- \"{public_url}/auth/choose-username?session={}&error={}\",\n- urlencoding::encode(&form.session),\n- urlencoding::encode(\"that username is taken — try another\"),\n- )).into_response();\n+ return choose_username_error_fragment(&form.session, \"that username is taken — try another\").into_response();\n }\n drop(reduced);\n \n@@ -324,7 +318,7 @@ pub async fn post_choose_username(\n s.complete = Some((canon_user.clone(), bearer.clone()));\n }\n \n- Redirect::to(&format!(\"{public_url}/auth/complete\")).into_response()\n+ auth_signed_in_fragment().into_response()\n }\n \n pub async fn post_pending_session(\ndiff --git a/server/src/html/auth.rs b/server/src/html/auth.rs\nindex 40b1ef30a6c1d4063aa2d8e9c93df8972df4b27c..0a14bdbfb66c65d1bd09993ffd4a7bb6f2fe041e 100644\n--- a/server/src/html/auth.rs\n+++ b/server/src/html/auth.rs\n@@ -1,20 +1,25 @@\n-use maud::{html, Markup, DOCTYPE};\n+use maud::{html, Markup};\n \n-/// Minimal layout for auth pages — no JS interceptor, real form navigation works.\n-fn auth_layout(title: &str, body: Markup) -> Markup {\n+fn form_inner(session: &str, error: Option<&str>) -> Markup {\n html! {\n- (DOCTYPE)\n- html {\n- head {\n- meta charset=\"utf-8\";\n- meta name=\"viewport\" content=\"width=device-width, initial-scale=1\";\n- title { (title) }\n- link rel=\"stylesheet\" href=\"/static/theme_default.css\";\n- }\n- body class=\"view-auth\" {\n- (body)\n- }\n+ input type=\"hidden\" name=\"session\" value=(session);\n+ label for=\"username\" { \"username\" }\n+ input\n+ type=\"text\"\n+ id=\"username\"\n+ name=\"username\"\n+ placeholder=\"e.g. alice\"\n+ pattern=\"[a-z0-9_\\\\-]{1,32}\"\n+ maxlength=\"32\"\n+ autocomplete=\"off\"\n+ autofocus;\n+ p.auth-hint {\n+ \"lowercase · alphanumeric · hyphens · underscores · max 32\"\n }\n+ @if let Some(msg) = error {\n+ p.auth-error { (msg) }\n+ }\n+ button type=\"submit\" { \"continue\" }\n }\n }\n \n@@ -28,27 +33,23 @@ pub fn choose_username_page(session: &str, error: Option<&str>) -> Markup {\n h1 { \"choose a username\" }\n p { \"pick a handle for slug.social.\" }\n form.auth-form method=\"POST\" action=\"/auth/choose-username\" {\n- input type=\"hidden\" name=\"session\" value=(session);\n- label for=\"username\" { \"username\" }\n- input\n- type=\"text\"\n- id=\"username\"\n- name=\"username\"\n- placeholder=\"e.g. alice\"\n- pattern=\"[a-z0-9_\\\\-]{1,32}\"\n- maxlength=\"32\"\n- autocomplete=\"off\"\n- autofocus;\n- p.auth-hint {\n- \"lowercase · alphanumeric · hyphens · underscores · max 32\"\n- }\n- @if let Some(msg) = error {\n- p.auth-error { (msg) }\n- }\n- button type=\"submit\" { \"continue\" }\n+ (form_inner(session, error))\n }\n };\n- auth_layout(\"join — slug.social\", body)\n+ super::layout(\"join — slug.social\", \"view-auth\", body, None)\n+}\n+\n+/// Fragment returned to the poem JS on error — replaces the form's innerHTML.\n+pub fn choose_username_error_fragment(session: &str, error: &str) -> Markup {\n+ form_inner(session, Some(error))\n+}\n+\n+/// Fragment returned to the poem JS on success — replaces the form's innerHTML.\n+pub fn auth_signed_in_fragment() -> Markup {\n+ html! {\n+ p.auth-success { \"you're signed in — return to your agent.\" }\n+ p.auth-hint { \"you can close this tab.\" }\n+ }\n }\n \n pub fn auth_complete_page() -> Markup {\n@@ -62,5 +63,5 @@ pub fn auth_complete_page() -> Markup {\n p { \"Return to your terminal — your agent is polling and will collect your token automatically.\" }\n p.auth-hint { \"You can close this tab.\" }\n };\n- auth_layout(\"signed in — slug.social\", body)\n+ super::layout(\"signed in — slug.social\", \"view-auth\", body, None)\n }\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex 2f16d701962d703db0c859bb586dfc08ec385690..8b48a25cce79f0eefc7e29849e1a667cae4c7986 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -15,7 +15,7 @@ mod search;\n mod tree;\n use breadcrumb_path::OntologyPath;\n \n-pub use auth::{auth_complete_page, choose_username_page};\n+pub use auth::{auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment, choose_username_page};\n pub use editor::{editor_check, editor_page};\n pub use forum::{index, thread_feed_html, thread_post_expand, thread_post_view, thread_view};\n pub use garden::{garden_index, ontology_path};\n@@ -114,6 +114,8 @@ script { (maud::PreEscaped(r#\"\n });\n \n // Poem: intercept POST forms, send via fetch, await SSE for DOM update.\n+ // If the response body is non-empty HTML, morph the form's innerHTML with it\n+ // (used for inline feedback without a page reload, e.g. auth forms).\n document.addEventListener('submit', async (e) => {\n const f = e.target;\n if (!f || f.tagName !== 'FORM') return;\n@@ -121,14 +123,19 @@ script { (maud::PreEscaped(r#\"\n e.preventDefault();\n const btn = f.querySelector('button[type=\"submit\"], input[type=\"submit\"]');\n if (btn) { btn.disabled = true; btn.textContent = '…'; }\n- await fetch(f.action, {\n+ const resp = await fetch(f.action, {\n method: 'POST',\n body: new URLSearchParams(new FormData(f)),\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n credentials: 'same-origin',\n });\n- if (btn) { btn.disabled = false; btn.textContent = 'submit'; }\n- f.reset();\n+ const html = await resp.text();\n+ if (html && html.trim()) {\n+ Idiomorph.morph(f, html, {morphStyle: 'innerHTML'});\n+ } else {\n+ if (btn) { btn.disabled = false; btn.textContent = 'submit'; }\n+ f.reset();\n+ }\n });\n \n // Search: debounced fetch + idiomorph.\ndiff --git a/server/static/theme_default.css b/server/static/theme_default.css\nindex 9e71574da4bed3a0116c347610636780678bd1af..a1d8d1765a7812191edc579125facf1694554c2c 100644\n--- a/server/static/theme_default.css\n+++ b/server/static/theme_default.css\n@@ -250,6 +250,11 @@ p.auth-error {\n font-size: 12px;\n margin: 4px 0 0;\n }\n+p.auth-success {\n+ color: var(--signal);\n+ font-size: 13px;\n+ margin: 4px 0 0;\n+}\n \n /* ----------------------------------------------------------------\n BUTTONS — raised, press on :active\ndiff --git a/server/static/theme_retro.css b/server/static/theme_retro.css\nindex dc9fa4654f529eb1843557fb580cf3982da46901..8ed8fd88efab32b36bd66cf200aa182219b0a8b5 100644\n--- a/server/static/theme_retro.css\n+++ b/server/static/theme_retro.css\n@@ -32,6 +32,7 @@ input[type=\"text\"] {\n input[type=\"text\"]:focus { border-color: #00ff41; }\n p.auth-hint { color: #555; font-family: monospace; font-size: 0.75rem; margin: 0; }\n p.auth-error { color: #ff4444; font-family: monospace; font-size: 0.8rem; margin: 0; }\n+p.auth-success { color: #00ff41; font-family: monospace; font-size: 0.8rem; margin: 0; }\n \n /* Ingest form (poem pattern) */\n .ingest-form-wrap { margin-top: 1.5rem; }\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[7964b28f] fix\n\nSide B — unified diff (full patch):\ndiff --git a/Cargo.lock b/Cargo.lock\nindex 8c43fb75c472b102e6e1d3b837dce3355be898f2..e55d87f32ab32064686431c7082ef8c9ca872d63 100644\n--- a/Cargo.lock\n+++ b/Cargo.lock\n@@ -923,6 +923,15 @@ version = \"0.2.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391\"\n \n+[[package]]\n+name = \"ppv-lite86\"\n+version = \"0.2.21\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9\"\n+dependencies = [\n+ \"zerocopy\",\n+]\n+\n [[package]]\n name = \"prettyplease\"\n version = \"0.2.37\"\n@@ -980,6 +989,36 @@ version = \"6.0.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf\"\n \n+[[package]]\n+name = \"rand\"\n+version = \"0.8.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a\"\n+dependencies = [\n+ \"libc\",\n+ \"rand_chacha\",\n+ \"rand_core\",\n+]\n+\n+[[package]]\n+name = \"rand_chacha\"\n+version = \"0.3.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88\"\n+dependencies = [\n+ \"ppv-lite86\",\n+ \"rand_core\",\n+]\n+\n+[[package]]\n+name = \"rand_core\"\n+version = \"0.6.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c\"\n+dependencies = [\n+ \"getrandom 0.2.17\",\n+]\n+\n [[package]]\n name = \"regex-automata\"\n version = \"0.4.14\"\n@@ -1270,6 +1309,7 @@ dependencies = [\n \"dotenvy\",\n \"futures-util\",\n \"maud\",\n+ \"rand\",\n \"reqwest\",\n \"serde\",\n \"serde_json\",\n@@ -1280,6 +1320,7 @@ dependencies = [\n \"tower-http 0.5.2\",\n \"tracing\",\n \"tracing-subscriber\",\n+ \"urlencoding\",\n ]\n \n [[package]]\n@@ -1656,6 +1697,12 @@ dependencies = [\n \"serde\",\n ]\n \n+[[package]]\n+name = \"urlencoding\"\n+version = \"2.1.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da\"\n+\n [[package]]\n name = \"utf8_iter\"\n version = \"1.0.4\"\n@@ -2052,6 +2099,26 @@ dependencies = [\n \"synstructure\",\n ]\n \n+[[package]]\n+name = \"zerocopy\"\n+version = \"0.8.50\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1\"\n+dependencies = [\n+ \"zerocopy-derive\",\n+]\n+\n+[[package]]\n+name = \"zerocopy-derive\"\n+version = \"0.8.50\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+]\n+\n [[package]]\n name = \"zerofrom\"\n version = \"0.1.8\"\ndiff --git a/server/Cargo.toml b/server/Cargo.toml\nindex c940acb687fb141d21760a3d6656172013cf6f41..6fb7bf52fa58f46f5e8fb0f7fd395247b57506d7 100644\n--- a/server/Cargo.toml\n+++ b/server/Cargo.toml\n@@ -20,6 +20,8 @@ reqwest = { version = \"0.12\", features = [\"json\"] }\n dotenvy = \"0.15\"\n async-stream = \"0.3\"\n futures-util = { version = \"0.3\", default-features = false, features = [\"std\"] }\n+rand = \"0.8\"\n+urlencoding = \"2\"\n \n [dev-dependencies]\n reqwest = { version = \"0.12\", features = [\"json\"] }\ndiff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs\nindex e649a7d192feade465e19ce6187a829f6ec74372..06001212820101e0cc953d3687dea64f85e60787 100644\n--- a/server/src/api/ui_html.rs\n+++ b/server/src/api/ui_html.rs\n@@ -47,6 +47,7 @@ pub async fn post_ui_html(\n ratio_left,\n ratio_right,\n scope,\n+ next,\n } => {\n let parent = parent_from_scope(&scope);\n if let Err(e) = state\n@@ -56,9 +57,19 @@ pub async fn post_ui_html(\n return ui_js_warn(&e).into_response();\n }\n let tree = state.tree.read().await;\n+ if !next.trim().is_empty() {\n+ drop(tree);\n+ return JsBuilder::new()\n+ .raw(&format!(\n+ \"window.location.href={};\",\n+ js_string_literal(next.trim())\n+ ))\n+ .into_response();\n+ }\n let empty = crate::reducer::NodeState::default();\n let node = tree.get(&parent).unwrap_or(&empty);\n let panel = ranking_panel(&parent, node, &tree);\n+ drop(tree);\n JsBuilder::new()\n .morph_selector(\"#ranking-panel\", panel)\n .into_response()\n@@ -126,6 +137,7 @@ mod tests {\n ratio_left: 3,\n ratio_right: 1,\n scope: String::new(),\n+ next: String::new(),\n }\n );\n }\ndiff --git a/server/src/form_template.rs b/server/src/form_template.rs\nindex b9bc3982a125e94e67c99175ea9055979541abba..bd4a7195a6eaabfed55333bab5640708ea108b81 100644\n--- a/server/src/form_template.rs\n+++ b/server/src/form_template.rs\n@@ -7,19 +7,30 @@ pub fn template_json_compact(v: &T) -> serde_json::Result\n serde_json::to_string(v)\n }\n \n-/// Recursively walk the JSON AST and replace `{\"$form\": \"key\"}` with the submitted\n-/// string for `key` (empty if missing). Other keys are unchanged.\n+/// Recursively walk the JSON AST and replace form holes with submitted values.\n+///\n+/// - `{\"$form\": \"key\"}` → string (empty if missing)\n+/// - `{\"$form:i32\": \"key\"}` → JSON number (0 if missing or unparseable)\n pub fn substitute_form_vars(val: &mut Value, form_data: &HashMap) {\n match val {\n Value::Object(map) => {\n if map.len() == 1 {\n- if let Some(Value::String(field_name)) = map.get(\"$form\") {\n- let submitted = form_data\n- .get(field_name.as_str())\n- .map(|s| s.as_str())\n- .unwrap_or(\"\");\n- *val = Value::String(submitted.to_string());\n- return;\n+ if let Some((hole_key, Value::String(field_name))) = map.iter().next() {\n+ if let Some(form_type) = hole_key.strip_prefix(\"$form\") {\n+ let submitted = form_data\n+ .get(field_name.as_str())\n+ .map(|s| s.as_str())\n+ .unwrap_or(\"\");\n+ *val = match form_type {\n+ \"\" => Value::String(submitted.to_string()),\n+ \":i32\" => {\n+ let n: i32 = submitted.trim().parse().unwrap_or(0);\n+ Value::Number(n.into())\n+ }\n+ _ => Value::String(submitted.to_string()),\n+ };\n+ return;\n+ }\n }\n }\n for v in map.values_mut() {\n@@ -62,6 +73,45 @@ mod tests {\n text: String,\n }\n \n+ #[test]\n+ fn i32_holes_become_numbers() {\n+ let json = r#\"{\n+ \"ratio_left\": {\"$form:i32\": \"ratio_left\"},\n+ \"ratio_right\": {\"$form:i32\": \"ratio_right\"}\n+ }\"#;\n+ let mut form = HashMap::new();\n+ form.insert(\"ratio_left\".into(), \"75\".into());\n+ form.insert(\"ratio_right\".into(), \"25\".into());\n+ let v = fill_template_from_form(json, &form).unwrap();\n+ assert_eq!(v[\"ratio_left\"], 75);\n+ assert_eq!(v[\"ratio_right\"], 25);\n+\n+ #[derive(Debug, Deserialize, PartialEq, Eq)]\n+ struct Ratios {\n+ ratio_left: i32,\n+ ratio_right: i32,\n+ }\n+ let r: Ratios = serde_json::from_value(v).unwrap();\n+ assert_eq!(\n+ r,\n+ Ratios {\n+ ratio_left: 75,\n+ ratio_right: 25,\n+ }\n+ );\n+ }\n+\n+ #[test]\n+ fn i32_hole_missing_or_bad_defaults_to_zero() {\n+ let json = r#\"{\"n\": {\"$form:i32\": \"missing\"}}\"#;\n+ let v = fill_template_from_form(json, &HashMap::new()).unwrap();\n+ assert_eq!(v[\"n\"], 0);\n+ let mut form = HashMap::new();\n+ form.insert(\"missing\".into(), \"nope\".into());\n+ let v = fill_template_from_form(json, &form).unwrap();\n+ assert_eq!(v[\"n\"], 0);\n+ }\n+\n #[test]\n fn holes_become_strings() {\n let json = r#\"{\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex 3bf9fc7e92e50beed24e2c25106a77421038c90b..61cdbc094819ddedb755572c59456ec0d6617619 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -20,6 +20,8 @@ use crate::{\n ui_action::UI_RPC_FIELD,\n };\n \n+pub mod vote;\n+\n const SORTER_CSS: &str = include_str!(\"../../static/sorter.css\");\n const SORTER_UI_JS: &str = include_str!(\"../../static/sorter_ui.js\");\n \n@@ -131,7 +133,7 @@ fn layout(title: &str, body: Markup, views: u64) -> Markup {\n }\n }\n \n-fn item_href(id: &ItemId) -> String {\n+pub(crate) fn item_href(id: &ItemId) -> String {\n id.browse_href()\n }\n \n@@ -309,11 +311,23 @@ async fn item_page(state: AppState, uri: Uri, item: ItemId) -> Markup {\n let empty_node = NodeState::default();\n let node = tree.get(&item).unwrap_or(&empty_node);\n \n+ let child_count = node.children.len();\n+ let vote_link = if child_count >= 2 {\n+ Some(vote::vote_href(&item))\n+ } else {\n+ None\n+ };\n+\n let body = html! {\n h1 { \"sorter\" }\n (input_panel(\"\", None))\n (breadcrumb_path(&item))\n (entity_section(&item, node, false))\n+ @if let Some(href) = vote_link {\n+ p class=\"vote-cta\" {\n+ a class=\"btn-primary\" href=(href) data-testid=\"vote-children\" { \"Vote on children\" }\n+ }\n+ }\n (ranking_panel(&item, node, &tree))\n };\n layout(\"sorter2\", body, views)\ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nindex da5f3ebecec1794b05a2a69cc78379551b2ad769..7f7e28c8ac3758de87f1f8e073b24be4132d38da 100644\n--- a/server/src/lib.rs\n+++ b/server/src/lib.rs\n@@ -4,6 +4,7 @@ pub mod events;\n pub mod fetch;\n pub mod form_template;\n pub mod html;\n+pub mod pair;\n pub mod parser;\n pub mod path_types;\n pub mod ranking;\n@@ -33,6 +34,7 @@ pub fn create_app(state: AppState) -> Router {\n .route(\"/static/:filename\", get(crate::html::serve_static))\n .route(\"/~/*item_path\", get(crate::html::browse))\n .route(\"/\", get(crate::html::home))\n+ .route(\"/vote\", get(crate::html::vote::vote_page))\n .route(\"/ui\", post(crate::api::ui_html::post_ui_html))\n .with_state(state)\n .layer(TraceLayer::new_for_http())\ndiff --git a/server/src/ui_action.rs b/server/src/ui_action.rs\nindex 2047713762c932ac9bc325fe15624f2aecb3364d..53581e1362bfcc5dfb4ae3069c41ea6f7be41437 100644\n--- a/server/src/ui_action.rs\n+++ b/server/src/ui_action.rs\n@@ -31,6 +31,9 @@ pub enum HtmlUiAction {\n /// Parent node [`ItemId`] string; empty = tree root.\n #[serde(default)]\n scope: String,\n+ /// After vote, navigate here (vote compare page).\n+ #[serde(default)]\n+ next: String,\n },\n /// Parse pasted Reddit URL/path; redirect to subreddit ranking on success.\n ParseQuery {\n@@ -70,6 +73,36 @@ pub fn parse_html_ui_from_form(\n mod tests {\n use super::*;\n \n+ #[test]\n+ fn record_vote_round_trip_with_typed_ratio_holes() {\n+ let template = serde_json::json!({\n+ \"action\": \"record_vote\",\n+ \"a\": \"x\",\n+ \"b\": \"y\",\n+ \"ratio_left\": {\"$form:i32\": \"ratio_left\"},\n+ \"ratio_right\": {\"$form:i32\": \"ratio_right\"},\n+ \"scope\": \"parent\",\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+ form.insert(\"ratio_left\".into(), \"60\".into());\n+ form.insert(\"ratio_right\".into(), \"40\".into());\n+ assert_eq!(\n+ parse_html_ui_from_form(&form).unwrap(),\n+ HtmlUiAction::RecordVote {\n+ a: \"x\".into(),\n+ b: \"y\".into(),\n+ ratio_left: 60,\n+ ratio_right: 40,\n+ scope: \"parent\".into(),\n+ next: String::new(),\n+ }\n+ );\n+ }\n+\n #[test]\n fn record_vote_round_trip_with_form_holes() {\n let template = serde_json::json!({\n@@ -96,6 +129,7 @@ mod tests {\n ratio_left: 2,\n ratio_right: 1,\n scope: \"amitheasshole\".into(),\n+ next: String::new(),\n }\n );\n }\n@@ -122,6 +156,7 @@ mod tests {\n ratio_left: 2,\n ratio_right: 1,\n scope: String::new(),\n+ next: String::new(),\n }\n );\n }\ndiff --git a/server/static/sorter.css b/server/static/sorter.css\nindex 021918f8e778b84e4b3eac8559fab7d223a3d490..3c2cec5cb5094013387147c621d75bd341bc06cc 100644\n--- a/server/static/sorter.css\n+++ b/server/static/sorter.css\n@@ -224,4 +224,148 @@ code {\n \n h1 {\n margin: 0;\n+}\n+\n+.vote-cta {\n+ margin: 0.75rem 0;\n+}\n+\n+.vote-compare-shell {\n+ max-width: 960px;\n+}\n+\n+.vote-compare-scope {\n+ margin: 0.5rem 0 1rem;\n+}\n+\n+.vote-compare-pair {\n+ display: grid;\n+ grid-template-columns: 1fr auto 1fr;\n+ gap: 1rem;\n+ align-items: start;\n+ margin: 1rem 0;\n+}\n+\n+.vote-compare-vs {\n+ align-self: center;\n+ font-size: 1.25rem;\n+ color: var(--muted);\n+ font-weight: 600;\n+}\n+\n+.vote-compare-side {\n+ background: var(--panel);\n+ border: 1px solid var(--border);\n+ border-radius: 8px;\n+ padding: 1rem;\n+ min-height: 120px;\n+}\n+\n+.vote-compare-item {\n+ color: var(--accent);\n+ text-decoration: none;\n+ display: block;\n+}\n+\n+.vote-compare-item:hover {\n+ text-decoration: underline;\n+}\n+\n+.vote-compare-figure {\n+ margin: 0.75rem 0 0;\n+}\n+\n+.vote-compare-image {\n+ display: block;\n+ max-width: 100%;\n+ height: auto;\n+ border-radius: 6px;\n+ border: 1px solid var(--border);\n+}\n+\n+.vote-compare-item-body {\n+ margin-top: 0.75rem;\n+ font-size: 0.9rem;\n+}\n+\n+.vote-compare-nav {\n+ display: flex;\n+ justify-content: space-between;\n+ align-items: center;\n+ gap: 1rem;\n+ margin: 1rem 0;\n+}\n+\n+.vote-compare-next {\n+ color: var(--accent);\n+ text-decoration: none;\n+}\n+\n+.vote-compare-next.is-disabled {\n+ color: var(--muted);\n+}\n+\n+.vote-compare-back {\n+ text-decoration: none;\n+}\n+\n+.vote-compare-back:hover {\n+ text-decoration: underline;\n+}\n+\n+.vote-compare-slider-label {\n+ display: grid;\n+ grid-template-columns: 1fr auto 1fr;\n+ gap: 0.75rem;\n+ align-items: center;\n+ margin: 1.25rem 0;\n+}\n+\n+.vote-compare-slider-label input[type=\"range\"] {\n+ width: 100%;\n+ min-width: 160px;\n+}\n+\n+#vote-slider-left-label {\n+ text-align: right;\n+}\n+\n+#vote-slider-right-label {\n+ text-align: left;\n+}\n+\n+.vote-edge-history-title {\n+ margin: 1.5rem 0 0.5rem;\n+ font-size: 1rem;\n+}\n+\n+.vote-edge-history {\n+ list-style: none;\n+ padding: 0;\n+ margin: 0;\n+}\n+\n+.vote-edge-history-row {\n+ margin-bottom: 0.75rem;\n+}\n+\n+.vote-edge-meta {\n+ font-size: 0.875rem;\n+ margin-bottom: 0.25rem;\n+}\n+\n+.ratio-bar {\n+ display: flex;\n+ height: 8px;\n+ border-radius: 4px;\n+ overflow: hidden;\n+ background: var(--border);\n+}\n+\n+.ratio-left {\n+ background: var(--accent);\n+}\n+\n+.ratio-right {\n+ background: var(--muted);\n }\n\\ No newline at end of file\ndiff --git a/server/static/sorter_ui.js b/server/static/sorter_ui.js\nindex 62c9158ecb3c2140b83efd2b27ea706a9e0ae127..c8d7c3ef2413399ced7898a7edb3e7a2c8e16163 100644\n--- a/server/static/sorter_ui.js\n+++ b/server/static/sorter_ui.js\n@@ -81,7 +81,22 @@\n });\n }\n \n+ function initVoteSlider() {\n+ var slider = document.getElementById('vote-preference-slider');\n+ if (!slider) return;\n+ var leftInput = document.getElementById('vote-ratio-left');\n+ var rightInput = document.getElementById('vote-ratio-right');\n+ function update() {\n+ var v = parseInt(slider.value, 10);\n+ if (leftInput) leftInput.value = String(v);\n+ if (rightInput) rightInput.value = String(100 - v);\n+ }\n+ slider.addEventListener('input', update);\n+ update();\n+ }\n+\n function initSorterUi() {\n+ initVoteSlider();\n document.addEventListener('submit', async function (e) {\n var f = e.target;\n if (!f || f.tagName !== 'FORM') return;\n@@ -89,10 +104,11 @@\n if (f.getAttribute('data-navigate') === 'full') return;\n e.preventDefault();\n await postUiForm(f);\n- if (f.id === 'vote-form') {\n+ if (f.id === 'vote-form' || f.id === 'vote-compare-form') {\n f.reset();\n- var firstField = f.querySelector('input[type=\"text\"]');\n- if (firstField) firstField.focus();\n+ var slider = f.querySelector('#vote-preference-slider');\n+ if (slider) slider.value = '50';\n+ initVoteSlider();\n }\n });\n }\n","role":"user"}],"model":"~anthropic/claude-sonnet-latest"}