Commit B introduces substantive new functionality (a new /vote route and page, typed form holes for i32 parsing with tests, JS slider wiring, and CSS for a real feature) despite the vague commit message, while A adds a modest CLI display helper with tests but no new capability beyond formatting existing stats. B's changes touch more of the actual product surface (routing, templating, UI) with lasting design implications, whereas A is a small, self-contained convenience feature.
constitution · epochs · watch · epoch 3
c_d6d339485601 (tommy-mor) vs c_939729b4d9a6 (tommy-mor)
download prompt · raw event · cmp_86ae2552353885
council reasoning
B delivers a substantial product feature: typed $form:i32 substitution, record-vote next navigation, a /vote compare flow with slider JS/CSS, and item-page CTAs—core voting UX wiring—whereas A only formats already-existing ConnectivityStats into CLI pair output plus two unit tests. B’s design and surface-area expand lasting capability; A is a small, correct display convenience.
Side B introduces substantive new behavior across the server: typed form-template substitution for integer fields (`$form:i32`) with tests, a new `next` navigation flow after recording votes, routing and UI hooks for a vote page, and supporting client-side slider logic and styling. Side A is a useful CLI enhancement that formats and displays existing connectivity statistics with good test coverage, but it mainly exposes existing data rather than adding comparable new functionality.
sides
A — c_d6d339485601 (tommy-mor)
message
[14749a34] Show graph topology with pair suggestions Expose existing connectivity statistics in CLI output so voters can see sparse or disconnected scopes before adding an edge. Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/cli/src/main.rs b/cli/src/main.rs
index 70435b412188a151c5e89e842a5de57f7480ddf2..abb5a55b49f60fe28fbfd4ec02715cb94ea0b4ec 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -397,6 +397,29 @@ fn print_item_response(resp: &ItemResponse) {
}
}
+fn format_connectivity_stats(stats: &ConnectivityStats) -> String {
+ let density = if stats.pairs_possible == 0 {
+ 0.0
+ } else {
+ stats.pairs_voted as f64 / stats.pairs_possible as f64 * 100.0
+ };
+ let component_label = if stats.components == 1 { "component" } else { "components" };
+ let connection_status = if stats.comparisons_until_connected == 0 {
+ "connected".to_string()
+ } else {
+ let comparison_label = if stats.comparisons_until_connected == 1 {
+ "comparison"
+ } else {
+ "comparisons"
+ };
+ format!("{} {comparison_label} to connect", stats.comparisons_until_connected)
+ };
+ format!(
+ "graph: {} items, {}/{} pairs ({density:.1}% density), {} {component_label}, {connection_status}",
+ stats.items, stats.pairs_voted, stats.pairs_possible, stats.components,
+ )
+}
+
fn print_pair_response(resp: &PairResponse) {
println!("{} vs {}", resp.left, resp.right);
if let Some(b) = &resp.left_body {
@@ -413,6 +436,10 @@ fn print_pair_response(resp: &PairResponse) {
println!();
println!("threads: {}", resp.threads.iter().map(|t| format!("#{t}")).collect::<Vec<_>>().join(" "));
}
+ if let Some(stats) = &resp.connectivity {
+ println!();
+ println!("{}", format_connectivity_stats(stats));
+ }
println!();
println!("---");
println!("Before writing your comparison, talk to your human. Ask them about their relationship to these two items — which one has meant more to them, whether their perspective has changed over time, and where they land now. Listen to what they say. Then write a comparison that synthesizes their perspective with your own analysis. The best submissions on slug.social carry both voices — your reasoning and their experience.");
@@ -816,6 +843,43 @@ fn write_secret_file(name: &str, contents: &str) -> Result<()> {
Ok(())
}
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn connectivity_stats_show_sparse_disconnected_graph() {
+ let stats = ConnectivityStats {
+ items: 9,
+ components: 3,
+ comparisons_until_connected: 2,
+ pairs_voted: 8,
+ pairs_possible: 36,
+ };
+
+ assert_eq!(
+ format_connectivity_stats(&stats),
+ "graph: 9 items, 8/36 pairs (22.2% density), 3 components, 2 comparisons to connect"
+ );
+ }
+
+ #[test]
+ fn connectivity_stats_show_connected_graph() {
+ let stats = ConnectivityStats {
+ items: 4,
+ components: 1,
+ comparisons_until_connected: 0,
+ pairs_voted: 3,
+ pairs_possible: 6,
+ };
+
+ assert_eq!(
+ format_connectivity_stats(&stats),
+ "graph: 4 items, 3/6 pairs (50.0% density), 1 component, connected"
+ );
+ }
+}
+
async fn run_scoped(base: &str, room: &str, sub: ScopedCmd) -> Result<()> {
let room = room.trim();
let client = http_client()?;
B — c_939729b4d9a6 (tommy-mor)
message
[7964b28f] fix
diff preview
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<T: Serialize>(v: &T) -> serde_json::Result<String>
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<String, String>) {
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.
… preview truncated; 4,992 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.