{"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[e8c85249] Fix GitHub resolver and vote compare flow (#149)\n\n* Fix GitHub resolver and vote compare flow\n\nCo-authored-by: tommy \n\n* Fix vote compare next pair helper\n\nCo-authored-by: tommy \n\n* Extend GitHub resolver and vote pair updates\n\nCo-authored-by: tommy \n\n* Fix resolver browser refresh coverage\n\nCo-authored-by: tommy \n\n* Stabilize GitHub resolver browser test\n\nCo-authored-by: tommy \n\n---------\n\nCo-authored-by: Cursor Agent \n\nSide A — unified diff (full patch):\ndiff --git a/agents.md b/agents.md\nindex c66f33789441eea193b4354fce4c03b7fffdd639..7508234d9b04223d0e64cfe69fedbebd06a256b5 100644\n--- a/agents.md\n+++ b/agents.md\n@@ -36,12 +36,18 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma\n \n - **`HtmlUiAction` / `POST /ui`** (`server/src/html/ui_action.rs`, `server/src/api/ui_html.rs`): **Browser session** (cookie) UI commands. Payload is `__rpc__` + form fields. Most responses are **JS morphs**; some actions return **HTTP redirects** (see below).\n \n+- **Do not add one-off POST routes** for browser mutations. New browser actions belong in **`HtmlUiAction`** behind **`POST /ui`**; new programmatic verbs belong in **`RpcCommand`** behind **`POST /api/v0/rpc`**. Ordinary shareable pages remain normal **`GET`** routes.\n+\n - **Non-morph `POST /ui` responses:** **`SetGardenPin`** returns **`303 See Other`** and **`Set-Cookie`** (same as **`POST /theme`**). Garden pin/unpin is a normal **`
`** — browser navigation applies cookies reliably (see **`test/browser_garden_pin.clj`**). Each **`__rpc__`** payload includes **`form_action: \"/ui\"`**; **`post_ui_html`** rejects mismatches to bind tokens to the UI endpoint.\n \n-- **`VoteComparePost`:** On success returns **`text/javascript`** that **morphs** **`#vote-compare-preview`** (new ingest card), **`#vote-edge-history-region`** (recomputed **`
    `** — ratios match **`left`/`right`** query order, bullets, sorted by strength toward **`left`** then newer). The compare **`GET`** page uses **`layout_full_bleed_chromeless`** (no breadcrumbs, no **`#controls`**, no **`slug-pin-hud`**; **`view-vote-compare-fullscreen`** full-width **`body`**). **`__rpc__`** carries **`form_action: \"/ui\"`**; **`thread_tag`** and ratio fields come from the same form as **`$form`** holes.\n+- **`VoteComparePost`:** On success returns **`text/javascript`** that **morphs** **`#vote-edge-history-region`** (recomputed **`
      `** — ratios match **`left`/`right`** query order, bullets, sorted by strength toward **`left`** then newer) and **`.vote-compare-nav`** (fresh next-pair link). The compare **`GET`** page uses **`layout_full_bleed_chromeless`** (no breadcrumbs, no **`#controls`**, no **`slug-pin-hud`**; **`view-vote-compare-fullscreen`** full-width **`body`**). **`__rpc__`** carries **`form_action: \"/ui\"`**; **`thread_tag`** and ratio fields come from the same form as **`$form`** holes.\n+\n+- **`ResolveExternal`:** GitHub resolver buttons are browser actions through **`POST /ui`**. Success responses morph **`#external-resolver-status`** then redirect to the sanitized shareable **`GET`** page so imported children render through the normal page path; errors morph the same status region. Resolver results are durable system ingests, while cooldown state is RAM-only.\n \n - **Garden pin / compare voting:** Cookie **`slug_garden_pin`** via **`set_garden_pin`**. Pairwise UI: **`GET /vote/compare?…`** / **`GET /r/:room_key/vote/compare?…`** (fullscreen **`GET`** page: no HUD; other garden pages). HUD (**`#slug-pin-hud`**): only 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.\n \n+- **Browser auth redirects:** `/login`, `/join/:token`, `/auth/login`, and `/auth/choose-username` may carry **`next`** (or legacy **`redirect`**) as a **safe local path only**. The value is stored on the RAM-only pending session and applied after OAuth / username selection.\n+\n **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.\n \n ---\ndiff --git a/server/src/api/auth.rs b/server/src/api/auth.rs\nindex c9c4082f251838b5ac46de7ce48392c136883d55..88bb3335e4873643530351266d213f258da5893e 100644\n--- a/server/src/api/auth.rs\n+++ b/server/src/api/auth.rs\n@@ -8,7 +8,10 @@ use axum::{\n use axum_extra::extract::cookie::CookieJar;\n use base64::Engine;\n use serde::Deserialize;\n-use slug_types::{PendingSessionPollResponse, PendingSessionStartRequest, PendingSessionStartResponse, WhoamiResponse};\n+use slug_types::{\n+ PendingSessionPollResponse, PendingSessionStartRequest, PendingSessionStartResponse,\n+ WhoamiResponse,\n+};\n use std::{collections::HashMap, sync::Arc};\n use tokio::sync::{oneshot, RwLock};\n \n@@ -17,7 +20,8 @@ use crate::{\n events::{Event, TokenIssued},\n html::{\n auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment,\n- choose_username_page, theme_cookie_header_from_jar, theme_from_jar, theme_next_from_uri, JsBuilder,\n+ choose_username_page, theme_cookie_header_from_jar, theme_from_jar, theme_next_from_uri,\n+ JsBuilder,\n },\n identity::{parse_agent, parse_username},\n reducer::ReducerState,\n@@ -36,12 +40,26 @@ pub const SLUG_SESSION_COOKIE: &str = \"slug_session\";\n \n /// `Set-Cookie` header value (full attribute string).\n pub fn session_cookie_header_value(bearer: &str) -> HeaderValue {\n- let s = format!(\n- \"{SLUG_SESSION_COOKIE}={bearer}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000\"\n- );\n+ let s =\n+ format!(\"{SLUG_SESSION_COOKIE}={bearer}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000\");\n HeaderValue::from_str(&s).expect(\"session cookie value must be ASCII\")\n }\n \n+fn safe_local_redirect(raw: Option<&str>) -> Option {\n+ let s = raw?.trim();\n+ if s.starts_with('/') && !s.starts_with(\"//\") && s.len() < 8192 {\n+ Some(s.to_string())\n+ } else {\n+ None\n+ }\n+}\n+\n+fn redirect_query(next: Option<&str>) -> String {\n+ safe_local_redirect(next)\n+ .map(|n| format!(\"&next={}\", urlencoding::encode(&n)))\n+ .unwrap_or_default()\n+}\n+\n fn js_form_error_fragment(session: &str, error: &str) -> Response {\n JsBuilder::new()\n .id(\"choose-username-form\")\n@@ -49,11 +67,11 @@ fn js_form_error_fragment(session: &str, error: &str) -> Response {\n .into_response()\n }\n \n-fn js_signed_in_fragment(bearer: &str, jar: &CookieJar) -> Response {\n+fn js_signed_in_fragment(bearer: &str, jar: &CookieJar, redirect_to: &str) -> Response {\n let mut response = JsBuilder::new()\n .id(\"choose-username-form\")\n .morph_inner(auth_signed_in_fragment())\n- .redirect(\"/auth/complete\")\n+ .redirect(redirect_to)\n .into_response();\n let headers = response.headers_mut();\n headers.append(header::SET_COOKIE, session_cookie_header_value(bearer));\n@@ -64,7 +82,11 @@ fn js_signed_in_fragment(bearer: &str, jar: &CookieJar) -> Response {\n }\n \n /// Resolve the signed-in username from `Authorization: Bearer` or `slug_session` cookie.\n-pub fn optional_principal(headers: &HeaderMap, jar: &CookieJar, reduced: &ReducerState) -> Option {\n+pub fn optional_principal(\n+ headers: &HeaderMap,\n+ jar: &CookieJar,\n+ reduced: &ReducerState,\n+) -> Option {\n if let Ok(u) = verify_bearer_principal(headers, reduced) {\n return Some(u);\n }\n@@ -80,7 +102,11 @@ pub struct WebSession {\n }\n \n /// Resolve username and bearer together for `POST /ui` dispatch (one read of headers + jar).\n-pub fn resolve_web_session(headers: &HeaderMap, jar: &CookieJar, reduced: &ReducerState) -> Option {\n+pub fn resolve_web_session(\n+ headers: &HeaderMap,\n+ jar: &CookieJar,\n+ reduced: &ReducerState,\n+) -> Option {\n let username = optional_principal(headers, jar, reduced)?;\n let bearer = headers\n .get(header::AUTHORIZATION)\n@@ -90,7 +116,12 @@ pub fn resolve_web_session(headers: &HeaderMap, jar: &CookieJar, reduced: &Reduc\n Some(WebSession { username, bearer })\n }\n \n-fn redirect_with_session_cookie(public_url: &str, path_and_query: &str, bearer: &str, jar: &CookieJar) -> Response {\n+fn redirect_with_session_cookie(\n+ public_url: &str,\n+ path_and_query: &str,\n+ bearer: &str,\n+ jar: &CookieJar,\n+) -> Response {\n let mut res = Response::builder()\n .status(StatusCode::TEMPORARY_REDIRECT)\n .header(header::LOCATION, format!(\"{public_url}{path_and_query}\"))\n@@ -112,21 +143,32 @@ fn pending_sessions(state: &AppState) -> Arc Option {\n let payload_b64 = jwt.split('.').nth(1)?;\n- let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64).ok()?;\n+ let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD\n+ .decode(payload_b64)\n+ .ok()?;\n let v: serde_json::Value = serde_json::from_slice(&decoded).ok()?;\n v.get(\"sub\")?.as_str().map(|s| s.to_string())\n }\n \n pub(crate) fn parse_bearer(headers: &HeaderMap) -> Result {\n let Some(value) = headers.get(axum::http::header::AUTHORIZATION) else {\n- return Err((StatusCode::UNAUTHORIZED, \"missing Authorization header\".to_string()));\n+ return Err((\n+ StatusCode::UNAUTHORIZED,\n+ \"missing Authorization header\".to_string(),\n+ ));\n };\n let Ok(s) = value.to_str() else {\n- return Err((StatusCode::UNAUTHORIZED, \"invalid Authorization header\".to_string()));\n+ return Err((\n+ StatusCode::UNAUTHORIZED,\n+ \"invalid Authorization header\".to_string(),\n+ ));\n };\n let s = s.trim();\n let Some(rest) = s.strip_prefix(\"Bearer \") else {\n- return Err((StatusCode::UNAUTHORIZED, \"Authorization must be Bearer\".to_string()));\n+ return Err((\n+ StatusCode::UNAUTHORIZED,\n+ \"Authorization must be Bearer\".to_string(),\n+ ));\n };\n Ok(rest.trim().to_string())\n }\n@@ -140,7 +182,10 @@ pub fn verify_bearer_principal(\n verify_token(reduced, &bearer)\n }\n \n-pub(crate) fn verify_token(reduced: &crate::reducer::ReducerState, bearer: &str) -> Result {\n+pub(crate) fn verify_token(\n+ reduced: &crate::reducer::ReducerState,\n+ bearer: &str,\n+) -> Result {\n // slug__\n let Some(rest) = bearer.strip_prefix(\"slug_\") else {\n return Err((StatusCode::UNAUTHORIZED, \"invalid token format\".to_string()));\n@@ -199,9 +244,25 @@ pub(crate) fn issue_token_for_user(stored_username: &str) -> (String, TokenIssue\n #[derive(Debug, Deserialize)]\n pub struct AuthLoginQuery {\n pub session: String,\n+ #[serde(default)]\n+ pub next: Option,\n+ #[serde(default)]\n+ pub redirect: Option,\n }\n \n-pub async fn get_join_invite(Path(token): Path, State(state): State) -> impl IntoResponse {\n+#[derive(Debug, Deserialize)]\n+pub struct JoinInviteQuery {\n+ #[serde(default)]\n+ pub next: Option,\n+ #[serde(default)]\n+ pub redirect: Option,\n+}\n+\n+pub async fn get_join_invite(\n+ Path(token): Path,\n+ Query(q): Query,\n+ State(state): State,\n+) -> impl IntoResponse {\n let token = token.trim().to_string();\n if token.is_empty() {\n return api_error(StatusCode::NOT_FOUND, \"invite invalid or expired\", None).into_response();\n@@ -219,37 +280,57 @@ pub async fn get_join_invite(Path(token): Path, State(state): State, State(state): State) -> impl IntoResponse {\n+pub async fn get_auth_login(\n+ Query(q): Query,\n+ State(state): State,\n+) -> impl IntoResponse {\n // Redirect to Google auth endpoint.\n let sessions = pending_sessions(&state);\n- let sessions_read = sessions.read().await;\n- let Some(_s) = sessions_read.get(&q.session) else {\n- return api_error(StatusCode::NOT_FOUND, \"unknown session\", None).into_response();\n- };\n- drop(sessions_read);\n+ {\n+ let mut sessions_write = sessions.write().await;\n+ let Some(s) = sessions_write.get_mut(&q.session) else {\n+ return api_error(StatusCode::NOT_FOUND, \"unknown session\", None).into_response();\n+ };\n+ if let Some(next) = safe_local_redirect(q.next.as_deref().or(q.redirect.as_deref())) {\n+ s.redirect_next = Some(next);\n+ }\n+ }\n \n let auth_url_base = std::env::var(\"SLUG_GOOGLE_AUTH_URL\")\n .unwrap_or_else(|_| \"https://accounts.google.com/o/oauth2/v2/auth\".to_string());\n let client_id = std::env::var(\"SLUG_GOOGLE_CLIENT_ID\").unwrap_or_else(|_| \"dev\".to_string());\n- let public_url = std::env::var(\"SLUG_PUBLIC_URL\").unwrap_or_else(|_| \"http://127.0.0.1:8080\".to_string());\n+ let public_url =\n+ std::env::var(\"SLUG_PUBLIC_URL\").unwrap_or_else(|_| \"http://127.0.0.1:8080\".to_string());\n let redirect_uri = format!(\"{public_url}/auth/callback\");\n let auth_url = format!(\n \"{auth_url_base}?client_id={}&redirect_uri={}&response_type=code&scope=openid%20email&state={}\",\n@@ -282,8 +363,10 @@ pub async fn get_auth_callback(\n let token_url = std::env::var(\"SLUG_GOOGLE_TOKEN_URL\")\n .unwrap_or_else(|_| \"https://oauth2.googleapis.com/token\".to_string());\n let client_id = std::env::var(\"SLUG_GOOGLE_CLIENT_ID\").unwrap_or_else(|_| \"dev\".to_string());\n- let client_secret = std::env::var(\"SLUG_GOOGLE_CLIENT_SECRET\").unwrap_or_else(|_| \"dev\".to_string());\n- let public_url = std::env::var(\"SLUG_PUBLIC_URL\").unwrap_or_else(|_| \"http://127.0.0.1:8080\".to_string());\n+ let client_secret =\n+ std::env::var(\"SLUG_GOOGLE_CLIENT_SECRET\").unwrap_or_else(|_| \"dev\".to_string());\n+ let public_url =\n+ std::env::var(\"SLUG_PUBLIC_URL\").unwrap_or_else(|_| \"http://127.0.0.1:8080\".to_string());\n let redirect_uri = format!(\"{public_url}/auth/callback\");\n \n // Exchange code for id_token + access_token.\n@@ -306,16 +389,37 @@ pub async fn get_auth_callback(\n {\n Ok(resp) => match resp.json().await {\n Ok(v) => v,\n- Err(err) => return api_error(StatusCode::BAD_GATEWAY, \"oauth token exchange failed\", Some(format!(\"{err}\"))).into_response(),\n+ Err(err) => {\n+ return api_error(\n+ StatusCode::BAD_GATEWAY,\n+ \"oauth token exchange failed\",\n+ Some(format!(\"{err}\")),\n+ )\n+ .into_response()\n+ }\n },\n- Err(err) => return api_error(StatusCode::BAD_GATEWAY, \"oauth token exchange failed\", Some(format!(\"{err}\"))).into_response(),\n+ Err(err) => {\n+ return api_error(\n+ StatusCode::BAD_GATEWAY,\n+ \"oauth token exchange failed\",\n+ Some(format!(\"{err}\")),\n+ )\n+ .into_response()\n+ }\n };\n \n // Extract sub from the id_token JWT payload (base64-decode middle segment).\n // The token arrived directly from Google over TLS — no need for an extra userinfo roundtrip.\n let sub = match extract_jwt_sub(&tr.id_token) {\n Some(s) => s,\n- None => return api_error(StatusCode::BAD_GATEWAY, \"oauth: could not extract sub from id_token\", None).into_response(),\n+ None => {\n+ return api_error(\n+ StatusCode::BAD_GATEWAY,\n+ \"oauth: could not extract sub from id_token\",\n+ None,\n+ )\n+ .into_response()\n+ }\n };\n \n // If user exists, issue token and complete session. Otherwise redirect to choose-username.\n@@ -327,7 +431,9 @@ pub async fn get_auth_callback(\n \n {\n let mut sessions_write = sessions.write().await;\n- let s = sessions_write.get_mut(&q.state).expect(\"session checked above\");\n+ let s = sessions_write\n+ .get_mut(&q.state)\n+ .expect(\"session checked above\");\n s.provider = Some(\"google\".to_string());\n s.provider_id = Some(sub.clone());\n if let Some(username) = existing {\n@@ -345,31 +451,61 @@ pub async fn get_auth_callback(\n .await\n .is_err()\n {\n- return api_error(StatusCode::INTERNAL_SERVER_ERROR, \"writer unavailable\", None).into_response();\n+ return api_error(\n+ StatusCode::INTERNAL_SERVER_ERROR,\n+ \"writer unavailable\",\n+ None,\n+ )\n+ .into_response();\n }\n match rx.await {\n Err(_) => {\n- return api_error(StatusCode::INTERNAL_SERVER_ERROR, \"writer dropped\", None).into_response();\n+ return api_error(StatusCode::INTERNAL_SERVER_ERROR, \"writer dropped\", None)\n+ .into_response();\n }\n Ok(Err(err)) => {\n- return api_error(StatusCode::INTERNAL_SERVER_ERROR, \"failed to persist token\", Some(err))\n- .into_response();\n+ return api_error(\n+ StatusCode::INTERNAL_SERVER_ERROR,\n+ \"failed to persist token\",\n+ Some(err),\n+ )\n+ .into_response();\n }\n Ok(Ok(())) => {}\n }\n+ let redirect_to =\n+ safe_local_redirect(s.redirect_next.as_deref()).unwrap_or_else(|| \"/\".to_string());\n let cookie_bearer = bearer.clone();\n s.complete = Some((username, bearer));\n- return redirect_with_session_cookie(&public_url, \"/\", &cookie_bearer, &jar).into_response();\n+ return redirect_with_session_cookie(&public_url, &redirect_to, &cookie_bearer, &jar)\n+ .into_response();\n }\n }\n \n- Redirect::temporary(&format!(\"{public_url}/auth/choose-username?session={}\", q.state)).into_response()\n+ let next_q = {\n+ let sessions_read = sessions.read().await;\n+ sessions_read\n+ .get(&q.state)\n+ .and_then(|s| s.redirect_next.as_deref())\n+ .map(|n| redirect_query(Some(n)))\n+ .unwrap_or_default()\n+ };\n+ Redirect::temporary(&format!(\n+ \"{public_url}/auth/choose-username?session={}{}\",\n+ urlencoding::encode(&q.state),\n+ next_q\n+ ))\n+ .into_response()\n }\n \n #[derive(Debug, Deserialize)]\n pub struct ChooseUsernameQuery {\n pub session: String,\n pub error: Option,\n+ #[serde(default)]\n+ pub next: Option,\n+ #[serde(default)]\n+ pub redirect: Option,\n }\n \n pub async fn get_choose_username(\n@@ -379,13 +515,18 @@ pub async fn get_choose_username(\n uri: Uri,\n ) -> impl IntoResponse {\n let sessions = pending_sessions(&state);\n- let sessions_read = sessions.read().await;\n- if !sessions_read.contains_key(&q.session) {\n- return api_error(StatusCode::NOT_FOUND, \"unknown session\", None).into_response();\n+ {\n+ let mut sessions_write = sessions.write().await;\n+ let Some(s) = sessions_write.get_mut(&q.session) else {\n+ return api_error(StatusCode::NOT_FOUND, \"unknown session\", None).into_response();\n+ };\n+ if let Some(next) = safe_local_redirect(q.next.as_deref().or(q.redirect.as_deref())) {\n+ s.redirect_next = Some(next);\n+ }\n }\n- drop(sessions_read);\n let next = theme_next_from_uri(&uri);\n- choose_username_page(&q.session, q.error.as_deref(), theme_from_jar(&jar), &next).into_response()\n+ choose_username_page(&q.session, q.error.as_deref(), theme_from_jar(&jar), &next)\n+ .into_response()\n }\n \n #[derive(Debug, Deserialize)]\n@@ -401,7 +542,10 @@ pub async fn post_choose_username(\n ) -> impl IntoResponse {\n let canon_user = match parse_username(&form.username) {\n Ok(u) => u,\n- Err(msg) => return js_form_error_fragment(&form.session, &format!(\"invalid username — {msg}\")).into_response(),\n+ Err(msg) => {\n+ return js_form_error_fragment(&form.session, &format!(\"invalid username — {msg}\"))\n+ .into_response()\n+ }\n };\n \n let sessions = pending_sessions(&state);\n@@ -420,12 +564,15 @@ pub async fn post_choose_username(\n };\n \n if let Err(msg) = parse_agent(&agent) {\n- return js_form_error_fragment(&form.session, &format!(\"invalid agent format — {msg}\")).into_response();\n+ return js_form_error_fragment(&form.session, &format!(\"invalid agent format — {msg}\"))\n+ .into_response();\n }\n \n let redeem_invite = {\n let sessions_read = sessions.read().await;\n- sessions_read.get(&form.session).and_then(|s| s.redeem_invite.clone())\n+ sessions_read\n+ .get(&form.session)\n+ .and_then(|s| s.redeem_invite.clone())\n };\n \n let (tx, rx) = oneshot::channel();\n@@ -441,12 +588,18 @@ pub async fn post_choose_username(\n .await\n .is_err()\n {\n- return api_error(StatusCode::INTERNAL_SERVER_ERROR, \"writer unavailable\", None).into_response();\n+ return api_error(\n+ StatusCode::INTERNAL_SERVER_ERROR,\n+ \"writer unavailable\",\n+ None,\n+ )\n+ .into_response();\n }\n \n let bearer = match rx.await {\n Err(_) => {\n- return api_error(StatusCode::INTERNAL_SERVER_ERROR, \"writer dropped\", None).into_response();\n+ return api_error(StatusCode::INTERNAL_SERVER_ERROR, \"writer dropped\", None)\n+ .into_response();\n }\n Ok(Err(msg)) => {\n return js_form_error_fragment(&form.session, &msg).into_response();\n@@ -454,31 +607,59 @@ pub async fn post_choose_username(\n Ok(Ok(b)) => b,\n };\n \n- {\n+ let redirect_to = {\n let mut sessions_write = sessions.write().await;\n- let s = sessions_write.get_mut(&form.session).expect(\"session checked above\");\n+ let s = sessions_write\n+ .get_mut(&form.session)\n+ .expect(\"session checked above\");\n s.complete = Some((canon_user.clone(), bearer.clone()));\n- }\n+ safe_local_redirect(s.redirect_next.as_deref())\n+ .unwrap_or_else(|| \"/auth/complete\".to_string())\n+ };\n \n- js_signed_in_fragment(&bearer, &jar).into_response()\n+ js_signed_in_fragment(&bearer, &jar, &redirect_to).into_response()\n }\n \n /// Start a browser-only OAuth flow (no CLI polling). Sets session cookie on success.\n-pub async fn get_web_login(State(state): State) -> impl IntoResponse {\n+#[derive(Debug, Deserialize)]\n+pub struct WebLoginQuery {\n+ #[serde(default)]\n+ pub next: Option,\n+ #[serde(default)]\n+ pub redirect: Option,\n+}\n+\n+pub async fn get_web_login(\n+ Query(q): Query,\n+ State(state): State,\n+) -> impl IntoResponse {\n let session = format!(\"p_{}\", uuid::Uuid::new_v4().simple());\n+ let redirect_next = safe_local_redirect(q.next.as_deref().or(q.redirect.as_deref()))\n+ .or_else(|| Some(\"/\".to_string()));\n let s = PendingSession {\n agent: WEB_BROWSER_AGENT.to_string(),\n created_ts: now_ms(),\n provider: None,\n provider_id: None,\n redeem_invite: None,\n+ redirect_next: redirect_next.clone(),\n complete: None,\n };\n- state.pending_sessions.write().await.insert(session.clone(), s);\n- let public_url = std::env::var(\"SLUG_PUBLIC_URL\").unwrap_or_else(|_| \"http://127.0.0.1:8080\".to_string());\n+ state\n+ .pending_sessions\n+ .write()\n+ .await\n+ .insert(session.clone(), s);\n+ let public_url =\n+ std::env::var(\"SLUG_PUBLIC_URL\").unwrap_or_else(|_| \"http://127.0.0.1:8080\".to_string());\n+ let next_q = redirect_next\n+ .as_deref()\n+ .map(|n| redirect_query(Some(n)))\n+ .unwrap_or_default();\n Redirect::temporary(&format!(\n- \"{public_url}/auth/login?session={}\",\n- urlencoding::encode(&session)\n+ \"{public_url}/auth/login?session={}{}\",\n+ urlencoding::encode(&session),\n+ next_q\n ))\n .into_response()\n }\n@@ -504,12 +685,17 @@ pub async fn post_pending_session(\n let agent_naked = match parse_agent(&req.agent) {\n Ok(a) => a,\n Err(msg) => {\n- return api_error(StatusCode::BAD_REQUEST, \"invalid agent format\", Some(msg)).into_response();\n+ return api_error(StatusCode::BAD_REQUEST, \"invalid agent format\", Some(msg))\n+ .into_response();\n }\n };\n let session = format!(\"p_{}\", uuid::Uuid::new_v4().simple());\n- let public_url = std::env::var(\"SLUG_PUBLIC_URL\").unwrap_or_else(|_| \"http://127.0.0.1:8080\".to_string());\n- let login_url = format!(\"{public_url}/auth/login?session={}\", urlencoding::encode(&session));\n+ let public_url =\n+ std::env::var(\"SLUG_PUBLIC_URL\").unwrap_or_else(|_| \"http://127.0.0.1:8080\".to_string());\n+ let login_url = format!(\n+ \"{public_url}/auth/login?session={}\",\n+ urlencoding::encode(&session)\n+ );\n let poll_url = format!(\"/api/v0/pending-session/{}\", session);\n let s = PendingSession {\n agent: agent_naked,\n@@ -517,6 +703,7 @@ pub async fn post_pending_session(\n provider: None,\n provider_id: None,\n redeem_invite: None,\n+ redirect_next: None,\n complete: None,\n };\n let sessions = pending_sessions(&state);\n@@ -567,11 +754,14 @@ pub async fn get_whoami(State(state): State, headers: HeaderMap) -> im\n Ok(u) => u,\n Err((st, msg)) => return api_error(st, msg, None).into_response(),\n };\n- let agents_bound = reduced.agent_bindings.values().filter(|u| *u == &username).count();\n+ let agents_bound = reduced\n+ .agent_bindings\n+ .values()\n+ .filter(|u| *u == &username)\n+ .count();\n Json(WhoamiResponse {\n user: username,\n agents_bound,\n })\n .into_response()\n }\n-\ndiff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs\nindex 336d54bfad5037e9d87437400747260d160bee2a..146792b9ec33d539ce8c6b106768162805dbb3f9 100644\n--- a/server/src/api/rpc.rs\n+++ b/server/src/api/rpc.rs\n@@ -20,6 +20,7 @@ use crate::{\n path_types::ItemId,\n ranking::{connected_components_from_voted_pairs, ranked_items_subset},\n reducer::{scope_from_room_wire, ReducerState, ScopeId},\n+ scope_rank::suggest_next_pair_in_pool,\n state::{AppState, InviteState},\n write_cmd::WriteCmd,\n };\n@@ -759,7 +760,8 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re\n }\n }\n }\n- pick.or_else(|| pick_random_distinct_item_pair(&pool))\n+ pick.or_else(|| suggest_next_pair_in_pool(group, &pool, None))\n+ .or_else(|| pick_random_distinct_item_pair(&pool))\n }\n };\n let Some((left, right)) = selected else {\ndiff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs\nindex cd501ba6d4d656eabad03afed4583efdf885695d..4b0214d18b173cd506d09176104f461dc4c4f208 100644\n--- a/server/src/api/ui_html.rs\n+++ b/server/src/api/ui_html.rs\n@@ -21,11 +21,11 @@ use crate::{\n external_resolver::resolve_github_children,\n html::vote_compare_post_success_js,\n html::{\n- fragment_new_thread_slot, login_to_post_hint_markup, parse_html_ui_from_form,\n- room_members_section_markup, thread_feed_html, thread_feed_html_for_room,\n- thread_feed_region_markup, thread_ui_collapse_redacted_post, thread_ui_expand_post_full,\n- thread_ui_expand_redacted_post, ui_js_warn, user_can_post_room, user_can_view_room,\n- HtmlUiAction, JsBuilder, ThreadNav,\n+ external_resolver_status_markup, fragment_new_thread_slot, login_to_post_hint_markup,\n+ parse_html_ui_from_form, room_members_section_markup, thread_feed_html,\n+ thread_feed_html_for_room, thread_feed_region_markup, thread_ui_collapse_redacted_post,\n+ thread_ui_expand_post_full, thread_ui_expand_redacted_post, ui_js_warn, user_can_post_room,\n+ user_can_view_room, HtmlUiAction, JsBuilder, ThreadNav,\n },\n reducer::{scope_from_room_wire, ScopeId},\n state::AppState,\n@@ -68,6 +68,13 @@ fn sanitize_garden_pin_next(next: &str) -> String {\n }\n }\n \n+fn login_redirect_for(next: &str) -> String {\n+ format!(\n+ \"/login?next={}\",\n+ urlencoding::encode(&sanitize_garden_pin_next(next))\n+ )\n+}\n+\n fn redirect_with_pin_cookie(cookie_header_value: &str, location: &str) -> Response {\n let loc = HeaderValue::try_from(location).unwrap_or_else(|_| HeaderValue::from_static(\"/\"));\n let hv = HeaderValue::try_from(cookie_header_value).expect(\"set-cookie header\");\n@@ -194,7 +201,7 @@ async fn dispatch_ui_action(\n .into_response();\n }\n let Some(session) = session else {\n- return js_redirect(\"/login\").into_response();\n+ return js_redirect(&login_redirect_for(&next)).into_response();\n };\n let err_tgt = Some(\"vote-compare-errors\".to_string());\n let room = room.trim().to_string();\n@@ -363,7 +370,7 @@ async fn dispatch_ui_action(\n .into_response();\n }\n let Some(session) = session else {\n- return js_redirect(\"/login\").into_response();\n+ return js_redirect(&login_redirect_for(&next)).into_response();\n };\n let room = room_wire.trim();\n if room.is_empty() {\n@@ -390,8 +397,19 @@ async fn dispatch_ui_action(\n item.normalized_storage()\n };\n match resolve_github_children(state, room, &target).await {\n- Ok(_) => js_redirect(&sanitize_garden_pin_next(&next)).into_response(),\n- Err(msg) => ui_js_warn(&msg).into_response(),\n+ Ok(n) => JsBuilder::new()\n+ .morph_inner_selector(\n+ \"#external-resolver-status\",\n+ external_resolver_status_markup(Ok(n), &sanitize_garden_pin_next(&next)),\n+ )\n+ .redirect(&sanitize_garden_pin_next(&next))\n+ .into_response(),\n+ Err(msg) => JsBuilder::new()\n+ .morph_inner_selector(\n+ \"#external-resolver-status\",\n+ external_resolver_status_markup(Err(msg.as_str()), &next),\n+ )\n+ .into_response(),\n }\n }\n HtmlUiAction::RedactPost { post_id } => {\ndiff --git a/server/src/external_resolver.rs b/server/src/external_resolver.rs\nindex 04bc3de32b6df1aefe197d942db67de2cbdbde4f..a5812250fed7613950b5417f396f886a55fafccf 100644\n--- a/server/src/external_resolver.rs\n+++ b/server/src/external_resolver.rs\n@@ -6,6 +6,7 @@ use crate::{path_types::ItemId, state::AppState, write_cmd::WriteCmd};\n \n const GITHUB_SYSTEM_PRINCIPAL: &str = \"system:github-resolver\";\n const GITHUB_RESOLVER_COOLDOWN_MS: i64 = 15_000;\n+const GITHUB_MAX_PAGES: usize = 3;\n \n fn now_ms() -> i64 {\n use std::time::{SystemTime, UNIX_EPOCH};\n@@ -69,6 +70,10 @@ impl GitHubResolver {\n [owner, repo] => Ok(github_repo_sections(owner, repo)),\n [owner, repo, section] if section == \"issues\" => self.list_issues(owner, repo).await,\n [owner, repo, section] if section == \"pulls\" => self.list_pulls(owner, repo).await,\n+ [owner, repo, section] if section == \"commits\" => self.list_commits(owner, repo).await,\n+ [owner, repo, section] if section == \"releases\" => {\n+ self.list_releases(owner, repo).await\n+ }\n _ => Ok(vec![]),\n }\n }\n@@ -95,17 +100,31 @@ impl GitHubResolver {\n .map_err(|e| format!(\"GitHub response JSON failed: {e}\"))\n }\n \n+ async fn get_json_array_pages(&self, path: &str) -> Result, String> {\n+ let sep = if path.contains('?') { '&' } else { '?' };\n+ let mut out = Vec::new();\n+ for page in 1..=GITHUB_MAX_PAGES {\n+ let value = self.get_json(&format!(\"{path}{sep}page={page}\")).await?;\n+ let arr = value\n+ .as_array()\n+ .ok_or_else(|| \"GitHub paged response was not an array\".to_string())?;\n+ let n = arr.len();\n+ out.extend(arr.iter().cloned());\n+ if n < 100 {\n+ break;\n+ }\n+ }\n+ Ok(out)\n+ }\n+\n async fn list_repos(&self, owner: &str) -> Result, String> {\n- let value = self\n- .get_json(&format!(\n+ let arr = self\n+ .get_json_array_pages(&format!(\n \"/users/{owner}/repos?per_page=100&sort=updated&type=owner\"\n ))\n .await?;\n- let arr = value\n- .as_array()\n- .ok_or_else(|| \"GitHub repos response was not an array\".to_string())?;\n let mut out = Vec::new();\n- for repo in arr {\n+ for repo in &arr {\n let name = repo\n .get(\"name\")\n .and_then(|v| v.as_str())\n@@ -121,7 +140,7 @@ impl GitHubResolver {\n out.push(ResolvedChild {\n url: format!(\"https://github.com/{full_name}\"),\n title: full_name.clone(),\n- body: Some(github_json_body(repo)),\n+ body: Some(github_repo_body(repo)),\n });\n }\n out.sort_by(|a, b| a.url.cmp(&b.url));\n@@ -129,16 +148,13 @@ impl GitHubResolver {\n }\n \n async fn list_issues(&self, owner: &str, repo: &str) -> Result, String> {\n- let value = self\n- .get_json(&format!(\n+ let arr = self\n+ .get_json_array_pages(&format!(\n \"/repos/{owner}/{repo}/issues?state=open&per_page=100\"\n ))\n .await?;\n- let arr = value\n- .as_array()\n- .ok_or_else(|| \"GitHub issues response was not an array\".to_string())?;\n let mut out = Vec::new();\n- for issue in arr {\n+ for issue in &arr {\n if issue.get(\"pull_request\").is_some() {\n continue;\n }\n@@ -152,7 +168,7 @@ impl GitHubResolver {\n out.push(ResolvedChild {\n url: format!(\"https://github.com/{owner}/{repo}/issues/{number}\"),\n title: format!(\"#{number} {title}\"),\n- body: Some(github_json_body(issue)),\n+ body: Some(github_issue_body(issue, \"issue\")),\n });\n }\n out.sort_by(|a, b| a.url.cmp(&b.url));\n@@ -160,16 +176,13 @@ impl GitHubResolver {\n }\n \n async fn list_pulls(&self, owner: &str, repo: &str) -> Result, String> {\n- let value = self\n- .get_json(&format!(\n+ let arr = self\n+ .get_json_array_pages(&format!(\n \"/repos/{owner}/{repo}/pulls?state=open&per_page=100\"\n ))\n .await?;\n- let arr = value\n- .as_array()\n- .ok_or_else(|| \"GitHub pulls response was not an array\".to_string())?;\n let mut out = Vec::new();\n- for pull in arr {\n+ for pull in &arr {\n let Some(number) = pull.get(\"number\").and_then(|v| v.as_i64()) else {\n continue;\n };\n@@ -180,7 +193,60 @@ impl GitHubResolver {\n out.push(ResolvedChild {\n url: format!(\"https://github.com/{owner}/{repo}/pulls/{number}\"),\n title: format!(\"#{number} {title}\"),\n- body: Some(github_json_body(pull)),\n+ body: Some(github_issue_body(pull, \"pull request\")),\n+ });\n+ }\n+ out.sort_by(|a, b| a.url.cmp(&b.url));\n+ Ok(out)\n+ }\n+\n+ async fn list_commits(&self, owner: &str, repo: &str) -> Result, String> {\n+ let arr = self\n+ .get_json_array_pages(&format!(\"/repos/{owner}/{repo}/commits?per_page=100\"))\n+ .await?;\n+ let mut out = Vec::new();\n+ for commit in &arr {\n+ let Some(sha) = github_string(commit, \"sha\") else {\n+ continue;\n+ };\n+ let short = sha.chars().take(7).collect::();\n+ let title = commit\n+ .get(\"commit\")\n+ .and_then(|c| c.get(\"message\"))\n+ .and_then(|v| v.as_str())\n+ .and_then(|m| m.lines().next())\n+ .filter(|s| !s.trim().is_empty())\n+ .unwrap_or(\"commit\");\n+ let url = github_string(commit, \"html_url\")\n+ .map(|s| s.to_string())\n+ .unwrap_or_else(|| format!(\"https://github.com/{owner}/{repo}/commit/{sha}\"));\n+ out.push(ResolvedChild {\n+ url,\n+ title: format!(\"{short} {title}\"),\n+ body: Some(github_commit_body(commit)),\n+ });\n+ }\n+ out.sort_by(|a, b| a.url.cmp(&b.url));\n+ Ok(out)\n+ }\n+\n+ async fn list_releases(&self, owner: &str, repo: &str) -> Result, String> {\n+ let arr = self\n+ .get_json_array_pages(&format!(\"/repos/{owner}/{repo}/releases?per_page=100\"))\n+ .await?;\n+ let mut out = Vec::new();\n+ for release in &arr {\n+ let Some(tag) = github_string(release, \"tag_name\") else {\n+ continue;\n+ };\n+ let title = github_string(release, \"name\").unwrap_or(tag);\n+ let url = github_string(release, \"html_url\")\n+ .map(|s| s.to_string())\n+ .unwrap_or_else(|| format!(\"https://github.com/{owner}/{repo}/releases/tag/{tag}\"));\n+ out.push(ResolvedChild {\n+ url,\n+ title: title.to_string(),\n+ body: Some(github_release_body(release)),\n });\n }\n out.sort_by(|a, b| a.url.cmp(&b.url));\n@@ -240,11 +306,147 @@ fn sanitize_body(s: &str) -> String {\n .collect()\n }\n \n-fn github_json_body(value: &Value) -> String {\n- let json = serde_json::to_string_pretty(value)\n- .unwrap_or_else(|_| value.to_string())\n- .replace(\"```\", \"` ` `\");\n- format!(\"```json\\n{json}\\n```\")\n+fn github_string<'a>(value: &'a Value, key: &str) -> Option<&'a str> {\n+ value\n+ .get(key)\n+ .and_then(|v| v.as_str())\n+ .filter(|s| !s.trim().is_empty())\n+}\n+\n+fn github_user_login(value: &Value) -> Option<&str> {\n+ value\n+ .get(\"user\")\n+ .and_then(|u| u.get(\"login\"))\n+ .and_then(|v| v.as_str())\n+ .filter(|s| !s.trim().is_empty())\n+}\n+\n+fn github_labels(value: &Value) -> Vec {\n+ value\n+ .get(\"labels\")\n+ .and_then(|v| v.as_array())\n+ .into_iter()\n+ .flat_map(|labels| labels.iter())\n+ .filter_map(|label| label.get(\"name\").and_then(|v| v.as_str()))\n+ .filter(|name| !name.trim().is_empty())\n+ .map(|name| name.to_string())\n+ .collect()\n+}\n+\n+fn github_repo_body(repo: &Value) -> String {\n+ let full_name = github_string(repo, \"full_name\")\n+ .or_else(|| github_string(repo, \"name\"))\n+ .unwrap_or(\"GitHub repository\");\n+ let mut lines = vec![full_name.to_string()];\n+ if let Some(desc) = github_string(repo, \"description\") {\n+ lines.push(String::new());\n+ lines.push(desc.to_string());\n+ }\n+ if let Some(url) = github_string(repo, \"html_url\") {\n+ lines.push(String::new());\n+ lines.push(format!(\"Source: {url}\"));\n+ }\n+ if let Some(lang) = github_string(repo, \"language\") {\n+ lines.push(format!(\"Language: {lang}\"));\n+ }\n+ lines.join(\"\\n\")\n+}\n+\n+fn github_issue_body(issue: &Value, kind: &str) -> String {\n+ let number = issue\n+ .get(\"number\")\n+ .and_then(|v| v.as_i64())\n+ .map(|n| format!(\"#{n} \"))\n+ .unwrap_or_default();\n+ let title = github_string(issue, \"title\").unwrap_or(\"Untitled\");\n+ let state = github_string(issue, \"state\").unwrap_or(\"unknown\");\n+ let mut lines = vec![format!(\"{kind} {number}{title}\")];\n+ lines.push(format!(\"State: {state}\"));\n+ if let Some(author) = github_user_login(issue) {\n+ lines.push(format!(\"Author: @{author}\"));\n+ }\n+ let labels = github_labels(issue);\n+ if !labels.is_empty() {\n+ lines.push(format!(\"Labels: {}\", labels.join(\", \")));\n+ }\n+ if let Some(url) = github_string(issue, \"html_url\") {\n+ lines.push(format!(\"Source: {url}\"));\n+ }\n+ if let Some(body) = github_string(issue, \"body\") {\n+ lines.push(String::new());\n+ lines.push(body.to_string());\n+ }\n+ lines.join(\"\\n\")\n+}\n+\n+fn github_commit_body(commit: &Value) -> String {\n+ let sha = github_string(commit, \"sha\").unwrap_or(\"unknown\");\n+ let short = sha.chars().take(7).collect::();\n+ let commit_obj = commit.get(\"commit\");\n+ let message = commit_obj\n+ .and_then(|c| c.get(\"message\"))\n+ .and_then(|v| v.as_str())\n+ .unwrap_or(\"commit\");\n+ let mut lines = vec![format!(\"commit {short}\")];\n+ if let Some(author) = commit_obj\n+ .and_then(|c| c.get(\"author\"))\n+ .and_then(|a| a.get(\"name\"))\n+ .and_then(|v| v.as_str())\n+ .filter(|s| !s.trim().is_empty())\n+ {\n+ lines.push(format!(\"Author: {author}\"));\n+ }\n+ if let Some(login) = github_user_login(commit) {\n+ lines.push(format!(\"GitHub user: @{login}\"));\n+ }\n+ if let Some(date) = commit_obj\n+ .and_then(|c| c.get(\"author\"))\n+ .and_then(|a| a.get(\"date\"))\n+ .and_then(|v| v.as_str())\n+ {\n+ lines.push(format!(\"Date: {date}\"));\n+ }\n+ if let Some(url) = github_string(commit, \"html_url\") {\n+ lines.push(format!(\"Source: {url}\"));\n+ }\n+ lines.push(String::new());\n+ lines.push(message.to_string());\n+ lines.join(\"\\n\")\n+}\n+\n+fn github_release_body(release: &Value) -> String {\n+ let tag = github_string(release, \"tag_name\").unwrap_or(\"untagged\");\n+ let title = github_string(release, \"name\").unwrap_or(tag);\n+ let mut lines = vec![format!(\"release {title}\")];\n+ lines.push(format!(\"Tag: {tag}\"));\n+ if release\n+ .get(\"draft\")\n+ .and_then(|v| v.as_bool())\n+ .unwrap_or(false)\n+ {\n+ lines.push(\"Draft: yes\".to_string());\n+ }\n+ if release\n+ .get(\"prerelease\")\n+ .and_then(|v| v.as_bool())\n+ .unwrap_or(false)\n+ {\n+ lines.push(\"Prerelease: yes\".to_string());\n+ }\n+ if let Some(author) = github_user_login(release) {\n+ lines.push(format!(\"Author: @{author}\"));\n+ }\n+ if let Some(published) = github_string(release, \"published_at\") {\n+ lines.push(format!(\"Published: {published}\"));\n+ }\n+ if let Some(url) = github_string(release, \"html_url\") {\n+ lines.push(format!(\"Source: {url}\"));\n+ }\n+ if let Some(body) = github_string(release, \"body\") {\n+ lines.push(String::new());\n+ lines.push(body.to_string());\n+ }\n+ lines.join(\"\\n\")\n }\n \n fn children_to_dsl(children: &[ResolvedChild]) -> String {\n@@ -382,4 +584,47 @@ mod tests {\n assert!(dsl.contains(\"{\\\"test\\\": true}\"));\n assert!(dsl.contains(\"```\\n}\\n\"));\n }\n+\n+ #[test]\n+ fn github_issue_body_is_readable_text_not_json_dump() {\n+ let issue = serde_json::json!({\n+ \"number\": 12,\n+ \"title\": \"Render children\",\n+ \"state\": \"open\",\n+ \"html_url\": \"https://github.com/o/r/issues/12\",\n+ \"user\": {\"login\": \"octo\"},\n+ \"labels\": [{\"name\": \"bug\"}],\n+ \"body\": \"The issue body.\"\n+ });\n+ let body = github_issue_body(&issue, \"issue\");\n+ assert!(body.contains(\"issue #12 Render children\"));\n+ assert!(body.contains(\"Author: @octo\"));\n+ assert!(body.contains(\"The issue body.\"));\n+ assert!(!body.trim_start().starts_with(\"```json\"));\n+ }\n+\n+ #[test]\n+ fn github_commit_and_release_bodies_are_readable() {\n+ let commit = serde_json::json!({\n+ \"sha\": \"abcdef123456\",\n+ \"html_url\": \"https://github.com/o/r/commit/abcdef123456\",\n+ \"author\": {\"login\": \"octo\"},\n+ \"commit\": {\n+ \"message\": \"Fix vote page\\n\\nDetails here.\",\n+ \"author\": {\"name\": \"Octo Dev\", \"date\": \"2026-05-17T00:00:00Z\"}\n+ }\n+ });\n+ let release = serde_json::json!({\n+ \"tag_name\": \"v1.2.3\",\n+ \"name\": \"Release 1.2.3\",\n+ \"html_url\": \"https://github.com/o/r/releases/tag/v1.2.3\",\n+ \"author\": {\"login\": \"octo\"},\n+ \"prerelease\": true,\n+ \"body\": \"Release notes.\"\n+ });\n+ assert!(github_commit_body(&commit).contains(\"commit abcdef1\"));\n+ assert!(github_commit_body(&commit).contains(\"Fix vote page\"));\n+ assert!(github_release_body(&release).contains(\"release Release 1.2.3\"));\n+ assert!(github_release_body(&release).contains(\"Prerelease: yes\"));\n+ }\n }\ndiff --git a/server/src/html/garden.rs b/server/src/html/garden.rs\nindex b28271e0316738eae245855b24aeb76486860554..9ca66e7c5860d428e95abf5df518fc1e7b4f6332 100644\n--- a/server/src/html/garden.rs\n+++ b/server/src/html/garden.rs\n@@ -22,7 +22,7 @@ use crate::{\n reducer::{ContentState, ReducerState, ScopeId},\n scope_rank::{\n build_children_rankings, build_rankings_for_item_set, resolve_scope_recursive,\n- ChildrenRankings,\n+ suggest_next_pair_in_pool, ChildrenRankings,\n },\n state::AppState,\n timeago,\n@@ -237,7 +237,7 @@ pub(crate) async fn vote_compare_post_success_js(\n state: &AppState,\n nav: &ThreadNav,\n _room_wire: &str,\n- _thread_tag: &str,\n+ thread_tag: &str,\n left: &ItemId,\n right: &ItemId,\n _post_id: &str,\n@@ -246,9 +246,13 @@ pub(crate) async fn vote_compare_post_success_js(\n let reduced = state.reduced.read().await;\n let content = content_for_garden_view(&reduced, &nav.scope());\n let edge_history = vote_edge_history_markup(content, left, right);\n+ let next_pair = suggest_next_vote_pair(content, left, right);\n+ let nav_markup =\n+ vote_compare_nav_markup(nav, next_pair.as_ref(), left, right, Some(thread_tag));\n drop(reduced);\n JsBuilder::new()\n .morph_inner_selector(\"#vote-edge-history-region\", edge_history)\n+ .morph_selector(\".vote-compare-nav\", nav_markup)\n .build()\n }\n \n@@ -288,6 +292,93 @@ fn vote_compare_href(\n }\n }\n \n+fn login_href_with_next(next: &str) -> String {\n+ let next = if next.trim().starts_with('/') && !next.trim().starts_with(\"//\") {\n+ next.trim()\n+ } else {\n+ \"/\"\n+ };\n+ format!(\"/login?next={}\", urlencoding::encode(next))\n+}\n+\n+fn vote_compare_nav_markup(\n+ nav: &ThreadNav,\n+ next_pair: Option<&(ItemId, ItemId)>,\n+ left: &ItemId,\n+ right: &ItemId,\n+ thread_override: Option<&str>,\n+) -> maud::Markup {\n+ let next_pair_href = next_pair.map(|(nl, nr)| vote_compare_href(nav, nl, nr, None));\n+ let swap_pair_href = vote_compare_href(nav, right, left, thread_override);\n+ html! {\n+ div class=\"vote-compare-nav\" {\n+ @if let Some(href) = &next_pair_href {\n+ a class=\"vote-compare-next\" data-testid=\"vote-next-pair\" href=(href) { \"next pair\" }\n+ } @else {\n+ span class=\"vote-compare-next is-disabled\" { \"no next pair\" }\n+ }\n+ a class=\"vote-compare-next\" href=(swap_pair_href) { \"swap sides\" }\n+ }\n+ }\n+}\n+\n+fn suggest_next_vote_pair(\n+ content: &ContentState,\n+ current_left: &ItemId,\n+ current_right: &ItemId,\n+) -> Option<(ItemId, ItemId)> {\n+ let mut pool: Vec = if current_left.parent().as_ref().map(|p| p.as_str())\n+ == current_right.parent().as_ref().map(|p| p.as_str())\n+ {\n+ current_left\n+ .parent()\n+ .and_then(|parent| {\n+ content\n+ .item_children\n+ .get(&parent.normalized_storage())\n+ .cloned()\n+ })\n+ .map(|children| children.into_iter().collect())\n+ .unwrap_or_default()\n+ } else {\n+ Vec::new()\n+ };\n+ if pool.len() < 2 {\n+ pool = content.items.iter().cloned().collect();\n+ }\n+ suggest_next_pair_in_pool(\n+ &content.ranking_group,\n+ &pool,\n+ Some((current_left, current_right)),\n+ )\n+}\n+\n+fn vote_compare_item_card(\n+ nav: &ThreadNav,\n+ item: &ItemId,\n+ body: Option<&String>,\n+ side_class: &str,\n+) -> maud::Markup {\n+ html! {\n+ div class=(format!(\"vote-compare-side {side_class}\")) {\n+ a class=(format!(\"vote-compare-item {side_class}\")) href=(nav.garden_item_href(item)) {\n+ code { (item_display_path(item.as_str())) }\n+ }\n+ @if let Some(body) = body.filter(|b| !b.trim().is_empty()) {\n+ div class=\"vote-compare-item-body\" {\n+ (render_linkified_with_embeds_in_scope(\n+ body,\n+ nav.garden_root_url(),\n+ None,\n+ ))\n+ }\n+ } @else {\n+ p class=\"muted vote-compare-item-body-empty\" { \"no body yet\" }\n+ }\n+ }\n+ }\n+}\n+\n fn ont_pin_vote_controls(\n nav: &ThreadNav,\n current_storage: &str,\n@@ -1153,7 +1244,7 @@ fn github_resolver_controls(item: &str, nav: &ThreadNav, next: &str) -> Option Option,\n+ next: &str,\n+) -> maud::Markup {\n+ let next = if next.trim().starts_with('/') && !next.trim().starts_with(\"//\") {\n+ next.trim()\n+ } else {\n+ \"/\"\n+ };\n+ html! {\n+ @match imported {\n+ Ok(n) => {\n+ p class=\"resolver-status-ok\" {\n+ @if n == 0 {\n+ \"No GitHub children found. \"\n+ } @else {\n+ (format!(\"Imported {n} GitHub item{}.\", if n == 1 { \"\" } else { \"s\" })) \" \"\n+ }\n+ a href=(next) { \"Refresh page\" }\n+ \" to render the updated ontology.\"\n+ }\n+ }\n+ Err(msg) => {\n+ p class=\"resolver-status-error\" { (msg) }\n+ }\n+ }\n+ }\n+}\n+\n async fn render_scope_view(\n state: AppState,\n browse: GardenBrowsePath,\n@@ -1468,6 +1590,9 @@ async fn vote_compare_inner(\n .unwrap_or_else(|| pick_autothread_for_vote_pair(content, &left, &right));\n let thread_tags = vote_thread_tags_for_pair(content, &left, &right);\n let edge_history = vote_edge_history_markup(content, &left, &right);\n+ let left_body = content.item_bodies.get(&left).cloned();\n+ let right_body = content.item_bodies.get(&right).cloned();\n+ let next_pair = suggest_next_vote_pair(content, &left, &right);\n drop(reduced);\n \n let title = format!(\n@@ -1495,52 +1620,51 @@ async fn vote_compare_inner(\n .expect(\"vote compare rpc json\");\n \n let body = html! {\n- h2 { \"compare\" }\n- div class=\"vote-compare-pair\" {\n- a class=\"vote-compare-item\" href=(nav.garden_item_href(&left)) {\n- code { (item_display_path(left.as_str())) }\n+ section class=\"vote-compare-shell\" {\n+ h2 { \"compare\" }\n+ div class=\"vote-compare-pair\" {\n+ (vote_compare_item_card(&nav, &left, left_body.as_ref(), \"vote-compare-left\"))\n+ span class=\"vote-compare-vs\" { \"vs\" }\n+ (vote_compare_item_card(&nav, &right, right_body.as_ref(), \"vote-compare-right\"))\n }\n- span class=\"vote-compare-vs\" { \"vs\" }\n- a class=\"vote-compare-item\" href=(nav.garden_item_href(&right)) {\n- code { (item_display_path(right.as_str())) }\n+ (vote_compare_nav_markup(&nav, next_pair.as_ref(), &left, &right, q.thread.as_deref()))\n+ div id=\"vote-edge-history-region\" {\n+ (edge_history)\n }\n- }\n- div id=\"vote-edge-history-region\" {\n- (edge_history)\n- }\n- @if can_post {\n- form id=\"vote-compare-form\" method=\"POST\" action=\"/ui\" {\n- input type=\"hidden\" name=(UI_RPC_FIELD) value=(rpc_json);\n- div class=\"vote-thread-picker\" {\n- label class=\"vote-thread-picker-label\" { \"thread\" }\n- select id=\"vote-thread-select\" name=\"thread_tag\" aria-label=\"Thread to post vote into\" {\n- @if thread_tags.is_empty() {\n- option value=\"vote\" selected { \"#vote\" }\n- }\n- @for t in &thread_tags {\n- @if *t == auto_thread {\n- option value=(t) selected { \"#\" (t) }\n- } @else {\n- option value=(t) { \"#\" (t) }\n+ @if can_post {\n+ form id=\"vote-compare-form\" method=\"POST\" action=\"/ui\" {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(rpc_json);\n+ div class=\"vote-thread-picker\" {\n+ label class=\"vote-thread-picker-label\" { \"thread\" }\n+ select id=\"vote-thread-select\" name=\"thread_tag\" aria-label=\"Thread to post vote into\" {\n+ @if thread_tags.is_empty() {\n+ option value=\"vote\" selected { \"#vote\" }\n+ }\n+ @for t in &thread_tags {\n+ @if *t == auto_thread {\n+ option value=(t) selected { \"#\" (t) }\n+ } @else {\n+ option value=(t) { \"#\" (t) }\n+ }\n }\n }\n }\n+ input type=\"hidden\" name=\"ratio_left\" id=\"vote-ratio-left\" value=\"50\";\n+ input type=\"hidden\" name=\"ratio_right\" id=\"vote-ratio-right\" value=\"50\";\n+ label class=\"vote-compare-slider-label\" {\n+ span id=\"vote-slider-left-label\" { (item_display_path(left.as_str())) }\n+ input type=\"range\" id=\"vote-preference-slider\" min=\"0\" max=\"100\" value=\"50\"\n+ aria-valuemin=\"0\" aria-valuemax=\"100\";\n+ span id=\"vote-slider-right-label\" { (item_display_path(right.as_str())) }\n+ }\n+ label class=\"vote-explain-label\" { \"reason (required)\" }\n+ textarea name=\"explanation\" id=\"vote-explain\" rows=\"5\" placeholder=\"why this split?\" required {}\n+ div id=\"vote-compare-errors\" {}\n+ p { button type=\"submit\" { \"post vote\" } }\n }\n- input type=\"hidden\" name=\"ratio_left\" id=\"vote-ratio-left\" value=\"50\";\n- input type=\"hidden\" name=\"ratio_right\" id=\"vote-ratio-right\" value=\"50\";\n- label class=\"vote-compare-slider-label\" {\n- span id=\"vote-slider-left-label\" { (item_display_path(left.as_str())) }\n- input type=\"range\" id=\"vote-preference-slider\" min=\"0\" max=\"100\" value=\"50\"\n- aria-valuemin=\"0\" aria-valuemax=\"100\";\n- span id=\"vote-slider-right-label\" { (item_display_path(right.as_str())) }\n- }\n- label class=\"vote-explain-label\" { \"reason (required)\" }\n- textarea name=\"explanation\" id=\"vote-explain\" rows=\"5\" placeholder=\"why this split?\" required {}\n- div id=\"vote-compare-errors\" {}\n- p { button type=\"submit\" { \"post vote\" } }\n+ } @else {\n+ p class=\"muted\" { a href=(login_href_with_next(&next_path)) { \"log in\" } \" to post this vote.\" }\n }\n- } @else {\n- p class=\"muted\" { a href=\"/login\" { \"log in\" } \" to post this vote.\" }\n }\n };\n \n@@ -1649,6 +1773,42 @@ mod tests {\n assert_eq!(edge_vote_entries_for_pair(content, &a, &b).len(), 2);\n }\n \n+ #[test]\n+ fn suggest_next_vote_pair_prefers_unvoted_sibling_pair() {\n+ use super::{content_for_garden_view, suggest_next_vote_pair};\n+ use crate::path_types::ItemId;\n+ let mut reduced = ReducerState::default();\n+ apply_ingest(\n+ &mut reduced,\n+ 1,\n+ \"@00000000-0000-0000-0000-000000000000:test:local/test\\n\\\n+ ~/topic {root}\\n\\\n+ ~/topic/a {alpha}\\n\\\n+ ~/topic/b {beta}\\n\\\n+ ~/topic/c {gamma}\\n\\\n+ {a beats b}\\n ~/topic/a 2:1 ~/topic/b\\n\",\n+ );\n+ let content = content_for_garden_view(&reduced, &ScopeId::Public);\n+ let a = ItemId::parse(\"~/topic/a\").unwrap().normalized_storage();\n+ let b = ItemId::parse(\"~/topic/b\").unwrap().normalized_storage();\n+ let next = suggest_next_vote_pair(content, &a, &b).expect(\"next sibling pair\");\n+ assert_ne!(\n+ super::canonical_edge_items(&next.0, &next.1),\n+ super::canonical_edge_items(&a, &b)\n+ );\n+ assert!(\n+ next.0.as_str().ends_with(\"/c\") || next.1.as_str().ends_with(\"/c\"),\n+ \"next pair should include the unvoted sibling: {next:?}\"\n+ );\n+ }\n+\n+ #[test]\n+ fn external_resolver_status_markup_reports_success_and_refresh() {\n+ let html = super::external_resolver_status_markup(Ok(2), \"/-/github.com/o/r\").into_string();\n+ assert!(html.contains(\"Imported 2 GitHub items.\"));\n+ assert!(html.contains(\"href=\\\"/-/github.com/o/r\\\"\"));\n+ }\n+\n #[test]\n fn item_page_model_includes_body_and_unranked_without_votes() {\n let mut reduced = ReducerState::default();\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex d5877eeb3632ed5d770f23a04cb4d1881209bc74..a1b929625acbd5298c0cf62f3ca0892079edcb2a 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -36,7 +36,10 @@ pub(crate) use forum::{\n thread_ui_collapse_redacted_post, thread_ui_expand_post_full, thread_ui_expand_redacted_post,\n user_can_post_room, user_can_view_room,\n };\n-pub(crate) use garden::{encode_pin_cookie_value, vote_compare_post_success_js, GARDEN_PIN_COOKIE};\n+pub(crate) use garden::{\n+ encode_pin_cookie_value, external_resolver_status_markup, vote_compare_post_success_js,\n+ GARDEN_PIN_COOKIE,\n+};\n pub use garden::{\n external_garden_index, external_ontology_path, garden_index, ontology_path,\n room_external_garden_index, room_external_ontology_path, room_garden_index, room_ontology_path,\ndiff --git a/server/src/scope_rank.rs b/server/src/scope_rank.rs\nindex 4fdaae150c74ec5162c2accc06d4cc778ad79914..06c560b8eff09b34896d3935d3917fb28f602bc6 100644\n--- a/server/src/scope_rank.rs\n+++ b/server/src/scope_rank.rs\n@@ -5,7 +5,7 @@ use std::collections::{HashMap, HashSet};\n \n use crate::path_types::ItemId;\n use crate::ranking::{connected_components_from_voted_pairs, ranked_items_subset, RankedItem};\n-use crate::reducer::ContentState;\n+use crate::reducer::{ContentState, GroupState};\n \n #[derive(Debug, Clone)]\n pub struct ScopedComponent {\n@@ -49,15 +49,16 @@ pub fn resolve_scope(content: &ContentState, specs: &[String]) -> Vec {\n /// Resolve scope specs recursively up to `depth` levels deep.\n /// depth=1 is equivalent to resolve_scope (direct children only).\n /// depth=2 includes grandchildren, etc.\n-pub fn resolve_scope_recursive(content: &ContentState, specs: &[String], depth: usize) -> Vec {\n+pub fn resolve_scope_recursive(\n+ content: &ContentState,\n+ specs: &[String],\n+ depth: usize,\n+) -> Vec {\n if depth == 0 {\n return vec![];\n }\n let mut visited: HashSet = HashSet::new();\n- let mut frontier: Vec = specs\n- .iter()\n- .filter_map(|s| ItemId::parse(s))\n- .collect();\n+ let mut frontier: Vec = specs.iter().filter_map(|s| ItemId::parse(s)).collect();\n \n for _level in 0..depth {\n let mut next_frontier: Vec = Vec::new();\n@@ -83,7 +84,10 @@ pub fn resolve_scope_recursive(content: &ContentState, specs: &[String], depth:\n \n /// Build connected-component rankings for an explicit set of item paths.\n /// Use this when scope comes from multiple parents (resolve_scope).\n-pub fn build_rankings_for_item_set(content: &ContentState, items_in_scope: &[ItemId]) -> ChildrenRankings {\n+pub fn build_rankings_for_item_set(\n+ content: &ContentState,\n+ items_in_scope: &[ItemId],\n+) -> ChildrenRankings {\n let group = &content.ranking_group;\n let mut items_in_scope: Vec = items_in_scope.to_vec();\n items_in_scope.sort();\n@@ -158,6 +162,67 @@ pub fn build_children_rankings(content: &ContentState, parent: &ItemId) -> Child\n build_rankings_for_item_set(content, &items)\n }\n \n+pub fn is_pair_voted_in_group(group: &GroupState, a: &ItemId, b: &ItemId) -> bool {\n+ let Some(&a_idx) = group.item_to_idx.get(a) else {\n+ return false;\n+ };\n+ let Some(&b_idx) = group.item_to_idx.get(b) else {\n+ return false;\n+ };\n+ let (i, j) = if a_idx < b_idx {\n+ (a_idx, b_idx)\n+ } else {\n+ (b_idx, a_idx)\n+ };\n+ group.voted_pairs.contains(&(i, j))\n+}\n+\n+fn canonical_pair(a: &ItemId, b: &ItemId) -> (ItemId, ItemId) {\n+ let ac = a.clone().normalized_storage();\n+ let bc = b.clone().normalized_storage();\n+ if ac.as_str() <= bc.as_str() {\n+ (ac, bc)\n+ } else {\n+ (bc, ac)\n+ }\n+}\n+\n+pub fn suggest_next_pair_in_pool(\n+ group: &GroupState,\n+ pool: &[ItemId],\n+ current_pair: Option<(&ItemId, &ItemId)>,\n+) -> Option<(ItemId, ItemId)> {\n+ let current = current_pair.map(|(a, b)| canonical_pair(a, b));\n+ let mut pool = pool.to_vec();\n+ pool.sort();\n+ pool.dedup();\n+ if pool.len() < 2 {\n+ return None;\n+ }\n+\n+ for i in 0..pool.len() {\n+ for j in (i + 1)..pool.len() {\n+ let pair = canonical_pair(&pool[i], &pool[j]);\n+ if current.as_ref() == Some(&pair) {\n+ continue;\n+ }\n+ if !is_pair_voted_in_group(group, &pool[i], &pool[j]) {\n+ return Some((pool[i].clone(), pool[j].clone()));\n+ }\n+ }\n+ }\n+\n+ for i in 0..pool.len() {\n+ for j in (i + 1)..pool.len() {\n+ let pair = canonical_pair(&pool[i], &pool[j]);\n+ if current.as_ref() != Some(&pair) {\n+ return Some((pool[i].clone(), pool[j].clone()));\n+ }\n+ }\n+ }\n+ None\n+}\n+\n #[cfg(test)]\n mod tests {\n use super::*;\n@@ -167,10 +232,7 @@ mod tests {\n let mut item_children: HashMap> = HashMap::new();\n for (parent, children) in edges {\n let parent = ItemId::parse(parent).unwrap();\n- let set: HashSet = children\n- .iter()\n- .map(|s| ItemId::parse(s).unwrap())\n- .collect();\n+ let set: HashSet = children.iter().map(|s| ItemId::parse(s).unwrap()).collect();\n item_children.insert(parent, set);\n }\n ContentState {\n@@ -187,9 +249,13 @@ mod tests {\n \n #[test]\n fn resolve_one_scope_literal() {\n- let content = content_with_children(&[\n- (\"https://slug.social/models\", &[\"https://slug.social/models/x\", \"https://slug.social/models/y\"]),\n- ]);\n+ let content = content_with_children(&[(\n+ \"https://slug.social/models\",\n+ &[\n+ \"https://slug.social/models/x\",\n+ \"https://slug.social/models/y\",\n+ ],\n+ )]);\n let out = resolve_one_scope(&content, \"models\");\n assert_eq!(out.len(), 2);\n assert!(out.contains(&ItemId::parse(\"https://slug.social/models/x\").unwrap()));\n@@ -199,8 +265,14 @@ mod tests {\n #[test]\n fn resolve_scope_multiple_parents_merges() {\n let content = content_with_children(&[\n- (\"https://slug.social/a\", &[\"https://slug.social/a/1\", \"https://slug.social/a/2\"]),\n- (\"https://slug.social/b\", &[\"https://slug.social/b/1\", \"https://slug.social/b/2\"]),\n+ (\n+ \"https://slug.social/a\",\n+ &[\"https://slug.social/a/1\", \"https://slug.social/a/2\"],\n+ ),\n+ (\n+ \"https://slug.social/b\",\n+ &[\"https://slug.social/b/1\", \"https://slug.social/b/2\"],\n+ ),\n ]);\n let out = resolve_scope(&content, &[\"a\".into(), \"b\".into()]);\n assert_eq!(out.len(), 4);\n@@ -209,4 +281,28 @@ mod tests {\n assert!(out.contains(&ItemId::parse(\"https://slug.social/b/1\").unwrap()));\n assert!(out.contains(&ItemId::parse(\"https://slug.social/b/2\").unwrap()));\n }\n+\n+ #[test]\n+ fn suggest_next_pair_skips_current_and_voted_pairs() {\n+ let mut group = crate::reducer::GroupState::new();\n+ let a = ItemId::parse(\"~/a\").unwrap().normalized_storage();\n+ let b = ItemId::parse(\"~/b\").unwrap().normalized_storage();\n+ let c = ItemId::parse(\"~/c\").unwrap().normalized_storage();\n+ group.apply_vote(crate::reducer::VoteData {\n+ ts: 1,\n+ a: a.clone(),\n+ b: b.clone(),\n+ ratio_left: 2,\n+ ratio_right: 1,\n+ body: \"a beats b\".to_string(),\n+ principal: \"tester\".to_string(),\n+ delegate: None,\n+ thread_tag: \"vote\".to_string(),\n+ });\n+ let next =\n+ suggest_next_pair_in_pool(&group, &[a.clone(), b.clone(), c.clone()], Some((&a, &b)))\n+ .expect(\"next pair\");\n+ assert!(next.0 == c || next.1 == c);\n+ assert_ne!(canonical_pair(&next.0, &next.1), canonical_pair(&a, &b));\n+ }\n }\ndiff --git a/server/src/state.rs b/server/src/state.rs\nindex 8a80fed0a6bc7ae3516c76ab9d1e8592d7038ad1..48298e2e66456268d23a6462536eb32bfeb5f29b 100644\n--- a/server/src/state.rs\n+++ b/server/src/state.rs\n@@ -27,6 +27,8 @@ pub struct PendingSession {\n pub provider_id: Option,\n /// When set, successful OAuth completion redeems this invite token and appends [`crate::events::GrantAdded`].\n pub redeem_invite: Option,\n+ /// Local path to navigate to after browser onboarding completes.\n+ pub redirect_next: Option,\n pub complete: Option<(String /*username*/, String /*bearer*/)>,\n }\n \ndiff --git a/server/static/theme_default.css b/server/static/theme_default.css\nindex fdcde86c718eb53cce8273e45a9844c2d8041f88..184e11a590e7019773f7f0abfa41e79161556c71 100644\n--- a/server/static/theme_default.css\n+++ b/server/static/theme_default.css\n@@ -930,6 +930,34 @@ button.ont-garden-pin-ico:focus-visible {\n outline: 2px solid var(--link);\n outline-offset: 2px;\n }\n+.resolver-actions {\n+ display: flex;\n+ flex-wrap: wrap;\n+ align-items: center;\n+ gap: 8px;\n+}\n+.resolver-refresh-link,\n+.vote-compare-next {\n+ border: var(--bv) solid;\n+ border-color: var(--hi) var(--lo) var(--lo) var(--hi);\n+ color: var(--ui);\n+ font-size: 12px;\n+ padding: 4px 10px;\n+ text-decoration: none;\n+}\n+.resolver-refresh-link:hover,\n+.vote-compare-next:hover {\n+ color: var(--signal);\n+}\n+.resolver-status {\n+ margin-top: 8px;\n+}\n+.resolver-status p {\n+ margin: 0;\n+}\n+.resolver-status-error {\n+ color: var(--danger, #b00020);\n+}\n body.view-ontology-light ol.ont-ranking-list li,\n body.view-ontology-light ul.ont-group-list li {\n display: flex;\n@@ -959,15 +987,19 @@ body.view-vote-compare .vote-compare-shell > h2 {\n color: var(--meta);\n }\n .vote-compare-pair {\n- display: flex;\n- flex-wrap: wrap;\n- align-items: center;\n+ display: grid;\n+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);\n+ align-items: start;\n gap: 10px 16px;\n- margin: 10px 0;\n+ margin: 0 0 10px;\n }\n .vote-compare-item {\n+ display: inline-block;\n text-decoration: none;\n }\n+.vote-compare-right {\n+ text-align: right;\n+}\n .vote-compare-item code {\n font-size: 13px;\n }\n@@ -980,6 +1012,33 @@ body.view-vote-compare .vote-compare-shell > h2 {\n letter-spacing: 0.1em;\n text-transform: uppercase;\n }\n+.vote-compare-item-body {\n+ margin-top: 8px;\n+ max-height: 42dvh;\n+ overflow: auto;\n+}\n+.vote-compare-item-body pre {\n+ background: var(--g1);\n+ font-size: 13px;\n+ line-height: 1.35;\n+ padding: 8px 10px;\n+}\n+.vote-compare-item-body-empty {\n+ font-size: 12px;\n+ margin: 8px 0 0;\n+}\n+.vote-compare-nav {\n+ display: flex;\n+ flex-wrap: wrap;\n+ gap: 8px;\n+ justify-content: center;\n+ margin: 12px 0;\n+}\n+.vote-compare-next.is-disabled {\n+ color: var(--meta);\n+ cursor: default;\n+ opacity: 0.65;\n+}\n .vote-compare-slider-label {\n display: flex;\n flex-wrap: wrap;\n@@ -1060,18 +1119,27 @@ body.view-vote-compare-fullscreen {\n width: 100%;\n margin-left: 0;\n margin-right: 0;\n- padding: clamp(8px, 2.5vw, 28px);\n- padding-bottom: max(20px, env(safe-area-inset-bottom, 0px));\n- padding-top: max(8px, env(safe-area-inset-top, 0px));\n+ padding: 0;\n min-height: 100vh;\n min-height: 100dvh;\n box-sizing: border-box;\n }\n+body.view-vote-compare-fullscreen > .view-meta {\n+ bottom: 4px;\n+ position: fixed;\n+ right: 6px;\n+ z-index: 20;\n+}\n body.view-vote-compare-fullscreen .vote-compare-shell {\n max-width: none;\n width: 100%;\n box-sizing: border-box;\n margin: 0;\n+ min-height: 100dvh;\n+ padding: 0;\n+}\n+body.view-vote-compare-fullscreen .vote-compare-shell > h2 {\n+ display: none;\n }\n \n .vote-edge-history-title {\ndiff --git a/server/static/theme_retro.css b/server/static/theme_retro.css\nindex 61e1448b2f66a075c0e33325d6980448712fc927..6747f59eb1ec5c335029fe92d4e5c55b3125a210 100644\n--- a/server/static/theme_retro.css\n+++ b/server/static/theme_retro.css\n@@ -135,6 +135,34 @@ body.view-ontology nav.breadcrumb a:hover {\n body.view-ontology nav.breadcrumb a.bc-current { color: #111; font-weight: 600; }\n body.view-ontology nav.breadcrumb .bc-sep { color: #888; padding: 0 2px; }\n \n+body.view-ontology .resolver-actions,\n+body.view-ontology .vote-compare-nav {\n+ display: flex;\n+ flex-wrap: wrap;\n+ align-items: center;\n+ gap: 0.5rem;\n+}\n+body.view-ontology .resolver-refresh-link,\n+body.view-ontology .vote-compare-next {\n+ border: 1px solid #bbb;\n+ color: #23a;\n+ padding: 0.2rem 0.55rem;\n+ text-decoration: none;\n+}\n+body.view-ontology .vote-compare-next.is-disabled {\n+ color: #666;\n+ opacity: 0.65;\n+}\n+body.view-ontology .resolver-status {\n+ margin-top: 0.5rem;\n+}\n+body.view-ontology .resolver-status p {\n+ margin: 0;\n+}\n+body.view-ontology .resolver-status-error {\n+ color: #9b1c1c;\n+}\n+\n body.view-ontology ol.ont-ranking-list {\n counter-reset: ont-rank;\n list-style: none;\n@@ -205,16 +233,48 @@ body.view-vote-compare-fullscreen {\n max-width: none;\n width: 100%;\n margin: 0;\n- padding: clamp(8px, 2.5vw, 20px);\n- padding-bottom: max(24px, env(safe-area-inset-bottom, 0px));\n- padding-top: max(8px, env(safe-area-inset-top, 0px));\n+ padding: 0;\n min-height: 100vh;\n min-height: 100dvh;\n box-sizing: border-box;\n }\n+body.view-vote-compare-fullscreen > .view-meta {\n+ bottom: 4px;\n+ position: fixed;\n+ right: 6px;\n+ z-index: 20;\n+}\n body.view-vote-compare-fullscreen .vote-compare-shell {\n max-width: none;\n width: 100%;\n box-sizing: border-box;\n margin: 0;\n+ min-height: 100dvh;\n+ padding: 0;\n+}\n+body.view-vote-compare-fullscreen .vote-compare-shell > h2 {\n+ display: none;\n+}\n+body.view-ontology .vote-compare-pair {\n+ align-items: start;\n+ display: grid;\n+ gap: 0.65rem 1rem;\n+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);\n+ margin: 0 0 0.75rem;\n+}\n+body.view-ontology .vote-compare-item {\n+ display: inline-block;\n+}\n+body.view-ontology .vote-compare-right {\n+ text-align: right;\n+}\n+body.view-ontology .vote-compare-item-body {\n+ margin-top: 0.45rem;\n+ max-height: 42dvh;\n+ overflow: auto;\n+}\n+body.view-ontology .vote-compare-item-body pre {\n+ background: #faf8f3;\n+ border: 1px solid #ccc;\n+ padding: 0.5rem 0.65rem;\n }\ndiff --git a/server/static/theme_retro_craft.css b/server/static/theme_retro_craft.css\nindex 7da102040484c887833158a37c307d078205c701..55d984a6dd70ebeadeca7baef86b844955f1d78c 100644\n--- a/server/static/theme_retro_craft.css\n+++ b/server/static/theme_retro_craft.css\n@@ -742,6 +742,34 @@ body.view-ontology button.ont-garden-pin-ico:focus-visible {\n outline-offset: 2px;\n }\n \n+body.view-ontology .resolver-actions {\n+ display: flex;\n+ flex-wrap: wrap;\n+ align-items: center;\n+ gap: 0.5rem;\n+}\n+body.view-ontology .resolver-refresh-link {\n+ border: 1px solid #c8c4bc;\n+ color: #3d3a34;\n+ font-family: var(--font-ui);\n+ font-size: 0.72rem;\n+ padding: 0.25rem 0.55rem;\n+ text-decoration: none;\n+}\n+body.view-ontology .resolver-refresh-link:hover {\n+ border-color: #a68e6b;\n+ color: #1a1814;\n+}\n+body.view-ontology .resolver-status {\n+ margin-top: 0.5rem;\n+}\n+body.view-ontology .resolver-status p {\n+ margin: 0;\n+}\n+body.view-ontology .resolver-status-error {\n+ color: #8b1f1f;\n+}\n+\n body.view-ontology ol.ont-ranking-list {\n counter-reset: ont-rank;\n list-style: none;\n@@ -809,21 +837,26 @@ body.view-ontology.view-vote-compare .vote-compare-shell {\n body.view-ontology.view-vote-compare.view-vote-compare-fullscreen .vote-compare-shell {\n max-width: none;\n width: 100%;\n- min-height: calc(100dvh - clamp(20px, 5vw, 56px));\n+ min-height: 100dvh;\n margin: 0;\n+ padding: 0;\n box-sizing: border-box;\n }\n body.view-vote-compare-fullscreen {\n max-width: none !important;\n width: 100%;\n margin: 0 !important;\n- padding: clamp(6px, 2vw, 1.75rem);\n- padding-bottom: max(1.25rem, env(safe-area-inset-bottom, 0px));\n- padding-top: max(6px, env(safe-area-inset-top, 0px));\n+ padding: 0;\n min-height: 100vh;\n min-height: 100dvh;\n box-sizing: border-box;\n }\n+body.view-vote-compare-fullscreen > .view-meta {\n+ bottom: 0.25rem;\n+ position: fixed;\n+ right: 0.35rem;\n+ z-index: 20;\n+}\n body.view-ontology.view-vote-compare .vote-compare-shell > h2 {\n margin-top: 0;\n font-size: 0.7rem;\n@@ -831,12 +864,15 @@ body.view-ontology.view-vote-compare .vote-compare-shell > h2 {\n text-transform: uppercase;\n color: #5c574e;\n }\n+body.view-ontology.view-vote-compare-fullscreen .vote-compare-shell > h2 {\n+ display: none;\n+}\n body.view-ontology .vote-compare-pair {\n- display: flex;\n- flex-wrap: wrap;\n- align-items: center;\n+ display: grid;\n+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);\n+ align-items: start;\n gap: 0.65rem 1rem;\n- margin: 0.65rem 0 0.85rem;\n+ margin: 0 0 0.85rem;\n }\n body.view-ontology .vote-compare-vs {\n color: #8a857a;\n@@ -844,14 +880,62 @@ body.view-ontology .vote-compare-vs {\n font-size: 0.72rem;\n letter-spacing: 0.1em;\n text-transform: uppercase;\n+ padding-top: 0.18rem;\n }\n body.view-ontology .vote-compare-item {\n+ display: inline-block;\n text-decoration: none;\n }\n+body.view-ontology .vote-compare-right {\n+ text-align: right;\n+}\n body.view-ontology .vote-compare-item:hover code {\n border-color: #a68e6b;\n background: #f0ebe3;\n }\n+body.view-ontology .vote-compare-item-body {\n+ margin-top: 0.45rem;\n+ max-height: 42dvh;\n+ overflow: auto;\n+}\n+body.view-ontology .vote-compare-item-body pre {\n+ background: #fdfcfa;\n+ border: 1px solid #d4cfc4;\n+ border-left: 3px solid #a68e6b;\n+ color: #1a1814;\n+ font-size: 0.82rem;\n+ line-height: 1.35;\n+ padding: 0.55rem 0.65rem;\n+}\n+body.view-ontology .vote-compare-item-body-empty {\n+ font-size: 0.78rem;\n+ margin: 0.45rem 0 0;\n+}\n+body.view-ontology .vote-compare-nav {\n+ display: flex;\n+ flex-wrap: wrap;\n+ gap: 0.45rem;\n+ justify-content: center;\n+ margin: 0.75rem 0;\n+}\n+body.view-ontology .vote-compare-next {\n+ border: 1px solid #c8c4bc;\n+ color: #3d3a34;\n+ font-family: var(--font-ui);\n+ font-size: 0.72rem;\n+ letter-spacing: 0.04em;\n+ padding: 0.25rem 0.65rem;\n+ text-decoration: none;\n+}\n+body.view-ontology .vote-compare-next:hover {\n+ border-color: #a68e6b;\n+ color: #1a1814;\n+}\n+body.view-ontology .vote-compare-next.is-disabled {\n+ color: #8a857a;\n+ cursor: default;\n+ opacity: 0.65;\n+}\n \n body.view-ontology .vote-thread-picker {\n margin: 0.85rem 0;\ndiff --git a/server/tests/integration.rs b/server/tests/integration.rs\nindex 3c9f0476c58abdc947fd50424bfea66a784fb83b..fb0b9335440181d4d50104d37926b0b2eeeb602a 100644\n--- a/server/tests/integration.rs\n+++ b/server/tests/integration.rs\n@@ -8,9 +8,9 @@ use slugsocial_server::{\n spawn_writer_actor_for_test,\n state::{AppConfig, AppState},\n };\n+use std::net::SocketAddr;\n use tempfile::TempDir;\n use tokio::net::TcpListener;\n-use std::net::SocketAddr;\n \n fn sha256_hex(s: &str) -> String {\n let mut hasher = Sha256::new();\n@@ -94,7 +94,13 @@ async fn seed_test_token(state: &AppState) {\n r.apply_event(ev);\n }\n \n-async fn create_test_server_with_state() -> (SocketAddr, TempDir, EventLog, AppState, tokio::task::JoinHandle<()>) {\n+async fn create_test_server_with_state() -> (\n+ SocketAddr,\n+ TempDir,\n+ EventLog,\n+ AppState,\n+ tokio::task::JoinHandle<()>,\n+) {\n let tmp = TempDir::new().unwrap();\n let log_path = tmp.path().join(\"events.jsonl\");\n let log = EventLog::new(&log_path);\n@@ -131,7 +137,11 @@ async fn create_test_server() -> (SocketAddr, TempDir, EventLog, tokio::task::Jo\n async fn test_healthz() {\n let (addr, _tmp, _log, _handle) = create_test_server().await;\n let client = reqwest::Client::new();\n- let response = client.get(&format!(\"http://{}/healthz\", addr)).send().await.unwrap();\n+ let response = client\n+ .get(&format!(\"http://{}/healthz\", addr))\n+ .send()\n+ .await\n+ .unwrap();\n assert!(response.status().is_success());\n assert_eq!(response.text().await.unwrap(), \"ok\");\n }\n@@ -189,7 +199,13 @@ async fn test_room_delete_rpc() {\n del[\"results\"][0]\n );\n \n- let list = rpc_batch(&client, addr, Some(&bearer), serde_json::json!([\"RoomList\"])).await;\n+ let list = rpc_batch(\n+ &client,\n+ addr,\n+ Some(&bearer),\n+ serde_json::json!([\"RoomList\"]),\n+ )\n+ .await;\n let rooms = list[\"results\"][0][\"result\"][\"RoomList\"][\"rooms\"]\n .as_array()\n .unwrap();\n@@ -278,7 +294,11 @@ async fn test_private_room_forum_read_requires_bearer() {\n )\n .await;\n let line_na = &no_auth[\"results\"][0];\n- assert_eq!(line_na[\"ok\"], false, \"expected failure without bearer: {:?}\", line_na);\n+ assert_eq!(\n+ line_na[\"ok\"], false,\n+ \"expected failure without bearer: {:?}\",\n+ line_na\n+ );\n assert_eq!(line_na[\"error\"], \"room not found\");\n \n let with_auth = rpc_batch(\n@@ -300,7 +320,11 @@ async fn test_private_room_forum_read_requires_bearer() {\n )\n .await;\n let line_ok = &with_auth[\"results\"][0];\n- assert_eq!(line_ok[\"ok\"], true, \"expected success with bearer: {:?}\", line_ok);\n+ assert_eq!(\n+ line_ok[\"ok\"], true,\n+ \"expected success with bearer: {:?}\",\n+ line_ok\n+ );\n let total = line_ok[\"result\"][\"ForumThread\"][\"total\"].as_u64().unwrap();\n assert!(total >= 1);\n }\n@@ -499,7 +523,11 @@ async fn test_feed_since_last_post_is_scoped_to_delegate() {\n )\n .await;\n let steal_line = &steal[\"results\"][0];\n- assert_eq!(steal_line[\"ok\"], false, \"expected rejection for unbound delegate: {:?}\", steal_line);\n+ assert_eq!(\n+ steal_line[\"ok\"], false,\n+ \"expected rejection for unbound delegate: {:?}\",\n+ steal_line\n+ );\n assert_eq!(steal_line[\"error\"], \"not your delegate\");\n \n let no_auth = rpc_batch(\n@@ -852,24 +880,24 @@ async fn test_choose_username_returns_evalable_js() {\n \n {\n let mut sessions = state.pending_sessions.write().await;\n- let pending = sessions.get_mut(session).expect(\"pending session must exist\");\n+ let pending = sessions\n+ .get_mut(session)\n+ .expect(\"pending session must exist\");\n pending.provider = Some(\"google\".to_string());\n pending.provider_id = Some(\"google-user-123\".to_string());\n }\n \n let choose = client\n .post(format!(\"http://{addr}/auth/choose-username\"))\n- .form(&[\n- (\"session\", session),\n- (\"username\", \"webuser\"),\n- ])\n+ .form(&[(\"session\", session), (\"username\", \"webuser\")])\n .send()\n .await\n .unwrap();\n \n assert_eq!(choose.status(), reqwest::StatusCode::OK);\n assert_eq!(\n- choose.headers()\n+ choose\n+ .headers()\n .get(reqwest::header::CONTENT_TYPE)\n .and_then(|v| v.to_str().ok()),\n Some(\"text/javascript; charset=utf-8\")\n@@ -880,6 +908,48 @@ async fn test_choose_username_returns_evalable_js() {\n assert!(body.contains(\"window.location = \\\"/auth/complete\\\"\"));\n }\n \n+#[tokio::test]\n+async fn test_choose_username_carries_redirect_next() {\n+ let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await;\n+ let client = reqwest::Client::builder()\n+ .redirect(reqwest::redirect::Policy::none())\n+ .build()\n+ .unwrap();\n+\n+ let start = client\n+ .post(format!(\"http://{addr}/api/v0/pending-session\"))\n+ .json(&serde_json::json!({\n+ \"agent\": \"00000000-0000-0000-0000-000000000123:test:web/form\"\n+ }))\n+ .send()\n+ .await\n+ .unwrap();\n+ assert!(start.status().is_success());\n+ let start_json: serde_json::Value = start.json().await.unwrap();\n+ let session = start_json[\"session\"].as_str().unwrap();\n+\n+ {\n+ let mut sessions = state.pending_sessions.write().await;\n+ let pending = sessions\n+ .get_mut(session)\n+ .expect(\"pending session must exist\");\n+ pending.provider = Some(\"google\".to_string());\n+ pending.provider_id = Some(\"google-user-redirect\".to_string());\n+ pending.redirect_next = Some(\"/vote/compare?left=%7E%2Fa&right=%7E%2Fb\".to_string());\n+ }\n+\n+ let choose = client\n+ .post(format!(\"http://{addr}/auth/choose-username\"))\n+ .form(&[(\"session\", session), (\"username\", \"redirectuser\")])\n+ .send()\n+ .await\n+ .unwrap();\n+\n+ assert_eq!(choose.status(), reqwest::StatusCode::OK);\n+ let body = choose.text().await.unwrap();\n+ assert!(body.contains(\"window.location = \\\"/vote/compare?left=%7E%2Fa&right=%7E%2Fb\\\"\"));\n+}\n+\n #[tokio::test]\n async fn test_sse_stream_emits_evalable_js_after_post() {\n let (addr, _tmp, _log, _state, _handle) = create_test_server_with_state().await;\n@@ -903,7 +973,10 @@ async fn test_sse_stream_emits_evalable_js_after_post() {\n let room_path = format!(\"/r/{room_seg}\");\n \n let sse_resp = client\n- .get(format!(\"http://{addr}/sse?path={}\", urlencoding::encode(&room_path)))\n+ .get(format!(\n+ \"http://{addr}/sse?path={}\",\n+ urlencoding::encode(&room_path)\n+ ))\n .send()\n .await\n .unwrap();\n@@ -1153,7 +1226,9 @@ async fn test_post_redact_removes_garden_and_marks_thread() {\n .await;\n let ra_line = &rank_after[\"results\"][0];\n assert_eq!(ra_line[\"ok\"], true);\n- let comps = ra_line[\"result\"][\"GardenRank\"][\"components\"].as_array().unwrap();\n+ let comps = ra_line[\"result\"][\"GardenRank\"][\"components\"]\n+ .as_array()\n+ .unwrap();\n assert!(\n comps.is_empty() || comps[0][\"ranking\"].as_array().unwrap().is_empty(),\n \"votes from redacted post should be removed: {:?}\",\n@@ -1267,7 +1342,9 @@ async fn test_check_endpoint_does_not_commit() {\n let threads_body = rpc_batch(&client, addr, None, list_batch).await;\n let tline = &threads_body[\"results\"][0];\n assert_eq!(tline[\"ok\"], true);\n- let threads = tline[\"result\"][\"ForumThreads\"][\"threads\"].as_array().unwrap();\n+ let threads = tline[\"result\"][\"ForumThreads\"][\"threads\"]\n+ .as_array()\n+ .unwrap();\n assert!(threads.is_empty());\n }\n \n@@ -1316,7 +1393,11 @@ async fn test_garden_item_pair_matchup_include_threads() {\n .iter()\n .filter_map(|t| t.as_str())\n .collect();\n- assert!(threads.contains(&\"sorting-hat\"), \"item threads should contain sorting-hat: {:?}\", threads);\n+ assert!(\n+ threads.contains(&\"sorting-hat\"),\n+ \"item threads should contain sorting-hat: {:?}\",\n+ threads\n+ );\n \n let pair_body = rpc_batch(\n &client,\n@@ -1337,7 +1418,11 @@ async fn test_garden_item_pair_matchup_include_threads() {\n .iter()\n .filter_map(|t| t.as_str())\n .collect();\n- assert!(pair_threads.contains(&\"sorting-hat\"), \"pair threads should contain sorting-hat: {:?}\", pair_threads);\n+ assert!(\n+ pair_threads.contains(&\"sorting-hat\"),\n+ \"pair threads should contain sorting-hat: {:?}\",\n+ pair_threads\n+ );\n \n let matchup_body = rpc_batch(\n &client,\n@@ -1429,7 +1514,11 @@ async fn test_garden_pair_and_rank_path_not_found_consistent() {\n )\n .await;\n let pair2_line = &pair2[\"results\"][0];\n- assert_eq!(pair2_line[\"ok\"], true, \"GetPair after parent materialized: {:?}\", pair2_line);\n+ assert_eq!(\n+ pair2_line[\"ok\"], true,\n+ \"GetPair after parent materialized: {:?}\",\n+ pair2_line\n+ );\n assert!(pair2_line[\"result\"][\"Pair\"].is_object());\n }\n \n@@ -1490,7 +1579,11 @@ async fn test_view_counts_increment_and_display() {\n .send()\n .await\n .unwrap();\n- assert!(v1.status().is_success(), \"vote compare GET 1: {}\", v1.status());\n+ assert!(\n+ v1.status().is_success(),\n+ \"vote compare GET 1: {}\",\n+ v1.status()\n+ );\n let v1_body = v1.text().await.unwrap();\n assert!(\n v1_body.contains(\"1 views\"),\n@@ -1502,7 +1595,11 @@ async fn test_view_counts_increment_and_display() {\n .send()\n .await\n .unwrap();\n- assert!(v2.status().is_success(), \"vote compare GET 2: {}\", v2.status());\n+ assert!(\n+ v2.status().is_success(),\n+ \"vote compare GET 2: {}\",\n+ v2.status()\n+ );\n let v2_body = v2.text().await.unwrap();\n assert!(\n v2_body.contains(\"2 views\"),\n@@ -1576,13 +1673,18 @@ async fn test_rank_history() {\n let history = resp[\"history\"].as_array().unwrap();\n assert_eq!(history.len(), 1, \"one ingest → one history entry\");\n let entry = &history[0];\n- assert_eq!(entry[\"scope_rank\"], 1, \"rust should be #1 in scope after first ingest\");\n- assert_eq!(entry[\"scope_rank_delta\"], 0, \"delta is 0 on first appearance\");\n+ assert_eq!(\n+ entry[\"scope_rank\"], 1,\n+ \"rust should be #1 in scope after first ingest\"\n+ );\n+ assert_eq!(\n+ entry[\"scope_rank_delta\"], 0,\n+ \"delta is 0 on first appearance\"\n+ );\n let caused_by = entry[\"caused_by\"].as_array().unwrap();\n assert_eq!(caused_by.len(), 2, \"both votes in the ingest touched rust\");\n assert_eq!(\n- entry[\"thread_post_index\"],\n- 0,\n+ entry[\"thread_post_index\"], 0,\n \"rank history links use same 0-based index as /t/hist-test/0\"\n );\n \n@@ -1610,16 +1712,16 @@ async fn test_rank_history() {\n \n let caused_by2 = hist2[1][\"caused_by\"].as_array().unwrap();\n assert_eq!(caused_by2.len(), 1);\n- assert!(caused_by2[0][\"a\"].as_str().unwrap().ends_with(\"python\") ||\n- caused_by2[0][\"b\"].as_str().unwrap().ends_with(\"python\"));\n+ assert!(\n+ caused_by2[0][\"a\"].as_str().unwrap().ends_with(\"python\")\n+ || caused_by2[0][\"b\"].as_str().unwrap().ends_with(\"python\")\n+ );\n assert_eq!(\n- hist2[0][\"thread_post_index\"],\n- 0,\n+ hist2[0][\"thread_post_index\"], 0,\n \"first hist-test post is chronological index 0\"\n );\n assert_eq!(\n- hist2[1][\"thread_post_index\"],\n- 1,\n+ hist2[1][\"thread_post_index\"], 1,\n \"second ingest is chronological index 1\"\n );\n \n@@ -1675,12 +1777,27 @@ async fn pair_returns_connectivity_stats() {\n let pair = &pair_body[\"results\"][0][\"result\"][\"Pair\"];\n \n let conn = &pair[\"connectivity\"];\n- assert!(!conn.is_null(), \"pair response should include connectivity stats\");\n+ assert!(\n+ !conn.is_null(),\n+ \"pair response should include connectivity stats\"\n+ );\n assert_eq!(conn[\"items\"].as_u64().unwrap(), 4, \"4 items in scope\");\n- assert_eq!(conn[\"components\"].as_u64().unwrap(), 3, \"3 components (1 connected + 2 isolates)\");\n- assert_eq!(conn[\"comparisons_until_connected\"].as_u64().unwrap(), 2, \"need 2 more comparisons\");\n+ assert_eq!(\n+ conn[\"components\"].as_u64().unwrap(),\n+ 3,\n+ \"3 components (1 connected + 2 isolates)\"\n+ );\n+ assert_eq!(\n+ conn[\"comparisons_until_connected\"].as_u64().unwrap(),\n+ 2,\n+ \"need 2 more comparisons\"\n+ );\n assert_eq!(conn[\"pairs_voted\"].as_u64().unwrap(), 1, \"1 pair voted\");\n- assert_eq!(conn[\"pairs_possible\"].as_u64().unwrap(), 6, \"4*3/2 = 6 possible pairs\");\n+ assert_eq!(\n+ conn[\"pairs_possible\"].as_u64().unwrap(),\n+ 6,\n+ \"4*3/2 = 6 possible pairs\"\n+ );\n \n let doc2 = serde_json::json!([{\n \"Post\": {\n@@ -1693,8 +1810,7 @@ async fn pair_returns_connectivity_stats() {\n }]);\n let resp2 = rpc_batch(&client, addr, Some(&test_bearer()), doc2).await;\n assert_eq!(\n- resp2[\"results\"][0][\"ok\"],\n- true,\n+ resp2[\"results\"][0][\"ok\"], true,\n \"second ingest failed: {:?}\",\n resp2\n );\n@@ -1713,8 +1829,19 @@ async fn pair_returns_connectivity_stats() {\n .await;\n let pair2 = &pair_body2[\"results\"][0][\"result\"][\"Pair\"];\n let conn2 = &pair2[\"connectivity\"];\n- assert_eq!(conn2[\"components\"].as_u64().unwrap(), 2, \"2 components after connecting c\");\n- assert_eq!(conn2[\"comparisons_until_connected\"].as_u64().unwrap(), 1, \"1 more comparison to connect\");\n- assert_eq!(conn2[\"pairs_voted\"].as_u64().unwrap(), 2, \"2 pairs voted now\");\n+ assert_eq!(\n+ conn2[\"components\"].as_u64().unwrap(),\n+ 2,\n+ \"2 components after connecting c\"\n+ );\n+ assert_eq!(\n+ conn2[\"comparisons_until_connected\"].as_u64().unwrap(),\n+ 1,\n+ \"1 more comparison to connect\"\n+ );\n+ assert_eq!(\n+ conn2[\"pairs_voted\"].as_u64().unwrap(),\n+ 2,\n+ \"2 pairs voted now\"\n+ );\n }\n-\ndiff --git a/test/browser_github_resolver.clj b/test/browser_github_resolver.clj\nindex 1d5b6f1c4575e67ec430e4ce7f02c1bca5a35775..fee272099457ab1f4478fff9aed0f5c08ea2e85e 100644\n--- a/test/browser_github_resolver.clj\n+++ b/test/browser_github_resolver.clj\n@@ -21,6 +21,18 @@\n (do (Thread/sleep 200) (recur))\n false))))))\n \n+(defn- wait-for-http-text [url expected timeout-ms]\n+ (let [deadline (+ (System/currentTimeMillis) timeout-ms)]\n+ (loop []\n+ (let [text (try\n+ (:body (oauth/http-get url))\n+ (catch Exception _ nil))]\n+ (if (and (string? text) (str/includes? text expected))\n+ true\n+ (if (< (System/currentTimeMillis) deadline)\n+ (do (Thread/sleep 200) (recur))\n+ false))))))\n+\n (defn- start-mock-github [port]\n (let [!paths (atom [])\n handler (fn [req]\n@@ -112,12 +124,21 @@\n (locator/click (page/locator pg \"[data-testid=\\\"github-resolve-children\\\"]\"))\n (is (wait-for-text pg \"body\" \"-/github.com/octo/hello/pulls\" 15000)\n \"repo resolver imports structural children\")\n+ (is (wait-for-text pg \"body\" \"-/github.com/octo/hello/commits\" 15000)\n+ \"repo resolver imports commits section\")\n+ (is (wait-for-text pg \"body\" \"-/github.com/octo/hello/releases\" 15000)\n+ \"repo resolver imports releases section\")\n \n (page/navigate pg (str base-url \"/-/github.com/octo/hello/issues/42\"))\n (locator/click (page/locator pg \"[data-testid=\\\"github-resolve-siblings\\\"]\"))\n- (page/navigate pg (str base-url \"/-/github.com/octo/hello/issues\"))\n- (is (wait-for-text pg \"body\" \"-/github.com/octo/hello/issues/43\" 15000)\n- \"issue sibling resolver imports issue siblings\")))))\n+ (is (wait-for-http-text (str base-url \"/-/github.com/octo/hello/issues\")\n+ \"-/github.com/octo/hello/issues/43\"\n+ 15000)\n+ \"issue sibling resolver persisted issue siblings\")\n+ (core/with-page [pg2 (core/new-page-from-context ctx)]\n+ (page/navigate pg2 (str base-url \"/-/github.com/octo/hello/issues\"))\n+ (is (wait-for-text pg2 \"body\" \"-/github.com/octo/hello/issues/43\" 15000)\n+ \"issue sibling resolver imports issue siblings\"))))))\n \n (is (some #{\"/users/octo/repos\"} @(:paths @!github))\n \"mock GitHub saw user repos request\")\ndiff --git a/test/browser_vote_compare.clj b/test/browser_vote_compare.clj\nindex f97a4a45e89c388f093694a1bf296f674bcb377a..7cd1e467fdd8a4480df1be2afd78adb45b9cdc6d 100644\n--- a/test/browser_vote_compare.clj\n+++ b/test/browser_vote_compare.clj\n@@ -50,9 +50,11 @@\n thread-tag \"browser-vote-compare\"\n left-url \"https://slug.social/~/gp-vote-a\"\n right-url \"https://slug.social/~/gp-vote-b\"\n+ third-url \"https://slug.social/~/gp-vote-c\"\n raw (str \"# \" thread-tag \"\\n\\n\"\n \"~/gp-vote-a {one}\\n\"\n \"~/gp-vote-b {two}\\n\"\n+ \"~/gp-vote-c {three}\\n\"\n \"{seed edge vote}\\n\"\n \"~/gp-vote-a 1:1 ~/gp-vote-b\\n\")\n post-resp (oauth/http-post-json\n@@ -64,7 +66,7 @@\n :headers {\"Authorization\" (str \"Bearer \" alice-token)})\n post-json (json/parse-string (:body post-resp) false)\n _ (is (true? (get-in post-json [\"results\" 0 \"ok\"])) \"seed items + edge vote via rpc\")\n- cmp-url (str base-url \"/vote/compare?left=\" (enc left-url) \"&right=\" (enc right-url))]\n+ cmp-url (str base-url \"/vote/compare?left=\" (enc left-url) \"&right=\" (enc third-url))]\n (core/with-playwright [pw]\n (core/with-browser [browser (core/launch-chromium pw {:headless true :channel \"chrome\"})]\n (core/with-context [ctx (core/new-context browser)]\n@@ -74,12 +76,17 @@\n (page/navigate pg cmp-url)\n ;; No .vote-compare-shell wrapper — wait on stable vote-compare UI instead.\n (is (wait-for-text pg \"body.view-vote-compare\" \"compare\" 15000) \"vote compare page\")\n- (is (wait-for-text pg \"ul.vote-edge-history\" \"seed edge vote\" 15000)\n- \"edge history lists canonical-order vote\")\n+ (is (wait-for-text pg \"#vote-edge-history-region\" \"no votes on this pair\" 15000)\n+ \"current pair starts without edge history\")\n (locator/fill (page/locator pg \"#vote-explain\") \"because playwright says so\")\n (locator/click (page/locator pg \"#vote-compare-form button[type=submit]\"))\n (is (wait-for-text pg \"ul.vote-edge-history\" \"because playwright\" 20000)\n- \"new vote appears in edge history after morph\"))))))\n+ \"new vote appears in edge history after morph\")\n+ (locator/click (page/locator pg \"[data-testid=\\\"vote-next-pair\\\"]\"))\n+ (is (wait-for-text pg \".vote-compare-pair\" \"~/gp-vote-b\" 15000)\n+ \"post-success next-pair nav points to a different pair\")\n+ (is (wait-for-text pg \".vote-compare-pair\" \"~/gp-vote-c\" 15000)\n+ \"next pair keeps the unvoted third item\"))))))\n \n (finally\n (when-some [s @!server] (common/kill-server s))\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[e8fdeacf] nice\n\nSide B — unified diff (full patch):\ndiff --git a/.gitignore b/.gitignore\nindex 73e8f22cf0d39c706e7cdce5e39f1903a0f9181b..4c7073f9fac0c30fd2050d79a60ef447af58ebeb 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -6,3 +6,4 @@\n .DS_Store\n data/\n repomix-output.xml\n+dev-data/\ndiff --git a/bb.edn b/bb.edn\nindex 1e8ec120921761a9a4f28f36578efb1567407cb3..43cc60c9461466e0f7cb08cfea5c600edf95a662 100644\n--- a/bb.edn\n+++ b/bb.edn\n@@ -9,11 +9,10 @@\n :task (do\n (deref (p/process [\"mkdir\" \"-p\" \"dev-data\"] {:inherit true}))\n (deref (p/process [\"cargo\" \"watch\"\n- \"-x\" \"run -p server\"\n+ \"-x\" \"run -p sorter2-server\"\n \"-w\" \"server/\"]\n {:inherit true\n :env (merge (into {} (System/getenv))\n- {\"SLUG_DATA_DIR\" \"dev-data\"\n- \"SLUG_KEYS\" \"dev:dev\"\n- \"PORT\" \"8080\"\n- \"RUST_LOG\" \"info\"})})))}}}\n+ {\"SORTER2_DATA_DIR\" \"dev-data\"\n+ \"PORT\" \"8080\"\n+ \"RUST_LOG\" \"info\"})})))}}}\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex 322dffefeaa3b8560c1c2b2f70ba8e5a8c555022..0acba3ac745f23be473243e0a9b9cc6e57e8d86f 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -232,7 +232,8 @@ pub fn input_panel(query: &str, error: Option<&str>) -> Markup {\n html! {\n section id=\"parser-panel\" class=\"demo-panel\" {\n form method=\"post\" action=\"/ui\" id=\"parser-form\" {\n- textarea\n+ input\n+ type=\"text\"\n name=\"query\"\n id=\"parser-input\"\n rows=\"3\"\ndiff --git a/server/static/sorter.css b/server/static/sorter.css\nindex 04f4fabdea3de7c3bc54805d0d3d9e0cea6eeefd..a648e88a57290346b1069868134a212b180def52 100644\n--- a/server/static/sorter.css\n+++ b/server/static/sorter.css\n@@ -34,29 +34,20 @@ body {\n font-size: 0.75rem;\n }\n \n-.demo-panel {\n- max-width: 40rem;\n- margin: 4rem auto 2rem;\n- padding: 2rem;\n- background: var(--panel);\n- border: 1px solid var(--border);\n- border-radius: 8px;\n-}\n-\n-.demo-panel h1 {\n- margin-top: 0;\n-}\n-\n .btn-primary {\n background: var(--accent);\n color: #0f1115;\n- border: none;\n+ border-style: outset;\n+ border-width: 3px;\n padding: 0.5rem 1rem;\n- border-radius: 4px;\n font-size: 1rem;\n cursor: pointer;\n }\n \n+.btn-primary:active {\n+ border-style: inset;\n+}\n+\n .btn-primary:hover {\n filter: brightness(1.1);\n }\n@@ -103,11 +94,15 @@ code {\n color: var(--muted);\n }\n \n+#parser-panel {\n+ display: flex;\n+ flex-direction: column;\n+}\n+\n #parser-input {\n- width: 100%;\n+ width: 90%;\n padding: 0.5rem;\n border: 1px solid var(--border);\n- border-radius: 4px;\n background: var(--bg);\n color: var(--fg);\n font-size: 1rem;\n@@ -115,7 +110,12 @@ code {\n resize: vertical;\n }\n \n+#parser-input:focus-visible {\n+ outline: 0;\n+}\n+\n #parser-form .btn-primary {\n+ width: 10%;\n margin-top: 0.5rem;\n }\n \n","role":"user"}],"model":"openai/gpt-chat-latest"}