You are a constitutional council ranking individual git commits for ownership allocation. Compare these two commits. Decide which contributed more lasting value to the project. Judge substance, not spectacle: - Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise. - Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one. - Do not favor a side merely because its patch is longer or noisier. - Weight what the change does for the project, not the contributor's name. Return ONLY a JSON object: {"winner": "A" or "B", "ratio": "N:M", "explanation": "..."} The explanation must cite concrete differences in the patches (1-3 sentences). Side A — contributor: tommy-mor Side A — commit message: [04b81f91] Fix vote compare highlighting Co-authored-by: Cursor Side A — unified diff (full patch): diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs index 588cf62bcea608ed4ede363810f022a67b43b960..e180a0ca542a33e2300c0a4809e6b9cfee07ecfe 100644 --- a/server/src/html/mod.rs +++ b/server/src/html/mod.rs @@ -291,18 +291,17 @@ fn rank_list( label: &str, items: &[RankedItem], start_rank: usize, - total_ranked: usize, - ordinal_offset: usize, highlighted: &HashSet, tree: &GlobalTree, ) -> Markup { + let group_len = items.len(); html! { @if !items.is_empty() { h3 class="rank-heading muted small" { (label) } ol class="rank-list" { @for (i, r) in items.iter().enumerate() { @let href = item_href(&r.item); - @let style = rank_row_style(parent, ordinal_offset + i, total_ranked); + @let style = rank_row_style(parent, i, group_len); @let class = rank_row_class(&r.item, highlighted); li class=(class) data-rank-item=(r.item.as_str()) @@ -420,8 +419,6 @@ pub fn ranking_panel_with_highlights( let has_ranked = !ranked_groups.is_empty(); let multi = ranked_groups.len() > 1; - let total_ranked: usize = ranked_groups.iter().map(|g| g.len()).sum(); - let mut ordinal_offset = 0usize; html! { section id="ranking-panel" class="demo-panel" { @@ -436,8 +433,7 @@ pub fn ranking_panel_with_highlights( } @else { @for (gi, ranked) in ranked_groups.iter().enumerate() { @let label = if multi { format!("Ranking group {}", gi + 1) } else { "Ranking".to_string() }; - (rank_list(item, &label, ranked, 1, total_ranked, ordinal_offset, highlighted, tree)) - @let _ = { ordinal_offset += ranked.len(); }; + (rank_list(item, &label, ranked, 1, highlighted, tree)) } (unranked_list("Unranked", &unranked, highlighted, tree)) } @@ -520,7 +516,20 @@ pub async fn browse(State(state): State, uri: Uri) -> impl IntoRespons #[cfg(test)] mod tests { - use super::SORTER_UI_JS; + use super::{rank_row_style, SORTER_UI_JS}; + use crate::path_types::ItemId; + + #[test] + fn rank_row_style_gradients_per_group_not_globally() { + let parent = ItemId::opaque("test-scope"); + let first_in_four = rank_row_style(&parent, 0, 4); + let last_in_four = rank_row_style(&parent, 3, 4); + let first_in_two = rank_row_style(&parent, 0, 2); + let last_in_two = rank_row_style(&parent, 1, 2); + assert_eq!(first_in_four, first_in_two); + assert_eq!(last_in_four, last_in_two); + assert_ne!(first_in_four, last_in_four); + } #[test] fn vote_slider_left_position_favors_left_item() { diff --git a/server/src/html/vote.rs b/server/src/html/vote.rs index 483c7ff8cd35266744957cf0087f2b5f76f1a237..30dab303b9d1e763a5b9261dffff13a778e3e4e3 100644 --- a/server/src/html/vote.rs +++ b/server/src/html/vote.rs @@ -19,7 +19,7 @@ use crate::{ ui_action::UI_RPC_FIELD, }; -use super::{breadcrumb_path, item_href, layout}; +use super::{breadcrumb_path, layout}; #[derive(Debug, Deserialize)] pub struct VoteQuery { @@ -59,17 +59,6 @@ fn child_title(tree: &GlobalTree, id: &ItemId) -> String { .unwrap_or_else(|| display_label(id)) } -fn ratio_pct(ratio_left: i32, ratio_right: i32) -> f64 { - let l = ratio_left.max(0) as f64; - let r = ratio_right.max(0) as f64; - let sum = l + r; - if sum <= 0.0 { - 50.0 - } else { - (l / sum) * 100.0 - } -} - fn ratios_for_page(v: &VoteData, page_left: &ItemId, page_right: &ItemId) -> (i32, i32) { match (v.a.as_str(), v.b.as_str()) { (a, b) if a == page_left.as_str() && b == page_right.as_str() => { @@ -94,6 +83,37 @@ fn edge_votes(group: &GroupState, left: &ItemId, right: &ItemId) -> Vec &'static str { + if r_left > r_right { + "left" + } else if r_right > r_left { + "right" + } else { + "even" + } +} + +fn winner_text(r_left: i32, r_right: i32) -> &'static str { + match winner_side(r_left, r_right) { + "left" => "left wins", + "right" => "right wins", + _ => "tie", + } +} + +/// Map stored ratios to the live slider position (0 = full left, 100 = full right). +/// Matches `sorter_ui.js`: `left = 100 - v`, `right = v`. +fn slider_value_from_ratios(r_left: i32, r_right: i32) -> i32 { + let l = r_left.max(0) as f64; + let r = r_right.max(0) as f64; + let sum = l + r; + if sum <= 0.0 { + return 50; + } + ((r / sum) * 100.0).round().clamp(0.0, 100.0) as i32 +} + fn vote_edge_history(tree: &GlobalTree, group: &GroupState, left: &ItemId, right: &ItemId) -> Markup { let mut votes = edge_votes(group, left, right); votes.sort_by(|a, b| b.ts.cmp(&a.ts)); @@ -106,17 +126,26 @@ fn vote_edge_history(tree: &GlobalTree, group: &GroupState, left: &ItemId, right h3 class="vote-edge-history-title" { "votes on this pair" } + p class="muted small vote-edge-legend" { + (format!("left: {legend_left} — right: {legend_right}")) + } ul class="vote-edge-history" { @for v in &votes { @let (r_left, r_right) = ratios_for_page(v, left, right); - @let pct = ratio_pct(r_left, r_right); + @let slider_val = slider_value_from_ratios(r_left, r_right); + @let side = winner_side(r_left, r_right); + @let label = winner_text(r_left, r_right); li class="vote-edge-history-row" { div class="vote-edge-meta" { span class="vote-edge-ratio" { (format!("{}:{}", r_left, r_right)) } + span class="vote-edge-winner muted small" { " · " (label) } } - div class="ratio-bar vote-edge-bar" aria-hidden="true" { - div class="ratio-left" style={(format!("width: {:.3}%;", pct))} {} - div class="ratio-right" style={(format!("width: {:.3}%;", 100.0 - pct))} {} + label class="vote-hud-slider vote-edge-slider" aria-hidden="true" { + input type="range" class="vote-edge-range" min="0" max="100" value=(slider_val) + data-winner=(side) + style={(format!("--vote-slider-pct: {}%;", slider_val))} + disabled + tabindex="-1"; } } } @@ -283,7 +312,6 @@ pub async fn vote_page( span class="vote-compare-vs" { "vs" } (vote_compare_item_card(&tree, &right, "vote-compare-right")) } - (vote_back_nav(&parent)) div id="vote-edge-history-region" { (edge_history) } @@ -308,3 +336,66 @@ pub async fn vote_page( ) .into_response() } + +#[cfg(test)] +mod polarity_tests { + use super::*; + use crate::ranking::ranked_items; + use crate::reducer::GlobalTree; + + fn id(s: &str) -> ItemId { + ItemId::parse(s).unwrap() + } + + /// The page's left number must always equal the vote's weight for the + /// item shown on the left, regardless of which order the vote stored a/b. + #[test] + fn ratios_for_page_orients_to_page_left() { + let left = id("left_item"); + let right = id("right_item"); + + // Stored a == page left: keep order. + let v1 = VoteData::from_recorded(1, left.as_str(), right.as_str(), 9, 1).unwrap(); + assert_eq!(ratios_for_page(&v1, &left, &right), (9, 1)); + + // Stored a == page right: swap so left stays left. + let v2 = VoteData::from_recorded(2, right.as_str(), left.as_str(), 9, 1).unwrap(); + assert_eq!(ratios_for_page(&v2, &left, &right), (1, 9)); + } + + #[test] + fn winner_side_follows_larger_ratio() { + assert_eq!(winner_side(9, 1), "left"); + assert_eq!(winner_side(1, 9), "right"); + assert_eq!(winner_side(1, 1), "even"); + } + + #[test] + fn slider_value_matches_hud_mapping() { + assert_eq!(slider_value_from_ratios(9, 1), 10); + assert_eq!(slider_value_from_ratios(1, 4), 80); + assert_eq!(slider_value_from_ratios(1, 1), 50); + } + + /// End-to-end polarity invariant: a vote that favours the LEFT item (higher + /// `ratio_left`, recorded as the RPC's `a`) must make that item rank #1. + /// This is the property the UI must preserve: sliding left => left wins. + #[test] + fn sliding_left_makes_left_item_win_ranking() { + let parent = id("scope"); + let left = id("left_item"); + let right = id("right_item"); + + // Slider dragged left yields e.g. 9:1 with a = left item. + let vote = VoteData::from_recorded(1, left.as_str(), right.as_str(), 9, 1).unwrap(); + let mut tree = GlobalTree::new(); + tree.apply_vote(&parent, vote); + + let group = &tree.get(&parent).unwrap().local_ranking; + let ranked = ranked_items(group); + assert_eq!( + ranked[0].item, left, + "left item should rank first when ratio favours the left" + ); + } +} diff --git a/server/static/sorter.css b/server/static/sorter.css index 5da967e8bfddc2bc34662fced841860f63f1ef36..bdd3d931365703705be08537a07f3ccaf975d3ea 100644 --- a/server/static/sorter.css +++ b/server/static/sorter.css @@ -124,7 +124,7 @@ code { z-index: 1; box-shadow: inset 0 0 0 3px var(--accent), - inset 0 0 0 5px color-mix(in oklch, var(--rank-fg) 70%, transparent); + inset 0 0 0 5px color-mix(in oklch, var(--rank-fg, var(--fg)) 70%, transparent); filter: saturate(1.15) brightness(1.04); } @@ -320,7 +320,7 @@ h1 { grid-template-columns: var(--vote-ratio-col) minmax(0, 1fr) var(--vote-actions-col); gap: 0.5rem 0.65rem; align-items: center; - --vote-ratio-col: 3.25rem; + --vote-ratio-col: 12.5rem; --vote-actions-col: 11.5rem; } @@ -332,7 +332,7 @@ h1 { margin: 0; width: var(--vote-ratio-col); min-width: var(--vote-ratio-col); - text-align: center; + text-align: right; white-space: nowrap; } @@ -474,6 +474,16 @@ h1 { --vote-track-muted: color-mix(in oklch, var(--muted) 55%, var(--bg)); } +/* Center-anchored fill: the accent grows from the midpoint toward the thumb, + so the highlighted mass always sits on the *winning* side (the side the + thumb was dragged toward). Left winning => accent on the left half. */ +.vote-hud-slider input[type="range"][data-winner="even"]::-webkit-slider-runnable-track { + height: 0.65rem; + border-radius: 0; + border: 2px solid var(--border); + background: var(--vote-track-muted); +} + .vote-hud-slider input[type="range"][data-winner="left"]::-webkit-slider-runnable-track { height: 0.65rem; border-radius: 0; @@ -483,7 +493,9 @@ h1 { var(--vote-track-muted) 0%, var(--vote-track-muted) var(--vote-slider-pct, 50%), var(--accent) var(--vote-slider-pct, 50%), - var(--accent) 100% + var(--accent) 50%, + var(--vote-track-muted) 50%, + var(--vote-track-muted) 100% ); } @@ -493,7 +505,9 @@ h1 { border: 2px solid var(--border); background: linear-gradient( to right, - var(--accent) 0%, + var(--vote-track-muted) 0%, + var(--vote-track-muted) 50%, + var(--accent) 50%, var(--accent) var(--vote-slider-pct, 50%), var(--vote-track-muted) var(--vote-slider-pct, 50%), var(--vote-track-muted) 100% @@ -513,26 +527,35 @@ h1 { } .vote-hud-slider input[type="range"][data-winner="left"]::-moz-range-track, -.vote-hud-slider input[type="range"][data-winner="right"]::-moz-range-track { +.vote-hud-slider input[type="range"][data-winner="right"]::-moz-range-track, +.vote-hud-slider input[type="range"][data-winner="even"]::-moz-range-track { height: 0.65rem; border-radius: 0; border: 2px solid var(--border); } +.vote-hud-slider input[type="range"][data-winner="even"]::-moz-range-track { + background: var(--vote-track-muted); +} + .vote-hud-slider input[type="range"][data-winner="left"]::-moz-range-track { background: linear-gradient( to right, var(--vote-track-muted) 0%, var(--vote-track-muted) var(--vote-slider-pct, 50%), var(--accent) var(--vote-slider-pct, 50%), - var(--accent) 100% + var(--accent) 50%, + var(--vote-track-muted) 50%, + var(--vote-track-muted) 100% ); } .vote-hud-slider input[type="range"][data-winner="right"]::-moz-range-track { background: linear-gradient( to right, - var(--accent) 0%, + var(--vote-track-muted) 0%, + var(--vote-track-muted) 50%, + var(--accent) 50%, var(--accent) var(--vote-slider-pct, 50%), var(--vote-track-muted) var(--vote-slider-pct, 50%), var(--vote-track-muted) 100% @@ -573,20 +596,23 @@ h1 { margin-bottom: 0.25rem; } -.ratio-bar { - display: flex; - height: 8px; - border-radius: 0; - overflow: hidden; - background: var(--border); +/* Read-only sliders in vote history — same track/thumb styling as the HUD. */ +.vote-edge-slider { + display: block; + margin-top: 0.15rem; } -.ratio-left { - background: var(--accent); +.vote-edge-slider input[type="range"] { + pointer-events: none; + cursor: default; +} + +.vote-edge-slider input[type="range"]:disabled { + opacity: 1; } -.ratio-right { - background: var(--muted); +.vote-edge-legend { + margin: 0 0 0.5rem; } @media (max-width: 900px) { diff --git a/server/static/sorter_ui.js b/server/static/sorter_ui.js index f665b45f4fd4771427dc4c37e563e4976873beee..5d6d765f69f301722e59248c76e919043559fb78 100644 --- a/server/static/sorter_ui.js +++ b/server/static/sorter_ui.js @@ -138,6 +138,10 @@ function update() { var v = parseInt(slider.value, 10); if (!Number.isFinite(v)) v = 50; + // Slider position drives the colored fill (a center-anchored bar that + // grows toward whichever side is winning — see sorter.css). Sliding the + // thumb left raises the left number; left winning keeps the accent mass + // on the left, matching the "votes on this pair" history bars. slider.style.setProperty('--vote-slider-pct', v + '%'); slider.setAttribute('aria-valuenow', String(v)); var left = Math.max(1, 100 - v); @@ -147,8 +151,12 @@ right = right / divisor; if (leftInput) leftInput.value = String(left); if (rightInput) rightInput.value = String(right); - if (ratioDisplay) ratioDisplay.textContent = left + ':' + right; - slider.dataset.winner = left >= right ? 'left' : 'right'; + var winner = left > right ? 'left' : (right > left ? 'right' : 'even'); + slider.dataset.winner = winner; + if (ratioDisplay) { + var label = winner === 'even' ? 'tie' : (winner + ' wins'); + ratioDisplay.textContent = left + ':' + right + ' \u00b7 ' + label; + } } slider.addEventListener('input', update); update(); Side B — contributor: tommy-mor Side B — commit message: [0366806e] Color rank rows by vote mass within each group, not list position. Min–max normalization keeps similar scores visually close while still using the full gradient as groups grow and absolute mass dilutes. Co-authored-by: Cursor Side B — unified diff (full patch): diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs index a58cbbee3490a08a625cb06df06848c59a615d65..4eff2e19ed4d303ff8e80c1eabd8a15b4990e643 100644 --- a/server/src/html/mod.rs +++ b/server/src/html/mod.rs @@ -264,12 +264,19 @@ pub fn scope_theme_style(parent: &ItemId) -> String { ) } -fn rank_row_style(parent: &ItemId, ordinal: usize, total: usize) -> String { - let t = if total <= 1 { - 0.0 - } else { - ordinal as f64 / (total - 1) as f64 - }; +/// Map vote mass to gradient position using the group's score range, not raw mass or +/// list position. Vote mass sums to 1 across the component, so absolute values dilute +/// as N grows; min–max within the visible list preserves similar scores → similar colors. +fn score_gradient_t(score: f64, min_score: f64, max_score: f64) -> f64 { + let spread = max_score - min_score; + if spread < 1e-9 { + return 0.5; + } + ((max_score - score) / spread).clamp(0.0, 1.0) +} + +fn rank_row_style(parent: &ItemId, score: f64, min_score: f64, max_score: f64) -> String { + let t = score_gradient_t(score, min_score, max_score); let base_hue = scope_base_hue(parent); let hue = (base_hue + 118.0 * t) % 360.0; let lightness = 0.74 - 0.34 * t; @@ -295,14 +302,15 @@ fn rank_list( highlighted: &HashSet, tree: &GlobalTree, ) -> Markup { - let group_len = items.len(); + let min_score = items.iter().map(|r| r.score).fold(f64::INFINITY, f64::min); + let max_score = items.iter().map(|r| r.score).fold(f64::NEG_INFINITY, f64::max); html! { @if !items.is_empty() { h3 class="rank-heading muted small" { (label) } ol class="rank-list" { @for (i, r) in items.iter().enumerate() { @let href = item_href(&r.item); - @let style = rank_row_style(parent, i, group_len); + @let style = rank_row_style(parent, r.score, min_score, max_score); @let class = rank_row_class(&r.item, highlighted); li class=(class) data-rank-item=(r.item.as_str()) @@ -517,19 +525,37 @@ pub async fn browse(State(state): State, uri: Uri) -> impl IntoRespons #[cfg(test)] mod tests { - use super::{rank_row_style, SORTER_UI_JS}; + use super::{rank_row_style, score_gradient_t, SORTER_UI_JS}; use crate::path_types::ItemId; #[test] - fn rank_row_style_gradients_per_group_not_globally() { + fn score_gradient_t_uses_group_range_not_absolute_mass() { + assert!((score_gradient_t(0.12, 0.08, 0.12) - 0.0).abs() < 1e-9); + assert!((score_gradient_t(0.08, 0.08, 0.12) - 1.0).abs() < 1e-9); + // Raw 12% mass would map near the dark end globally; within this group it's the top. + assert!(score_gradient_t(0.12, 0.08, 0.12) < score_gradient_t(0.12, 0.0, 1.0)); + } + + #[test] + fn score_gradient_t_similar_scores_similar_t() { + let a = score_gradient_t(0.41, 0.20, 0.60); + let b = score_gradient_t(0.40, 0.20, 0.60); + assert!((a - b).abs() < 0.05); + assert!((a - score_gradient_t(0.60, 0.20, 0.60)).abs() > 0.3); + } + + #[test] + fn score_gradient_t_tied_scores_neutral() { + assert!((score_gradient_t(0.25, 0.25, 0.25) - 0.5).abs() < 1e-9); + } + + #[test] + fn rank_row_style_same_inputs_same_color() { let parent = ItemId::opaque("test-scope"); - let first_in_four = rank_row_style(&parent, 0, 4); - let last_in_four = rank_row_style(&parent, 3, 4); - let first_in_two = rank_row_style(&parent, 0, 2); - let last_in_two = rank_row_style(&parent, 1, 2); - assert_eq!(first_in_four, first_in_two); - assert_eq!(last_in_four, last_in_two); - assert_ne!(first_in_four, last_in_four); + assert_eq!( + rank_row_style(&parent, 0.33, 0.20, 0.60), + rank_row_style(&parent, 0.33, 0.20, 0.60), + ); } #[test]