{"messages":[{"content":"You are a constitutional council ranking individual git commits for ownership allocation.\n\nCompare these two commits. Decide which contributed more lasting value to the project.\n\nJudge substance, not spectacle:\n- Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise.\n- Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one.\n- Do not favor a side merely because its patch is longer or noisier.\n- Weight what the change does for the project, not the contributor's name.\n\nReturn ONLY a JSON object: {\"winner\": \"A\" or \"B\", \"ratio\": \"N:M\", \"explanation\": \"...\"}\nThe explanation must cite concrete differences in the patches (1-3 sentences).\n\nSide A — contributor: tommy-mor\nSide A — commit message:\n[04b81f91] Fix vote compare highlighting\n\nCo-authored-by: Cursor \n\nSide A — unified diff (full patch):\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex 588cf62bcea608ed4ede363810f022a67b43b960..e180a0ca542a33e2300c0a4809e6b9cfee07ecfe 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -291,18 +291,17 @@ fn rank_list(\n label: &str,\n items: &[RankedItem],\n start_rank: usize,\n- total_ranked: usize,\n- ordinal_offset: usize,\n highlighted: &HashSet,\n tree: &GlobalTree,\n ) -> Markup {\n+ let group_len = items.len();\n html! {\n @if !items.is_empty() {\n h3 class=\"rank-heading muted small\" { (label) }\n ol class=\"rank-list\" {\n @for (i, r) in items.iter().enumerate() {\n @let href = item_href(&r.item);\n- @let style = rank_row_style(parent, ordinal_offset + i, total_ranked);\n+ @let style = rank_row_style(parent, i, group_len);\n @let class = rank_row_class(&r.item, highlighted);\n li class=(class)\n data-rank-item=(r.item.as_str())\n@@ -420,8 +419,6 @@ pub fn ranking_panel_with_highlights(\n \n let has_ranked = !ranked_groups.is_empty();\n let multi = ranked_groups.len() > 1;\n- let total_ranked: usize = ranked_groups.iter().map(|g| g.len()).sum();\n- let mut ordinal_offset = 0usize;\n \n html! {\n section id=\"ranking-panel\" class=\"demo-panel\" {\n@@ -436,8 +433,7 @@ pub fn ranking_panel_with_highlights(\n } @else {\n @for (gi, ranked) in ranked_groups.iter().enumerate() {\n @let label = if multi { format!(\"Ranking group {}\", gi + 1) } else { \"Ranking\".to_string() };\n- (rank_list(item, &label, ranked, 1, total_ranked, ordinal_offset, highlighted, tree))\n- @let _ = { ordinal_offset += ranked.len(); };\n+ (rank_list(item, &label, ranked, 1, highlighted, tree))\n }\n (unranked_list(\"Unranked\", &unranked, highlighted, tree))\n }\n@@ -520,7 +516,20 @@ pub async fn browse(State(state): State, uri: Uri) -> impl IntoRespons\n \n #[cfg(test)]\n mod tests {\n- use super::SORTER_UI_JS;\n+ use super::{rank_row_style, SORTER_UI_JS};\n+ use crate::path_types::ItemId;\n+\n+ #[test]\n+ fn rank_row_style_gradients_per_group_not_globally() {\n+ let parent = ItemId::opaque(\"test-scope\");\n+ let first_in_four = rank_row_style(&parent, 0, 4);\n+ let last_in_four = rank_row_style(&parent, 3, 4);\n+ let first_in_two = rank_row_style(&parent, 0, 2);\n+ let last_in_two = rank_row_style(&parent, 1, 2);\n+ assert_eq!(first_in_four, first_in_two);\n+ assert_eq!(last_in_four, last_in_two);\n+ assert_ne!(first_in_four, last_in_four);\n+ }\n \n #[test]\n fn vote_slider_left_position_favors_left_item() {\ndiff --git a/server/src/html/vote.rs b/server/src/html/vote.rs\nindex 483c7ff8cd35266744957cf0087f2b5f76f1a237..30dab303b9d1e763a5b9261dffff13a778e3e4e3 100644\n--- a/server/src/html/vote.rs\n+++ b/server/src/html/vote.rs\n@@ -19,7 +19,7 @@ use crate::{\n ui_action::UI_RPC_FIELD,\n };\n \n-use super::{breadcrumb_path, item_href, layout};\n+use super::{breadcrumb_path, layout};\n \n #[derive(Debug, Deserialize)]\n pub struct VoteQuery {\n@@ -59,17 +59,6 @@ fn child_title(tree: &GlobalTree, id: &ItemId) -> String {\n .unwrap_or_else(|| display_label(id))\n }\n \n-fn ratio_pct(ratio_left: i32, ratio_right: i32) -> f64 {\n- let l = ratio_left.max(0) as f64;\n- let r = ratio_right.max(0) as f64;\n- let sum = l + r;\n- if sum <= 0.0 {\n- 50.0\n- } else {\n- (l / sum) * 100.0\n- }\n-}\n-\n fn ratios_for_page(v: &VoteData, page_left: &ItemId, page_right: &ItemId) -> (i32, i32) {\n match (v.a.as_str(), v.b.as_str()) {\n (a, b) if a == page_left.as_str() && b == page_right.as_str() => {\n@@ -94,6 +83,37 @@ fn edge_votes(group: &GroupState, left: &ItemId, right: &ItemId) -> Vec &'static str {\n+ if r_left > r_right {\n+ \"left\"\n+ } else if r_right > r_left {\n+ \"right\"\n+ } else {\n+ \"even\"\n+ }\n+}\n+\n+fn winner_text(r_left: i32, r_right: i32) -> &'static str {\n+ match winner_side(r_left, r_right) {\n+ \"left\" => \"left wins\",\n+ \"right\" => \"right wins\",\n+ _ => \"tie\",\n+ }\n+}\n+\n+/// Map stored ratios to the live slider position (0 = full left, 100 = full right).\n+/// Matches `sorter_ui.js`: `left = 100 - v`, `right = v`.\n+fn slider_value_from_ratios(r_left: i32, r_right: i32) -> i32 {\n+ let l = r_left.max(0) as f64;\n+ let r = r_right.max(0) as f64;\n+ let sum = l + r;\n+ if sum <= 0.0 {\n+ return 50;\n+ }\n+ ((r / sum) * 100.0).round().clamp(0.0, 100.0) as i32\n+}\n+\n fn vote_edge_history(tree: &GlobalTree, group: &GroupState, left: &ItemId, right: &ItemId) -> Markup {\n let mut votes = edge_votes(group, left, right);\n votes.sort_by(|a, b| b.ts.cmp(&a.ts));\n@@ -106,17 +126,26 @@ fn vote_edge_history(tree: &GlobalTree, group: &GroupState, left: &ItemId, right\n h3 class=\"vote-edge-history-title\" {\n \"votes on this pair\"\n }\n+ p class=\"muted small vote-edge-legend\" {\n+ (format!(\"left: {legend_left} — right: {legend_right}\"))\n+ }\n ul class=\"vote-edge-history\" {\n @for v in &votes {\n @let (r_left, r_right) = ratios_for_page(v, left, right);\n- @let pct = ratio_pct(r_left, r_right);\n+ @let slider_val = slider_value_from_ratios(r_left, r_right);\n+ @let side = winner_side(r_left, r_right);\n+ @let label = winner_text(r_left, r_right);\n li class=\"vote-edge-history-row\" {\n div class=\"vote-edge-meta\" {\n span class=\"vote-edge-ratio\" { (format!(\"{}:{}\", r_left, r_right)) }\n+ span class=\"vote-edge-winner muted small\" { \" · \" (label) }\n }\n- div class=\"ratio-bar vote-edge-bar\" aria-hidden=\"true\" {\n- div class=\"ratio-left\" style={(format!(\"width: {:.3}%;\", pct))} {}\n- div class=\"ratio-right\" style={(format!(\"width: {:.3}%;\", 100.0 - pct))} {}\n+ label class=\"vote-hud-slider vote-edge-slider\" aria-hidden=\"true\" {\n+ input type=\"range\" class=\"vote-edge-range\" min=\"0\" max=\"100\" value=(slider_val)\n+ data-winner=(side)\n+ style={(format!(\"--vote-slider-pct: {}%;\", slider_val))}\n+ disabled\n+ tabindex=\"-1\";\n }\n }\n }\n@@ -283,7 +312,6 @@ pub async fn vote_page(\n span class=\"vote-compare-vs\" { \"vs\" }\n (vote_compare_item_card(&tree, &right, \"vote-compare-right\"))\n }\n- (vote_back_nav(&parent))\n div id=\"vote-edge-history-region\" {\n (edge_history)\n }\n@@ -308,3 +336,66 @@ pub async fn vote_page(\n )\n .into_response()\n }\n+\n+#[cfg(test)]\n+mod polarity_tests {\n+ use super::*;\n+ use crate::ranking::ranked_items;\n+ use crate::reducer::GlobalTree;\n+\n+ fn id(s: &str) -> ItemId {\n+ ItemId::parse(s).unwrap()\n+ }\n+\n+ /// The page's left number must always equal the vote's weight for the\n+ /// item shown on the left, regardless of which order the vote stored a/b.\n+ #[test]\n+ fn ratios_for_page_orients_to_page_left() {\n+ let left = id(\"left_item\");\n+ let right = id(\"right_item\");\n+\n+ // Stored a == page left: keep order.\n+ let v1 = VoteData::from_recorded(1, left.as_str(), right.as_str(), 9, 1).unwrap();\n+ assert_eq!(ratios_for_page(&v1, &left, &right), (9, 1));\n+\n+ // Stored a == page right: swap so left stays left.\n+ let v2 = VoteData::from_recorded(2, right.as_str(), left.as_str(), 9, 1).unwrap();\n+ assert_eq!(ratios_for_page(&v2, &left, &right), (1, 9));\n+ }\n+\n+ #[test]\n+ fn winner_side_follows_larger_ratio() {\n+ assert_eq!(winner_side(9, 1), \"left\");\n+ assert_eq!(winner_side(1, 9), \"right\");\n+ assert_eq!(winner_side(1, 1), \"even\");\n+ }\n+\n+ #[test]\n+ fn slider_value_matches_hud_mapping() {\n+ assert_eq!(slider_value_from_ratios(9, 1), 10);\n+ assert_eq!(slider_value_from_ratios(1, 4), 80);\n+ assert_eq!(slider_value_from_ratios(1, 1), 50);\n+ }\n+\n+ /// End-to-end polarity invariant: a vote that favours the LEFT item (higher\n+ /// `ratio_left`, recorded as the RPC's `a`) must make that item rank #1.\n+ /// This is the property the UI must preserve: sliding left => left wins.\n+ #[test]\n+ fn sliding_left_makes_left_item_win_ranking() {\n+ let parent = id(\"scope\");\n+ let left = id(\"left_item\");\n+ let right = id(\"right_item\");\n+\n+ // Slider dragged left yields e.g. 9:1 with a = left item.\n+ let vote = VoteData::from_recorded(1, left.as_str(), right.as_str(), 9, 1).unwrap();\n+ let mut tree = GlobalTree::new();\n+ tree.apply_vote(&parent, vote);\n+\n+ let group = &tree.get(&parent).unwrap().local_ranking;\n+ let ranked = ranked_items(group);\n+ assert_eq!(\n+ ranked[0].item, left,\n+ \"left item should rank first when ratio favours the left\"\n+ );\n+ }\n+}\ndiff --git a/server/static/sorter.css b/server/static/sorter.css\nindex 5da967e8bfddc2bc34662fced841860f63f1ef36..bdd3d931365703705be08537a07f3ccaf975d3ea 100644\n--- a/server/static/sorter.css\n+++ b/server/static/sorter.css\n@@ -124,7 +124,7 @@ code {\n z-index: 1;\n box-shadow:\n inset 0 0 0 3px var(--accent),\n- inset 0 0 0 5px color-mix(in oklch, var(--rank-fg) 70%, transparent);\n+ inset 0 0 0 5px color-mix(in oklch, var(--rank-fg, var(--fg)) 70%, transparent);\n filter: saturate(1.15) brightness(1.04);\n }\n \n@@ -320,7 +320,7 @@ h1 {\n grid-template-columns: var(--vote-ratio-col) minmax(0, 1fr) var(--vote-actions-col);\n gap: 0.5rem 0.65rem;\n align-items: center;\n- --vote-ratio-col: 3.25rem;\n+ --vote-ratio-col: 12.5rem;\n --vote-actions-col: 11.5rem;\n }\n \n@@ -332,7 +332,7 @@ h1 {\n margin: 0;\n width: var(--vote-ratio-col);\n min-width: var(--vote-ratio-col);\n- text-align: center;\n+ text-align: right;\n white-space: nowrap;\n }\n \n@@ -474,6 +474,16 @@ h1 {\n --vote-track-muted: color-mix(in oklch, var(--muted) 55%, var(--bg));\n }\n \n+/* Center-anchored fill: the accent grows from the midpoint toward the thumb,\n+ so the highlighted mass always sits on the *winning* side (the side the\n+ thumb was dragged toward). Left winning => accent on the left half. */\n+.vote-hud-slider input[type=\"range\"][data-winner=\"even\"]::-webkit-slider-runnable-track {\n+ height: 0.65rem;\n+ border-radius: 0;\n+ border: 2px solid var(--border);\n+ background: var(--vote-track-muted);\n+}\n+\n .vote-hud-slider input[type=\"range\"][data-winner=\"left\"]::-webkit-slider-runnable-track {\n height: 0.65rem;\n border-radius: 0;\n@@ -483,7 +493,9 @@ h1 {\n var(--vote-track-muted) 0%,\n var(--vote-track-muted) var(--vote-slider-pct, 50%),\n var(--accent) var(--vote-slider-pct, 50%),\n- var(--accent) 100%\n+ var(--accent) 50%,\n+ var(--vote-track-muted) 50%,\n+ var(--vote-track-muted) 100%\n );\n }\n \n@@ -493,7 +505,9 @@ h1 {\n border: 2px solid var(--border);\n background: linear-gradient(\n to right,\n- var(--accent) 0%,\n+ var(--vote-track-muted) 0%,\n+ var(--vote-track-muted) 50%,\n+ var(--accent) 50%,\n var(--accent) var(--vote-slider-pct, 50%),\n var(--vote-track-muted) var(--vote-slider-pct, 50%),\n var(--vote-track-muted) 100%\n@@ -513,26 +527,35 @@ h1 {\n }\n \n .vote-hud-slider input[type=\"range\"][data-winner=\"left\"]::-moz-range-track,\n-.vote-hud-slider input[type=\"range\"][data-winner=\"right\"]::-moz-range-track {\n+.vote-hud-slider input[type=\"range\"][data-winner=\"right\"]::-moz-range-track,\n+.vote-hud-slider input[type=\"range\"][data-winner=\"even\"]::-moz-range-track {\n height: 0.65rem;\n border-radius: 0;\n border: 2px solid var(--border);\n }\n \n+.vote-hud-slider input[type=\"range\"][data-winner=\"even\"]::-moz-range-track {\n+ background: var(--vote-track-muted);\n+}\n+\n .vote-hud-slider input[type=\"range\"][data-winner=\"left\"]::-moz-range-track {\n background: linear-gradient(\n to right,\n var(--vote-track-muted) 0%,\n var(--vote-track-muted) var(--vote-slider-pct, 50%),\n var(--accent) var(--vote-slider-pct, 50%),\n- var(--accent) 100%\n+ var(--accent) 50%,\n+ var(--vote-track-muted) 50%,\n+ var(--vote-track-muted) 100%\n );\n }\n \n .vote-hud-slider input[type=\"range\"][data-winner=\"right\"]::-moz-range-track {\n background: linear-gradient(\n to right,\n- var(--accent) 0%,\n+ var(--vote-track-muted) 0%,\n+ var(--vote-track-muted) 50%,\n+ var(--accent) 50%,\n var(--accent) var(--vote-slider-pct, 50%),\n var(--vote-track-muted) var(--vote-slider-pct, 50%),\n var(--vote-track-muted) 100%\n@@ -573,20 +596,23 @@ h1 {\n margin-bottom: 0.25rem;\n }\n \n-.ratio-bar {\n- display: flex;\n- height: 8px;\n- border-radius: 0;\n- overflow: hidden;\n- background: var(--border);\n+/* Read-only sliders in vote history — same track/thumb styling as the HUD. */\n+.vote-edge-slider {\n+ display: block;\n+ margin-top: 0.15rem;\n }\n \n-.ratio-left {\n- background: var(--accent);\n+.vote-edge-slider input[type=\"range\"] {\n+ pointer-events: none;\n+ cursor: default;\n+}\n+\n+.vote-edge-slider input[type=\"range\"]:disabled {\n+ opacity: 1;\n }\n \n-.ratio-right {\n- background: var(--muted);\n+.vote-edge-legend {\n+ margin: 0 0 0.5rem;\n }\n \n @media (max-width: 900px) {\ndiff --git a/server/static/sorter_ui.js b/server/static/sorter_ui.js\nindex f665b45f4fd4771427dc4c37e563e4976873beee..5d6d765f69f301722e59248c76e919043559fb78 100644\n--- a/server/static/sorter_ui.js\n+++ b/server/static/sorter_ui.js\n@@ -138,6 +138,10 @@\n function update() {\n var v = parseInt(slider.value, 10);\n if (!Number.isFinite(v)) v = 50;\n+ // Slider position drives the colored fill (a center-anchored bar that\n+ // grows toward whichever side is winning — see sorter.css). Sliding the\n+ // thumb left raises the left number; left winning keeps the accent mass\n+ // on the left, matching the \"votes on this pair\" history bars.\n slider.style.setProperty('--vote-slider-pct', v + '%');\n slider.setAttribute('aria-valuenow', String(v));\n var left = Math.max(1, 100 - v);\n@@ -147,8 +151,12 @@\n right = right / divisor;\n if (leftInput) leftInput.value = String(left);\n if (rightInput) rightInput.value = String(right);\n- if (ratioDisplay) ratioDisplay.textContent = left + ':' + right;\n- slider.dataset.winner = left >= right ? 'left' : 'right';\n+ var winner = left > right ? 'left' : (right > left ? 'right' : 'even');\n+ slider.dataset.winner = winner;\n+ if (ratioDisplay) {\n+ var label = winner === 'even' ? 'tie' : (winner + ' wins');\n+ ratioDisplay.textContent = left + ':' + right + ' \\u00b7 ' + label;\n+ }\n }\n slider.addEventListener('input', update);\n update();\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[6b6eb0c3] Auth form: poem JS morphs form innerHTML on response; no redirect\n\n- post_choose_username returns HTML fragments instead of redirects:\n success → auth_signed_in_fragment (\"you're signed in — return to your agent\")\n error → choose_username_error_fragment (form re-rendered with error inline)\n- Poem JS now reads response body; if non-empty, morphs form innerHTML with it\n (existing ingest forms return empty body, so they're unaffected)\n- auth.rs: keep full layout() with poem JS — revert to single layout\n- auth-success CSS class added to both themes\n\nCo-Authored-By: Claude Sonnet 4.6 (1M context) \n\nSide B — unified diff (full patch):\ndiff --git a/server/src/api/auth.rs b/server/src/api/auth.rs\nindex c1194f79fd89fb47fe6b425b494a0ffa667abb2d..cb0faa29b834931e2c2b2f5c174c875e2e2e9346 100644\n--- a/server/src/api/auth.rs\n+++ b/server/src/api/auth.rs\n@@ -16,7 +16,7 @@ use crate::{\n canonicalize_username, validate_agent_format, validate_username,\n Event, TokenIssued, UserRegistered,\n },\n- html::{auth_complete_page, choose_username_page},\n+ html::{auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment, choose_username_page},\n state::{AppState, PendingSession},\n };\n \n@@ -274,8 +274,6 @@ pub async fn post_choose_username(\n return api_error(StatusCode::BAD_REQUEST, \"invalid agent format\", Some(msg)).into_response();\n }\n \n- let public_url = std::env::var(\"SLUG_PUBLIC_URL\").unwrap_or_else(|_| \"http://127.0.0.1:8080\".to_string());\n-\n let reduced_arc = state.reduced.clone();\n let reduced = reduced_arc.read().await;\n let provider_key = (provider.to_lowercase(), provider_id.clone());\n@@ -284,11 +282,7 @@ pub async fn post_choose_username(\n }\n if reduced.users_by_provider.values().any(|u| u == &canonicalize_username(&form.username)) {\n drop(reduced);\n- return Redirect::to(&format!(\n- \"{public_url}/auth/choose-username?session={}&error={}\",\n- urlencoding::encode(&form.session),\n- urlencoding::encode(\"that username is taken — try another\"),\n- )).into_response();\n+ return choose_username_error_fragment(&form.session, \"that username is taken — try another\").into_response();\n }\n drop(reduced);\n \n@@ -324,7 +318,7 @@ pub async fn post_choose_username(\n s.complete = Some((canon_user.clone(), bearer.clone()));\n }\n \n- Redirect::to(&format!(\"{public_url}/auth/complete\")).into_response()\n+ auth_signed_in_fragment().into_response()\n }\n \n pub async fn post_pending_session(\ndiff --git a/server/src/html/auth.rs b/server/src/html/auth.rs\nindex 40b1ef30a6c1d4063aa2d8e9c93df8972df4b27c..0a14bdbfb66c65d1bd09993ffd4a7bb6f2fe041e 100644\n--- a/server/src/html/auth.rs\n+++ b/server/src/html/auth.rs\n@@ -1,20 +1,25 @@\n-use maud::{html, Markup, DOCTYPE};\n+use maud::{html, Markup};\n \n-/// Minimal layout for auth pages — no JS interceptor, real form navigation works.\n-fn auth_layout(title: &str, body: Markup) -> Markup {\n+fn form_inner(session: &str, error: Option<&str>) -> Markup {\n html! {\n- (DOCTYPE)\n- html {\n- head {\n- meta charset=\"utf-8\";\n- meta name=\"viewport\" content=\"width=device-width, initial-scale=1\";\n- title { (title) }\n- link rel=\"stylesheet\" href=\"/static/theme_default.css\";\n- }\n- body class=\"view-auth\" {\n- (body)\n- }\n+ input type=\"hidden\" name=\"session\" value=(session);\n+ label for=\"username\" { \"username\" }\n+ input\n+ type=\"text\"\n+ id=\"username\"\n+ name=\"username\"\n+ placeholder=\"e.g. alice\"\n+ pattern=\"[a-z0-9_\\\\-]{1,32}\"\n+ maxlength=\"32\"\n+ autocomplete=\"off\"\n+ autofocus;\n+ p.auth-hint {\n+ \"lowercase · alphanumeric · hyphens · underscores · max 32\"\n }\n+ @if let Some(msg) = error {\n+ p.auth-error { (msg) }\n+ }\n+ button type=\"submit\" { \"continue\" }\n }\n }\n \n@@ -28,27 +33,23 @@ pub fn choose_username_page(session: &str, error: Option<&str>) -> Markup {\n h1 { \"choose a username\" }\n p { \"pick a handle for slug.social.\" }\n form.auth-form method=\"POST\" action=\"/auth/choose-username\" {\n- input type=\"hidden\" name=\"session\" value=(session);\n- label for=\"username\" { \"username\" }\n- input\n- type=\"text\"\n- id=\"username\"\n- name=\"username\"\n- placeholder=\"e.g. alice\"\n- pattern=\"[a-z0-9_\\\\-]{1,32}\"\n- maxlength=\"32\"\n- autocomplete=\"off\"\n- autofocus;\n- p.auth-hint {\n- \"lowercase · alphanumeric · hyphens · underscores · max 32\"\n- }\n- @if let Some(msg) = error {\n- p.auth-error { (msg) }\n- }\n- button type=\"submit\" { \"continue\" }\n+ (form_inner(session, error))\n }\n };\n- auth_layout(\"join — slug.social\", body)\n+ super::layout(\"join — slug.social\", \"view-auth\", body, None)\n+}\n+\n+/// Fragment returned to the poem JS on error — replaces the form's innerHTML.\n+pub fn choose_username_error_fragment(session: &str, error: &str) -> Markup {\n+ form_inner(session, Some(error))\n+}\n+\n+/// Fragment returned to the poem JS on success — replaces the form's innerHTML.\n+pub fn auth_signed_in_fragment() -> Markup {\n+ html! {\n+ p.auth-success { \"you're signed in — return to your agent.\" }\n+ p.auth-hint { \"you can close this tab.\" }\n+ }\n }\n \n pub fn auth_complete_page() -> Markup {\n@@ -62,5 +63,5 @@ pub fn auth_complete_page() -> Markup {\n p { \"Return to your terminal — your agent is polling and will collect your token automatically.\" }\n p.auth-hint { \"You can close this tab.\" }\n };\n- auth_layout(\"signed in — slug.social\", body)\n+ super::layout(\"signed in — slug.social\", \"view-auth\", body, None)\n }\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex 2f16d701962d703db0c859bb586dfc08ec385690..8b48a25cce79f0eefc7e29849e1a667cae4c7986 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -15,7 +15,7 @@ mod search;\n mod tree;\n use breadcrumb_path::OntologyPath;\n \n-pub use auth::{auth_complete_page, choose_username_page};\n+pub use auth::{auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment, choose_username_page};\n pub use editor::{editor_check, editor_page};\n pub use forum::{index, thread_feed_html, thread_post_expand, thread_post_view, thread_view};\n pub use garden::{garden_index, ontology_path};\n@@ -114,6 +114,8 @@ script { (maud::PreEscaped(r#\"\n });\n \n // Poem: intercept POST forms, send via fetch, await SSE for DOM update.\n+ // If the response body is non-empty HTML, morph the form's innerHTML with it\n+ // (used for inline feedback without a page reload, e.g. auth forms).\n document.addEventListener('submit', async (e) => {\n const f = e.target;\n if (!f || f.tagName !== 'FORM') return;\n@@ -121,14 +123,19 @@ script { (maud::PreEscaped(r#\"\n e.preventDefault();\n const btn = f.querySelector('button[type=\"submit\"], input[type=\"submit\"]');\n if (btn) { btn.disabled = true; btn.textContent = '…'; }\n- await fetch(f.action, {\n+ const resp = await fetch(f.action, {\n method: 'POST',\n body: new URLSearchParams(new FormData(f)),\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n credentials: 'same-origin',\n });\n- if (btn) { btn.disabled = false; btn.textContent = 'submit'; }\n- f.reset();\n+ const html = await resp.text();\n+ if (html && html.trim()) {\n+ Idiomorph.morph(f, html, {morphStyle: 'innerHTML'});\n+ } else {\n+ if (btn) { btn.disabled = false; btn.textContent = 'submit'; }\n+ f.reset();\n+ }\n });\n \n // Search: debounced fetch + idiomorph.\ndiff --git a/server/static/theme_default.css b/server/static/theme_default.css\nindex 9e71574da4bed3a0116c347610636780678bd1af..a1d8d1765a7812191edc579125facf1694554c2c 100644\n--- a/server/static/theme_default.css\n+++ b/server/static/theme_default.css\n@@ -250,6 +250,11 @@ p.auth-error {\n font-size: 12px;\n margin: 4px 0 0;\n }\n+p.auth-success {\n+ color: var(--signal);\n+ font-size: 13px;\n+ margin: 4px 0 0;\n+}\n \n /* ----------------------------------------------------------------\n BUTTONS — raised, press on :active\ndiff --git a/server/static/theme_retro.css b/server/static/theme_retro.css\nindex dc9fa4654f529eb1843557fb580cf3982da46901..8ed8fd88efab32b36bd66cf200aa182219b0a8b5 100644\n--- a/server/static/theme_retro.css\n+++ b/server/static/theme_retro.css\n@@ -32,6 +32,7 @@ input[type=\"text\"] {\n input[type=\"text\"]:focus { border-color: #00ff41; }\n p.auth-hint { color: #555; font-family: monospace; font-size: 0.75rem; margin: 0; }\n p.auth-error { color: #ff4444; font-family: monospace; font-size: 0.8rem; margin: 0; }\n+p.auth-success { color: #00ff41; font-family: monospace; font-size: 0.8rem; margin: 0; }\n \n /* Ingest form (poem pattern) */\n .ingest-form-wrap { margin-top: 1.5rem; }\n","role":"user"}],"model":"~x-ai/grok-latest"}