Side A is a focused, well-explained fix that replaces awkward redirect-based auth flow with in-place HTML fragment responses, consolidates layout code, and adds matching CSS — a clear, self-contained improvement. Side B, despite being labeled 'fix', bundles unrelated concerns (new dependencies, Cargo.lock churn, a new vote-compare feature, typed form-hole parsing, JS slider logic) into one vague commit, making it harder to evaluate as a coherent, necessary change even though it includes some useful tests.
constitution · epochs · watch · epoch 3
c_64faa3bee86f (tommy-mor) vs c_939729b4d9a6 (tommy-mor)
download prompt · raw event · cmp_857b2c4f1e8006
council reasoning
B adds lasting product surface: typed $form:i32 template holes with tests, RecordVote next-navigation, a /vote route and vote CTA, plus the compare-page CSS/slider wiring that make voting usable. A is a focused auth UX win (fragment responses + poem innerHTML morph, shared layout), but it stays scoped to signup feedback and does not expand core sorter behavior the way B does.
Side A implements a coherent authentication UX improvement: the auth endpoint now returns HTML fragments instead of redirects, the shared form layout is refactored into reusable fragments, the client-side Poem JS morphs form contents when a non-empty response is returned, and matching success styling is added. Side B mixes dependency additions, UI scaffolding, typed form-template support, and CSS for a voting feature, but much of the visible functionality depends on code not present in the patch (such as the new vote module), making its standalone lasting contribution less substantial.
sides
A — c_64faa3bee86f (tommy-mor)
message
[6b6eb0c3] Auth form: poem JS morphs form innerHTML on response; no redirect
- post_choose_username returns HTML fragments instead of redirects:
success → auth_signed_in_fragment ("you're signed in — return to your agent")
error → choose_username_error_fragment (form re-rendered with error inline)
- Poem JS now reads response body; if non-empty, morphs form innerHTML with it
(existing ingest forms return empty body, so they're unaffected)
- auth.rs: keep full layout() with poem JS — revert to single layout
- auth-success CSS class added to both themes
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>diff preview
diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs
index c1194f79fd89fb47fe6b425b494a0ffa667abb2d..cb0faa29b834931e2c2b2f5c174c875e2e2e9346 100644
--- a/server/src/api/auth.rs
+++ b/server/src/api/auth.rs
@@ -16,7 +16,7 @@ use crate::{
canonicalize_username, validate_agent_format, validate_username,
Event, TokenIssued, UserRegistered,
},
- html::{auth_complete_page, choose_username_page},
+ html::{auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment, choose_username_page},
state::{AppState, PendingSession},
};
@@ -274,8 +274,6 @@ pub async fn post_choose_username(
return api_error(StatusCode::BAD_REQUEST, "invalid agent format", Some(msg)).into_response();
}
- let public_url = std::env::var("SLUG_PUBLIC_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_string());
-
let reduced_arc = state.reduced.clone();
let reduced = reduced_arc.read().await;
let provider_key = (provider.to_lowercase(), provider_id.clone());
@@ -284,11 +282,7 @@ pub async fn post_choose_username(
}
if reduced.users_by_provider.values().any(|u| u == &canonicalize_username(&form.username)) {
drop(reduced);
- return Redirect::to(&format!(
- "{public_url}/auth/choose-username?session={}&error={}",
- urlencoding::encode(&form.session),
- urlencoding::encode("that username is taken — try another"),
- )).into_response();
+ return choose_username_error_fragment(&form.session, "that username is taken — try another").into_response();
}
drop(reduced);
@@ -324,7 +318,7 @@ pub async fn post_choose_username(
s.complete = Some((canon_user.clone(), bearer.clone()));
}
- Redirect::to(&format!("{public_url}/auth/complete")).into_response()
+ auth_signed_in_fragment().into_response()
}
pub async fn post_pending_session(
diff --git a/server/src/html/auth.rs b/server/src/html/auth.rs
index 40b1ef30a6c1d4063aa2d8e9c93df8972df4b27c..0a14bdbfb66c65d1bd09993ffd4a7bb6f2fe041e 100644
--- a/server/src/html/auth.rs
+++ b/server/src/html/auth.rs
@@ -1,20 +1,25 @@
-use maud::{html, Markup, DOCTYPE};
+use maud::{html, Markup};
-/// Minimal layout for auth pages — no JS interceptor, real form navigation works.
-fn auth_layout(title: &str, body: Markup) -> Markup {
+fn form_inner(session: &str, error: Option<&str>) -> Markup {
html! {
- (DOCTYPE)
- html {
- head {
- meta charset="utf-8";
- meta name="viewport" content="width=device-width, initial-scale=1";
- title { (title) }
- link rel="stylesheet" href="/static/theme_default.css";
- }
- body class="view-auth" {
- (body)
- }
+ input type="hidden" name="session" value=(session);
+ label for="username" { "username" }
+ input
+ type="text"
+ id="username"
+ name="username"
+ placeholder="e.g. alice"
+ pattern="[a-z0-9_\\-]{1,32}"
+ maxlength="32"
+ autocomplete="off"
+ autofocus;
+ p.auth-hint {
+ "lowercase · alphanumeric · hyphens · underscores · max 32"
}
+ @if let Some(msg) = error {
+ p.auth-error { (msg) }
+ }
+ button type="submit" { "continue" }
}
}
@@ -28,27 +33,23 @@ pub fn choose_username_page(session: &str, error: Option<&str>) -> Markup {
h1 { "choose a username" }
p { "pick a handle for slug.social." }
form.auth-form method="POST" action="/auth/choose-username" {
- input type="hidden" name="session" value=(session);
- label for="username" { "username" }
- input
- type="text"
- id="username"
- name="username"
- placeholder="e.g. alice"
- pattern="[a-z0-9_\\-]{1,32}"
- maxlength="32"
- autocomplete="off"
- autofocus;
- p.auth-hint {
- "lowercase · alphanumeric · hyphens · underscores · max 32"
- }
- @if let Some(msg) = error {
- p.auth-error { (msg) }
- }
- button type="submit" { "continue" }
+ (form_inner(session, error))
}
};
- auth_layout("join — slug.social", body)
+ super::layout("join — slug.social", "view-auth", body, None)
+}
+
+/// Fragment returned to the poem JS on error — replaces the form's innerHTML.
+pub fn choose_username_error_fragment(session: &str, error: &str) -> Markup {
+ form_inner(session, Some(error))
+}
+
+/// Fragment returned to the poem JS on success — replaces the form's innerHTML.
+pub fn auth_signed_in_fragment() -> Markup {
+ html! {
+ p.auth-success { "you're signed in — return to your agent." }
+ p.auth-hint { "you can close this tab." }
+ }
}
pub fn auth_complete_page() -> Markup {
@@ -62,5 +63,5 @@ pub fn auth_complete_page() -> Markup {
p { "Return to your terminal — your agent is polling and will collect your token automatically." }
p.auth-hint { "You can close this tab." }
};
- auth_layout("signed in — slug.social", body)
+ super::layout("signed in — slug.social", "view-auth", body, None)
}
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 2f16d701962d703db0c859bb586dfc08ec385690..8b48a25cce79f0eefc7e29849e1a667cae4c7986 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -15,7 +15,7 @@ mod search;
mod tree;
use breadcrumb_path::OntologyPath;
-pub use auth::{auth_complete_page, choose_username_page};
+pub use auth::{auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment, choose_username_page};
pub use editor::{editor_check, editor_page};
pub use forum::{index, thread_feed_html, thread_post_expand, thread_post_view, thread_view};
pub use garden::{garden_index, ontology_path};
@@ -114,6 +114,8 @@ script { (maud::PreEscaped(r#"
});
// Poem: intercept POST forms, send via fetch, await SSE for DOM update.
+ // If the response body is non-empty HTML, morph the form's innerHTML with it
+ // (used for inline feedback without a page reload, e.g. auth forms).
document.addEventListener('submit', async (e) => {
const f = e.target;
if (!f || f.tagName !== 'FORM') return;
@@ -121,14 +123,19 @@ script { (maud::PreEscaped(r#"
e.preventDefault();
const btn = f.querySelector('button[type="submit"], input[type="submit"]');
if (btn) { btn.disabled = true; btn.textContent = '…'; }
- await fetch(f.action, {
+ const resp = await fetch(f.action, {
method: 'POST',
body: new URLSearchParams(new FormData(f)),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
credentials: 'same-origin',
});
- if (btn) { btn.disabled = false; btn.textContent = 'submit'; }
- f.reset();
+ const html = await resp.text();
+ if (html && html.trim()) {
+ Idiomorph.morph(f, html, {morphStyle: 'innerHTML'});
+ } else {
+ if (btn) { btn.disabled = false; btn.textContent = 'submit'; }
+ f.reset();
+ }
});
// Search: debounced fetch + idiomorph.
diff --git a/server/static/theme_default.css b/server/static/theme_default.css
index 9e71574da4bed3a0116c347610636780678bd1af..a1d8d1765a7812191edc579125facf1694554c2c 100644
--- a/server/static/theme_default.css
+++ b/server/static/theme_default.css
@@ -250,6 +250,11 @@ p.auth-error {
font-size: 12px;
margin: 4px 0 0;
}
+p.auth-success {
+ color: var(--signal);
+ font-size: 13px;
+ margin: 4px 0 0;
+}
/* ----------------------------------------------------------------
BUTTONS — raised, press on :active
diff --git a/server/static/theme_retro.css b/server/static/theme_retro.css
index dc9fa4654f529eb1843557fb580cf3982da46901..8ed8fd88efab32b36bd66cf200aa182219b0a8b5 100644
--- a/server/static/theme_retro.css
+++ b/server/static/theme_retro.css
@@ -32,6 +32,7 @@ input[type="text"] {
input[type="text"]:focus { border-color: #00ff41; }
p.auth-hint { color: #555; font-family: monospace; font-size: 0.75rem; margin: 0; }
p.auth-error { color: #ff4444; font-family: monospace; font-size: 0.8rem; margin: 0; }
+p.auth-success { color: #00ff41; font-family: monospace; font-size: 0.8rem; margin: 0; }
/* Ingest form (poem pattern) */
.ingest-form-wrap { margin-top: 1.5rem; }
B — 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 omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.