constitution · epochs · watch · epoch 3

comparison

c_8dc1a8119370 (tommy-mor) vs c_effff18688f9 (tommy-mor)

download prompt · raw event · cmp_8e6b82077470fe

council reasoning

~anthropic/claude-sonnet-latest · winner A · 6:4 · permalink

Commit A deletes ~1800 lines of an unreliable, over-engineered keystroke-transition-graph parser and replaces it with a small, testable, correct URL-parsing function plus a simpler paste-and-go UI, genuinely simplifying the codebase and fixing a real reliability problem (also removing an entire flaky browser-race test). Commit B is a legitimate consolidation of POST /post, /post/check, /post/redact into a single /ui RPC endpoint with shared session resolution, which is solid but more mechanical refactor/plumbing with less net risk reduction and more residual duplication (e.g., duplicated helper functions) than A's decisive cleanup.

~x-ai/grok-latest · winner A · 3:1 · permalink

A deletes an unreliable ~1.8k-line keystroke graph parser plus race-handling JS/Playwright and replaces it with a small, correct paste-and-go subreddit extract/redirect that matches real product need. B is a solid consolidation (fold web_post into POST /ui, WebSession once, form __rpc__), but it mostly relocates existing ingest/check/redact behavior rather than fixing a broken design.

openai/gpt-chat-latest · winner B · 3:2 · permalink

Side B performs a substantive architectural consolidation by moving posting, checking, and redaction workflows behind the existing `POST /ui` RPC mechanism, introducing `resolve_web_session` to avoid repeated authentication parsing, deleting the separate `web_post` module, and updating forms and integration tests accordingly. Side A simplifies an unreliable autocomplete graph into a paste-and-go URL parser with redirect behavior and removes a large amount of parser complexity, but it also drops richer navigation/autocomplete functionality in favor of a narrower workflow, making its long-term design impact somewhat less broadly beneficial.

sides

A — c_8dc1a8119370 (tommy-mor)

message

[529cc941] Replace autocomplete parser with paste-and-go navigate.

The keystroke transition graph was unreliable; a textarea plus Go button now parses pasted Reddit URLs and redirects to the subreddit ranking scope.

Co-authored-by: Cursor <cursoragent@cursor.com>

diff preview

diff --git a/AGENTS.md b/AGENTS.md
index 77f2e31d4e860a92a77255ca5106c8b6c4510ee7..36ee4c0ec700bffbe226ee775ba9cf59cf35c770 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -11,7 +11,6 @@ Single Rust web app **`sorter2-server`**: pairwise voting, rank-centrality ranki
 - **Rust 1.88+** is required (some transitive crates need a recent Cargo). The image may ship older `/usr/local/cargo` (1.83); use **rustup** and `rustup default 1.88.0` before building.
 - **System packages** for builds: `pkg-config`, `libssl-dev` (for `reqwest` / OpenSSL in integration tests and release builds).
 - **Clojure CLI 1.12.0.1530** (optional but used in CI): install from https://clojure.org/guides/install_clojure — needed for `./scripts/clj-test.sh` / Kaocha tests.
-- **Playwright browser** for the spel browser test (`test/parser_race.clj`): install once with `clojure -M -e "(com.microsoft.playwright.CLI/main (into-array String [\"install\" \"chromium\" \"--with-deps\"]))"`. The browser binary is cached under `~/.cache/ms-playwright`.
 
 ### Commands (see also `TEST.sh`)
 
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index 1339048c955c0a3eb5120381aaf031424cbed540..c4ab9d65c7b3cd42a5b4d093ba429993c101e9a8 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -8,7 +8,7 @@ use std::collections::HashMap;
 use crate::{
     html::{js_string_literal, ranking_panel, JsBuilder},
     parser::parse_reddit_url,
-    parser_render::parser_panel_morph,
+    parser_render::navigate_panel,
     state::AppState,
     ui_action::{parse_html_ui_from_form, HtmlUiAction},
 };
@@ -57,18 +57,23 @@ pub async fn post_ui_html(
                 .morph_selector("#ranking-panel", panel)
                 .into_response()
         }
