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: [30a67104] fixes Side B — unified diff (full patch): diff --git a/agents.md b/agents.md index a6a283716e09fcaba1fd690f4e877e0bbecda2c0..d9a924d2f77c444d9b112bbf37a480b963ace4f0 100644 --- a/agents.md +++ b/agents.md @@ -39,7 +39,7 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma - **`VoteComparePost`:** On success returns **`text/javascript`** that **morphs** **`#vote-compare-preview`** (new ingest card) and **`#vote-edge-history-region`** (recomputed edge list). Uses **`RpcResult::PostOk`**’s **`post_id`** / **`post_index`** for the card. **`__rpc__`** carries **`form_action: "/ui"`**; **`thread_tag`** and ratio fields come from the same form as **`$form`** holes. -- **Garden pin / compare voting:** Cookie **`slug_garden_pin`** via **`set_garden_pin`**. Pairwise UI: **`GET /vote/compare?…`** / **`GET /r/:room_key/vote/compare?…`**. HUD: **`#slug-pin-hud`** when **`layout`** passes garden metadata on **`body`**. +- **Garden pin / compare voting:** Cookie **`slug_garden_pin`** via **`set_garden_pin`**. Pairwise UI: **`GET /vote/compare?…`** / **`GET /r/:room_key/vote/compare?…`**. HUD: **`#slug-pin-hud`** when **`layout`** passes garden metadata on **`body`**; the label is **`POST /ui`** **`set_garden_pin`** **`clear:true`** (**`slug_ui.js`**), not a permalink to the item. **Rule of thumb:** New **CLI or API** verbs → `RpcCommand`. New **in-page morph or form-driven** behavior that only makes sense in the browser → `HtmlUiAction`. If both need the same operation, implement the real work once (e.g. call shared RPC helpers from `post_ui_html`) and keep the wire shapes separate. diff --git a/server/src/html/garden.rs b/server/src/html/garden.rs index 8a46b1e92d38f2c390f88d48750d95d11388cd73..121d9498e8cb93d4d001dc1bbce23d74fbb958f5 100644 --- a/server/src/html/garden.rs +++ b/server/src/html/garden.rs @@ -16,7 +16,6 @@ use crate::{ canonical_path::{canonicalize_item, canonicalize_tag}, form_template::template_json_compact, html::{ - forum::ingest_entry_markup, ui_action::UI_RPC_FIELD, user_can_post_room, JsBuilder, @@ -111,6 +110,23 @@ fn votes_for_edge(content: &ContentState, a: &ItemId, b: &ItemId) -> Vec usize { + let (lo, hi) = canonical_edge_items(a, b); + let lo_s = lo.as_str(); + let hi_s = hi.as_str(); + content + .item_votes + .get(&lo) + .into_iter() + .flat_map(|q| q.iter()) + .filter(|v| { + (v.a.as_str() == lo_s && v.b.as_str() == hi_s) + || (v.a.as_str() == hi_s && v.b.as_str() == lo_s) + }) + .count() +} + fn vote_thread_tags_for_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> Vec { let set: HashSet = content .item_threads @@ -288,6 +304,7 @@ fn child_row_pin_or_vote( nav: &ThreadNav, row_item: &ItemId, pinned_room_and_item: Option<&(String, ItemId)>, + scope_content: &ContentState, next_path: &str, ) -> maud::Markup { let pin_matches_scope = pinned_room_and_item @@ -315,7 +332,16 @@ fn child_row_pin_or_vote( @if pi == row_item { span class="ont-garden-pinned-here" title="Pinned" aria-label="Pinned" { "📌" } } @else { - a class="ont-garden-vote-ico" href=(vote_compare_href(nav, pi, row_item, None)) title="Vote vs pinned" aria-label="Vote" { "⚖" } + @let nv = edge_vote_count_for_pair(scope_content, pi, row_item); + @let tip = format!( + "Compare and vote — {nv} pairwise vote{} in this scope for pinned vs this row", + if nv == 1 { "" } else { "s" }, + ); + @let aria = format!("Vote; {} pairwise {}", nv, if nv == 1 { "vote" } else { "votes" }); + a class="ont-garden-vote-ico" href=(vote_compare_href(nav, pi, row_item, None)) title=(tip) aria-label=(aria) { + span class="ont-garden-vote-glyph" aria-hidden="true" { "⚖" } + span class="ont-garden-vote-count" { (format!("{}", nv)) } + } } } @else { form method="POST" action="/ui" data-navigate="full" class="ont-pin-form ont-garden-pin-form" { @@ -978,10 +1004,9 @@ async fn render_scope_view( ) -> axum::response::Response { let scope = nav.scope(); let pin_ref = pinned_item_from_jar(&jar); - let model = { - let reduced = state.reduced.read().await; - build_item_page_view_model(&reduced, &scope, browse.item()) - }; + let reduced = state.reduced.read().await; + let model = build_item_page_view_model(&reduced, &scope, browse.item()); + let scope_content = content_for_garden_view(&reduced, &scope); let thread_href = |tag: &str| nav.thread_url(tag); let external_empty_body = browse.is_external() && model.body.is_none(); let cli_path_arg = item_display_path(&model.item); @@ -1108,7 +1133,7 @@ async fn render_scope_view( @let item_url = item_href(r.item.as_str(), &nav); @let score_str = format!("{:.3}", r.score); li data-garden-item=(r.item.as_str()) { - (child_row_pin_or_vote(&nav, &r.item, pin_ref.as_ref(), &next_for_pin)) + (child_row_pin_or_vote(&nav, &r.item, pin_ref.as_ref(), scope_content, &next_for_pin)) a class="item-link" href=(item_url) { code { (item_display_path(r.item.as_str())) } } span class="ont-rank-score" { (score_str) } } @@ -1124,7 +1149,7 @@ async fn render_scope_view( ul class="ont-group-list" { @for name in &model.child_rankings.unranked_items { li data-garden-item=(name.as_str()) { - (child_row_pin_or_vote(&nav, name, pin_ref.as_ref(), &next_for_pin)) + (child_row_pin_or_vote(&nav, name, pin_ref.as_ref(), scope_content, &next_for_pin)) @let href = item_href(name.as_str(), &nav); a class="item-link" href=(href) { code { (item_display_path(name.as_str())) } } } @@ -1363,6 +1388,33 @@ mod tests { })); } + #[test] + fn edge_vote_count_for_pair_matches_votes_for_edge_len() { + use super::{ + content_for_garden_view, edge_vote_count_for_pair, votes_for_edge, + }; + use crate::path_types::ItemId; + let mut reduced = ReducerState::default(); + apply_ingest( + &mut reduced, + 1, + "@00000000-0000-0000-0000-000000000000:test:local/test\n\ + ~/topic {root}\n\ + ~/topic/a {alpha}\n\ + ~/topic/b {beta}\n\ + ~/topic/a 3:2 ~/topic/b {first vote}\n\ + ~/topic/b 2:3 ~/topic/a {second vote}\n", + ); + let content = content_for_garden_view(&reduced, &ScopeId::Public); + let a = ItemId::parse("~/topic/a").unwrap().normalized_storage(); + let b = ItemId::parse("~/topic/b").unwrap().normalized_storage(); + assert_eq!( + edge_vote_count_for_pair(content, &a, &b), + votes_for_edge(content, &a, &b).len() + ); + assert_eq!(votes_for_edge(content, &a, &b).len(), 2); + } + #[test] fn item_page_model_includes_body_and_unranked_without_votes() { let mut reduced = ReducerState::default(); diff --git a/server/static/slug_ui.js b/server/static/slug_ui.js index c0de1cddbba78227dfb80bfd41e7b855b3a42bc3..86f935f8dd998f3d6df016f33b1e47f9780782ea 100644 --- a/server/static/slug_ui.js +++ b/server/static/slug_ui.js @@ -170,15 +170,6 @@ return { room: raw.slice(0, i), item: raw.slice(i + 1) }; } - function gardenItemHref(prefix, storageUrl) { - var marker = 'https://slug.social/~/'; - if (storageUrl.indexOf(marker) === 0) { - var tail = storageUrl.slice(marker.length); - return prefix.replace(/\/$/, '') + (tail ? '/' + tail : ''); - } - return storageUrl; - } - function refreshPinHud() { var hud = document.getElementById('slug-pin-hud'); if (!hud) return; @@ -187,19 +178,37 @@ var pin = decodePinCookie(); hud.innerHTML = ''; if (!pin || !prefix || pin.room !== bodyRoom) return; - var a = document.createElement('a'); - a.className = 'slug-pin-hud-link'; - a.href = gardenItemHref(prefix, pin.item); - a.title = 'Pinned item'; + var form = document.createElement('form'); + form.method = 'POST'; + form.action = '/ui'; + form.setAttribute('data-navigate', 'full'); + form.className = 'slug-pin-hud-form'; + var rpc = document.createElement('input'); + rpc.type = 'hidden'; + rpc.name = '__rpc__'; + rpc.value = JSON.stringify({ + action: 'set_garden_pin', + clear: true, + room_wire: '', + next: window.location.pathname + window.location.search, + form_action: '/ui', + }); + form.appendChild(rpc); + var btn = document.createElement('button'); + btn.type = 'submit'; + btn.className = 'slug-pin-hud-link slug-pin-hud-unpin-btn'; + btn.title = 'Unpin — removes this item from the corner HUD'; + btn.setAttribute('aria-label', 'Unpin pinned item'); var span = document.createElement('span'); span.className = 'slug-pin-hud-glyph'; span.setAttribute('aria-hidden', 'true'); span.textContent = '📌'; - a.appendChild(span); + btn.appendChild(span); var label = pin.item.replace(/^https:\/\/slug\.social\/~\/?/, '~/'); if (label.length > 36) label = label.slice(0, 34) + '…'; - a.appendChild(document.createTextNode(' ' + label)); - hud.appendChild(a); + btn.appendChild(document.createTextNode(' ' + label)); + form.appendChild(btn); + hud.appendChild(form); } refreshPinHud(); diff --git a/server/static/theme_default.css b/server/static/theme_default.css index ec0fbe7acee0aa2802f978f14a9b0fc86e78c5b8..9178f0629cfb868348740e6dea1626dc6afb8345 100644 --- a/server/static/theme_default.css +++ b/server/static/theme_default.css @@ -799,6 +799,12 @@ details > summary::-webkit-details-marker { display: none; } } /* Pinned item HUD — bottom bar, same plane as spread */ +.slug-pin-hud-form { + display: inline; + margin: 0; + padding: 0; + border: none; +} #slug-pin-hud.slug-pin-hud { margin-left: auto; max-width: min(42vw, 280px); @@ -808,6 +814,13 @@ details > summary::-webkit-details-marker { display: none; } overflow: hidden; text-overflow: ellipsis; } +.slug-pin-hud-link.slug-pin-hud-unpin-btn { + background: transparent; + border: none; + cursor: pointer; + font-size: inherit; + font-family: inherit; +} .slug-pin-hud-link { color: var(--ui); text-decoration: none; @@ -815,7 +828,10 @@ details > summary::-webkit-details-marker { display: none; } align-items: center; gap: 4px; } -.slug-pin-hud-link:hover { color: var(--signal); } +.slug-pin-hud-link:hover, +.slug-pin-hud-unpin-btn:hover { + color: var(--signal); +} .slug-pin-hud-glyph { font-size: 13px; line-height: 1; } /* Garden pin / vote controls */ @@ -890,10 +906,21 @@ span.ont-garden-pinned-here { align-items: center; justify-content: center; } +a.ont-garden-vote-ico { + gap: 4px; +} a.ont-garden-vote-ico:hover { color: var(--signal); background: var(--g4); } +.ont-garden-vote-count { + font-size: 10px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} +span.ont-garden-vote-glyph { + line-height: 1; +} span.ont-garden-pinned-here { border-color: var(--link); color: var(--signal); diff --git a/server/static/theme_retro_craft.css b/server/static/theme_retro_craft.css index 092d4b3540b052cc86870179e4c264541c4446cc..efaeed06d01fd835df0621f4c621c8f034c8b536 100644 --- a/server/static/theme_retro_craft.css +++ b/server/static/theme_retro_craft.css @@ -593,6 +593,12 @@ body.view-ontology #controls { (Global craft `code` + `button[type=submit]` are tuned for dark thread pages; these overrides match the cream ontology shell.) ---------------------------------------------------------------- */ +body.view-ontology .slug-pin-hud-form { + display: inline; + margin: 0; + padding: 0; + border: none; +} body.view-ontology #slug-pin-hud.slug-pin-hud { margin-left: auto; max-width: min(42vw, 280px); @@ -603,6 +609,14 @@ body.view-ontology #slug-pin-hud.slug-pin-hud { overflow: hidden; text-overflow: ellipsis; } +body.view-ontology .slug-pin-hud-link.slug-pin-hud-unpin-btn { + background: transparent; + border: none; + cursor: pointer; + font-size: inherit; + font-family: inherit; + padding: 0; +} body.view-ontology .slug-pin-hud-link { color: #1a4a8c; text-decoration: none; @@ -610,7 +624,8 @@ body.view-ontology .slug-pin-hud-link { align-items: center; gap: 0.25rem; } -body.view-ontology .slug-pin-hud-link:hover { +body.view-ontology .slug-pin-hud-link:hover, +body.view-ontology .slug-pin-hud-unpin-btn:hover { color: #0d3d82; text-decoration: underline; } @@ -676,6 +691,20 @@ body.view-ontology .ont-garden-child-actions { gap: 0.1rem; } body.view-ontology button.ont-garden-pin-ico, +body.view-ontology a.ont-garden-vote-ico { + gap: 0.2rem; +} +body.view-ontology .ont-garden-vote-count { + font-size: 0.65rem; + font-family: var(--font-ui); + font-weight: 600; + font-variant-numeric: tabular-nums; + opacity: 0.92; +} +body.view-ontology span.ont-garden-vote-glyph { + line-height: 1; +} +body.view-ontology button.ont-garden-pin-ico, body.view-ontology a.ont-garden-vote-ico, body.view-ontology span.ont-garden-pinned-here { font-size: 0.95rem; diff --git a/test/browser_garden_pin.clj b/test/browser_garden_pin.clj index 169ff9b6913049094611fb0e5b6683b30220b285..98c086d5647207f3ac72dbb9cbc13488c030eef1 100644 --- a/test/browser_garden_pin.clj +++ b/test/browser_garden_pin.clj @@ -21,6 +21,17 @@ (do (Thread/sleep 200) (recur)) false)))))) +(defn- wait-for-absence-substr [pg selector substr timeout-ms] + "True once selector text does not contain substr (or is empty), or timeout." + (let [deadline (+ (System/currentTimeMillis) timeout-ms)] + (loop [] + (let [text (locator/text-content (page/locator pg selector))] + (if (not (and (string? text) (str/includes? text substr))) + true + (if (< (System/currentTimeMillis) deadline) + (do (Thread/sleep 200) (recur)) + false)))))) + (defn garden-pin-flow! [] (println "\n━━━ browser garden pin (POST /ui set_garden_pin + HUD + vote link) ━━━\n") @@ -75,8 +86,14 @@ (is (wait-for-text pg ".ont-item-shell" "~/gp-pin-b" 15000) "on item b page") (is (wait-for-text pg "a.ont-vote-compare-btn" "vote" 10000) "vote link visible vs pinned item") + ;; HUD clears pin (POST set_garden_pin clear), not navigate to item + (locator/click (page/locator pg "#slug-pin-hud button.slug-pin-hud-unpin-btn")) + (is (wait-for-absence-substr pg "#slug-pin-hud" "gp-pin-a" 15000) + "HUD clears after unpin from HUD button") + (is (wait-for-absence-substr pg "body" "ont-vote-compare-btn" 15000) + "compare vote CTA removed after HUD unpin on same reload") (is (str/includes? (or (page/url pg) "") "/~/gp-pin-b") - "still on item b after vote link check")))))) + "still on item b after HUD unpin")))))) (finally (when-some [s @!server] (common/kill-server s))