Side B fixes a real UX bug (HUD 'pinned item' link navigating away instead of properly unpinning, replaced with a correct POST-based unpin form), adds a genuinely useful vote-count indicator with backing unit test, and includes corresponding CSS and browser-test coverage. Side A is a smaller, more localized change replacing a fallback default with a panic/expect and updating doc comments/tests, which is reasonable but lower-impact and riskier (introducing a potential panic) compared to B's broader, well-tested feature fix.
constitution · epochs · watch · epoch 3
c_6a02ffb06a41 (tommy-mor) vs c_3ff71f7eaeda (tommy-mor)
download prompt · raw event · cmp_bcc1232887832b
council reasoning
A permanently reconciles rank-history thread_post_index with the 0-based /t/tag/N routes (types, RPC, garden recv view), replaces silent unwrap_or(0)/1-based offsets with a hard expect, and locks the contract in integration asserts—fixing link bugs class. B adds solid UX (edge vote counts, HUD unpin via set_garden_pin clear) plus tests/CSS, but it is product surface polish rather than a shared data/URL invariant fix.
Side B adds substantive user-facing functionality: it introduces pairwise vote counts in the garden UI with a tested helper (`edge_vote_count_for_pair`), changes the pin HUD to perform an unpin action via `POST /ui`, updates styling, and adds browser and unit tests covering the new behavior. Side A mainly corrects thread post indexing semantics by replacing a fallback with `expect`, always rendering the post link, updating documentation, and extending integration tests, which is valuable but much narrower in scope and lasting impact.
sides
A — c_6a02ffb06a41 (tommy-mor)
message
[3bc88847] removed optional
diff preview
diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs
index ba93ca87182d49f57dffc8220f604ffa150f9a6c..5049a26b096bd8435d6eb9e75ccb751b6f489061 100644
--- a/server/src/api/rpc.rs
+++ b/server/src/api/rpc.rs
@@ -1337,8 +1337,7 @@ pub async fn handle_rpc_batch(
.ingests_by_scope_thread
.get(&(scope.clone(), e.thread.clone()))
.and_then(|q| q.iter().rev().position(|id| id == &e.post_id))
- .map(|i| i + 1)
- .unwrap_or(0);
+ .expect("rank history post_id must be in ingests_by_scope_thread for (scope, thread)");
RankHistoryRow {
ts: e.ts,
scope_rank: e.scope_rank,
diff --git a/server/src/html/garden.rs b/server/src/html/garden.rs
index 319feb15a6b68d2b5df98b4289fedbc9bdd048d3..23245d6fdfc9019b199ab8417150faf5f3297067 100644
--- a/server/src/html/garden.rs
+++ b/server/src/html/garden.rs
@@ -450,6 +450,7 @@ struct RankHistoryEntryView {
scope_total: usize,
scope_rank_delta: i32,
thread: String,
+ /// 0-based index as [`crate::html::forum::ingest::thread_post_index_in_scope`] / `/t/tag/N`.
thread_post_index: usize,
caused_by: Vec<crate::reducer::VoteData>,
}
@@ -573,11 +574,11 @@ fn build_rank_history(
})
.unwrap_or_default();
- let thread_post_index = reduced.ingests_by_scope_thread
+ let thread_post_index = reduced
+ .ingests_by_scope_thread
.get(&(scope.clone(), e.thread.clone()))
.and_then(|q| q.iter().rev().position(|id| id == &e.post_id))
- .map(|i| i + 1)
- .unwrap_or(0);
+ .expect("rank history post_id must be in ingests_by_scope_thread for (scope, thread)");
RankHistoryEntryView {
ts: e.ts,
@@ -704,11 +705,9 @@ async fn render_scope_view(
span class="muted" { (ago) (label) }
" · "
a href=(thread_href(&e.thread)) { "#" (e.thread) }
- @if e.thread_post_index > 0 {
- " "
- a href=(format!("{}/{}", thread_href(&e.thread), e.thread_post_index)) {
- span class="muted" { "post #" (e.thread_post_index) }
- }
+ " "
+ a href=(format!("{}/{}", thread_href(&e.thread), e.thread_post_index)) {
+ span class="muted" { "post #" (e.thread_post_index) }
}
}
@if e.caused_by.is_empty() {
diff --git a/server/tests/integration.rs b/server/tests/integration.rs
index d4c5bfe9c6f1c71dc61878bd8c5e729b1b7c69ef..9766adb43ad315a64f5df17b79f66718ce149509 100644
--- a/server/tests/integration.rs
+++ b/server/tests/integration.rs
@@ -1322,6 +1322,11 @@ async fn test_rank_history() {
assert_eq!(entry["scope_rank_delta"], 0, "delta is 0 on first appearance");
let caused_by = entry["caused_by"].as_array().unwrap();
assert_eq!(caused_by.len(), 2, "both votes in the ingest touched rust");
+ assert_eq!(
+ entry["thread_post_index"],
+ 0,
+ "rank history links use same 0-based index as /t/hist-test/0"
+ );
ingest(
"00000000-0000-0000-0000-000000000002:rig:test/model",
@@ -1349,6 +1354,16 @@ async fn test_rank_history() {
assert_eq!(caused_by2.len(), 1);
assert!(caused_by2[0]["a"].as_str().unwrap().ends_with("python") ||
caused_by2[0]["b"].as_str().unwrap().ends_with("python"));
+ assert_eq!(
+ hist2[0]["thread_post_index"],
+ 0,
+ "first hist-test post is chronological index 0"
+ );
+ assert_eq!(
+ hist2[1]["thread_post_index"],
+ 1,
+ "second ingest is chronological index 1"
+ );
let hist_rust2 = rpc_batch(
&client,
diff --git a/types/src/lib.rs b/types/src/lib.rs
index edbb923e4d7d41ad82dfc254c3bd697562383527..49abba8ea9f786ef68e3157d2a5d309e15e09ba0 100644
--- a/types/src/lib.rs
+++ b/types/src/lib.rs
@@ -253,7 +253,7 @@ pub struct FeedPost {
/// Primary thread tag (without #), if the ingest declared one.
#[serde(skip_serializing_if = "Option::is_none")]
pub thread: Option<String>,
- /// 1-indexed chronological position of this post within the thread.
+ /// 1-based display ordinal for this post within the thread (feed only; URLs use 0-based paths).
#[serde(skip_serializing_if = "Option::is_none")]
pub thread_post_index: Option<usize>,
/// Full raw body of the ingest document.
@@ -628,7 +628,7 @@ pub struct RankHistoryRow {
pub score: f64,
/// Thread tag of the ingest that triggered this rank change.
pub thread: String,
- /// 1-indexed chronological position of this post within the thread.
+ /// 0-indexed chronological position of this post within the thread (same as `/t/tag/N` routes).
pub thread_post_index: usize,
/// Votes from this ingest that directly touched this item. Empty when change was transitive.
pub caused_by: Vec<VoteRow>,
B — c_3ff71f7eaeda (tommy-mor)
message
[30a67104] fixes
diff preview
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<crate::
out
}
+/// Number of vote ingests recorded for this unordered pair in `content` (same scope as ranking).
+fn edge_vote_count_for_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> 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<String> {
let set: HashSet<String> = 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-v
… preview truncated; 4,237 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.