-        HtmlUiAction::ParseQuery { query } => {
-            let action = parse_reddit_url(&query);
-            let panel = parser_panel_morph(&query, &action);
-            let mut js = JsBuilder::new().morph_selector("#parser-panel", panel);
-            if let Some(comp) = action.primary_completion() {
-                js = js.raw(&format!(
-                    "var __pi=document.getElementById('parser-input'); if(__pi){{__pi.dataset.completion={};}}",
-                    js_string_literal(comp)
-                ));
+        HtmlUiAction::ParseQuery { query } => match parse_reddit_url(&query) {
+            Ok(subreddit) => {
+                let dest = format!("/?sub={subreddit}");
+                JsBuilder::new()
+                    .raw(&format!(
+                        "window.location.href={};",
+                        js_string_literal(&dest)
+                    ))
+                    .into_response()
             }
-            js.into_response()
-        }
+            Err(message) => {
+                let panel = navigate_panel(&query, Some(&message));
+                JsBuilder::new()
+                    .morph_selector("#parser-panel", panel)
+                    .into_response()
+            }
+        },
     }
 }
 
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 94a5cfb561d1c8464bd2782f7ffd4e0965ccf95d..9650d333d29c4ac94ceb407aee3ee00399c7f40b 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -11,8 +11,7 @@ use serde::Deserialize;
 
 use crate::{
     form_template::template_json_compact,
-    parser_action::ParserAction,
-    parser_render::parser_panel,
+    parser_render::navigate_panel,
     ranking::{top_bottom, RankedItem},
     reducer::GroupState,
     state::{normalize_scope, AppState},
@@ -336,10 +335,9 @@ pub async fn home(
     let empty = GroupState::new();
     let group = groups.get(&scope).unwrap_or(&empty);
 
-    let empty_action = ParserAction::suggest(String::new(), None);
     let body = html! {
         h1 { "sorter2" }
-        (parser_panel("", &empty_action))
+        (navigate_panel("", None))
         (vote_panel(&scope))
         (ranking_panel(&scope, group))
     };
diff --git a/server/src/lib.rs b/server/src/lib.rs
index fa423640d598f4ba97a5885d228e78d7b97f7a22..de8bca48cbf689cad22337883e7791966e7c4919 100644
--- a/server/src/lib.rs
+++ b/server/src/lib.rs
@@ -4,7 +4,6 @@ pub mod events;
 pub mod form_template;
 pub mod html;
 pub mod parser;
-pub mod parser_action;
 pub mod parser_render;
 pub mod path_types;
 pub mod ranking;
diff --git a/server/src/parser.rs b/server/src/parser.rs
index ea437c4434045b44cba04a2b3416df850db518e7..50571a59f0d3ece1e2538f88e00ef46ec40ea545 100644
--- a/server/src/parser.rs
+++ b/server/src/parser.rs
@@ -1,1811 +1,87 @@
-use std::collections::HashMap;
-use std::sync::OnceLock;
+//! Extract a subreddit name from a pasted Reddit URL or path.
 
-use crate::parser_action::{GuideOption, ParserAction, ScrollingSuggestion, Suggestion};
-
-// --- Core Abstractions ---
-
-/// Unique identifier for nodes in the graph
-type NodeId = &'static str;
-
-/// Pattern matching for edges
-#[derive(Debug, Clone)]
-pub enum EdgePattern {
-    /// Matches exact literal string
-    Literal(&'static str),
-    
-    /// Matches any prefix of a string and suggests the full string
-    /// e.g., PrefixOf("reddit.com") matches "r", "re", "red", "reddit", "reddit.com"
-    PrefixOf(&'static str),
-    
-    /// Captures a variable segment (e.g., subreddit name, username)
-    Variable(&'static str),
-    
-    /// Matches any string (wildcard)
-    Any,
-}
-
-impl EdgePattern {
-    /// Try to match this pattern against input, return (consumed_chars, captured_value)
-    fn matches(&self, input: &str) -> Option<(usize, Option<String>)> {
-        match self {
-            EdgePattern::Literal(lit) => {
-                if input.starts_with(lit) {
-                    Some((lit.len(), None))
-                } else {
-                    None
-                }
-            }
-            EdgePattern::PrefixOf(target) => {
-                // Check if input is a prefix of target
-                if target.starts_with(input) && !input.is_empty() {
-                    // It's a valid prefix
-                    Some((input.len(), None))
-                } else if input.starts_with(target) {
-                    // Full match
-                    Some((target.len(), None))
-                } else {
-                    None
-                }
-            }
-            EdgePattern::Variable(var_name) => {
-                // Consume until next '/' or end of string
-                let end = input.find('/').unwrap_or(input.len());
-                if end > 0 {
-                    let captured = input[..end].to_string();
-                    // Validate based on variable type
-                    if is_valid_variable(var_name, &captured) {
-                        Some((end, Some(captured)))
-                    } else {
-                        None
-                    }
-                } else {
-                    None
-                }
-            }
-            EdgePattern::Any => {
-                // Match everything until next '/' or end
-                let end = input.find('/').unwrap_or(input.len());
-                if end > 0 {
-                    Some((end, Some(input[..end].to_string())))
-                } else {
-                    None
-                }
-            }
-        }
-    }
-    
-    /// Get the completion suggestion for this pattern
-    fn completion(&self, partial: &str) -> Option<String> {
-        match self {
-            EdgePattern::PrefixOf(target) => {
-                if target.starts_with(partial) && partial != *target {
-                    Some(target.to_string())
-                } else {
-                    None
-                }
-            }
-            _ => None,
-        }
+pub fn parse_reddit_url(query: &str) -> Result<String, String> {
+    let q = query.trim();
+    if q.is_empty() {
+        return Err("Paste a Reddit URL or r/subreddit path".into());
     }
-}
 
-/// Edge in the graph
-pub struct Edge {
-    pattern: EdgePattern,
-    target: NodeId,
-    /// Optional description for autocomplete
-    description: Option<&'static str>,
-}
-
-/// Handler function for generating UI actions (Send + Sync so the graph can live in `OnceLock`).
-type Handler = Box<dyn Fn(&str, &str, &HashMap<String, String>) -> ParserAction + Send + Sync>;
-
-/// Node in the graph
-pub struct Node {
-    #[allow(dead_code)]
-    id: NodeId,
-    edges: Vec<Edge>,
-    handler: Option<Handler>,
-}
-
-/// The composable parser graph (immutable after `build`).
-pub struct Graph {
-    nodes: HashMap<NodeId, Node>,
-    root: NodeId,
-}
-
-// --- Graph Builder (Fluent API) ---
-
-pub struct GraphBuilder {
-    nodes: HashMap<NodeId, Node>,
-    current_node: Option<NodeId>,
-    root: NodeId,
-}
-
-impl GraphBuilder {
-    pub fn new() -> Self {
-        let mut nodes = HashMap::new();
-        nodes.insert(
-            "root",
-            Node {
-                id: "root",
-                edges: Vec::new(),
-                handler: None,
-            },
-        );
-
-        GraphBuilder {
-            nodes,
-            current_node: Some("root"),
-            root: "root",
-        }
+    if let Some(sub) = subreddit_after_prefix(q, "r/") {
+        return Ok(sub);
     }
 
-    /// Select a node to add edges to
-    pub fn at(mut self, node_id: NodeId) -> Self {
-        self.nodes.entry(node_id).or_insert_with(|| Node {
-            id: node_id,
-            edges: Vec::new(),
-            handler: None,
-        });
-        self.current_node = Some(node_id);
-        self
+    if let Some(sub) = subreddit_from_path_segment(q, "/r/") {
+        return Ok(sub);
     }
-    
-    /// Add an edge from the current node
-    pub fn edge(self, pattern: EdgePattern, target: NodeId) -> Self {
-        self.edge_with_desc(pattern, target, None)
-    }
-    
-    /// Add an edge with description
-    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");
-
-        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<F>(mut self, handler: F) -> Self
-    where
-        F: Fn(&str, &str, &HashMap<String, String>) -> ParserAction + Send + Sync + 'static,
-    {
-        let current = self.current_node.expect("No current node selected");
-        if let Some(node) = self.nodes.get_mut(current) {
-            node.handler = Some(Box::new(handler));
-        }
-        self
-    }
-    
-    /// Build the final graph
-    pub fn build(self) -> Graph {
-        Graph {
-            nodes: self.nodes,
-            root: self.root,
-        }
-    }
+    Err("Could not find a subreddit in that URL".into())
 }
 
-// --- Parser Implementation ---
-
-impl Graph {
-    pub fn parse(&self, input: &str) -> ParserAction {
-        let normalized = input.trim().to_lowercase();
-        let mut state = ParserState {
-            input: &normalized,
-            cursor: 0,
-            current_node_id: self.root,
-            context: HashMap::new(),
-            original_query: input.to_string(),
-            current_prefix: String::new(),
-        };
-        
-        self.parse_recursive(&mut state)
-    }
-    
-    fn parse_recursive(&self, state: &mut ParserState) -> ParserAction {
-        let node = self
-            .nodes
-            .get(state.current_node_id)
-            .expect("Node not found in graph");
-
-        // If we've consumed all input, check for handler or suggestions
-        if state.cursor >= state.input.len() {
-            if let Some(handler) = &node.han

… preview truncated; 92,533 characters omitted

download full diff A

B — c_effff18688f9 (tommy-mor)

message

[c3cbcaa7] refactor

diff preview

diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs
index b3631b06153d52f88348fef927e7a024b7b85ad6..cd556eb89e9dd8203eba6c8969ffd144db5d329d 100644
--- a/server/src/api/auth.rs
+++ b/server/src/api/auth.rs
@@ -71,6 +71,24 @@ pub fn optional_principal(headers: &HeaderMap, jar: &CookieJar, reduced: &Reduce
     verify_token(reduced, c.value()).ok()
 }
 
+/// Browser session: principal + bearer token string (same shape as CLI session cookie).
+#[derive(Debug, Clone)]
+pub struct WebSession {
+    pub username: String,
+    pub bearer: String,
+}
+
+/// Resolve username and bearer together for `POST /ui` dispatch (one read of headers + jar).
+pub fn resolve_web_session(headers: &HeaderMap, jar: &CookieJar, reduced: &ReducerState) -> Option<WebSession> {
+    let username = optional_principal(headers, jar, reduced)?;
+    let bearer = headers
+        .get(header::AUTHORIZATION)
+        .and_then(|v| v.to_str().ok())
+        .and_then(|s| s.strip_prefix("Bearer ").map(|t| t.trim().to_string()))
+        .or_else(|| jar.get(SLUG_SESSION_COOKIE).map(|c| c.value().to_string()))?;
+    Some(WebSession { username, bearer })
+}
+
 fn redirect_with_session_cookie(public_url: &str, path_and_query: &str, bearer: &str, jar: &CookieJar) -> Response {
     let mut res = Response::builder()
         .status(StatusCode::TEMPORARY_REDIRECT)
diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs
index a986f706ea4b261cbaf004c02b4cf84184b41371..4e223a7460997c464706dca850281447bd754ed5 100644
--- a/server/src/api/mod.rs
+++ b/server/src/api/mod.rs
@@ -4,7 +4,6 @@ mod rpc;
 mod stream;
 mod validate;
 mod ui_html;
-mod web_post;
 
 pub use auth::{
     get_join_invite,
@@ -19,7 +18,9 @@ pub use auth::{
     get_web_login,
     get_logout,
     optional_principal,
+    resolve_web_session,
     session_cookie_header_value,
+    WebSession,
     SLUG_SESSION_COOKIE,
 };
 
@@ -35,7 +36,6 @@ pub use stream::{get_html_stream, get_stream};
 pub use validate::{normalize_room_and_thread, validate_ingest_document, ValidatedIngest};
 
 pub use ui_html::post_ui_html;
-pub use web_post::{check_web_ingest, post_web_ingest, post_web_redact};
 
 #[cfg(test)]
 mod tests {
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index 2b40a72059981d558768f73d189b991f3448c257..497f8fdd222a8ec7c3d76b0695e0352e973ca3ba 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -1,25 +1,29 @@
-//! Single `POST /ui` entry for browser [`crate::html::ui_action::HtmlUiAction`] (JSON in `__rpc__` + holes).
+//! Single `POST /ui` entry: parse `__rpc__` → [`HtmlUiAction`], resolve [`WebSession`] once, dispatch.
 
 use axum::{
-    body::Body,
+    body,
     extract::State,
-    http::{header, HeaderMap, StatusCode},
+    http::{header, HeaderMap, HeaderValue, StatusCode},
     response::{IntoResponse, Response},
     Form,
 };
 use axum_extra::extract::cookie::CookieJar;
+use slug_types::{RpcBatch, RpcBatchResponse, RpcCommand, RpcResult};
 use std::collections::HashMap;
 
 use crate::{
     api::{
-        auth::optional_principal,
-        web_post::{run_check_web_ingest, run_post_web_ingest, run_post_web_redact, WebPostForm, WebRedactForm},
+        auth::{resolve_web_session, WebSession},
+        handle_rpc_batch,
+        rpc::{rpc_post_redact, rpc_post_with_bearer},
     },
+    canonical_path::canonicalize_tag,
     html::{
         fragment_public_new_thread_form, fragment_room_new_thread_form, login_to_post_hint_markup,
-        parse_html_ui_from_form, user_can_post_room, user_can_view_room, HtmlUiAction, JsBuilder,
-        ThreadNav,
+        parse_html_ui_from_form, thread_feed_html, thread_feed_html_for_room, thread_feed_region_markup,
+        user_can_post_room, user_can_view_room, HtmlUiAction, JsBuilder, ThreadNav,
     },
+    reducer::{scope_from_room_wire, ScopeId},
     state::AppState,
 };
 
@@ -34,6 +38,19 @@ pub async fn post_ui_html(
         Err(e) => return ui_js_warn(&e.to_string()).into_response(),
     };
 
+    let reduced = state.reduced.read().await;
+    let session = resolve_web_session(&headers, &jar, &reduced);
+    drop(reduced);
+
+    dispatch_ui_action(&state, session.as_ref(), action).await
+}
+
+/// All UI command logic: HTTP extractors stop above; this only sees [`AppState`], session, and [`HtmlUiAction`].
+async fn dispatch_ui_action(
+    state: &AppState,
+    session: Option<&WebSession>,
+    action: HtmlUiAction,
+) -> Response {
     match action {
         HtmlUiAction::PostIngest {
             room,
@@ -42,47 +59,87 @@ pub async fn post_ui_html(
             error_target,
             form_id,
         } => {
-            run_post_web_ingest(
-                &state,
-                &headers,
-                &jar,
-                WebPostForm {
-                    room,
-                    thread_tag,
-                    text,
-                    error_target,
-                    form_id,
-                },
-            )
-            .await
+            let Some(session) = session else {
+                return js_redirect("/login").into_response();
+            };
+            let room = room.trim().to_string();
+            let thread_tag = thread_tag.trim().to_string();
+            if text.trim().is_empty() {
+                return form_js_error(
+                    error_target.as_ref(),
+                    "empty post",
+                    "Write something in the text area (DSL / prose).",
+                )
+                .into_response();
+            }
+            match rpc_post_with_bearer(state, &session.bearer, room.clone(), thread_tag.clone(), text).await {
+                Ok(RpcResult::PostOk { .. }) => {
+                    post_success_response(
+                        state,
+                        &room,
+                        &thread_tag,
+                        error_target.as_ref(),
+                        form_id.as_ref(),
+                        Some(session.username.as_str()),
+                    )
+                    .await
+                    .into_response()
+                }
+                Ok(_) => form_js_error(
+                    error_target.as_ref(),
+                    "unexpected response",
+                    "Post did not return PostOk.",
+                )
+                .into_response(),
+                Err((msg, hint)) => form_js_error(error_target.as_ref(), &msg, hint.as_deref().unwrap_or("")).into_response(),
+            }
         }
         HtmlUiAction::CheckIngest {
             room,
             thread_tag,
             text,
             error_target,
-            form_id,
+            form_id: _,
         } => {
-            run_check_web_ingest(
-                &state,
-                &headers,
-                &jar,
-                WebPostForm {
-                    room,
-                    thread_tag,
-                    text,
-                    error_target,
-                    form_id,
-                },
-            )
-            .await
+            let Some(session) = session else {
+                return js_redirect("/login").into_response();
+            };
+            let room = room.trim().to_string();
+            let thread_tag = canonicalize_tag(&thread_tag);
+            if thread_tag.is_empty() {
+                return form_js_error(
+                    error_target.as_ref(),
+                    "missing thread tag",
+                    "Set a thread tag before posting.",
+                )
+                .into_response();
+            }
+            if text.trim().is_empty() {
+                return js_clear_errors(&form_error_target(error_target.as_ref())).into_response();
+            }
+            match rpc_check_with_bearer(state, &session.bearer, room, text.clone()).await {
+                Ok(RpcResult::CheckOk { .. }) => js_clear_errors(&form_error_target(error_target.as_ref())).into_response(),
+                Ok(_) => form_js_error(error_target.as_ref(), "unexpected response", "Check did not return CheckOk.").into_response(),
+                Err((msg, hint)) => form_js_error(error_target.as_ref(), &msg, hint.as_deref().unwrap_or("")).into_response(),
+            }
         }
         HtmlUiAction::RedactPost { post_id } => {
-            run_post_web_redact(&state, &headers, &jar, WebRedactForm { post_id }).await
+            let Some(session) = session else {
+                return js_redirect("/login").into_response();
+            };
+            let h = headers_from_bearer(&session.bearer);
+            match rpc_post_redact(state, &h, post_id).await {
+                Ok(RpcResult::RedactPostOk {}) => redact_success_response(state).await.into_response(),
+                Ok(_) => (StatusCode::BAD_REQUEST, "unexpected response").into_response(),
+                Err((msg, hint)) => {
+                    let detail = hint.as_deref().unwrap_or("");
+                    js_error("#errors", &msg, detail).into_response()
+                }
+            }
         }
         HtmlUiAction::ExpandPublicNewThreadForm => {
             let reduced = state.reduced.read().await;
-            let user = optional_principal(&headers, &jar, &reduced);
+            let user = session.map(|s| s.username.as_str());
             drop(reduced);
             let markup = if user.is_some() {
                 fragment_public_new_thread_form(true)
@@ -99,18 +156,18 @@ pub async fn post_ui_html(
                 return ui_js_warn("missing room").into_response();
             }
             let reduced = state.reduced.read().await;
-            let user = optional_principal(&headers, &jar, &reduced);
+            let user = session.map(|s| s.username.as_str());
             if !reduced.rooms.contains(&room_wire) {
                 drop(reduced);
                 return ui_js_warn("room not found").into_response();
             }
-            if !user_can_view_room(&reduced, &room_wire, user.as_deref()) {
+            if !user_can_view_room(&reduced, &room_wire, user) {
                 drop(reduced);
                 return ui_js_warn("forbidden").into_response();
             }
-            let can_post = user
+            let can_post = session
                 .as_ref()
-                .map(|u| user_can_post_room(&reduced, &room_wire, u))
+                .map(|s| user_can_post_room(&reduced, &room_wire, &s.username))
                 .unwrap_or(false);
             drop(reduced);
             let Some(nav) = ThreadNav::from_room_id(&room_wire) else {
@@ -128,12 +185,195 @@ pub async fn post_ui_html(
     }
 }
 
+fn headers_from_bearer(bearer: &str) -> HeaderMap {
+    let mut headers = HeaderMap::new();
+    if let Ok(hv) = HeaderValue::from_str(&format!("Bearer {bearer}")) {
+        headers.insert(header::AUTHORIZATION, hv);
+    }
+    headers
+}
+
+fn post_redirect_location(room: &str, thread_tag: &str) -> String {
+    let tag = canonicalize_tag(thread_tag);
+    if room.trim() == "public" {
+        format!("/t/{tag}")
+    } else {
+        let room = room.trim();
+        let Some((a, b)) = room.split_once('/') else {
+            return "/".to_string();
+        };
+        format!("/r/{a}/{b}/t/{tag}")
+    }
+}
+
+fn js_quote(s: &str) -> String {
+    serde_json::to_string(s).expect("js string escaping must succeed")
+}
+
+fn js_redirect(to: &str) -> Response {
+    let js = format!("window.location = {};", js_quote(to));
+    Response::builder()
+        .status(StatusCode::OK)
+        .header(header::CONTENT_TYPE, "text/javascript; charset=utf-8")
+        .body(axum::body::Body::from(js))
+        .unwrap()
+}
+
+fn js_error(error_target: &str, title: &str, detail: &str) -> Response {
+    let markup = maud::html! {
+        div id=(error_target.trim_start_matches('#')) {
+            p class="auth-error" { (title) }
+            @if !detail.is_empty() {
+                pre class="muted" { (detail) }
+            }
+        }
+    };
+    JsBuilder::new()
+        .morph_selector(error_targe

… preview truncated; 33,445 characters omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.