{"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[1531154d] dequeue -> vec\n\nSide A — unified diff (full patch):\ndiff --git a/server/src/projection_apply.rs b/server/src/projection_apply.rs\nindex 9c8990a8af927f35d3344c8d0872a516aba56b86..ad404bacb8bcdd5ae0e682cff97f974fd44528ea 100644\n--- a/server/src/projection_apply.rs\n+++ b/server/src/projection_apply.rs\n@@ -6,8 +6,6 @@\n //! batch as the (non-idempotent) edge merges guarantees exactly-once application\n //! across replay.\n \n-use std::collections::BTreeSet;\n-\n use crate::{\n event_log::EventLogError,\n events::{Event, EventRecord},\n@@ -44,7 +42,6 @@ pub fn apply_records(\n \n let db = projection_store.db();\n let mut batch = db.batch();\n- let mut vote_parents: BTreeSet = BTreeSet::new();\n let mut last_seq = 0u64;\n \n for record in records {\n@@ -70,7 +67,6 @@ pub fn apply_records(\n *ts,\n )\n .map_err(|e| EventLogError::Apply(e.to_string()))?;\n- vote_parents.insert(parent);\n }\n Event::NodeEnsured { id } => {\n let parsed = parse_event_id(id)?;\n@@ -85,11 +81,5 @@ pub fn apply_records(\n .commit_with(durable::Durability::DisableWal)\n .map_err(|e| EventLogError::Apply(e.to_string()))?;\n \n- for parent in vote_parents {\n- projection_store\n- .trim_recent_votes(&parent)\n- .map_err(|e| EventLogError::Apply(e.to_string()))?;\n- }\n-\n Ok(())\n }\ndiff --git a/server/src/projection_store.rs b/server/src/projection_store.rs\nindex 8576d671f351004426207894ac35594ddb0f70cf..9a8953d010029d3639dc3987687554bab8b7663e 100644\n--- a/server/src/projection_store.rs\n+++ b/server/src/projection_store.rs\n@@ -18,7 +18,7 @@ use crate::{\n \n const PROJECTION_CURSOR_KEY: &str = \"cursor\";\n const PROJECTION_SCHEMA_KEY: &str = \"schema_version\";\n-const PROJECTION_SCHEMA_VERSION: u64 = 3;\n+const PROJECTION_SCHEMA_VERSION: u64 = 4;\n \n #[derive(Debug, thiserror::Error)]\n pub enum ProjectionStoreError {\n@@ -142,16 +142,6 @@ impl ProjectionStore {\n Ok(tree)\n }\n \n- /// Cap a node's recent-vote window after applying votes (best-effort, blind).\n- pub(crate) fn trim_recent_votes(&self, parent: &ItemId) -> Result<(), ProjectionStoreError> {\n- node(parent).recent_votes().truncate_back(\n- &self.db,\n- crate::storage_schema::RECENT_VOTES_CAP,\n- Durability::DisableWal,\n- )?;\n- Ok(())\n- }\n-\n /// Cache Reddit display content outside the event log (must be evicted per policy).\n pub fn put_ephemeral_content(\n &self,\ndiff --git a/server/src/reducer.rs b/server/src/reducer.rs\nindex 0c75c85150bb9e5f578bbadf58b3e43f8a80be4b..759918b8c0eb8f8bf1ed0911d8877adaa55c8ea6 100644\n--- a/server/src/reducer.rs\n+++ b/server/src/reducer.rs\n@@ -1,4 +1,4 @@\n-use std::collections::{HashMap, HashSet, VecDeque};\n+use std::collections::{HashMap, HashSet};\n \n use serde::{Deserialize, Serialize};\n \n@@ -52,7 +52,7 @@ pub struct GroupState {\n pub idx_to_item: Vec,\n pub edges: HashMap<(usize, usize), f64>,\n pub voted_pairs: HashSet<(usize, usize)>,\n- pub recent_votes: VecDeque,\n+ pub recent_votes: Vec,\n }\n \n impl GroupState {\n@@ -62,7 +62,7 @@ impl GroupState {\n idx_to_item: Vec::new(),\n edges: HashMap::new(),\n voted_pairs: HashSet::new(),\n- recent_votes: VecDeque::with_capacity(200),\n+ recent_votes: Vec::new(),\n }\n }\n \n@@ -111,10 +111,7 @@ impl GroupState {\n self.add_edge_weight(b_idx, a_idx, w_a);\n self.add_edge_weight(a_idx, b_idx, w_b);\n \n- self.recent_votes.push_front(vote);\n- while self.recent_votes.len() > 200 {\n- self.recent_votes.pop_back();\n- }\n+ self.recent_votes.push(vote);\n }\n }\n \ndiff --git a/server/src/storage_dto.rs b/server/src/storage_dto.rs\nindex 9dfb13c53efe4389277625a6ab3bfc18f566a453..3fd6db5cb909ac4896bd8a3ecace796de5f08781 100644\n--- a/server/src/storage_dto.rs\n+++ b/server/src/storage_dto.rs\n@@ -39,7 +39,7 @@ pub struct StoredEntityDataV1 {\n pub link_url: Option,\n }\n \n-/// One vote stored in a node's `recent_votes` deque.\n+/// One vote stored in a node's `recent_votes` list.\n #[derive(Debug, Clone, Serialize, Deserialize)]\n pub struct StoredVoteV1 {\n pub version: u32,\ndiff --git a/server/src/storage_schema.rs b/server/src/storage_schema.rs\nindex bd26e665e084b95b10fdfff091c31e8dc84d07b8..5d2bb1d56927fb61c7c6d2d8602bd6882327f862 100644\n--- a/server/src/storage_schema.rs\n+++ b/server/src/storage_schema.rs\n@@ -2,13 +2,13 @@\n //! durable collections instead of one blob per node.\n //!\n //! A vote updates a handful of keys: a few edge-weight merges, a voted-pair flag,\n-//! a recent-vote deque push, and child-link set entries. The in-memory\n+//! a recent-vote list append, and child-link set entries. The in-memory\n //! [`crate::reducer::GroupState`] is reconstructed from these keys on read for\n //! rank-centrality.\n \n use std::collections::{BTreeSet, HashMap, HashSet};\n \n-use durable::{Batch, Db, Deque, Durable, Leaf, Map, Sum};\n+use durable::{Batch, Db, Durable, Leaf, List, Map, Sum};\n \n use crate::{\n path_types::ItemId,\n@@ -38,8 +38,8 @@ pub struct NodeSchema {\n pub edges: Map>,\n /// Voted pairs `(min, max) -> true`.\n pub voted_pairs: Map>,\n- /// Recent votes, newest at the front (capped on write).\n- pub recent_votes: Deque>,\n+ /// Recent votes, append-only oldest-first (cap applied on read).\n+ pub recent_votes: List>,\n /// When ephemeral Reddit display content was last fetched (ms); absent after eviction.\n pub fetched_at: Leaf,\n }\n@@ -55,7 +55,7 @@ pub struct Store {\n pub view_meta: Map>,\n }\n \n-/// Cap on the per-node recent-vote window (matches the in-memory reducer).\n+/// Max recent votes returned when loading a node (query-time cap only).\n pub const RECENT_VOTES_CAP: u64 = 200;\n \n fn id_key(id: &ItemId) -> String {\n@@ -148,11 +148,14 @@ fn build_group_state(\n }\n }\n \n- // Deque is front=newest; in-memory VecDeque is also front=newest.\n- let mut recent_votes = std::collections::VecDeque::new();\n- for stored in np.recent_votes().iter(db)? {\n- recent_votes.push_back(decode_vote(stored).map_err(durable::Error::Deserialize)?);\n- }\n+ // List is index order (oldest first); keep the newest RECENT_VOTES_CAP entries.\n+ let stored = np.recent_votes().iter(db)?;\n+ let cap = RECENT_VOTES_CAP as usize;\n+ let start = stored.len().saturating_sub(cap);\n+ let recent_votes = stored[start..]\n+ .iter()\n+ .map(|s| decode_vote(s.clone()).map_err(durable::Error::Deserialize))\n+ .collect::, _>>()?;\n \n Ok(GroupState {\n item_to_idx,\n@@ -248,7 +251,7 @@ pub fn vote_writes(\n };\n batch.write(pnode.voted_pairs().key(&(lo, hi)).set(&true));\n \n- // Recent votes (newest at front).\n+ // Recent votes (append-only; cap on read).\n let stored = encode_vote(&VoteData {\n ts,\n a: a_id,\n@@ -260,7 +263,7 @@ pub fn vote_writes(\n delegate: None,\n thread_tag: \"default\".to_string(),\n });\n- batch.push_front(&pnode.recent_votes(), &stored)?;\n+ batch.push(&pnode.recent_votes(), &stored)?;\n Ok(())\n }\n \n@@ -314,6 +317,35 @@ mod tests {\n assert!(load_node_state(&db, &parent).unwrap().is_none());\n }\n \n+ #[test]\n+ fn load_caps_recent_votes_at_query_time() {\n+ let dir = tempfile::tempdir().unwrap();\n+ let db = Db::open(dir.path()).unwrap();\n+ let parent = ItemId::root();\n+\n+ let mut batch = db.batch();\n+ for i in 0..RECENT_VOTES_CAP + 10 {\n+ vote_writes(&mut batch, &parent, \"alpha\", \"beta\", 1, 0, i as i64).unwrap();\n+ }\n+ batch.commit().unwrap();\n+\n+ assert_eq!(\n+ node(&parent).recent_votes().len(&db).unwrap(),\n+ RECENT_VOTES_CAP + 10\n+ );\n+\n+ let node_state = load_node_state(&db, &parent).unwrap().unwrap();\n+ assert_eq!(node_state.local_ranking.recent_votes.len(), RECENT_VOTES_CAP as usize);\n+ assert_eq!(\n+ node_state.local_ranking.recent_votes.first().map(|v| v.ts),\n+ Some(10)\n+ );\n+ assert_eq!(\n+ node_state.local_ranking.recent_votes.last().map(|v| v.ts),\n+ Some(RECENT_VOTES_CAP as i64 + 9)\n+ );\n+ }\n+\n #[test]\n fn missing_node_is_none() {\n let dir = tempfile::tempdir().unwrap();\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[04b81f91] Fix vote compare highlighting\n\nCo-authored-by: Cursor \n\nSide B — unified diff (full patch):\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex 588cf62bcea608ed4ede363810f022a67b43b960..e180a0ca542a33e2300c0a4809e6b9cfee07ecfe 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -291,18 +291,17 @@ fn rank_list(\n label: &str,\n items: &[RankedItem],\n start_rank: usize,\n- total_ranked: usize,\n- ordinal_offset: usize,\n highlighted: &HashSet,\n tree: &GlobalTree,\n ) -> Markup {\n+ let group_len = items.len();\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 @let href = item_href(&r.item);\n- @let style = rank_row_style(parent, ordinal_offset + i, total_ranked);\n+ @let style = rank_row_style(parent, i, group_len);\n @let class = rank_row_class(&r.item, highlighted);\n li class=(class)\n data-rank-item=(r.item.as_str())\n@@ -420,8 +419,6 @@ pub fn ranking_panel_with_highlights(\n \n let has_ranked = !ranked_groups.is_empty();\n let multi = ranked_groups.len() > 1;\n- let total_ranked: usize = ranked_groups.iter().map(|g| g.len()).sum();\n- let mut ordinal_offset = 0usize;\n \n html! {\n section id=\"ranking-panel\" class=\"demo-panel\" {\n@@ -436,8 +433,7 @@ pub fn ranking_panel_with_highlights(\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(item, &label, ranked, 1, total_ranked, ordinal_offset, highlighted, tree))\n- @let _ = { ordinal_offset += ranked.len(); };\n+ (rank_list(item, &label, ranked, 1, highlighted, tree))\n }\n (unranked_list(\"Unranked\", &unranked, highlighted, tree))\n }\n@@ -520,7 +516,20 @@ pub async fn browse(State(state): State, uri: Uri) -> impl IntoRespons\n \n #[cfg(test)]\n mod tests {\n- use super::SORTER_UI_JS;\n+ use super::{rank_row_style, SORTER_UI_JS};\n+ use crate::path_types::ItemId;\n+\n+ #[test]\n+ fn rank_row_style_gradients_per_group_not_globally() {\n+ let parent = ItemId::opaque(\"test-scope\");\n+ let first_in_four = rank_row_style(&parent, 0, 4);\n+ let last_in_four = rank_row_style(&parent, 3, 4);\n+ let first_in_two = rank_row_style(&parent, 0, 2);\n+ let last_in_two = rank_row_style(&parent, 1, 2);\n+ assert_eq!(first_in_four, first_in_two);\n+ assert_eq!(last_in_four, last_in_two);\n+ assert_ne!(first_in_four, last_in_four);\n+ }\n \n #[test]\n fn vote_slider_left_position_favors_left_item() {\ndiff --git a/server/src/html/vote.rs b/server/src/html/vote.rs\nindex 483c7ff8cd35266744957cf0087f2b5f76f1a237..30dab303b9d1e763a5b9261dffff13a778e3e4e3 100644\n--- a/server/src/html/vote.rs\n+++ b/server/src/html/vote.rs\n@@ -19,7 +19,7 @@ use crate::{\n ui_action::UI_RPC_FIELD,\n };\n \n-use super::{breadcrumb_path, item_href, layout};\n+use super::{breadcrumb_path, layout};\n \n #[derive(Debug, Deserialize)]\n pub struct VoteQuery {\n@@ -59,17 +59,6 @@ fn child_title(tree: &GlobalTree, id: &ItemId) -> String {\n .unwrap_or_else(|| display_label(id))\n }\n \n-fn ratio_pct(ratio_left: i32, ratio_right: i32) -> f64 {\n- let l = ratio_left.max(0) as f64;\n- let r = ratio_right.max(0) as f64;\n- let sum = l + r;\n- if sum <= 0.0 {\n- 50.0\n- } else {\n- (l / sum) * 100.0\n- }\n-}\n-\n fn ratios_for_page(v: &VoteData, page_left: &ItemId, page_right: &ItemId) -> (i32, i32) {\n match (v.a.as_str(), v.b.as_str()) {\n (a, b) if a == page_left.as_str() && b == page_right.as_str() => {\n@@ -94,6 +83,37 @@ fn edge_votes(group: &GroupState, left: &ItemId, right: &ItemId) -> Vec &'static str {\n+ if r_left > r_right {\n+ \"left\"\n+ } else if r_right > r_left {\n+ \"right\"\n+ } else {\n+ \"even\"\n+ }\n+}\n+\n+fn winner_text(r_left: i32, r_right: i32) -> &'static str {\n+ match winner_side(r_left, r_right) {\n+ \"left\" => \"left wins\",\n+ \"right\" => \"right wins\",\n+ _ => \"tie\",\n+ }\n+}\n+\n+/// Map stored ratios to the live slider position (0 = full left, 100 = full right).\n+/// Matches `sorter_ui.js`: `left = 100 - v`, `right = v`.\n+fn slider_value_from_ratios(r_left: i32, r_right: i32) -> i32 {\n+ let l = r_left.max(0) as f64;\n+ let r = r_right.max(0) as f64;\n+ let sum = l + r;\n+ if sum <= 0.0 {\n+ return 50;\n+ }\n+ ((r / sum) * 100.0).round().clamp(0.0, 100.0) as i32\n+}\n+\n fn vote_edge_history(tree: &GlobalTree, group: &GroupState, left: &ItemId, right: &ItemId) -> Markup {\n let mut votes = edge_votes(group, left, right);\n votes.sort_by(|a, b| b.ts.cmp(&a.ts));\n@@ -106,17 +126,26 @@ fn vote_edge_history(tree: &GlobalTree, group: &GroupState, left: &ItemId, right\n h3 class=\"vote-edge-history-title\" {\n \"votes on this pair\"\n }\n+ p class=\"muted small vote-edge-legend\" {\n+ (format!(\"left: {legend_left} — right: {legend_right}\"))\n+ }\n ul class=\"vote-edge-history\" {\n @for v in &votes {\n @let (r_left, r_right) = ratios_for_page(v, left, right);\n- @let pct = ratio_pct(r_left, r_right);\n+ @let slider_val = slider_value_from_ratios(r_left, r_right);\n+ @let side = winner_side(r_left, r_right);\n+ @let label = winner_text(r_left, r_right);\n li class=\"vote-edge-history-row\" {\n div class=\"vote-edge-meta\" {\n span class=\"vote-edge-ratio\" { (format!(\"{}:{}\", r_left, r_right)) }\n+ span class=\"vote-edge-winner muted small\" { \" · \" (label) }\n }\n- div class=\"ratio-bar vote-edge-bar\" aria-hidden=\"true\" {\n- div class=\"ratio-left\" style={(format!(\"width: {:.3}%;\", pct))} {}\n- div class=\"ratio-right\" style={(format!(\"width: {:.3}%;\", 100.0 - pct))} {}\n+ label class=\"vote-hud-slider vote-edge-slider\" aria-hidden=\"true\" {\n+ input type=\"range\" class=\"vote-edge-range\" min=\"0\" max=\"100\" value=(slider_val)\n+ data-winner=(side)\n+ style={(format!(\"--vote-slider-pct: {}%;\", slider_val))}\n+ disabled\n+ tabindex=\"-1\";\n }\n }\n }\n@@ -283,7 +312,6 @@ pub async fn vote_page(\n span class=\"vote-compare-vs\" { \"vs\" }\n (vote_compare_item_card(&tree, &right, \"vote-compare-right\"))\n }\n- (vote_back_nav(&parent))\n div id=\"vote-edge-history-region\" {\n (edge_history)\n }\n@@ -308,3 +336,66 @@ pub async fn vote_page(\n )\n .into_response()\n }\n+\n+#[cfg(test)]\n+mod polarity_tests {\n+ use super::*;\n+ use crate::ranking::ranked_items;\n+ use crate::reducer::GlobalTree;\n+\n+ fn id(s: &str) -> ItemId {\n+ ItemId::parse(s).unwrap()\n+ }\n+\n+ /// The page's left number must always equal the vote's weight for the\n+ /// item shown on the left, regardless of which order the vote stored a/b.\n+ #[test]\n+ fn ratios_for_page_orients_to_page_left() {\n+ let left = id(\"left_item\");\n+ let right = id(\"right_item\");\n+\n+ // Stored a == page left: keep order.\n+ let v1 = VoteData::from_recorded(1, left.as_str(), right.as_str(), 9, 1).unwrap();\n+ assert_eq!(ratios_for_page(&v1, &left, &right), (9, 1));\n+\n+ // Stored a == page right: swap so left stays left.\n+ let v2 = VoteData::from_recorded(2, right.as_str(), left.as_str(), 9, 1).unwrap();\n+ assert_eq!(ratios_for_page(&v2, &left, &right), (1, 9));\n+ }\n+\n+ #[test]\n+ fn winner_side_follows_larger_ratio() {\n+ assert_eq!(winner_side(9, 1), \"left\");\n+ assert_eq!(winner_side(1, 9), \"right\");\n+ assert_eq!(winner_side(1, 1), \"even\");\n+ }\n+\n+ #[test]\n+ fn slider_value_matches_hud_mapping() {\n+ assert_eq!(slider_value_from_ratios(9, 1), 10);\n+ assert_eq!(slider_value_from_ratios(1, 4), 80);\n+ assert_eq!(slider_value_from_ratios(1, 1), 50);\n+ }\n+\n+ /// End-to-end polarity invariant: a vote that favours the LEFT item (higher\n+ /// `ratio_left`, recorded as the RPC's `a`) must make that item rank #1.\n+ /// This is the property the UI must preserve: sliding left => left wins.\n+ #[test]\n+ fn sliding_left_makes_left_item_win_ranking() {\n+ let parent = id(\"scope\");\n+ let left = id(\"left_item\");\n+ let right = id(\"right_item\");\n+\n+ // Slider dragged left yields e.g. 9:1 with a = left item.\n+ let vote = VoteData::from_recorded(1, left.as_str(), right.as_str(), 9, 1).unwrap();\n+ let mut tree = GlobalTree::new();\n+ tree.apply_vote(&parent, vote);\n+\n+ let group = &tree.get(&parent).unwrap().local_ranking;\n+ let ranked = ranked_items(group);\n+ assert_eq!(\n+ ranked[0].item, left,\n+ \"left item should rank first when ratio favours the left\"\n+ );\n+ }\n+}\ndiff --git a/server/static/sorter.css b/server/static/sorter.css\nindex 5da967e8bfddc2bc34662fced841860f63f1ef36..bdd3d931365703705be08537a07f3ccaf975d3ea 100644\n--- a/server/static/sorter.css\n+++ b/server/static/sorter.css\n@@ -124,7 +124,7 @@ code {\n z-index: 1;\n box-shadow:\n inset 0 0 0 3px var(--accent),\n- inset 0 0 0 5px color-mix(in oklch, var(--rank-fg) 70%, transparent);\n+ inset 0 0 0 5px color-mix(in oklch, var(--rank-fg, var(--fg)) 70%, transparent);\n filter: saturate(1.15) brightness(1.04);\n }\n \n@@ -320,7 +320,7 @@ h1 {\n grid-template-columns: var(--vote-ratio-col) minmax(0, 1fr) var(--vote-actions-col);\n gap: 0.5rem 0.65rem;\n align-items: center;\n- --vote-ratio-col: 3.25rem;\n+ --vote-ratio-col: 12.5rem;\n --vote-actions-col: 11.5rem;\n }\n \n@@ -332,7 +332,7 @@ h1 {\n margin: 0;\n width: var(--vote-ratio-col);\n min-width: var(--vote-ratio-col);\n- text-align: center;\n+ text-align: right;\n white-space: nowrap;\n }\n \n@@ -474,6 +474,16 @@ h1 {\n --vote-track-muted: color-mix(in oklch, var(--muted) 55%, var(--bg));\n }\n \n+/* Center-anchored fill: the accent grows from the midpoint toward the thumb,\n+ so the highlighted mass always sits on the *winning* side (the side the\n+ thumb was dragged toward). Left winning => accent on the left half. */\n+.vote-hud-slider input[type=\"range\"][data-winner=\"even\"]::-webkit-slider-runnable-track {\n+ height: 0.65rem;\n+ border-radius: 0;\n+ border: 2px solid var(--border);\n+ background: var(--vote-track-muted);\n+}\n+\n .vote-hud-slider input[type=\"range\"][data-winner=\"left\"]::-webkit-slider-runnable-track {\n height: 0.65rem;\n border-radius: 0;\n@@ -483,7 +493,9 @@ h1 {\n var(--vote-track-muted) 0%,\n var(--vote-track-muted) var(--vote-slider-pct, 50%),\n var(--accent) var(--vote-slider-pct, 50%),\n- var(--accent) 100%\n+ var(--accent) 50%,\n+ var(--vote-track-muted) 50%,\n+ var(--vote-track-muted) 100%\n );\n }\n \n@@ -493,7 +505,9 @@ h1 {\n border: 2px solid var(--border);\n background: linear-gradient(\n to right,\n- var(--accent) 0%,\n+ var(--vote-track-muted) 0%,\n+ var(--vote-track-muted) 50%,\n+ var(--accent) 50%,\n var(--accent) var(--vote-slider-pct, 50%),\n var(--vote-track-muted) var(--vote-slider-pct, 50%),\n var(--vote-track-muted) 100%\n@@ -513,26 +527,35 @@ h1 {\n }\n \n .vote-hud-slider input[type=\"range\"][data-winner=\"left\"]::-moz-range-track,\n-.vote-hud-slider input[type=\"range\"][data-winner=\"right\"]::-moz-range-track {\n+.vote-hud-slider input[type=\"range\"][data-winner=\"right\"]::-moz-range-track,\n+.vote-hud-slider input[type=\"range\"][data-winner=\"even\"]::-moz-range-track {\n height: 0.65rem;\n border-radius: 0;\n border: 2px solid var(--border);\n }\n \n+.vote-hud-slider input[type=\"range\"][data-winner=\"even\"]::-moz-range-track {\n+ background: var(--vote-track-muted);\n+}\n+\n .vote-hud-slider input[type=\"range\"][data-winner=\"left\"]::-moz-range-track {\n background: linear-gradient(\n to right,\n var(--vote-track-muted) 0%,\n var(--vote-track-muted) var(--vote-slider-pct, 50%),\n var(--accent) var(--vote-slider-pct, 50%),\n- var(--accent) 100%\n+ var(--accent) 50%,\n+ var(--vote-track-muted) 50%,\n+ var(--vote-track-muted) 100%\n );\n }\n \n .vote-hud-slider input[type=\"range\"][data-winner=\"right\"]::-moz-range-track {\n background: linear-gradient(\n to right,\n- var(--accent) 0%,\n+ var(--vote-track-muted) 0%,\n+ var(--vote-track-muted) 50%,\n+ var(--accent) 50%,\n var(--accent) var(--vote-slider-pct, 50%),\n var(--vote-track-muted) var(--vote-slider-pct, 50%),\n var(--vote-track-muted) 100%\n@@ -573,20 +596,23 @@ h1 {\n margin-bottom: 0.25rem;\n }\n \n-.ratio-bar {\n- display: flex;\n- height: 8px;\n- border-radius: 0;\n- overflow: hidden;\n- background: var(--border);\n+/* Read-only sliders in vote history — same track/thumb styling as the HUD. */\n+.vote-edge-slider {\n+ display: block;\n+ margin-top: 0.15rem;\n }\n \n-.ratio-left {\n- background: var(--accent);\n+.vote-edge-slider input[type=\"range\"] {\n+ pointer-events: none;\n+ cursor: default;\n+}\n+\n+.vote-edge-slider input[type=\"range\"]:disabled {\n+ opacity: 1;\n }\n \n-.ratio-right {\n- background: var(--muted);\n+.vote-edge-legend {\n+ margin: 0 0 0.5rem;\n }\n \n @media (max-width: 900px) {\ndiff --git a/server/static/sorter_ui.js b/server/static/sorter_ui.js\nindex f665b45f4fd4771427dc4c37e563e4976873beee..5d6d765f69f301722e59248c76e919043559fb78 100644\n--- a/server/static/sorter_ui.js\n+++ b/server/static/sorter_ui.js\n@@ -138,6 +138,10 @@\n function update() {\n var v = parseInt(slider.value, 10);\n if (!Number.isFinite(v)) v = 50;\n+ // Slider position drives the colored fill (a center-anchored bar that\n+ // grows toward whichever side is winning — see sorter.css). Sliding the\n+ // thumb left raises the left number; left winning keeps the accent mass\n+ // on the left, matching the \"votes on this pair\" history bars.\n slider.style.setProperty('--vote-slider-pct', v + '%');\n slider.setAttribute('aria-valuenow', String(v));\n var left = Math.max(1, 100 - v);\n@@ -147,8 +151,12 @@\n right = right / divisor;\n if (leftInput) leftInput.value = String(left);\n if (rightInput) rightInput.value = String(right);\n- if (ratioDisplay) ratioDisplay.textContent = left + ':' + right;\n- slider.dataset.winner = left >= right ? 'left' : 'right';\n+ var winner = left > right ? 'left' : (right > left ? 'right' : 'even');\n+ slider.dataset.winner = winner;\n+ if (ratioDisplay) {\n+ var label = winner === 'even' ? 'tie' : (winner + ' wins');\n+ ratioDisplay.textContent = left + ':' + right + ' \\u00b7 ' + label;\n+ }\n }\n slider.addEventListener('input', update);\n update();\n","role":"user"}],"model":"~x-ai/grok-latest"}