Side A adds a genuinely new capability (vote-compare page/route, slider UI, typed :i32 form holes, next-navigation) with new tests covering the added behavior, representing net-new functionality. Side B is a clean simplification/dead-code removal (dropping a redundant toolbar and action) which is valuable but smaller in scope and mostly deletes rather than builds lasting functionality.
constitution · epochs · watch · epoch 3
c_939729b4d9a6 (tommy-mor) vs c_f515f8a12d7a (tommy-mor)
download prompt · raw event · cmp_550ea3aaf244c1
council reasoning
A lands a real feature slice: typed $form:i32 substitution with tests, RecordVote next-navigation, vote CTA/routes/CSS/slider wiring, and dependency support—durable product behavior. B only deletes ExpandNewThreadForm and SSRs the home compose slot, a worthwhile consistency cleanup that removes churn but adds no comparable capability.
Side A introduces substantive functionality: typed form-template substitution for `i32` fields with tests, adds a `next` navigation field to vote handling, wires a new `/vote` route and vote entry point, and updates the client to support vote sliders and redirects. Side B is primarily a UI simplification that removes the `ExpandNewThreadForm` action and replaces a toolbar with server-rendered content, improving flow but mostly deleting and reorganizing existing behavior rather than adding lasting capabilities.
sides
A — 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 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.