Side A fixes multiple real bugs across the request path (out-of-band delegate attribution, incorrect fallback that silently expanded the sibling pool, dead swap-side UI, and a route rename), touching rpc, ui_html, vote.rs, lib.rs and updating integration tests/browser tests consistently. Side B is a smaller, cosmetic-only change (color gradient normalization) with good test coverage but lower functional impact and narrower scope than A's cross-cutting correctness fixes.
constitution · epochs · watch · epoch 3
c_11d6a0ec9839 (tommy-mor) vs c_66eb04076a98 (tommy-mor)
download prompt · raw event · cmp_471be74fdf4032
council reasoning
A fixes real behavioral bugs: out-of-band WEB_BROWSER_AGENT delegation (correct attribution instead of stuffing it into DSL text), and refusing next-pair suggestions when the sibling pool has fewer than 2 items instead of silently falling back to the entire item set. B’s min–max score gradient is a clear UX improvement over ordinal coloring, but it is presentational polish versus A’s correctness and routing fixes.
Side A fixes multiple functional issues: it passes delegate attribution out-of-band instead of embedding it in DSL text, prevents an incorrect fallback to all items when the sibling pool has fewer than two candidates by returning no next pair, and simplifies the voting flow by removing the swap button while consistently renaming the route from `/vote/compare` to `/vote` across server code and tests. Side B improves UI coloring by basing gradients on normalized vote scores within a group and adds solid tests, but this is primarily a presentation enhancement rather than a correctness or workflow fix.
sides
A — c_11d6a0ec9839 (tommy-mor)
message
[36e92cf2] Fix /vote/compare page: attribution, fallback, swap button, and route rename. - Pass WEB_BROWSER_AGENT as delegate_opt out-of-band (not in DSL body text) - Error instead of falling back to all items when sibling pool < 2 - Remove pointless "swap sides" button - Rename route /vote/compare → /vote Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
diff preview
diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs
index 0b7070a2b7289e5c40778c8d062bd4150285a8df..8e3d8e34922ddaaa89ea699edd99402bd40fdb1b 100644
--- a/server/src/api/rpc.rs
+++ b/server/src/api/rpc.rs
@@ -281,6 +281,7 @@ pub async fn rpc_post_with_bearer(
bearer_token: &str,
room: String,
thread_tag: String,
+ delegate_opt: Option<String>,
text: String,
) -> Result<RpcResult, RpcErr> {
use axum::http::{header, HeaderMap, HeaderValue};
@@ -288,7 +289,7 @@ pub async fn rpc_post_with_bearer(
let hv = HeaderValue::from_str(&format!("Bearer {bearer_token}"))
.map_err(|_| ("invalid session token".into(), None))?;
headers.insert(header::AUTHORIZATION, hv);
- rpc_post(state, &headers, room, thread_tag, None, text, false).await
+ rpc_post(state, &headers, room, thread_tag, delegate_opt, text, false).await
}
async fn rpc_check(
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index a13a94767d8905adc09e31effff838e166781b6c..5faa642451d69555cb391974beb0ad22c9355c8c 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -118,6 +118,7 @@ async fn dispatch_ui_action(
&session.bearer,
room.clone(),
thread_tag.clone(),
+ None,
text,
)
.await
@@ -246,8 +247,7 @@ async fn dispatch_ui_action(
}
let text = format!(
- "@{}\n{{\n{}\n}}\n{} {}:{} {}\n",
- crate::api::auth::WEB_BROWSER_AGENT,
+ "{{\n{}\n}}\n{} {}:{} {}\n",
exp,
left_id.as_str(),
rl,
@@ -260,6 +260,7 @@ async fn dispatch_ui_action(
&session.bearer,
room.clone(),
thread_tag.clone(),
+ Some(crate::api::auth::WEB_BROWSER_AGENT.to_string()),
text,
)
.await
diff --git a/server/src/html/forum/nav.rs b/server/src/html/forum/nav.rs
index d33dc50f0b89a5ed7730c3bca204a67f6309388f..ae67f550a4bdf0bbeff58384c29a715015b64bd5 100644
--- a/server/src/html/forum/nav.rs
+++ b/server/src/html/forum/nav.rs
@@ -85,7 +85,7 @@ impl ThreadNav {
format!("{}/{}/{}", self.thread_path_prefix, tag, idx)
}
- /// Empty for public; `/r/:seg` for room — prefix for routes like `/vote/compare`.
+ /// Empty for public; `/r/:seg` for room — prefix for routes like `/vote`.
pub(crate) fn room_path_prefix_for_vote_compare(&self) -> String {
match &self.scope {
ScopeId::Public => String::new(),
diff --git a/server/src/html/garden/vote.rs b/server/src/html/garden/vote.rs
index bdb4008c29ba49d7dc97b1cd2c32d9cdfce1ca04..1d7fc8aa7436bfa9c1186dfd940a8d751ab7e088 100644
--- a/server/src/html/garden/vote.rs
+++ b/server/src/html/garden/vote.rs
@@ -208,7 +208,7 @@ pub(crate) async fn vote_compare_post_success_js(
state: &AppState,
nav: &ThreadNav,
_room_wire: &str,
- thread_tag: &str,
+ _thread_tag: &str,
left: &ItemId,
right: &ItemId,
_post_id: &str,
@@ -218,8 +218,7 @@ pub(crate) async fn vote_compare_post_success_js(
let content = content_for_garden_view(&reduced, &nav.scope());
let edge_history = vote_edge_history_markup(content, left, right);
let next_pair = suggest_next_vote_pair(content, left, right);
- let nav_markup =
- vote_compare_nav_markup(nav, next_pair.as_ref(), left, right, Some(thread_tag));
+ let nav_markup = vote_compare_nav_markup(nav, next_pair.as_ref());
drop(reduced);
JsBuilder::new()
.morph_inner_selector("#vote-edge-history-region", edge_history)
@@ -236,7 +235,7 @@ pub(super) fn vote_compare_href(
let left_q = urlencoding::encode(left.as_str());
let right_q = urlencoding::encode(right.as_str());
let base = format!(
- "{}/vote/compare?left={}&right={}",
+ "{}/vote?left={}&right={}",
nav.room_path_prefix_for_vote_compare(),
left_q,
right_q
@@ -251,12 +250,8 @@ pub(super) fn vote_compare_href(
fn vote_compare_nav_markup(
nav: &ThreadNav,
next_pair: Option<&(ItemId, ItemId)>,
- left: &ItemId,
- right: &ItemId,
- thread_override: Option<&str>,
) -> maud::Markup {
let next_pair_href = next_pair.map(|(nl, nr)| vote_compare_href(nav, nl, nr, None));
- let swap_pair_href = vote_compare_href(nav, right, left, thread_override);
html! {
div class="vote-compare-nav" {
@if let Some(href) = &next_pair_href {
@@ -264,7 +259,6 @@ fn vote_compare_nav_markup(
} @else {
span class="vote-compare-next is-disabled" { "no next pair" }
}
- a class="vote-compare-next" href=(swap_pair_href) { "swap sides" }
}
}
}
@@ -274,7 +268,7 @@ pub(super) fn suggest_next_vote_pair(
current_left: &ItemId,
current_right: &ItemId,
) -> Option<(ItemId, ItemId)> {
- let mut pool: Vec<ItemId> = if current_left.parent().as_ref().map(|p| p.as_str())
+ let pool: Vec<ItemId> = if current_left.parent().as_ref().map(|p| p.as_str())
== current_right.parent().as_ref().map(|p| p.as_str())
{
current_left
@@ -291,7 +285,7 @@ pub(super) fn suggest_next_vote_pair(
Vec::new()
};
if pool.len() < 2 {
- pool = content.items.iter().cloned().collect();
+ return None;
}
suggest_next_pair_in_pool(
&content.ranking_group,
@@ -334,7 +328,7 @@ pub struct VoteCompareQuery {
pub thread: Option<String>,
}
-/// Public pairwise vote UI — `/vote/compare?left=&right=&thread=`.
+/// Public pairwise vote UI — `/vote?left=&right=&thread=`.
pub async fn vote_compare_page(
State(state): State<AppState>,
Query(q): Query<VoteCompareQuery>,
@@ -426,7 +420,7 @@ async fn vote_compare_inner(
let next_path = uri
.path_and_query()
.map(|pq| pq.as_str().to_string())
- .unwrap_or_else(|| "/vote/compare".into());
+ .unwrap_or_else(|| "/vote".into());
let rpc_json = template_json_compact(&json!({
"action": "vote_compare_post",
@@ -462,7 +456,7 @@ async fn vote_compare_inner(
Some(&item_bodies_for_cards),
))
}
- (vote_compare_nav_markup(&nav, next_pair.as_ref(), &left, &right, q.thread.as_deref()))
+ (vote_compare_nav_markup(&nav, next_pair.as_ref()))
div id="vote-edge-history-region" {
(edge_history)
}
diff --git a/server/src/html/ui_action.rs b/server/src/html/ui_action.rs
index c7b643c8dde37dd1e3001f5ff716fe4f79d24c5e..dd5294202569454acf9f41b458718700bbf96f39 100644
--- a/server/src/html/ui_action.rs
+++ b/server/src/html/ui_action.rs
@@ -38,7 +38,7 @@ pub enum HtmlUiAction {
#[serde(default)]
form_id: Option<String>,
},
- /// Post a pairwise vote from `/vote/compare` (browser compose).
+ /// Post a pairwise vote from `/vote` (browser compose).
VoteComparePost {
room: String,
thread_tag: String,
diff --git a/server/src/lib.rs b/server/src/lib.rs
index ad8e31099c807fb5844acb16cd5086a2f19327a7..e102e73ec167fa35dcc22d4ec8c6f40202982429 100644
--- a/server/src/lib.rs
+++ b/server/src/lib.rs
@@ -82,9 +82,9 @@ pub fn create_app(state: AppState) -> Router {
.route("/u/:username", get(crate::html::user_profile_page))
.route("/try", get(crate::html::editor_page))
.route("/try/check", post(crate::html::editor_check))
- .route("/vote/compare", get(crate::html::vote_compare_page))
+ .route("/vote", get(crate::html::vote_compare_page))
.route(
- "/r/:room_key/vote/compare",
+ "/r/:room_key/vote",
get(crate::html::room_vote_compare_page),
)
.route("/~", get(crate::html::garden_index))
diff --git a/server/tests/integration_html.rs b/server/tests/integration_html.rs
index a25a456c7cafd74a69e65e0c2e8ed2bfb2e27498..b3e31376737b2d5aabb739f408984a1f5ae0c974 100644
--- a/server/tests/integration_html.rs
+++ b/server/tests/integration_html.rs
@@ -52,12 +52,12 @@ async fn test_view_counts_increment_and_display() {
.normalized_storage()
.to_storage_string();
let vote_q_right_first = format!(
- "/vote/compare?right={}&left={}",
+ "/vote?right={}&left={}",
urlencoding::encode(&right),
urlencoding::encode(&left)
);
let vote_q_left_first = format!(
- "/vote/compare?left={}&right={}",
+ "/vote?left={}&right={}",
urlencoding::encode(&left),
urlencoding::encode(&right)
);
@@ -148,7 +148,7 @@ https://github.com/ghvotehi/a/issues/10 {\n\
.normalized_storage()
.to_storage_string();
let q = format!(
- "/vote/compare?left={}&right={}",
+ "/vote?left={}&right={}",
urlencoding::encode(&left),
urlencoding::encode(&right)
);
diff --git a/server/tests/integration_ui.rs b/server/tests/integration_ui.rs
index 091afd922d28f48ab36aa9b9084aa825036d1f74..23db7b5672418d5bb0ab7529e05ef1f89e59062a 100644
--- a/server/tests/integration_ui.rs
+++ b/server/tests/integration_ui.rs
@@ -110,7 +110,7 @@ async fn test_choose_username_carries_redirect_next() {
.expect("pending session must exist");
pending.provider = Some("google".to_string());
pending.provider_id = Some("google-user-redirect".to_string());
- pending.redirect_next = Some("/vote/compare?left=%7E%2Fa&right=%7E%2Fb".to_string());
+ pending.redirect_next = Some("/vote?left=%7E%2Fa&right=%7E%2Fb".to_string());
}
let choose = client
@@ -122,7 +122,7 @@ async fn test_choose_username_carries_redirect_next() {
assert_eq!(choose.status(), reqwest::StatusCode::OK);
let body = choose.text().await.unwrap();
- assert!(body.contains("window.location = \"/vote/compare?left=%7E%2Fa&right=%7E%2Fb\""));
+ assert!(body.contains("window.location = \"/vote?left=%7E%2Fa&right=%7E%2Fb\""));
}
#[tokio::test]
diff --git a/test/browser_vote_compare.clj b/test/browser_vote_compare.clj
index 7cd1e467fdd8a4480df1be2afd78adb45b9cdc6d..a98e144756421e1fa5064db6a9fc0dd8b08471c7 100644
--- a/test/browser_vote_compare.clj
+++ b/test/browser_vote_compare.clj
@@ -66,7 +66,7 @@
:headers {"Authorization" (str "Bearer " alice-token)})
post-json (json/parse-string (:body post-resp) false)
_ (is (true? (get-in post-json ["results" 0 "ok"])) "seed items + edge vote via rpc")
- cmp-url (str base-url "/vote/compare?left=" (enc left-url) "&right=" (enc third-url))]
+ cmp-url (str base-url "/vote?left=" (enc left-url) "&right=" (enc third-url))]
(core/with-playwright [pw]
(core/with-browser [browser (core/launch-chromium pw {:headless true :channel "chrome"})]
(core/with-context [ctx (core/new-context browser)]
B — c_66eb04076a98 (tommy-mor)
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 <cursoragent@cursor.com>
diff preview
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<ItemId>,
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<AppState>, 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]
Hardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.