Side A implements a coherent, security-relevant feature (Reddit OAuth linking, UUID-canonical identity, conflict handling, private linked-providers UI) with corresponding storage/schema changes, tests, and mock server updates. Side B is a mixed 'refactor' that adds a new vote-compare page/pair-selection logic (useful) but is labeled generically and bundles unrelated changes (ItemId normalization, JS behavior tweaks) with less architectural clarity than A's identity model overhaul.
constitution · epochs · watch · epoch 3
c_afa638171cf7 (tommy-mor) vs c_af08bd851e49 (tommy-mor)
download prompt · raw event · cmp_d93e7ee0961aba
council reasoning
A lands a lasting identity redesign: UUID as sole principal, multi-provider OAuth *linking* (GitHub + Reddit) with logged-in attach/conflict handling, private linked-provider UI, and a real projection fix (pending_weights so multi-link trust updates in one batch). B adds useful vote-compare morph UX, bridge-aware pair selection, and ItemId::from_storage normalization, but much of the diff is module extraction/refactor and UI wiring rather than comparable foundational design.
Side A introduces a substantial identity model change by making UUIDs the canonical account identity, adding Reddit OAuth support, implementing provider linking with conflict handling, exposing linked providers privately, updating routing, storage, and projection logic, and fixing trust-weight batching with tests. Side B adds a useful pairwise voting UI flow and item ID normalization, but much of it is feature/UI work and refactoring, whereas Side A establishes a broader, lasting authentication and identity architecture affecting core account behavior.
sides
A — c_afa638171cf7 (tommy-mor)
message
[52f5c51c] Add Reddit OAuth linking and make UUID the only account identity. OAuth providers only attach to a session UUID (first link creates the principal); linked providers stay private on the account page. Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/AGENTS.md b/AGENTS.md
index 6e0fd8ebb65d665c9c1438e3275971d62b98fd95..e9cc3173dbeb21ad0fc090ca7b407b027c7820a9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -35,8 +35,11 @@ Environment variables (defaults in `server/src/state.rs`):
- `SORTER2_DATA_DIR` — default `./data` (created on startup)
- `SORTER2_EVENT_LOG` — default `{data_dir}/events.jsonl`
- `SORTER2_BASE_URL` — public origin (also drives Secure cookies when `https://`)
-- `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` — GitHub OAuth (optional; login disabled if unset)
-- `SORTER2_ALLOW_MOCK_OAUTH=1` — allow `mock_user` on `/auth/github` (tests only)
+- `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` — GitHub OAuth linking (optional)
+- `REDDIT_CLIENT_ID` / `REDDIT_CLIENT_SECRET` (or `REDDIT_APP_*`) — Reddit API import + OAuth linking (optional)
+- `SORTER2_ALLOW_MOCK_OAUTH=1` — allow `mock_user` on `/auth/github` and `/auth/reddit` (tests only)
+
+Identity: UUID is canonical. OAuth providers only *link* to a UUID (first link creates the principal). Linked providers are private to the account owner.
Health check: `GET /healthz` → `ok`.
diff --git a/server/src/auth/mod.rs b/server/src/auth/mod.rs
index 5906f93b13853421e96a3c37bc9d8202a47842bf..c706ae8045a811e5941f5f6c72da88f42a403a82 100644
--- a/server/src/auth/mod.rs
+++ b/server/src/auth/mod.rs
@@ -1,4 +1,8 @@
-//! GitHub OAuth login, session cookies, and vote actor resolution.
+//! OAuth linking, session cookies, and vote actor resolution.
+//!
+//! Canonical identity is a UUID. OAuth providers only *link* to that UUID
+//! (first link creates the principal; later links attach while logged in).
+//! Which providers are linked is private to the account owner.
pub mod config;
pub mod identity;
@@ -22,7 +26,9 @@ use crate::{
form_template::template_json_compact,
html::layout,
state::AppState,
- storage_schema::{oauth_link_owner, pseudonym_owner, Store, StoreFields},
+ storage_schema::{
+ linked_providers_for_uuid, oauth_link_owner, pseudonym_owner, Store, StoreFields,
+ },
ui_action::UI_RPC_FIELD,
};
@@ -53,10 +59,12 @@ fn new_actor_uuid() -> String {
pub struct LoginQuery {
#[serde(default)]
pub return_to: Option<String>,
+ #[serde(default)]
+ pub error: Option<String>,
}
#[derive(Debug, Deserialize)]
-pub struct GitHubStartQuery {
+pub struct OAuthStartQuery {
#[serde(default)]
pub return_to: Option<String>,
#[serde(default)]
@@ -72,15 +80,22 @@ fn return_from_query_or_jar(jar: &CookieJar, query: Option<&str>) -> String {
.unwrap_or_else(|| "/".to_string())
}
-fn oauth_providers(base_url: &str, return_to: &str) -> Vec<(&'static str, String)> {
+/// Available OAuth link targets: `(provider_key, label, start_href)`.
+fn oauth_providers(base_url: &str, return_to: &str) -> Vec<(&'static str, &'static str, String)> {
let mut out = Vec::new();
+ let enc = urlencoding::encode(return_to);
if oauth::GitHubConfig::from_env(base_url).is_some() {
out.push((
- "GitHub",
- format!(
- "/auth/github?return_to={}",
- urlencoding::encode(return_to)
- ),
+ "github",
+ oauth::provider_label("github"),
+ format!("/auth/github?return_to={enc}"),
+ ));
+ }
+ if oauth::RedditConfig::from_env(base_url).is_some() {
+ out.push((
+ "reddit",
+ oauth::provider_label("reddit"),
+ format!("/auth/reddit?return_to={enc}"),
));
}
out
@@ -125,23 +140,41 @@ fn alias_claim_forms(return_to: &str, submit_label: &str) -> Result<Markup, Stat
})
}
-fn signed_out_body(providers: &[(&str, String)]) -> Markup {
+fn login_error_message(code: Option<&str>) -> Option<&'static str> {
+ match code {
+ Some("oauth_taken") => {
+ Some("that OAuth account is already linked to a different sorter2 account")
+ }
+ Some("oauth_failed") => Some("OAuth failed — try again"),
+ _ => None,
+ }
+}
+
+fn signed_out_body(
+ providers: &[(&str, &str, String)],
+ error: Option<&str>,
+) -> Markup {
html! {
main class="panel login-page" {
section class="login-section" {
h1 { "sign in" }
- p class="muted" { "link an account to vote under a lasting alias" }
+ p class="muted" {
+ "link an OAuth account to create your identity, then claim an alias to vote"
+ }
+ @if let Some(msg) = login_error_message(error) {
+ p class="alias-bad" data-testid="login-error" { (msg) }
+ }
@if providers.is_empty() {
p class="muted" {
- "OAuth is not configured. Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET."
+ "OAuth is not configured. Set GitHub and/or Reddit client credentials."
}
} @else {
ul class="oauth-provider-list" {
- @for (name, href) in providers {
+ @for (key, label, href) in providers {
li {
a href=(href) class="btn-primary oauth-provider"
- data-testid=(format!("oauth-{}", name.to_lowercase())) {
- (format!("Continue with {name}"))
+ data-testid=(format!("oauth-{key}")) {
+ (format!("Link {label}"))
}
}
}
@@ -156,7 +189,10 @@ fn signed_out_body(providers: &[(&str, String)]) -> Markup {
fn account_body(
actor: &session::SessionActor,
aliases: &[String],
- providers: &[(&str, String)],
+ // Provider keys already linked to this UUID (private).
+ linked: &[String],
+ // Providers available to link: not yet attached.
+ unlinkable: &[(&str, &str, String)],
claim_forms: Markup,
) -> Markup {
let current = actor.pseudonym.trim();
@@ -212,16 +248,29 @@ fn account_body(
(claim_forms)
}
- @if !providers.is_empty() {
- section class="login-section" {
- h2 { "linked sign-in" }
- p class="muted small" { "sign in again with the same provider to return to this account" }
+ section class="login-section" {
+ h2 { "linked sign-in" }
+ p class="muted small" {
+ "private to you — linking more providers raises trust weight without publishing which accounts you use"
+ }
+ @if linked.is_empty() {
+ p class="muted" data-testid="linked-providers-empty" { "none yet" }
+ } @else {
+ ul class="linked-provider-list" data-testid="linked-providers" {
+ @for key in linked {
+ li data-testid=(format!("linked-{key}")) {
+ (oauth::provider_label(key))
+ }
+ }
+ }
+ }
+ @if !unlinkable.is_empty() {
ul class="oauth-provider-list" {
- @for (name, href) in providers {
+ @for (key, label, href) in unlinkable {
li {
a href=(href) class="btn-secondary oauth-provider"
- data-testid=(format!("oauth-relink-{}", name.to_lowercase())) {
- (format!("Re-link {name}"))
+ data-testid=(format!("oauth-link-{key}")) {
+ (format!("Link {label}"))
}
}
}
@@ -243,12 +292,21 @@ fn account_body(
fn login_body(
session: Option<&session::SessionActor>,
aliases: &[String],
- providers: &[(&str, String)],
+ linked: &[String],
+ providers: &[(&str, &str, String)],
claim_forms: Option<Markup>,
+ error: Option<&str>,
) -> Markup {
match (session, claim_forms) {
- (Some(actor), Some(forms)) => account_body(actor, aliases, providers, forms),
- _ => signed_out_body(providers),
+ (Some(actor), Some(forms)) => {
+ let unlinkable: Vec<_> = providers
+ .iter()
+ .filter(|(key, _, _)| !linked.iter().any(|p| p == key))
+ .cloned()
+ .collect();
+ account_body(actor, aliases, linked, &unlinkable, forms)
+ }
+ _ => signed_out_body(providers, error),
}
}
@@ -268,6 +326,10 @@ pub async fn login_page(
.as_ref()
.map(|s| alias_list(db, &s.uuid))
.unwrap_or_default();
+ let linked = session
+ .as_ref()
+ .map(|s| linked_providers_for_uuid(db, &s.uuid).unwrap_or_default())
+ .unwrap_or_default();
let providers = oauth_providers(&base_url_from_env(state.cfg.port), &return_to);
let claim_forms = if session.is_some() {
@@ -282,7 +344,14 @@ pub async fn login_page(
} else {
"login · sorter2"
},
- login_body(session.as_ref(), &aliases, &providers, claim_forms),
+ login_body(
+ session.as_ref(),
+ &aliases,
+ &linked,
+ &providers,
+ claim_forms,
+ query.error.as_deref(),
+ ),
state.views.get_views("/login"),
session
.as_ref()
@@ -302,7 +371,6 @@ pub async fn alias_page(
let db = state.projection_store.db();
let session = session::load_valid_session(db, &session_id).ok_or(StatusCode::UNAUTHORIZED)?;
if session::session_has_pseudonym(&session) {
- // Already onboarded — manage aliases on the account page.
return Ok(Redirect::to("/login").into_response());
}
@@ -331,7 +399,7 @@ pub async fn alias_page(
pub async fn github_start(
State(state): State<AppState>,
jar: CookieJar,
- Query(query): Query<GitHubStartQuery>,
+ Query(query): Query<OAuthStartQuery>,
) -> Result<Response, StatusCode> {
let cfg = oauth::GitHubConfig::from_env(&base_url_from_env(state.cfg.port))
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
@@ -342,7 +410,28 @@ pub async fn github_start(
} else {
None
};
- let url = oauth::authorize_url(&cfg, &state_token, mock_user);
+ let url = oauth::github_authorize_url(&cfg, &state_token, mock_user);
+ let jar = jar
+ .add(session::oauth_state_cookie_value(&state_token))
+ .add(session::auth_return_cookie_value(&return_to));
+ Ok((jar, Redirect::temporary(&url)).into_response())
+}
+
+pub async fn reddit_start(
+ State(state): State<AppState>,
+ jar: CookieJar,
+ Query(query): Query<OAuthStartQuery>,
+) -> Result<Response, StatusCode> {
+ let cfg = oauth::RedditConfig::from_env(&base_url_from_env(state.cfg.port))
+ .ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
+ let return_to = return_from_query_or_jar(&jar, query.return_to.as_deref());
+ let state_token = session::new_oauth_state();
+ let mock_user = if config::mock_oauth_allowed() {
+ query.mock_user.as_deref()
+ } else {
+ None
+ };
+ let url = oauth::reddit_authorize_url(&cfg, &state_token, mock_user);
let jar = jar
.add(session::oauth_state_cookie_value(&state_token))
.add(session::auth_return_cookie_value(&return_to));
@@ -355,6 +444,13 @@ pub struct OAuthCallbackQuery {
pub state: String,
}
+/// Link `provider:provider_id` to a UUID.
+///
+/// - Logged in + new provider → attach to session UUID
+/// - Logged in + already ours → no-op
+/// - Logged in
… preview truncated; 29,823 characters omittedB — c_af08bd851e49 (tommy-mor)
message
[2bc302c3] refactor
diff preview
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index 06001212820101e0cc953d3687dea64f85e60787..d2f9769108def7ca2c5857aec8b4319426188a66 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -47,7 +47,7 @@ pub async fn post_ui_html(
ratio_left,
ratio_right,
scope,
- next,
+ vote_compare,
} => {
let parent = parent_from_scope(&scope);
if let Err(e) = state
@@ -57,14 +57,12 @@ pub async fn post_ui_html(
return ui_js_warn(&e).into_response();
}
let tree = state.tree.read().await;
- if !next.trim().is_empty() {
+ if vote_compare {
+ let left = parse_item_param(&a);
+ let right = parse_item_param(&b);
+ let morph = crate::html::vote::vote_recorded_morph(&tree, &parent, &left, &right);
drop(tree);
- return JsBuilder::new()
- .raw(&format!(
- "window.location.href={};",
- js_string_literal(next.trim())
- ))
- .into_response();
+ return morph.into_response();
}
let empty = crate::reducer::NodeState::default();
let node = tree.get(&parent).unwrap_or(&empty);
@@ -137,7 +135,7 @@ mod tests {
ratio_left: 3,
ratio_right: 1,
scope: String::new(),
- next: String::new(),
+ vote_compare: false,
}
);
}
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 61cdbc094819ddedb755572c59456ec0d6617619..cd578a5fea46a9a1d49e238d08a80c2caf18708d 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -65,6 +65,15 @@ impl JsBuilder {
self
}
+ pub(crate) fn morph_inner_selector(mut self, selector: &str, markup: Markup) -> Self {
+ let html = js_string_literal(&markup.into_string());
+ self.snippets.push(format!(
+ "var __el = document.querySelector({sel}); if (__el) {{ Idiomorph.morph(__el, {html}, {{ morphStyle: 'innerHTML' }}); }}",
+ sel = js_string_literal(selector),
+ ));
+ self
+ }
+
pub(crate) fn raw(mut self, js: &str) -> Self {
if !js.is_empty() {
self.snippets.push(js.to_string());
diff --git a/server/src/html/vote.rs b/server/src/html/vote.rs
new file mode 100644
index 0000000000000000000000000000000000000000..89ed621ad861214e147add2062224fc4137ff8ad
--- /dev/null
+++ b/server/src/html/vote.rs
@@ -0,0 +1,306 @@
+//! Pairwise vote UI — `/vote?parent=` with optional `left` / `right`.
+
+use axum::{
+ extract::{Query, State},
+ response::{Html, IntoResponse},
+};
+use maud::{html, Markup};
+use serde::Deserialize;
+
+use crate::{
+ form_template::template_json_compact,
+ html::JsBuilder,
+ pair::{children_of, resolve_pair, suggest_next_pair_in_pool},
+ path_types::ItemId,
+ reducer::{GlobalTree, GroupState, NodeState, VoteData},
+ state::{parse_item_param, AppState},
+ ui_action::UI_RPC_FIELD,
+};
+
+use super::{breadcrumb_path, item_href, layout};
+
+#[derive(Debug, Deserialize)]
+pub struct VoteQuery {
+ pub parent: String,
+ #[serde(default)]
+ pub left: Option<String>,
+ #[serde(default)]
+ pub right: Option<String>,
+}
+
+pub fn vote_href(parent: &ItemId) -> String {
+ format!(
+ "/vote?parent={}",
+ urlencoding::encode(parent.as_str())
+ )
+}
+
+fn vote_compare_href(parent: &ItemId, left: &ItemId, right: &ItemId) -> String {
+ format!(
+ "/vote?parent={}&left={}&right={}",
+ urlencoding::encode(parent.as_str()),
+ urlencoding::encode(left.as_str()),
+ urlencoding::encode(right.as_str()),
+ )
+}
+
+fn display_label(id: &ItemId) -> String {
+ id.segments()
+ .last()
+ .map_or("item".into(), |v| v.to_string())
+}
+
+fn child_title(tree: &GlobalTree, id: &ItemId) -> String {
+ tree.get(id)
+ .and_then(|n| n.data.as_ref())
+ .map(|d| d.title.clone())
+ .unwrap_or_else(|| display_label(id))
+}
+
+fn ratio_pct(ratio_left: i32, ratio_right: i32) -> f64 {
+ let l = ratio_left.max(0) as f64;
+ let r = ratio_right.max(0) as f64;
+ let sum = l + r;
+ if sum <= 0.0 {
+ 50.0
+ } else {
+ (l / sum) * 100.0
+ }
+}
+
+fn ratios_for_page(v: &VoteData, page_left: &ItemId, page_right: &ItemId) -> (i32, i32) {
+ match (v.a.as_str(), v.b.as_str()) {
+ (a, b) if a == page_left.as_str() && b == page_right.as_str() => {
+ (v.ratio_left, v.ratio_right)
+ }
+ (a, b) if a == page_right.as_str() && b == page_left.as_str() => {
+ (v.ratio_right, v.ratio_left)
+ }
+ _ => (v.ratio_left, v.ratio_right),
+ }
+}
+
+fn edge_votes(group: &GroupState, left: &ItemId, right: &ItemId) -> Vec<VoteData> {
+ group
+ .recent_votes
+ .iter()
+ .filter(|v| {
+ (v.a.as_str() == left.as_str() && v.b.as_str() == right.as_str())
+ || (v.a.as_str() == right.as_str() && v.b.as_str() == left.as_str())
+ })
+ .cloned()
+ .collect()
+}
+
+fn vote_edge_history(tree: &GlobalTree, group: &GroupState, left: &ItemId, right: &ItemId) -> Markup {
+ let mut votes = edge_votes(group, left, right);
+ votes.sort_by(|a, b| b.ts.cmp(&a.ts));
+ let legend_left = child_title(tree, left);
+ let legend_right = child_title(tree, right);
+ html! {
+ @if votes.is_empty() {
+ p class="muted vote-edge-empty" { "no votes on this pair yet" }
+ } @else {
+ h3 class="vote-edge-history-title" {
+ "votes on this pair"
+ span class="vote-edge-history-axis muted" { " · " (legend_left) " : " (legend_right) }
+ }
+ ul class="vote-edge-history" {
+ @for v in &votes {
+ @let (r_left, r_right) = ratios_for_page(v, left, right);
+ @let pct = ratio_pct(r_left, r_right);
+ li class="vote-edge-history-row" {
+ div class="vote-edge-meta" {
+ span class="vote-edge-ratio" { (format!("{}:{}", r_left, r_right)) }
+ }
+ div class="ratio-bar vote-edge-bar" aria-hidden="true" {
+ div class="ratio-left" style={(format!("width: {:.3}%;", pct))} {}
+ div class="ratio-right" style={(format!("width: {:.3}%;", 100.0 - pct))} {}
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+fn vote_back_nav(parent: &ItemId) -> Markup {
+ html! {
+ div class="vote-compare-nav" {
+ a class="vote-compare-back muted" href=(item_href(parent)) { "← back to " (display_label(parent)) }
+ }
+ }
+}
+
+fn vote_compare_actions(parent: &ItemId, next: Option<&(ItemId, ItemId)>) -> Markup {
+ let next_href = next.map(|(l, r)| vote_compare_href(parent, l, r));
+ html! {
+ div id="vote-compare-actions" class="vote-compare-actions" {
+ button type="submit" class="btn-primary" data-testid="vote-post" { "post vote" }
+ @if let Some(href) = &next_href {
+ a class="btn-secondary vote-compare-next" data-testid="vote-next-pair" href=(href) { "next pair" }
+ } @else {
+ span class="btn-secondary vote-compare-next is-disabled" { "no next pair" }
+ }
+ }
+ }
+}
+
+/// After recording a vote on the compare page: refresh edge history and next-pair link.
+pub(crate) fn vote_recorded_morph(
+ tree: &GlobalTree,
+ parent: &ItemId,
+ left: &ItemId,
+ right: &ItemId,
+) -> JsBuilder {
+ let pool = children_of(tree, parent);
+ let empty = NodeState::default();
+ let group = tree
+ .get(parent)
+ .unwrap_or(&empty)
+ .local_ranking
+ .clone();
+ let edge_history = vote_edge_history(tree, &group, left, right);
+ let next_pair = suggest_next(&group, left, right, &pool);
+ let actions = vote_compare_actions(parent, next_pair.as_ref());
+ JsBuilder::new()
+ .morph_inner_selector("#vote-edge-history-region", edge_history)
+ .morph_selector("#vote-compare-actions", actions)
+}
+
+fn vote_compare_item_card(tree: &GlobalTree, item: &ItemId, side_class: &str) -> Markup {
+ let href = item_href(item);
+ let title = child_title(tree, item);
+ html! {
+ div class=(format!("vote-compare-side {side_class}")) {
+ a class=(format!("vote-compare-item {side_class}")) href=(href) {
+ @if let Some(row) = crate::render::reddit::child_row_markup(tree, item, &href) {
+ (row)
+ } @else {
+ strong { (title) }
+ }
+ }
+ @if let Some(node) = tree.get(item) {
+ @if crate::render::reddit::is_reddit_post(item) {
+ @if let Some(data) = &node.data {
+ @if let Some(src) = data.image_url.as_ref().or(data.thumb_url.as_ref()) {
+ figure class="vote-compare-figure" {
+ img class="vote-compare-image" src=(src) alt="" loading="lazy";
+ }
+ }
+ @if let Some(author) = &data.author {
+ p class="muted small" { "by " (author) }
+ }
+ }
+ } @else if let Some(data) = &node.data {
+ @if let Some(body) = &data.body_html {
+ div class="vote-compare-item-body" {
+ (maud::PreEscaped(body))
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+
+fn suggest_next(group: &GroupState, left: &ItemId, right: &ItemId, pool: &[ItemId]) -> Option<(ItemId, ItemId)> {
+ suggest_next_pair_in_pool(group, pool, Some((left, right)))
+}
+
+pub async fn vote_page(
+ State(state): State<AppState>,
+ Query(q): Query<VoteQuery>,
+) -> impl IntoResponse {
+ let parent = parse_item_param(&q.parent);
+ let left_param = q.left.as_deref().map(parse_item_param);
+ let right_param = q.right.as_deref().map(parse_item_param);
+
+ let tree = state.tree.read().await;
+ let empty = NodeState::default();
+ let parent_node = tree.get(&parent).unwrap_or(&empty);
+
+ let (left, right) = match resolve_pair(
+ &tree,
+ &parent,
+ left_param.as_ref(),
+ right_param.as_ref(),
+ ) {
+ Ok(p) => p,
+ Err(e) => {
+ let (msg, status) = e.status_message();
+ return (status, msg).into_response();
+ }
+ };
+
+ let pool = children_of(&tree, &parent);
+ let group = &parent_node.local_ranking;
+ let next_pair = suggest_next(group, &left, &right, &pool);
+ let edge_history = vote_edge_history(&tree, group, &left, &right);
+
+ let rpc_json = template_json_compact(&serde_json::json!({
+ "action": "record_vote",
+ "a": left.as_str(),
+ "b": right.as_str(),
+ "ratio_left": {"$form:i32": "ratio_left"},
+ "ratio_right": {"$form:i32": "ratio_right"},
+ "scope": parent.as_str(),
+ "vote_compare": true,
+ }))
+ .expect("vote rpc json");
+
+ let title = format!(
+ "vote — {} vs {}",
+ child_title(&tree, &left),
+ child_title(&tree, &right)
+ );
+
+ let body = html! {
+ section class="vote-compare-shell" {
+ h1 { "compare" }
+ (breadcrumb_path(&parent))
+ p class="muted vote-compare-scope" {
+ "ranking children of "
+ a href=(item_href(&par
… preview truncated; 32,381 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.