{"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[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":"openai/gpt-chat-latest"}