You are a constitutional council ranking individual git commits for ownership allocation. Compare these two commits. Decide which contributed more lasting value to the project. Judge substance, not spectacle: - Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise. - Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one. - Do not favor a side merely because its patch is longer or noisier. - Weight what the change does for the project, not the contributor's name. Return ONLY a JSON object: {"winner": "A" or "B", "ratio": "N:M", "explanation": "..."} The explanation must cite concrete differences in the patches (1-3 sentences). Side A — contributor: tommy-mor Side A — commit message: [d63870bc] fix Side A — unified diff (full patch): diff --git a/server/src/parser.rs b/server/src/parser.rs index 4bf201008857e3082b70a55e3dcbb2502ab3ed22..ea437c4434045b44cba04a2b3416df850db518e7 100644 --- a/server/src/parser.rs +++ b/server/src/parser.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use std::rc::Rc; -use std::cell::RefCell; +use std::sync::OnceLock; + use crate::parser_action::{GuideOption, ParserAction, ScrollingSuggestion, Suggestion}; // --- Core Abstractions --- @@ -98,8 +98,8 @@ pub struct Edge { description: Option<&'static str>, } -/// Handler function for generating UI actions -type Handler = Box) -> ParserAction>; +/// Handler function for generating UI actions (Send + Sync so the graph can live in `OnceLock`). +type Handler = Box) -> ParserAction + Send + Sync>; /// Node in the graph pub struct Node { @@ -109,16 +109,16 @@ pub struct Node { handler: Option, } -/// The composable parser graph +/// The composable parser graph (immutable after `build`). pub struct Graph { - nodes: HashMap>>, + nodes: HashMap, root: NodeId, } // --- Graph Builder (Fluent API) --- pub struct GraphBuilder { - nodes: HashMap>>, + nodes: HashMap, current_node: Option, root: NodeId, } @@ -126,31 +126,29 @@ pub struct GraphBuilder { impl GraphBuilder { pub fn new() -> Self { let mut nodes = HashMap::new(); - let root_node = Rc::new(RefCell::new(Node { - id: "root", - edges: Vec::new(), - handler: None, - })); - nodes.insert("root", root_node); - + nodes.insert( + "root", + Node { + id: "root", + edges: Vec::new(), + handler: None, + }, + ); + GraphBuilder { nodes, current_node: Some("root"), root: "root", } } - + /// Select a node to add edges to pub fn at(mut self, node_id: NodeId) -> Self { - // Create node if it doesn't exist - if !self.nodes.contains_key(node_id) { - let node = Rc::new(RefCell::new(Node { - id: node_id, - edges: Vec::new(), - handler: None, - })); - self.nodes.insert(node_id, node); - } + self.nodes.entry(node_id).or_insert_with(|| Node { + id: node_id, + edges: Vec::new(), + handler: None, + }); self.current_node = Some(node_id); self } @@ -161,39 +159,39 @@ impl GraphBuilder { } /// Add an edge with description - pub fn edge_with_desc(mut self, pattern: EdgePattern, target: NodeId, desc: Option<&'static str>) -> Self { + pub fn edge_with_desc( + mut self, + pattern: EdgePattern, + target: NodeId, + desc: Option<&'static str>, + ) -> Self { let current = self.current_node.expect("No current node selected"); - - // Create target node if it doesn't exist - if !self.nodes.contains_key(target) { - let node = Rc::new(RefCell::new(Node { - id: target, - edges: Vec::new(), - handler: None, - })); - self.nodes.insert(target, node); - } - - // Add edge to current node - if let Some(node) = self.nodes.get(current) { - node.borrow_mut().edges.push(Edge { + + self.nodes.entry(target).or_insert_with(|| Node { + id: target, + edges: Vec::new(), + handler: None, + }); + + if let Some(node) = self.nodes.get_mut(current) { + node.edges.push(Edge { pattern, target, description: desc, }); } - + self } - + /// Set handler for current node - pub fn handler(self, handler: F) -> Self - where - F: Fn(&str, &str, &HashMap) -> ParserAction + 'static + pub fn handler(mut self, handler: F) -> Self + where + F: Fn(&str, &str, &HashMap) -> ParserAction + Send + Sync + 'static, { let current = self.current_node.expect("No current node selected"); - if let Some(node) = self.nodes.get(current) { - node.borrow_mut().handler = Some(Box::new(handler)); + if let Some(node) = self.nodes.get_mut(current) { + node.handler = Some(Box::new(handler)); } self } @@ -225,24 +223,25 @@ impl Graph { } fn parse_recursive(&self, state: &mut ParserState) -> ParserAction { - let node = self.nodes.get(state.current_node_id) + let node = self + .nodes + .get(state.current_node_id) .expect("Node not found in graph"); - let node_ref = node.borrow(); - + // If we've consumed all input, check for handler or suggestions if state.cursor >= state.input.len() { - if let Some(handler) = &node_ref.handler { + if let Some(handler) = &node.handler { return handler(&state.original_query, &state.current_prefix, &state.context); } - + // No handler, try to suggest based on available edges - return self.suggest_from_edges(&node_ref, state); + return self.suggest_from_edges(node, state); } - + let remaining = &state.input[state.cursor..]; - + // Try to match each edge - for edge in &node_ref.edges { + for edge in &node.edges { if let Some((consumed, captured)) = edge.pattern.matches(remaining) { // Save state for potential backtracking let saved_cursor = state.cursor; @@ -297,7 +296,7 @@ impl Graph { } // No edges matched - try to provide suggestions - self.suggest_from_edges(&node_ref, state) + self.suggest_from_edges(node, state) } fn suggest_from_edges(&self, node: &Node, state: &ParserState) -> ParserAction { @@ -692,10 +691,15 @@ pub fn build_reddit_graph() -> Graph { // --- Public API --- +static REDDIT_GRAPH: OnceLock = OnceLock::new(); + +fn reddit_graph() -> &'static Graph { + REDDIT_GRAPH.get_or_init(build_reddit_graph) +} + /// Parse a query string and return a UI action pub fn parse_reddit_url(query: &str) -> ParserAction { - let graph = build_reddit_graph(); - graph.parse(query) + reddit_graph().parse(query) } #[cfg(test)] Side B — contributor: tommy-mor Side B — commit message: [d6b4a41e] Turn /login into an account page with alias switching. Signed-in users can see trust weight, switch aliases, claim new ones, and re-link OAuth from one place. Co-authored-by: Cursor Side B — unified diff (full patch): diff --git a/server/src/auth/mod.rs b/server/src/auth/mod.rs index d4a85ef52c15dc35148e4c743f0d646cbbdb056d..5906f93b13853421e96a3c37bc9d8202a47842bf 100644 --- a/server/src/auth/mod.rs +++ b/server/src/auth/mod.rs @@ -94,63 +94,169 @@ fn alias_list(db: &durable::Db, uuid: &str) -> Vec { .unwrap_or_default() } -fn login_body( - session: Option<&session::SessionActor>, +fn alias_claim_forms(return_to: &str, submit_label: &str) -> Result { + let check_rpc = template_json_compact(&serde_json::json!({ + "action": "check_pseudonym", + "pseudonym": {"$form": "pseudonym"}, + })) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let claim_rpc = template_json_compact(&serde_json::json!({ + "action": "claim_pseudonym", + "pseudonym": {"$form": "pseudonym"}, + "return_to": return_to, + })) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(html! { + div class="alias-claim" { + form id="alias-check-form" method="POST" action="/ui" { + input type="hidden" name=(UI_RPC_FIELD) value=(check_rpc); + label for="alias-input" { "alias" } + input type="text" id="alias-input" name="pseudonym" autocomplete="off" + data-testid="alias-input" maxlength="64" placeholder="letters, numbers, _ -"; + p id="alias-status" class="muted" data-testid="alias-status" { "type to check availability" } + } + form id="alias-claim-form" method="POST" action="/ui" { + input type="hidden" name=(UI_RPC_FIELD) value=(claim_rpc); + input type="hidden" name="pseudonym" id="alias-claim-field" value=""; + button type="submit" class="btn-primary" data-testid="alias-claim" { (submit_label) } + } + } + }) +} + +fn signed_out_body(providers: &[(&str, String)]) -> 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" } + @if providers.is_empty() { + p class="muted" { + "OAuth is not configured. Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET." + } + } @else { + ul class="oauth-provider-list" { + @for (name, href) in providers { + li { + a href=(href) class="btn-primary oauth-provider" + data-testid=(format!("oauth-{}", name.to_lowercase())) { + (format!("Continue with {name}")) + } + } + } + } + } + } + p class="login-back" { a href="/" { "← back" } } + } + } +} + +fn account_body( + actor: &session::SessionActor, aliases: &[String], providers: &[(&str, String)], + claim_forms: Markup, ) -> Markup { + let current = actor.pseudonym.trim(); html! { - main class="panel login-page" { - div class="login-grid" { - section class="login-oauth" { - h1 { "sign in" } - @if providers.is_empty() { - p class="muted" { - "OAuth is not configured. Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET." - } - } @else { - ul class="oauth-provider-list" { - @for (name, href) in providers { - li { - a href=(href) class="button oauth-provider" data-testid=(format!("oauth-{}", name.to_lowercase())) { - (format!("Continue with {name}")) + main class="panel login-page account-page" { + section class="login-section" { + h1 { "account" } + @if current.is_empty() { + p class="muted" { "finish setup by choosing an alias below" } + } @else { + p class="account-current" { + "voting as " + strong data-testid="account-current" { (current) } + } + } + p class="muted small" data-testid="account-weight" { + "trust weight " (format!("{:.1}", actor.trust_weight)) + " · rises when you link more OAuth providers" + } + } + + section class="login-section" { + h2 { "aliases" } + @if aliases.is_empty() { + p class="muted" data-testid="alias-list-empty" { "none yet — claim one below" } + } @else { + ul id="alias-list" class="alias-list" data-testid="alias-list" { + @for alias in aliases { + @let is_current = alias == current; + li class=(if is_current { "alias-item alias-current" } else { "alias-item" }) { + span class="alias-name" { (alias) } + @if is_current { + span class="alias-badge" data-testid="alias-current-badge" { "current" } + } @else { + form class="alias-switch" method="post" action="/auth/switch" + data-navigate="full" { + input type="hidden" name="pseudonym" value=(alias); + button type="submit" class="btn-secondary" + data-testid=(format!("alias-switch-{alias}")) { + "use" + } } } } } } - @if let Some(actor) = session { - p class="muted small" { - "session active · weight " (format!("{:.1}", actor.trust_weight)) - } - form method="post" action="/auth/logout" data-navigate="full" { - button type="submit" { "log out" } - } - } } - section class="login-aliases" { - h2 { "your aliases" } - ul id="alias-list" class="alias-list" { - @if aliases.is_empty() { - li class="muted" data-testid="alias-list-empty" { "none yet" } - } @else { - @for alias in aliases { - li { (alias) } + } + + section class="login-section" { + h2 { "add alias" } + p class="muted small" { "each alias is unique across sorter2" } + (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" } + ul class="oauth-provider-list" { + @for (name, href) in providers { + li { + a href=(href) class="btn-secondary oauth-provider" + data-testid=(format!("oauth-relink-{}", name.to_lowercase())) { + (format!("Re-link {name}")) + } } } } } } - p { a href="/" { "← back" } } + + section class="login-section login-actions" { + form method="post" action="/auth/logout" data-navigate="full" { + button type="submit" class="btn-secondary" data-testid="account-logout" { "log out" } + } + } + + p class="login-back" { a href="/" { "← back" } } } } } +fn login_body( + session: Option<&session::SessionActor>, + aliases: &[String], + providers: &[(&str, String)], + claim_forms: Option, +) -> Markup { + match (session, claim_forms) { + (Some(actor), Some(forms)) => account_body(actor, aliases, providers, forms), + _ => signed_out_body(providers), + } +} + pub async fn login_page( State(state): State, jar: CookieJar, Query(query): Query, -) -> Response { +) -> Result { let return_to = return_from_query_or_jar(&jar, query.return_to.as_deref()); let jar = jar.add(session::auth_return_cookie_value(&return_to)); @@ -164,16 +270,26 @@ pub async fn login_page( .unwrap_or_default(); let providers = oauth_providers(&base_url_from_env(state.cfg.port), &return_to); + let claim_forms = if session.is_some() { + Some(alias_claim_forms("/login", "claim alias")?) + } else { + None + }; + let markup = layout( - "login · sorter2", - login_body(session.as_ref(), &aliases, &providers), + if session.is_some() { + "account · sorter2" + } else { + "login · sorter2" + }, + login_body(session.as_ref(), &aliases, &providers, claim_forms), state.views.get_views("/login"), session .as_ref() .filter(|s| !s.pseudonym.trim().is_empty()) .map(|s| s.pseudonym.as_str()), ); - (jar, Html(markup.into_string())).into_response() + Ok((jar, Html(markup.into_string())).into_response()) } pub async fn alias_page( @@ -186,38 +302,17 @@ 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) { - return Ok(Redirect::to(&return_to).into_response()); + // Already onboarded — manage aliases on the account page. + return Ok(Redirect::to("/login").into_response()); } - let check_rpc = template_json_compact(&serde_json::json!({ - "action": "check_pseudonym", - "pseudonym": {"$form": "pseudonym"}, - })) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let claim_rpc = template_json_compact(&serde_json::json!({ - "action": "claim_pseudonym", - "pseudonym": {"$form": "pseudonym"}, - "return_to": return_to, - })) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - + let claim_forms = alias_claim_forms(&return_to, "continue")?; let body = html! { main class="panel alias-page" { h1 { "choose alias" } p class="muted" { "pick a unique display name for your votes" } - form id="alias-check-form" method="POST" action="/ui" { - input type="hidden" name=(UI_RPC_FIELD) value=(check_rpc); - label { "alias" } - input type="text" id="alias-input" name="pseudonym" autocomplete="off" - data-testid="alias-input" maxlength="64"; - p id="alias-status" class="muted" data-testid="alias-status" { "type to check availability" } - } - form id="alias-claim-form" method="POST" action="/ui" { - input type="hidden" name=(UI_RPC_FIELD) value=(claim_rpc); - input type="hidden" name="pseudonym" id="alias-claim-field" value=""; - button type="submit" class="btn-primary" data-testid="alias-claim" { "continue" } - } - p { a href="/login" { "← back to login" } } + (claim_forms) + p class="login-back" { a href="/login" { "← back to login" } } } }; diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs index 3cc3d7bdf55b5cb5d009600f4ade1fcd201a410b..5b9b73c7ad24499737f0576b3603c3ff5355251e 100644 --- a/server/src/html/mod.rs +++ b/server/src/html/mod.rs @@ -149,7 +149,7 @@ pub(crate) fn layout(title: &str, body: Markup, views: u64, nav_user: Option<&st nav class="top-nav" { @if let Some(name) = nav_user { span class="top-nav-user" data-testid="nav-user" { (name) } - a href="/login" { "account" } + a href="/login" data-testid="nav-account" { "account" } form class="top-nav-logout" method="post" action="/auth/logout" data-navigate="full" { button type="submit" data-testid="nav-logout" { "log out" } } diff --git a/server/static/sorter.css b/server/static/sorter.css index 257280b6b490c65222e580a325b77351cac6cc6b..e7aca23b426d504938b90b5a25ad06b07843ba02 100644 --- a/server/static/sorter.css +++ b/server/static/sorter.css @@ -108,6 +108,152 @@ body { cursor: wait; } +.panel { + max-width: 36rem; + margin: 1.5rem auto; + padding: 1.25rem 1.5rem; + background: var(--panel); + border: 1px solid var(--border); +} + +.login-page h1, +.alias-page h1 { + margin: 0 0 0.35rem; + font-size: 1.5rem; +} + +.login-page h2, +.alias-page h2 { + margin: 0 0 0.5rem; + font-size: 1.05rem; + font-weight: 600; +} + +.login-section { + margin-bottom: 1.5rem; +} + +.login-section:last-of-type { + margin-bottom: 0.75rem; +} + +.account-current { + margin: 0.25rem 0 0.5rem; + font-size: 1.1rem; +} + +.oauth-provider-list { + list-style: none; + margin: 1rem 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.oauth-provider-list a { + text-align: center; + text-decoration: none; + margin-top: 0; +} + +.alias-list { + list-style: none; + margin: 0.5rem 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.alias-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.55rem 0.75rem; + border: 1px solid var(--border); + background: var(--bg); +} + +.alias-item.alias-current { + border-color: var(--accent); +} + +.alias-name { + font-weight: 600; +} + +.alias-badge { + font-size: 0.75rem; + color: var(--accent); + text-transform: lowercase; +} + +.alias-switch { + margin: 0; +} + +.alias-switch .btn-secondary { + margin-top: 0; +} + +.alias-claim label { + display: block; + margin-bottom: 0.35rem; + color: var(--muted); + font-size: 0.875rem; +} + +.alias-claim input[type="text"] { + width: 100%; + padding: 0.5rem 0.65rem; + background: var(--bg); + border: 1px solid var(--border); + color: var(--fg); + font: inherit; +} + +.alias-claim input[type="text"]:focus { + outline: 1px solid var(--accent); + border-color: var(--accent); +} + +#alias-status { + margin: 0.4rem 0 0.75rem; + min-height: 1.25em; +} + +#alias-status.alias-ok { + color: #8fd19e; +} + +#alias-status.alias-bad { + color: #e8a0a0; +} + +.login-actions { + padding-top: 0.75rem; + border-top: 1px solid var(--border); +} + +.login-actions .btn-secondary { + margin-top: 0; +} + +.login-back { + margin: 1rem 0 0; +} + +.login-back a { + color: var(--muted); + text-decoration: none; +} + +.login-back a:hover { + color: var(--fg); +} + .fetch-entity-form { margin-top: 0.5rem; } diff --git a/test/auth_login.clj b/test/auth_login.clj index a81e064ebb88fe359c56cf6718880dc1e4c1a7d9..6f2f00d7aa7340e63d1ac465b0a2234cec7a983f 100644 --- a/test/auth_login.clj +++ b/test/auth_login.clj @@ -55,7 +55,6 @@ (move-vote-slider-left pg) (loc/click (page/get-by-test-id pg "vote-post")) (page/wait-for-selector pg "[data-testid=oauth-github]" {:timeout 15000}) - (is (str/includes? (or (element-text pg "alias-list-empty") "") "none yet")) (loc/click (page/get-by-test-id pg "oauth-github")) (page/wait-for-selector pg "[data-testid=alias-input]" {:timeout 15000}) (type-alias! pg "seeder")