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: [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 A — 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] Side B — contributor: tommy-mor Side B — commit message: [7964b28f] fix Side B — unified diff (full patch): diff --git a/Cargo.lock b/Cargo.lock index 8c43fb75c472b102e6e1d3b837dce3355be898f2..e55d87f32ab32064686431c7082ef8c9ca872d63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -923,6 +923,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -980,6 +989,36 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -1270,6 +1309,7 @@ dependencies = [ "dotenvy", "futures-util", "maud", + "rand", "reqwest", "serde", "serde_json", @@ -1280,6 +1320,7 @@ dependencies = [ "tower-http 0.5.2", "tracing", "tracing-subscriber", + "urlencoding", ] [[package]] @@ -1656,6 +1697,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -2052,6 +2099,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/server/Cargo.toml b/server/Cargo.toml index c940acb687fb141d21760a3d6656172013cf6f41..6fb7bf52fa58f46f5e8fb0f7fd395247b57506d7 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -20,6 +20,8 @@ reqwest = { version = "0.12", features = ["json"] } dotenvy = "0.15" async-stream = "0.3" futures-util = { version = "0.3", default-features = false, features = ["std"] } +rand = "0.8" +urlencoding = "2" [dev-dependencies] reqwest = { version = "0.12", features = ["json"] } diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs index e649a7d192feade465e19ce6187a829f6ec74372..06001212820101e0cc953d3687dea64f85e60787 100644 --- a/server/src/api/ui_html.rs +++ b/server/src/api/ui_html.rs @@ -47,6 +47,7 @@ pub async fn post_ui_html( ratio_left, ratio_right, scope, + next, } => { let parent = parent_from_scope(&scope); if let Err(e) = state @@ -56,9 +57,19 @@ pub async fn post_ui_html( return ui_js_warn(&e).into_response(); } let tree = state.tree.read().await; + if !next.trim().is_empty() { + drop(tree); + return JsBuilder::new() + .raw(&format!( + "window.location.href={};", + js_string_literal(next.trim()) + )) + .into_response(); + } let empty = crate::reducer::NodeState::default(); let node = tree.get(&parent).unwrap_or(&empty); let panel = ranking_panel(&parent, node, &tree); + drop(tree); JsBuilder::new() .morph_selector("#ranking-panel", panel) .into_response() @@ -126,6 +137,7 @@ mod tests { ratio_left: 3, ratio_right: 1, scope: String::new(), + next: String::new(), } ); } diff --git a/server/src/form_template.rs b/server/src/form_template.rs index b9bc3982a125e94e67c99175ea9055979541abba..bd4a7195a6eaabfed55333bab5640708ea108b81 100644 --- a/server/src/form_template.rs +++ b/server/src/form_template.rs @@ -7,19 +7,30 @@ pub fn template_json_compact(v: &T) -> serde_json::Result serde_json::to_string(v) } -/// Recursively walk the JSON AST and replace `{"$form": "key"}` with the submitted -/// string for `key` (empty if missing). Other keys are unchanged. +/// Recursively walk the JSON AST and replace form holes with submitted values. +/// +/// - `{"$form": "key"}` → string (empty if missing) +/// - `{"$form:i32": "key"}` → JSON number (0 if missing or unparseable) pub fn substitute_form_vars(val: &mut Value, form_data: &HashMap) { match val { Value::Object(map) => { if map.len() == 1 { - if let Some(Value::String(field_name)) = map.get("$form") { - let submitted = form_data - .get(field_name.as_str()) - .map(|s| s.as_str()) - .unwrap_or(""); - *val = Value::String(submitted.to_string()); - return; + if let Some((hole_key, Value::String(field_name))) = map.iter().next() { + if let Some(form_type) = hole_key.strip_prefix("$form") { + let submitted = form_data + .get(field_name.as_str()) + .map(|s| s.as_str()) + .unwrap_or(""); + *val = match form_type { + "" => Value::String(submitted.to_string()), + ":i32" => { + let n: i32 = submitted.trim().parse().unwrap_or(0); + Value::Number(n.into()) + } + _ => Value::String(submitted.to_string()), + }; + return; + } } } for v in map.values_mut() { @@ -62,6 +73,45 @@ mod tests { text: String, } + #[test] + fn i32_holes_become_numbers() { + let json = r#"{ + "ratio_left": {"$form:i32": "ratio_left"}, + "ratio_right": {"$form:i32": "ratio_right"} + }"#; + let mut form = HashMap::new(); + form.insert("ratio_left".into(), "75".into()); + form.insert("ratio_right".into(), "25".into()); + let v = fill_template_from_form(json, &form).unwrap(); + assert_eq!(v["ratio_left"], 75); + assert_eq!(v["ratio_right"], 25); + + #[derive(Debug, Deserialize, PartialEq, Eq)] + struct Ratios { + ratio_left: i32, + ratio_right: i32, + } + let r: Ratios = serde_json::from_value(v).unwrap(); + assert_eq!( + r, + Ratios { + ratio_left: 75, + ratio_right: 25, + } + ); + } + + #[test] + fn i32_hole_missing_or_bad_defaults_to_zero() { + let json = r#"{"n": {"$form:i32": "missing"}}"#; + let v = fill_template_from_form(json, &HashMap::new()).unwrap(); + assert_eq!(v["n"], 0); + let mut form = HashMap::new(); + form.insert("missing".into(), "nope".into()); + let v = fill_template_from_form(json, &form).unwrap(); + assert_eq!(v["n"], 0); + } + #[test] fn holes_become_strings() { let json = r#"{ diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs index 3bf9fc7e92e50beed24e2c25106a77421038c90b..61cdbc094819ddedb755572c59456ec0d6617619 100644 --- a/server/src/html/mod.rs +++ b/server/src/html/mod.rs @@ -20,6 +20,8 @@ use crate::{ ui_action::UI_RPC_FIELD, }; +pub mod vote; + const SORTER_CSS: &str = include_str!("../../static/sorter.css"); const SORTER_UI_JS: &str = include_str!("../../static/sorter_ui.js"); @@ -131,7 +133,7 @@ fn layout(title: &str, body: Markup, views: u64) -> Markup { } } -fn item_href(id: &ItemId) -> String { +pub(crate) fn item_href(id: &ItemId) -> String { id.browse_href() } @@ -309,11 +311,23 @@ async fn item_page(state: AppState, uri: Uri, item: ItemId) -> Markup { let empty_node = NodeState::default(); let node = tree.get(&item).unwrap_or(&empty_node); + let child_count = node.children.len(); + let vote_link = if child_count >= 2 { + Some(vote::vote_href(&item)) + } else { + None + }; + let body = html! { h1 { "sorter" } (input_panel("", None)) (breadcrumb_path(&item)) (entity_section(&item, node, false)) + @if let Some(href) = vote_link { + p class="vote-cta" { + a class="btn-primary" href=(href) data-testid="vote-children" { "Vote on children" } + } + } (ranking_panel(&item, node, &tree)) }; layout("sorter2", body, views) diff --git a/server/src/lib.rs b/server/src/lib.rs index da5f3ebecec1794b05a2a69cc78379551b2ad769..7f7e28c8ac3758de87f1f8e073b24be4132d38da 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -4,6 +4,7 @@ pub mod events; pub mod fetch; pub mod form_template; pub mod html; +pub mod pair; pub mod parser; pub mod path_types; pub mod ranking; @@ -33,6 +34,7 @@ pub fn create_app(state: AppState) -> Router { .route("/static/:filename", get(crate::html::serve_static)) .route("/~/*item_path", get(crate::html::browse)) .route("/", get(crate::html::home)) + .route("/vote", get(crate::html::vote::vote_page)) .route("/ui", post(crate::api::ui_html::post_ui_html)) .with_state(state) .layer(TraceLayer::new_for_http()) diff --git a/server/src/ui_action.rs b/server/src/ui_action.rs index 2047713762c932ac9bc325fe15624f2aecb3364d..53581e1362bfcc5dfb4ae3069c41ea6f7be41437 100644 --- a/server/src/ui_action.rs +++ b/server/src/ui_action.rs @@ -31,6 +31,9 @@ pub enum HtmlUiAction { /// Parent node [`ItemId`] string; empty = tree root. #[serde(default)] scope: String, + /// After vote, navigate here (vote compare page). + #[serde(default)] + next: String, }, /// Parse pasted Reddit URL/path; redirect to subreddit ranking on success. ParseQuery { @@ -70,6 +73,36 @@ pub fn parse_html_ui_from_form( mod tests { use super::*; + #[test] + fn record_vote_round_trip_with_typed_ratio_holes() { + let template = serde_json::json!({ + "action": "record_vote", + "a": "x", + "b": "y", + "ratio_left": {"$form:i32": "ratio_left"}, + "ratio_right": {"$form:i32": "ratio_right"}, + "scope": "parent", + }); + let mut form = HashMap::new(); + form.insert( + UI_RPC_FIELD.to_string(), + serde_json::to_string(&template).unwrap(), + ); + form.insert("ratio_left".into(), "60".into()); + form.insert("ratio_right".into(), "40".into()); + assert_eq!( + parse_html_ui_from_form(&form).unwrap(), + HtmlUiAction::RecordVote { + a: "x".into(), + b: "y".into(), + ratio_left: 60, + ratio_right: 40, + scope: "parent".into(), + next: String::new(), + } + ); + } + #[test] fn record_vote_round_trip_with_form_holes() { let template = serde_json::json!({ @@ -96,6 +129,7 @@ mod tests { ratio_left: 2, ratio_right: 1, scope: "amitheasshole".into(), + next: String::new(), } ); } @@ -122,6 +156,7 @@ mod tests { ratio_left: 2, ratio_right: 1, scope: String::new(), + next: String::new(), } ); } diff --git a/server/static/sorter.css b/server/static/sorter.css index 021918f8e778b84e4b3eac8559fab7d223a3d490..3c2cec5cb5094013387147c621d75bd341bc06cc 100644 --- a/server/static/sorter.css +++ b/server/static/sorter.css @@ -224,4 +224,148 @@ code { h1 { margin: 0; +} + +.vote-cta { + margin: 0.75rem 0; +} + +.vote-compare-shell { + max-width: 960px; +} + +.vote-compare-scope { + margin: 0.5rem 0 1rem; +} + +.vote-compare-pair { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 1rem; + align-items: start; + margin: 1rem 0; +} + +.vote-compare-vs { + align-self: center; + font-size: 1.25rem; + color: var(--muted); + font-weight: 600; +} + +.vote-compare-side { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem; + min-height: 120px; +} + +.vote-compare-item { + color: var(--accent); + text-decoration: none; + display: block; +} + +.vote-compare-item:hover { + text-decoration: underline; +} + +.vote-compare-figure { + margin: 0.75rem 0 0; +} + +.vote-compare-image { + display: block; + max-width: 100%; + height: auto; + border-radius: 6px; + border: 1px solid var(--border); +} + +.vote-compare-item-body { + margin-top: 0.75rem; + font-size: 0.9rem; +} + +.vote-compare-nav { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + margin: 1rem 0; +} + +.vote-compare-next { + color: var(--accent); + text-decoration: none; +} + +.vote-compare-next.is-disabled { + color: var(--muted); +} + +.vote-compare-back { + text-decoration: none; +} + +.vote-compare-back:hover { + text-decoration: underline; +} + +.vote-compare-slider-label { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 0.75rem; + align-items: center; + margin: 1.25rem 0; +} + +.vote-compare-slider-label input[type="range"] { + width: 100%; + min-width: 160px; +} + +#vote-slider-left-label { + text-align: right; +} + +#vote-slider-right-label { + text-align: left; +} + +.vote-edge-history-title { + margin: 1.5rem 0 0.5rem; + font-size: 1rem; +} + +.vote-edge-history { + list-style: none; + padding: 0; + margin: 0; +} + +.vote-edge-history-row { + margin-bottom: 0.75rem; +} + +.vote-edge-meta { + font-size: 0.875rem; + margin-bottom: 0.25rem; +} + +.ratio-bar { + display: flex; + height: 8px; + border-radius: 4px; + overflow: hidden; + background: var(--border); +} + +.ratio-left { + background: var(--accent); +} + +.ratio-right { + background: var(--muted); } \ No newline at end of file diff --git a/server/static/sorter_ui.js b/server/static/sorter_ui.js index 62c9158ecb3c2140b83efd2b27ea706a9e0ae127..c8d7c3ef2413399ced7898a7edb3e7a2c8e16163 100644 --- a/server/static/sorter_ui.js +++ b/server/static/sorter_ui.js @@ -81,7 +81,22 @@ }); } + function initVoteSlider() { + var slider = document.getElementById('vote-preference-slider'); + if (!slider) return; + var leftInput = document.getElementById('vote-ratio-left'); + var rightInput = document.getElementById('vote-ratio-right'); + function update() { + var v = parseInt(slider.value, 10); + if (leftInput) leftInput.value = String(v); + if (rightInput) rightInput.value = String(100 - v); + } + slider.addEventListener('input', update); + update(); + } + function initSorterUi() { + initVoteSlider(); document.addEventListener('submit', async function (e) { var f = e.target; if (!f || f.tagName !== 'FORM') return; @@ -89,10 +104,11 @@ if (f.getAttribute('data-navigate') === 'full') return; e.preventDefault(); await postUiForm(f); - if (f.id === 'vote-form') { + if (f.id === 'vote-form' || f.id === 'vote-compare-form') { f.reset(); - var firstField = f.querySelector('input[type="text"]'); - if (firstField) firstField.focus(); + var slider = f.querySelector('#vote-preference-slider'); + if (slider) slider.value = '50'; + initVoteSlider(); } }); }