Side B fixes a real UX/architecture flaw (redirect-based auth flow) by unifying the auth form with the existing poem/fetch pattern, adding proper fragment responses and consistent layout reuse, which reduces special-casing and duplicated layout code. Side A is a solid, well-tested UI bugfix (vote highlighting/slider polarity) with good tests, but it's narrower in scope and mostly CSS/JS polish plus a per-group ranking fix, offering less structural improvement than B's consolidation of auth handling into the existing generic form-submission mechanism.
constitution · epochs · watch · epoch 3
c_b00c261f06c2 (tommy-mor) vs c_64faa3bee86f (tommy-mor)
download prompt · raw event · cmp_76f5d059a206e5
council reasoning
A fixes core vote/ranking UI correctness (per-group gradients, center-anchored winner fill, ratio→slider polarity) and locks it in with polarity and rank-style tests, replacing mismatched history bars with HUD-consistent sliders. B is a clean but narrower auth UX change (fragments + poem innerHTML morph instead of redirects) that mostly refactors the choose-username path without comparable invariant fixes.
Side B introduces a reusable interaction pattern by changing auth POST handling from redirect-based navigation to HTML fragments that the existing Poem JS morphs into the form, enabling inline success/error feedback and restoring the standard layout while adding fragment-rendering helpers. Side A fixes a real UI bug by making rank-row gradients apply per ranking group instead of globally and adds targeted tests, but much of the remaining patch is broader vote-history UI polish rather than the core bug fix.
sides
A — c_b00c261f06c2 (tommy-mor)
message
[04b81f91] Fix vote compare highlighting Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 588cf62bcea608ed4ede363810f022a67b43b960..e180a0ca542a33e2300c0a4809e6b9cfee07ecfe 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -291,18 +291,17 @@ fn rank_list(
label: &str,
items: &[RankedItem],
start_rank: usize,
- total_ranked: usize,
- ordinal_offset: usize,
highlighted: &HashSet<ItemId>,
tree: &GlobalTree,
) -> Markup {
+ let group_len = items.len();
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, ordinal_offset + i, total_ranked);
+ @let style = rank_row_style(parent, i, group_len);
@let class = rank_row_class(&r.item, highlighted);
li class=(class)
data-rank-item=(r.item.as_str())
@@ -420,8 +419,6 @@ pub fn ranking_panel_with_highlights(
let has_ranked = !ranked_groups.is_empty();
let multi = ranked_groups.len() > 1;
- let total_ranked: usize = ranked_groups.iter().map(|g| g.len()).sum();
- let mut ordinal_offset = 0usize;
html! {
section id="ranking-panel" class="demo-panel" {
@@ -436,8 +433,7 @@ pub fn ranking_panel_with_highlights(
} @else {
@for (gi, ranked) in ranked_groups.iter().enumerate() {
@let label = if multi { format!("Ranking group {}", gi + 1) } else { "Ranking".to_string() };
- (rank_list(item, &label, ranked, 1, total_ranked, ordinal_offset, highlighted, tree))
- @let _ = { ordinal_offset += ranked.len(); };
+ (rank_list(item, &label, ranked, 1, highlighted, tree))
}
(unranked_list("Unranked", &unranked, highlighted, tree))
}
@@ -520,7 +516,20 @@ pub async fn browse(State(state): State<AppState>, uri: Uri) -> impl IntoRespons
#[cfg(test)]
mod tests {
- use super::SORTER_UI_JS;
+ use super::{rank_row_style, SORTER_UI_JS};
+ use crate::path_types::ItemId;
+
+ #[test]
+ fn rank_row_style_gradients_per_group_not_globally() {
+ 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);
+ }
#[test]
fn vote_slider_left_position_favors_left_item() {
diff --git a/server/src/html/vote.rs b/server/src/html/vote.rs
index 483c7ff8cd35266744957cf0087f2b5f76f1a237..30dab303b9d1e763a5b9261dffff13a778e3e4e3 100644
--- a/server/src/html/vote.rs
+++ b/server/src/html/vote.rs
@@ -19,7 +19,7 @@ use crate::{
ui_action::UI_RPC_FIELD,
};
-use super::{breadcrumb_path, item_href, layout};
+use super::{breadcrumb_path, layout};
#[derive(Debug, Deserialize)]
pub struct VoteQuery {
@@ -59,17 +59,6 @@ fn child_title(tree: &GlobalTree, id: &ItemId) -> String {
.unwrap_or_else(|| display_label(id))
}
-fn ratio_pct(ratio_left: i32, ratio_right: i32) -> f64 {
- let l = ratio_left.max(0) as f64;
- let r = ratio_right.max(0) as f64;
- let sum = l + r;
- if sum <= 0.0 {
- 50.0
- } else {
- (l / sum) * 100.0
- }
-}
-
fn ratios_for_page(v: &VoteData, page_left: &ItemId, page_right: &ItemId) -> (i32, i32) {
match (v.a.as_str(), v.b.as_str()) {
(a, b) if a == page_left.as_str() && b == page_right.as_str() => {
@@ -94,6 +83,37 @@ fn edge_votes(group: &GroupState, left: &ItemId, right: &ItemId) -> Vec<VoteData
.collect()
}
+/// HUD `data-winner` value: which side the ratio favours on this page.
+fn winner_side(r_left: i32, r_right: i32) -> &'static str {
+ if r_left > r_right {
+ "left"
+ } else if r_right > r_left {
+ "right"
+ } else {
+ "even"
+ }
+}
+
+fn winner_text(r_left: i32, r_right: i32) -> &'static str {
+ match winner_side(r_left, r_right) {
+ "left" => "left wins",
+ "right" => "right wins",
+ _ => "tie",
+ }
+}
+
+/// Map stored ratios to the live slider position (0 = full left, 100 = full right).
+/// Matches `sorter_ui.js`: `left = 100 - v`, `right = v`.
+fn slider_value_from_ratios(r_left: i32, r_right: i32) -> i32 {
+ let l = r_left.max(0) as f64;
+ let r = r_right.max(0) as f64;
+ let sum = l + r;
+ if sum <= 0.0 {
+ return 50;
+ }
+ ((r / sum) * 100.0).round().clamp(0.0, 100.0) as i32
+}
+
fn vote_edge_history(tree: &GlobalTree, group: &GroupState, left: &ItemId, right: &ItemId) -> Markup {
let mut votes = edge_votes(group, left, right);
votes.sort_by(|a, b| b.ts.cmp(&a.ts));
@@ -106,17 +126,26 @@ fn vote_edge_history(tree: &GlobalTree, group: &GroupState, left: &ItemId, right
h3 class="vote-edge-history-title" {
"votes on this pair"
}
+ p class="muted small vote-edge-legend" {
+ (format!("left: {legend_left} — right: {legend_right}"))
+ }
ul class="vote-edge-history" {
@for v in &votes {
@let (r_left, r_right) = ratios_for_page(v, left, right);
- @let pct = ratio_pct(r_left, r_right);
+ @let slider_val = slider_value_from_ratios(r_left, r_right);
+ @let side = winner_side(r_left, r_right);
+ @let label = winner_text(r_left, r_right);
li class="vote-edge-history-row" {
div class="vote-edge-meta" {
span class="vote-edge-ratio" { (format!("{}:{}", r_left, r_right)) }
+ span class="vote-edge-winner muted small" { " · " (label) }
}
- div class="ratio-bar vote-edge-bar" aria-hidden="true" {
- div class="ratio-left" style={(format!("width: {:.3}%;", pct))} {}
- div class="ratio-right" style={(format!("width: {:.3}%;", 100.0 - pct))} {}
+ label class="vote-hud-slider vote-edge-slider" aria-hidden="true" {
+ input type="range" class="vote-edge-range" min="0" max="100" value=(slider_val)
+ data-winner=(side)
+ style={(format!("--vote-slider-pct: {}%;", slider_val))}
+ disabled
+ tabindex="-1";
}
}
}
@@ -283,7 +312,6 @@ pub async fn vote_page(
span class="vote-compare-vs" { "vs" }
(vote_compare_item_card(&tree, &right, "vote-compare-right"))
}
- (vote_back_nav(&parent))
div id="vote-edge-history-region" {
(edge_history)
}
@@ -308,3 +336,66 @@ pub async fn vote_page(
)
.into_response()
}
+
+#[cfg(test)]
+mod polarity_tests {
+ use super::*;
+ use crate::ranking::ranked_items;
+ use crate::reducer::GlobalTree;
+
+ fn id(s: &str) -> ItemId {
+ ItemId::parse(s).unwrap()
+ }
+
+ /// The page's left number must always equal the vote's weight for the
+ /// item shown on the left, regardless of which order the vote stored a/b.
+ #[test]
+ fn ratios_for_page_orients_to_page_left() {
+ let left = id("left_item");
+ let right = id("right_item");
+
+ // Stored a == page left: keep order.
+ let v1 = VoteData::from_recorded(1, left.as_str(), right.as_str(), 9, 1).unwrap();
+ assert_eq!(ratios_for_page(&v1, &left, &right), (9, 1));
+
+ // Stored a == page right: swap so left stays left.
+ let v2 = VoteData::from_recorded(2, right.as_str(), left.as_str(), 9, 1).unwrap();
+ assert_eq!(ratios_for_page(&v2, &left, &right), (1, 9));
+ }
+
+ #[test]
+ fn winner_side_follows_larger_ratio() {
+ assert_eq!(winner_side(9, 1), "left");
+ assert_eq!(winner_side(1, 9), "right");
+ assert_eq!(winner_side(1, 1), "even");
+ }
+
+ #[test]
+ fn slider_value_matches_hud_mapping() {
+ assert_eq!(slider_value_from_ratios(9, 1), 10);
+ assert_eq!(slider_value_from_ratios(1, 4), 80);
+ assert_eq!(slider_value_from_ratios(1, 1), 50);
+ }
+
+ /// End-to-end polarity invariant: a vote that favours the LEFT item (higher
+ /// `ratio_left`, recorded as the RPC's `a`) must make that item rank #1.
+ /// This is the property the UI must preserve: sliding left => left wins.
+ #[test]
+ fn sliding_left_makes_left_item_win_ranking() {
+ let parent = id("scope");
+ let left = id("left_item");
+ let right = id("right_item");
+
+ // Slider dragged left yields e.g. 9:1 with a = left item.
+ let vote = VoteData::from_recorded(1, left.as_str(), right.as_str(), 9, 1).unwrap();
+ let mut tree = GlobalTree::new();
+ tree.apply_vote(&parent, vote);
+
+ let group = &tree.get(&parent).unwrap().local_ranking;
+ let ranked = ranked_items(group);
+ assert_eq!(
+ ranked[0].item, left,
+ "left item should rank first when ratio favours the left"
+ );
+ }
+}
diff --git a/server/static/sorter.css b/server/static/sorter.css
index 5da967e8bfddc2bc34662fced841860f63f1ef36..bdd3d931365703705be08537a07f3ccaf975d3ea 100644
--- a/server/static/sorter.css
+++ b/server/static/sorter.css
@@ -124,7 +124,7 @@ code {
z-index: 1;
box-shadow:
inset 0 0 0 3px var(--accent),
- inset 0 0 0 5px color-mix(in oklch, var(--rank-fg) 70%, transparent);
+ inset 0 0 0 5px color-mix(in oklch, var(--rank-fg, var(--fg)) 70%, transparent);
filter: saturate(1.15) brightness(1.04);
}
@@ -320,7 +320,7 @@ h1 {
grid-template-columns: var(--vote-ratio-col) minmax(0, 1fr) var(--vote-actions-col);
gap: 0.5rem 0.65rem;
align-items: center;
- --vote-ratio-col: 3.25rem;
+ --vote-ratio-col: 12.5rem;
--vote-actions-col: 11.5rem;
}
@@ -332,7 +332,7 @@ h1 {
margin: 0;
width: var(--vote-ratio-col);
min-width: var(--vote-ratio-col);
- text-align: center;
+ text-align: right;
white-space: nowrap;
}
@@ -474,6 +474,16 @@ h1 {
--vote-track-muted: color-mix(in oklch, var(--muted) 55%, var(--bg));
}
+/* Center-anchored fill: the accent grows from the midpoint toward the thumb,
+ so the highlighted mass always sits on the *winning* side (the side the
+ thumb was dragged toward). Left winning => accent on the left half. */
+.vote-hud-slider input[type="range"][data-winner="even"]::-webkit-slider-runnable-track {
+ height: 0.65rem;
+ border-radius: 0;
+ border: 2px solid var(--border);
+ background: var(--vote-track-muted);
+}
+
.vote-hud-slider input[type="range"][data-winner="left"]::-webkit-slider-runnable-track {
height: 0.65rem;
border-radius: 0;
@@ -483,7 +493,9 @@ h1 {
var(--vote-track-muted) 0%,
var(--vote-track-muted) var(--vote-slider-pct, 50%),
var(--accent) var(--vote-slider-pct, 50%),
- var(--accent) 100%
+ var(--accent) 50%,
+ var(--vote-track-muted) 50%,
+ var(--vote-track-muted) 100%
);
}
@@ -493,7 +505,9 @@ h1 {
border: 2px solid var(--border);
background: linear-gradient(
to right,
- var(--accent) 0%,
+ var(--vote-track-muted) 0%,
+ var(--vote-track-muted) 50%,
+ var(--accent) 50%,
var(--accent) var(--vote-slider-pct, 50%),
var(--vote-track-muted) va
… preview truncated; 3,536 characters omittedB — 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; }
Hardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.