Side B removes real dead code and a duplicated code path (ExpandNewThreadForm action plus its toolbar), unifying home page compose rendering with room pages via direct SSR—reducing complexity and maintenance burden. Side A adds a legitimate feature (vote counts, unpin form) but with more surface area of speculative UI polish (CSS across two themes, vote count formatting) that is less structurally significant than B's consolidation, though A does include a solid regression test for its new helper function.
constitution · epochs · watch · epoch 3
c_3ff71f7eaeda (tommy-mor) vs c_f515f8a12d7a (tommy-mor)
download prompt · raw event · cmp_e922caa5b0b20b
council reasoning
A adds lasting product behavior: correct unpin via POST set_garden_pin clear (replacing a wrong permalink HUD), pairwise edge vote counts in the garden UI with a focused helper + unit/browser tests, and supporting CSS/docs. B only deletes ExpandNewThreadForm and the home toolbar +, inlining the already-existing new-thread slot SSR—valuable cleanup but no new capability comparable to A’s fix and feature.
Side B simplifies the UI architecture by removing the `ExpandNewThreadForm` HTML action and its server-side dispatch, replacing the home page's extra toolbar with the same SSR `#new-thread-ui-slot` used on room pages. This eliminates duplicated interaction flow and dead code across `ui_html.rs`, `ui_action.rs`, and `feed.rs`, whereas Side A mainly adds UI enhancements (vote counts, HUD unpin button), styling, and supporting helpers/tests without a comparable architectural simplification.
sides
A — 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 omittedB — c_f515f8a12d7a (tommy-mor)
message
[601d3a05] fix(html): drop home toolbar + and ExpandNewThreadForm (single + flow) Public home now SSRs #new-thread-ui-slot like room pages: collapsed compose for signed-in users, login hint when logged out. Removes the extra toolbar that morphed the same collapsed state and the expand_new_thread_form action. Made-with: Cursor
diff preview
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index e979053ff1ba8c0e2bf55add0a32b7de11cf1e56..f3ce5cb2ab2f923440a8479d0f1fb4acbba166ca 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -139,51 +139,6 @@ async fn dispatch_ui_action(
}
}
}
- HtmlUiAction::ExpandNewThreadForm { room_wire } => {
- let room_wire = room_wire.trim().to_string();
- if room_wire.is_empty() {
- return ui_js_warn("missing room").into_response();
- }
- if room_wire == "public" {
- let reduced = state.reduced.read().await;
- let user = session.map(|s| s.username.as_str());
- drop(reduced);
- let markup = if user.is_some() {
- fragment_new_thread_slot(&ThreadNav::public(), true, false)
- } else {
- login_to_post_hint_markup()
- };
- return JsBuilder::new()
- .morph_inner_selector("#new-thread-ui-slot", markup)
- .into_response();
- }
- let reduced = state.reduced.read().await;
- let user = session.map(|s| s.username.as_str());
- if !reduced.rooms.contains(&room_wire) {
- drop(reduced);
- return ui_js_warn("room not found").into_response();
- }
- if !user_can_view_room(&reduced, &room_wire, user) {
- drop(reduced);
- return ui_js_warn("forbidden").into_response();
- }
- let can_post = session
- .as_ref()
- .map(|s| user_can_post_room(&reduced, &room_wire, &s.username))
- .unwrap_or(false);
- drop(reduced);
- let Some(nav) = ThreadNav::from_room_id(&room_wire) else {
- return ui_js_warn("bad room").into_response();
- };
- let markup = if can_post {
- fragment_new_thread_slot(&nav, true, false)
- } else {
- login_to_post_hint_markup()
- };
- JsBuilder::new()
- .morph_inner_selector("#new-thread-ui-slot", markup)
- .into_response()
- }
HtmlUiAction::SetRoomMembersExpanded { room_wire, expanded } => {
let room_wire = room_wire.trim().to_string();
if room_wire.is_empty() {
diff --git a/server/src/html/forum/feed.rs b/server/src/html/forum/feed.rs
index 1b4ae7baa3ad4757b74172d7b67f3f2b33d1075d..945bdd6c48bc164e2cf91fd0996c4321b75f5abf 100644
--- a/server/src/html/forum/feed.rs
+++ b/server/src/html/forum/feed.rs
@@ -14,6 +14,7 @@ use crate::timeago;
use super::ingest::ingest_entry_markup;
use super::nav::ThreadNav;
+use super::new_thread::{fragment_new_thread_slot, login_to_post_hint_markup};
use super::page::auth_strip;
use super::paginator::{render_thread_paginator, PAGE_SIZE};
use crate::html::{
@@ -217,9 +218,6 @@ pub async fn home(
let strip = auth_strip(&headers, &jar, &reduced_read);
drop(reduced_read);
- use crate::html::ui_action::{HtmlUiAction, UI_RPC_FIELD};
- use crate::form_template::template_json_compact;
-
let page = layout(
"slug.social",
"view-thread",
@@ -243,15 +241,13 @@ pub async fn home(
}
}
p class="muted" { "dark = time-ordered · light = vote-ranked" }
- div class="thread-feed-toolbar" {
- form method="POST" action="/ui" {
- input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::ExpandNewThreadForm {
- room_wire: "public".into(),
- }).expect("static json"));
- button type="submit" class="section-add-btn" { "+" }
+ div id="new-thread-ui-slot" {
+ @if user.is_some() {
+ (fragment_new_thread_slot(&nav, true, false))
+ } @else {
+ (login_to_post_hint_markup())
}
}
- div id="new-thread-ui-slot" {}
(render_thread_feed(Some(&nav), "thread-feed", &public_rows, now))
(cli_panel(&["npx slugsocial public forum list"]))
},
diff --git a/server/src/html/ui_action.rs b/server/src/html/ui_action.rs
index 5031ebfeb928f28e471f23210b8c644654adb7c7..da0c9b3541e8e4988a78768cddef22324755c3f2 100644
--- a/server/src/html/ui_action.rs
+++ b/server/src/html/ui_action.rs
@@ -38,11 +38,6 @@ pub enum HtmlUiAction {
RedactPost {
post_id: String,
},
- /// Morph `#new-thread-ui-slot` inner to the collapsed compose toggle (or login hint).
- /// Use `room_wire: "public"` for the public forum home; otherwise a private room id (`short/slug`).
- ExpandNewThreadForm {
- room_wire: String,
- },
/// Morph `#room-members-section` — members list open or collapsed (server-rendered).
SetRoomMembersExpanded {
room_wire: String,
@@ -131,26 +126,6 @@ mod tests {
);
}
- #[test]
- fn expand_new_thread_form_public() {
- let template = serde_json::json!({
- "action": "expand_new_thread_form",
- "room_wire": "public",
- });
- let mut form = HashMap::new();
- form.insert(
- UI_RPC_FIELD.to_string(),
- serde_json::to_string(&template).unwrap(),
- );
- let a = parse_html_ui_from_form(&form).unwrap();
- assert_eq!(
- a,
- HtmlUiAction::ExpandNewThreadForm {
- room_wire: "public".into(),
- }
- );
- }
-
#[test]
fn expand_post_full_round_trip() {
let template = serde_json::json!({
Hardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.