{"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[529cc941] Replace autocomplete parser with paste-and-go navigate.\n\nThe keystroke transition graph was unreliable; a textarea plus Go button now parses pasted Reddit URLs and redirects to the subreddit ranking scope.\n\nCo-authored-by: Cursor \n\nSide A — unified diff (full patch):\ndiff --git a/AGENTS.md b/AGENTS.md\nindex 77f2e31d4e860a92a77255ca5106c8b6c4510ee7..36ee4c0ec700bffbe226ee775ba9cf59cf35c770 100644\n--- a/AGENTS.md\n+++ b/AGENTS.md\n@@ -11,7 +11,6 @@ Single Rust web app **`sorter2-server`**: pairwise voting, rank-centrality ranki\n - **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.\n - **System packages** for builds: `pkg-config`, `libssl-dev` (for `reqwest` / OpenSSL in integration tests and release builds).\n - **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.\n-- **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`.\n \n ### Commands (see also `TEST.sh`)\n \ndiff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs\nindex 1339048c955c0a3eb5120381aaf031424cbed540..c4ab9d65c7b3cd42a5b4d093ba429993c101e9a8 100644\n--- a/server/src/api/ui_html.rs\n+++ b/server/src/api/ui_html.rs\n@@ -8,7 +8,7 @@ use std::collections::HashMap;\n use crate::{\n html::{js_string_literal, ranking_panel, JsBuilder},\n parser::parse_reddit_url,\n- parser_render::parser_panel_morph,\n+ parser_render::navigate_panel,\n state::AppState,\n ui_action::{parse_html_ui_from_form, HtmlUiAction},\n };\n@@ -57,18 +57,23 @@ pub async fn post_ui_html(\n .morph_selector(\"#ranking-panel\", panel)\n .into_response()\n }\n- HtmlUiAction::ParseQuery { query } => {\n- let action = parse_reddit_url(&query);\n- let panel = parser_panel_morph(&query, &action);\n- let mut js = JsBuilder::new().morph_selector(\"#parser-panel\", panel);\n- if let Some(comp) = action.primary_completion() {\n- js = js.raw(&format!(\n- \"var __pi=document.getElementById('parser-input'); if(__pi){{__pi.dataset.completion={};}}\",\n- js_string_literal(comp)\n- ));\n+ HtmlUiAction::ParseQuery { query } => match parse_reddit_url(&query) {\n+ Ok(subreddit) => {\n+ let dest = format!(\"/?sub={subreddit}\");\n+ JsBuilder::new()\n+ .raw(&format!(\n+ \"window.location.href={};\",\n+ js_string_literal(&dest)\n+ ))\n+ .into_response()\n }\n- js.into_response()\n- }\n+ Err(message) => {\n+ let panel = navigate_panel(&query, Some(&message));\n+ JsBuilder::new()\n+ .morph_selector(\"#parser-panel\", panel)\n+ .into_response()\n+ }\n+ },\n }\n }\n \ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex 94a5cfb561d1c8464bd2782f7ffd4e0965ccf95d..9650d333d29c4ac94ceb407aee3ee00399c7f40b 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -11,8 +11,7 @@ use serde::Deserialize;\n \n use crate::{\n form_template::template_json_compact,\n- parser_action::ParserAction,\n- parser_render::parser_panel,\n+ parser_render::navigate_panel,\n ranking::{top_bottom, RankedItem},\n reducer::GroupState,\n state::{normalize_scope, AppState},\n@@ -336,10 +335,9 @@ pub async fn home(\n let empty = GroupState::new();\n let group = groups.get(&scope).unwrap_or(&empty);\n \n- let empty_action = ParserAction::suggest(String::new(), None);\n let body = html! {\n h1 { \"sorter2\" }\n- (parser_panel(\"\", &empty_action))\n+ (navigate_panel(\"\", None))\n (vote_panel(&scope))\n (ranking_panel(&scope, group))\n };\ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nindex fa423640d598f4ba97a5885d228e78d7b97f7a22..de8bca48cbf689cad22337883e7791966e7c4919 100644\n--- a/server/src/lib.rs\n+++ b/server/src/lib.rs\n@@ -4,7 +4,6 @@ pub mod events;\n pub mod form_template;\n pub mod html;\n pub mod parser;\n-pub mod parser_action;\n pub mod parser_render;\n pub mod path_types;\n pub mod ranking;\ndiff --git a/server/src/parser.rs b/server/src/parser.rs\nindex ea437c4434045b44cba04a2b3416df850db518e7..50571a59f0d3ece1e2538f88e00ef46ec40ea545 100644\n--- a/server/src/parser.rs\n+++ b/server/src/parser.rs\n@@ -1,1811 +1,87 @@\n-use std::collections::HashMap;\n-use std::sync::OnceLock;\n+//! Extract a subreddit name from a pasted Reddit URL or path.\n \n-use crate::parser_action::{GuideOption, ParserAction, ScrollingSuggestion, Suggestion};\n-\n-// --- Core Abstractions ---\n-\n-/// Unique identifier for nodes in the graph\n-type NodeId = &'static str;\n-\n-/// Pattern matching for edges\n-#[derive(Debug, Clone)]\n-pub enum EdgePattern {\n- /// Matches exact literal string\n- Literal(&'static str),\n- \n- /// Matches any prefix of a string and suggests the full string\n- /// e.g., PrefixOf(\"reddit.com\") matches \"r\", \"re\", \"red\", \"reddit\", \"reddit.com\"\n- PrefixOf(&'static str),\n- \n- /// Captures a variable segment (e.g., subreddit name, username)\n- Variable(&'static str),\n- \n- /// Matches any string (wildcard)\n- Any,\n-}\n-\n-impl EdgePattern {\n- /// Try to match this pattern against input, return (consumed_chars, captured_value)\n- fn matches(&self, input: &str) -> Option<(usize, Option)> {\n- match self {\n- EdgePattern::Literal(lit) => {\n- if input.starts_with(lit) {\n- Some((lit.len(), None))\n- } else {\n- None\n- }\n- }\n- EdgePattern::PrefixOf(target) => {\n- // Check if input is a prefix of target\n- if target.starts_with(input) && !input.is_empty() {\n- // It's a valid prefix\n- Some((input.len(), None))\n- } else if input.starts_with(target) {\n- // Full match\n- Some((target.len(), None))\n- } else {\n- None\n- }\n- }\n- EdgePattern::Variable(var_name) => {\n- // Consume until next '/' or end of string\n- let end = input.find('/').unwrap_or(input.len());\n- if end > 0 {\n- let captured = input[..end].to_string();\n- // Validate based on variable type\n- if is_valid_variable(var_name, &captured) {\n- Some((end, Some(captured)))\n- } else {\n- None\n- }\n- } else {\n- None\n- }\n- }\n- EdgePattern::Any => {\n- // Match everything until next '/' or end\n- let end = input.find('/').unwrap_or(input.len());\n- if end > 0 {\n- Some((end, Some(input[..end].to_string())))\n- } else {\n- None\n- }\n- }\n- }\n- }\n- \n- /// Get the completion suggestion for this pattern\n- fn completion(&self, partial: &str) -> Option {\n- match self {\n- EdgePattern::PrefixOf(target) => {\n- if target.starts_with(partial) && partial != *target {\n- Some(target.to_string())\n- } else {\n- None\n- }\n- }\n- _ => None,\n- }\n+pub fn parse_reddit_url(query: &str) -> Result {\n+ let q = query.trim();\n+ if q.is_empty() {\n+ return Err(\"Paste a Reddit URL or r/subreddit path\".into());\n }\n-}\n \n-/// Edge in the graph\n-pub struct Edge {\n- pattern: EdgePattern,\n- target: NodeId,\n- /// Optional description for autocomplete\n- description: Option<&'static str>,\n-}\n-\n-/// Handler function for generating UI actions (Send + Sync so the graph can live in `OnceLock`).\n-type Handler = Box) -> ParserAction + Send + Sync>;\n-\n-/// Node in the graph\n-pub struct Node {\n- #[allow(dead_code)]\n- id: NodeId,\n- edges: Vec,\n- handler: Option,\n-}\n-\n-/// The composable parser graph (immutable after `build`).\n-pub struct Graph {\n- nodes: HashMap,\n- root: NodeId,\n-}\n-\n-// --- Graph Builder (Fluent API) ---\n-\n-pub struct GraphBuilder {\n- nodes: HashMap,\n- current_node: Option,\n- root: NodeId,\n-}\n-\n-impl GraphBuilder {\n- pub fn new() -> Self {\n- let mut nodes = HashMap::new();\n- nodes.insert(\n- \"root\",\n- Node {\n- id: \"root\",\n- edges: Vec::new(),\n- handler: None,\n- },\n- );\n-\n- GraphBuilder {\n- nodes,\n- current_node: Some(\"root\"),\n- root: \"root\",\n- }\n+ if let Some(sub) = subreddit_after_prefix(q, \"r/\") {\n+ return Ok(sub);\n }\n \n- /// Select a node to add edges to\n- pub fn at(mut self, node_id: NodeId) -> Self {\n- self.nodes.entry(node_id).or_insert_with(|| Node {\n- id: node_id,\n- edges: Vec::new(),\n- handler: None,\n- });\n- self.current_node = Some(node_id);\n- self\n+ if let Some(sub) = subreddit_from_path_segment(q, \"/r/\") {\n+ return Ok(sub);\n }\n- \n- /// Add an edge from the current node\n- pub fn edge(self, pattern: EdgePattern, target: NodeId) -> Self {\n- self.edge_with_desc(pattern, target, None)\n- }\n- \n- /// Add an edge with description\n- pub fn edge_with_desc(\n- mut self,\n- pattern: EdgePattern,\n- target: NodeId,\n- desc: Option<&'static str>,\n- ) -> Self {\n- let current = self.current_node.expect(\"No current node selected\");\n-\n- self.nodes.entry(target).or_insert_with(|| Node {\n- id: target,\n- edges: Vec::new(),\n- handler: None,\n- });\n-\n- if let Some(node) = self.nodes.get_mut(current) {\n- node.edges.push(Edge {\n- pattern,\n- target,\n- description: desc,\n- });\n- }\n \n- self\n- }\n-\n- /// Set handler for current node\n- pub fn handler(mut self, handler: F) -> Self\n- where\n- F: Fn(&str, &str, &HashMap) -> ParserAction + Send + Sync + 'static,\n- {\n- let current = self.current_node.expect(\"No current node selected\");\n- if let Some(node) = self.nodes.get_mut(current) {\n- node.handler = Some(Box::new(handler));\n- }\n- self\n- }\n- \n- /// Build the final graph\n- pub fn build(self) -> Graph {\n- Graph {\n- nodes: self.nodes,\n- root: self.root,\n- }\n- }\n+ Err(\"Could not find a subreddit in that URL\".into())\n }\n \n-// --- Parser Implementation ---\n-\n-impl Graph {\n- pub fn parse(&self, input: &str) -> ParserAction {\n- let normalized = input.trim().to_lowercase();\n- let mut state = ParserState {\n- input: &normalized,\n- cursor: 0,\n- current_node_id: self.root,\n- context: HashMap::new(),\n- original_query: input.to_string(),\n- current_prefix: String::new(),\n- };\n- \n- self.parse_recursive(&mut state)\n- }\n- \n- fn parse_recursive(&self, state: &mut ParserState) -> ParserAction {\n- let node = self\n- .nodes\n- .get(state.current_node_id)\n- .expect(\"Node not found in graph\");\n-\n- // If we've consumed all input, check for handler or suggestions\n- if state.cursor >= state.input.len() {\n- if let Some(handler) = &node.handler {\n- return handler(&state.original_query, &state.current_prefix, &state.context);\n- }\n-\n- // No handler, try to suggest based on available edges\n- return self.suggest_from_edges(node, state);\n- }\n-\n- let remaining = &state.input[state.cursor..];\n-\n- // Try to match each edge\n- for edge in &node.edges {\n- if let Some((consumed, captured)) = edge.pattern.matches(remaining) {\n- // Save state for potential backtracking\n- let saved_cursor = state.cursor;\n- let saved_node = state.current_node_id;\n- let saved_prefix = state.current_prefix.clone();\n- \n- // Update state\n- state.cursor += consumed;\n- state.current_node_id = edge.target;\n- state.current_prefix.push_str(&remaining[..consumed]);\n- \n- // Store captured variable if any\n- if let Some(value) = captured {\n- if let EdgePattern::Variable(var_name) = &edge.pattern {\n- state.context.insert(var_name.to_string(), value);\n- }\n- }\n- \n- // Check if this is a partial match that needs completion\n- if state.cursor == state.input.len() {\n- if let Some(completion_suffix) = edge.pattern.completion(remaining) {\n- // Use the current_prefix plus the completion suffix\n- let full_completion = format!(\"{}{}\", \n- state.current_prefix, \n- completion_suffix.strip_prefix(remaining).unwrap_or(&completion_suffix)\n- );\n- return ParserAction::suggest(\n- state.original_query.clone(),\n- Some(Suggestion {\n- text: full_completion.clone(),\n- completion: full_completion,\n- description: edge.description.map(|d| d.to_string()),\n- score: 1.0,\n- })\n- );\n- }\n- }\n- \n- // Continue parsing from the target node\n- let result = self.parse_recursive(state);\n- \n- // If we got a valid response, return it\n- if !matches!(result, ParserAction::ShowError(_)) {\n- return result;\n- }\n- \n- // Otherwise, restore state and try next edge\n- state.cursor = saved_cursor;\n- state.current_node_id = saved_node;\n- state.current_prefix = saved_prefix;\n- }\n- }\n- \n- // No edges matched - try to provide suggestions\n- self.suggest_from_edges(node, state)\n- }\n- \n- fn suggest_from_edges(&self, node: &Node, state: &ParserState) -> ParserAction {\n- let remaining = &state.input[state.cursor..];\n- \n- // Find edges that could match with more input\n- for edge in &node.edges {\n- match &edge.pattern {\n- EdgePattern::PrefixOf(target) => {\n- if target.starts_with(remaining) && !remaining.is_empty() {\n- // Use current_prefix instead of rebuilding from input\n- let full_completion = format!(\"{}{}\", state.current_prefix, target);\n- return ParserAction::suggest(\n- state.original_query.clone(),\n- Some(Suggestion {\n- text: full_completion.clone(),\n- completion: full_completion,\n- description: edge.description.map(|d| d.to_string()),\n- score: 1.0,\n- })\n- );\n- }\n- }\n- EdgePattern::Literal(lit) => {\n- if lit.starts_with(remaining) && !remaining.is_empty() {\n- let full_completion = format!(\"{}{}\", state.current_prefix, lit);\n- return ParserAction::suggest(\n- state.original_query.clone(),\n- Some(Suggestion {\n- text: full_completion.clone(),\n- completion: full_completion,\n- description: edge.description.map(|d| d.to_string()),\n- score: 1.0,\n- })\n- );\n- }\n- }\n- _ => {}\n- }\n- }\n- \n- ParserAction::error(\n- \"InvalidPath\".to_string(),\n- format!(\"'{}' doesn't match any known pattern\", state.original_query)\n- )\n- }\n+fn subreddit_after_prefix(text: &str, prefix: &str) -> Option {\n+ let rest = text.strip_prefix(prefix)?;\n+ let sub = rest.split(['/', '?', '#']).next()?.trim();\n+ valid_subreddit(sub)\n }\n \n-struct ParserState<'a> {\n- input: &'a str,\n- cursor: usize,\n- current_node_id: NodeId,\n- context: HashMap,\n- original_query: String,\n- current_prefix: String,\n+fn subreddit_from_path_segment(text: &str, needle: &str) -> Option {\n+ let idx = text.find(needle)?;\n+ let rest = &text[idx + needle.len()..];\n+ let sub = rest.split(['/', '?', '#']).next()?.trim();\n+ valid_subreddit(sub)\n }\n \n-// --- Helper Functions ---\n-\n-fn is_valid_variable(var_name: &str, value: &str) -> bool {\n- match var_name {\n- \"subreddit\" => {\n- !value.is_empty() && \n- value.len() <= 21 && \n- value.chars().all(|c| c.is_alphanumeric() || c == '_')\n- }\n- \"username\" => {\n- !value.is_empty() && \n- value.len() <= 20 && \n- value.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-')\n- }\n- \"post_id\" => {\n- !value.is_empty() && \n- value.len() <= 10 && \n- value.chars().all(|c| c.is_alphanumeric())\n- }\n- _ => true, // Allow any value for unknown variables\n+fn valid_subreddit(name: &str) -> Option {\n+ if name.is_empty() {\n+ return None;\n+ }\n+ if name\n+ .chars()\n+ .all(|c| c.is_ascii_alphanumeric() || c == '_')\n+ {\n+ Some(name.to_ascii_lowercase())\n+ } else {\n+ None\n }\n-}\n-\n-// --- Define the Reddit Graph ---\n-\n-pub fn build_reddit_graph() -> Graph {\n- GraphBuilder::new()\n- // === ROOT LEVEL: Direct aliases and domain/protocol patterns ===\n- .at(\"root\")\n- // Direct aliases to subreddit and user selection\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"r/\"),\n- \"subreddit_selection\",\n- Some(\"Browse subreddits (e.g., r/programming)\")\n- )\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"u/\"),\n- \"user_selection\",\n- Some(\"Browse users (e.g., u/spez)\")\n- )\n- \n- // Reddit shortcuts - one pattern handles ALL prefixes!\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"reddit.com\"), \n- \"reddit_domain\",\n- Some(\"Go to Reddit\")\n- )\n- \n- // Protocol patterns - \"h\" can suggest \"https://\"\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"https://\"),\n- \"https_protocol\",\n- Some(\"HTTPS protocol\")\n- )\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"http://\"),\n- \"http_protocol\", \n- Some(\"HTTP protocol\")\n- )\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"www.\"),\n- \"www_prefix\",\n- Some(\"World Wide Web\")\n- )\n- \n-\n- \n- // === HTTPS PROTOCOL: Can go to any domain ===\n- .at(\"https_protocol\")\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"reddit.com\"),\n- \"reddit_domain\",\n- Some(\"Reddit (HTTPS)\")\n- )\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"www.\"),\n- \"https_www\",\n- Some(\"WWW prefix\")\n- )\n- \n- // === HTTP PROTOCOL: Similar to HTTPS ===\n- .at(\"http_protocol\")\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"reddit.com\"),\n- \"reddit_domain\",\n- Some(\"Reddit (HTTP)\")\n- )\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"www.\"),\n- \"http_www\",\n- Some(\"WWW prefix\")\n- )\n- \n- // === HTTPS + WWW ===\n- .at(\"https_www\")\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"reddit.com\"),\n- \"reddit_domain\",\n- Some(\"Reddit\")\n- )\n- \n- // === HTTP + WWW ===\n- .at(\"http_www\")\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"reddit.com\"),\n- \"reddit_domain\",\n- Some(\"Reddit\")\n- )\n- \n- // === WWW PREFIX (without protocol) ===\n- .at(\"www_prefix\")\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"reddit.com\"),\n- \"reddit_domain\",\n- Some(\"Reddit\")\n- )\n- \n- // === REDDIT DOMAIN: Expect \"/\" ===\n- .at(\"reddit_domain\")\n- .edge(EdgePattern::Literal(\"/\"), \"reddit_root\")\n- .handler(|query, prefix, _ctx| {\n- // If someone just types \"reddit.com\" (or with protocol) without slash\n- // Suggest adding the slash using the current prefix\n- let completion = format!(\"{}/\", prefix);\n- \n- ParserAction::suggest(\n- query.to_string(),\n- Some(Suggestion {\n- text: completion.clone(),\n- completion,\n- description: Some(\"Continue to Reddit homepage\".to_string()),\n- score: 1.0,\n- })\n- )\n- })\n- \n- // === REDDIT ROOT: The main Reddit navigation ===\n- .at(\"reddit_root\")\n- .edge(EdgePattern::Literal(\"r/\"), \"subreddit_selection\")\n- .edge(EdgePattern::Literal(\"u/\"), \"user_selection\")\n- .handler(|query, prefix, _ctx| {\n- ParserAction::multiple(vec![\n- ParserAction::scrolling_suggestions(\n- query.to_string(),\n- vec![\n- ScrollingSuggestion {\n- completion: format!(\"{}r/\", prefix),\n- },\n- ScrollingSuggestion {\n- completion: format!(\"{}u/\", prefix),\n- },\n- ],\n- 1400, // 1.4 second interval (slower)\n- true // loop through\n- ),\n- ParserAction::guide(\n- query.to_string(),\n- \"Welcome to Sorter for Reddit\".to_string(),\n- \"Where would you like to start?\".to_string(),\n- vec![\n- GuideOption { \n- key: \"r\".to_string(), \n- label: \"Sort a Subreddit\".to_string(), \n- description: \"Find the best posts in a community.\".to_string(), \n- completion: format!(\"{}r/\", prefix)\n- },\n- GuideOption { \n- key: \"u\".to_string(), \n- label: \"Sort User Content\".to_string(), \n- description: \"Explore and rank a user's posts and comments.\".to_string(), \n- completion: format!(\"{}u/\", prefix)\n- },\n- ]\n- ),\n- ])\n- })\n- \n-\n- \n- // === SUBREDDIT SELECTION: THE UNIFIED NODE ===\n- // This node is now reached from `r/` OR `reddit.com/r/`\n- .at(\"subreddit_selection\")\n- .edge(EdgePattern::Variable(\"subreddit\"), \"subreddit_page\")\n- .handler(|query, prefix, _ctx| {\n- ParserAction::multiple(vec![\n- ParserAction::scrolling_suggestions(\n- query.to_string(),\n- vec![\n- ScrollingSuggestion {\n- completion: format!(\"{}programming\", prefix),\n- },\n- ScrollingSuggestion {\n- completion: format!(\"{}askreddit\", prefix),\n- },\n- ScrollingSuggestion {\n- completion: format!(\"{}aww\", prefix),\n- },\n- ScrollingSuggestion {\n- completion: format!(\"{}rust\", prefix),\n- },\n- ScrollingSuggestion {\n- completion: format!(\"{}webdev\", prefix),\n- },\n- ],\n- 1600, // 1.6 second interval (slower)\n- true // loop through\n- ),\n- // Live DB-backed suggestions for subreddits as the user types\n- ParserAction::SuggestSubredditsFromDb { partial: query.to_string(), prefix: prefix.to_string() }\n- ])\n- })\n- \n- // === Specific subreddit page ===\n- .at(\"subreddit_page\")\n- .edge(EdgePattern::Literal(\"/\"), \"subreddit_slash\")\n- .handler(|_query, prefix, ctx| {\n- // Use the new unified subreddit resolution logic\n- let subreddit = ctx.get(\"subreddit\").cloned().unwrap_or_default();\n- ParserAction::ResolveAndDisplaySubreddit {\n- subreddit,\n- prefix: prefix.to_string(),\n- }\n- })\n- \n- .at(\"subreddit_slash\")\n- .edge(EdgePattern::Literal(\"hot\"), \"subreddit_hot\")\n- .edge(EdgePattern::Literal(\"top\"), \"subreddit_top\")\n- .edge(EdgePattern::Literal(\"new\"), \"subreddit_new\")\n- .edge(EdgePattern::Literal(\"comments\"), \"subreddit_comments\")\n- .handler(|query, prefix, ctx| {\n- let subreddit = ctx.get(\"subreddit\").cloned().unwrap_or_default();\n- ParserAction::guide(\n- query.to_string(), \n- format!(\"What to sort in r/{}?\", subreddit), \n- \"Choose a category to begin sorting.\".to_string(), \n- vec![\n- GuideOption { \n- key: \"hot\".to_string(), \n- label: \"Hot Posts\".to_string(), \n- description: \"Import and sort posts currently on the front page.\".to_string(), \n- completion: format!(\"{}hot\", prefix) \n- },\n- GuideOption { \n- key: \"top\".to_string(), \n- label: \"Top Posts\".to_string(), \n- description: \"Import and sort the highest-rated posts.\".to_string(), \n- completion: format!(\"{}top\", prefix) \n- },\n- GuideOption { \n- key: \"new\".to_string(), \n- label: \"New Posts\".to_string(), \n- description: \"Import and sort the newest posts.\".to_string(), \n- completion: format!(\"{}new\", prefix) \n- },\n- GuideOption { \n- key: \"comments\".to_string(), \n- label: \"All Comments\".to_string(), \n- description: \"Find the best comment across all imported threads.\".to_string(), \n- completion: format!(\"{}comments/\", prefix) \n- },\n- ]\n- )\n- })\n- \n-\n- \n- .at(\"user_selection\")\n- .edge(EdgePattern::Variable(\"username\"), \"user_profile\")\n- .handler(|query, prefix, _ctx| {\n- ParserAction::guide(\n- query.to_string(),\n- \"User Profile Sorting\".to_string(),\n- \"Enter a Reddit username to sort their content.\".to_string(),\n- vec![\n- GuideOption {\n- key: \"popular\".to_string(),\n- label: \"Popular Users\".to_string(),\n- description: \"Browse well-known Reddit users.\".to_string(),\n- completion: prefix.to_string(),\n- },\n- ]\n- )\n- })\n- \n- // === Subreddit sort types ===\n- .at(\"subreddit_hot\")\n- .handler(|_query, _prefix, ctx| {\n- let subreddit = ctx.get(\"subreddit\").cloned().unwrap_or_default();\n- ParserAction::RenderEntityView {\n- ns: \"reddit.subreddit\".to_string(),\n- pk: subreddit,\n- }\n- })\n- \n- .at(\"subreddit_top\")\n- .handler(|_query, _prefix, ctx| {\n- let subreddit = ctx.get(\"subreddit\").cloned().unwrap_or_default();\n- ParserAction::RenderEntityView {\n- ns: \"reddit.subreddit\".to_string(),\n- pk: subreddit,\n- }\n- })\n- \n- .at(\"subreddit_new\")\n- .handler(|_query, _prefix, ctx| {\n- let subreddit = ctx.get(\"subreddit\").cloned().unwrap_or_default();\n- ParserAction::RenderEntityView {\n- ns: \"reddit.subreddit\".to_string(),\n- pk: subreddit,\n- }\n- })\n- \n- .at(\"subreddit_comments\")\n- .handler(|_query, _prefix, ctx| {\n- let subreddit = ctx.get(\"subreddit\").cloned().unwrap_or_default();\n- ParserAction::RenderEntityView {\n- ns: \"reddit.subreddit\".to_string(),\n- pk: subreddit,\n- }\n- })\n- \n- // === User profile ===\n- .at(\"user_profile\")\n- .handler(|_query, _prefix, ctx| {\n- let username = ctx.get(\"username\").cloned().unwrap_or_default();\n- ParserAction::RenderEntityView {\n- ns: \"reddit.user\".to_string(),\n- pk: username,\n- }\n- })\n- \n- // === Build the graph ===\n- .build()\n-}\n-\n-// --- Public API ---\n-\n-static REDDIT_GRAPH: OnceLock = OnceLock::new();\n-\n-fn reddit_graph() -> &'static Graph {\n- REDDIT_GRAPH.get_or_init(build_reddit_graph)\n-}\n-\n-/// Parse a query string and return a UI action\n-pub fn parse_reddit_url(query: &str) -> ParserAction {\n- reddit_graph().parse(query)\n }\n \n #[cfg(test)]\n mod tests {\n use super::*;\n \n- /// Represents a keystroke action\n- #[derive(Debug, Clone, PartialEq)]\n- enum KeyAction {\n- Type(String), // Type characters\n- Tab, // Press tab (accept completion)\n-\n- }\n-\n- /// Expected state after a keystroke\n- #[derive(Debug, Clone)]\n- enum ExpectedAction {\n- Suggestion { completion: String },\n- ScrollingSuggestions { completions: Vec },\n- Guide { title_contains: String },\n-\n- RenderSubreddit { subreddit: String, sort: Option },\n- RenderSubredditComments { subreddit: String },\n- RenderUser { username: String },\n- ResolveSubreddit { subreddit: String, prefix: String }, // New unified subreddit resolution\n- Error { error_type: String },\n- Multiple { expected_actions: Vec }, // Multiple responses with specific expectations\n- MultipleAny, // Multiple responses (any sub-actions - legacy)\n- DbSuggestions { partial: String, prefix: String }, // Database-backed suggestions\n-\n- }\n-\n- impl ExpectedAction {\n- fn matches(&self, action: &ParserAction) -> bool {\n- match (self, action) {\n- (ExpectedAction::Suggestion { completion }, ParserAction::ShowSuggestions(data)) => {\n- data.suggestion.as_ref()\n- .map(|s| s.completion == *completion)\n- .unwrap_or(false)\n- }\n- (ExpectedAction::ScrollingSuggestions { completions }, ParserAction::ShowScrollingSuggestions { suggestions, .. }) => {\n- let actual_completions: Vec = suggestions.iter().map(|s| s.completion.clone()).collect();\n- *completions == actual_completions\n- }\n- (ExpectedAction::Guide { title_contains }, ParserAction::ShowStaticGuide { title, .. }) => {\n- title.contains(title_contains)\n- }\n- (ExpectedAction::RenderSubreddit { subreddit, sort: _ }, \n- ParserAction::RenderEntityView { ns, pk }) => {\n- ns == \"reddit.subreddit\" && pk == subreddit\n- }\n- (ExpectedAction::RenderSubredditComments { subreddit }, \n- ParserAction::RenderEntityView { ns, pk }) => {\n- ns == \"reddit.subreddit\" && pk == subreddit\n- }\n- (ExpectedAction::RenderUser { username }, ParserAction::RenderEntityView { ns, pk }) => {\n- ns == \"reddit.user\" && pk == username\n- }\n- (ExpectedAction::ResolveSubreddit { subreddit, prefix }, \n- ParserAction::ResolveAndDisplaySubreddit { subreddit: s, prefix: p }) => {\n- s == subreddit && p == prefix\n- }\n- (ExpectedAction::Error { error_type }, ParserAction::ShowError(data)) => {\n- data.error_type == *error_type\n- }\n- (ExpectedAction::Multiple { expected_actions }, ParserAction::ShowMultiple { actions }) => {\n- // Check that all expected actions are present\n- if expected_actions.len() != actions.len() {\n- return false;\n- }\n- expected_actions.iter().zip(actions.iter()).all(|(expected, actual)| {\n- expected.matches(actual)\n- })\n- }\n- (ExpectedAction::MultipleAny, ParserAction::ShowMultiple { .. }) => true,\n- (ExpectedAction::DbSuggestions { partial, prefix }, \n- ParserAction::SuggestSubredditsFromDb { partial: p, prefix: pr }) => {\n- p == partial && pr == prefix\n- }\n-\n- _ => false,\n- }\n- }\n- }\n-\n- /// Test helper to simulate a sequence of keystrokes\n- fn simulate_keystrokes(actions: Vec) -> Vec<(String, ParserAction)> {\n- let graph = build_reddit_graph();\n- let mut current_text = String::new();\n- let mut results = Vec::new();\n- \n- for action in actions {\n- match action {\n- KeyAction::Type(text) => {\n- current_text.push_str(&text);\n- let result = graph.parse(¤t_text);\n- results.push((current_text.clone(), result));\n- }\n- KeyAction::Tab => {\n- // Tab accepts the current suggestion if there is one\n- let result = graph.parse(¤t_text);\n- if let ParserAction::ShowSuggestions(ref data) = result {\n- if let Some(ref suggestion) = data.suggestion {\n- current_text = suggestion.completion.clone();\n- let new_result = graph.parse(¤t_text);\n- results.push((current_text.clone(), new_result));\n- }\n- }\n- }\n-\n- }\n- }\n- \n- results\n- }\n-\n- /// Test a flow using declarative (KeyAction, ExpectedAction) tuples\n- fn test_flow(name: &str, flow: Vec<(KeyAction, ExpectedAction)>) {\n- let graph = build_reddit_graph();\n- let mut current_text = String::new();\n- \n- println!(\"\\n=== Flow: {} ===\", name);\n- \n- for (i, (key_action, expected)) in flow.iter().enumerate() {\n- // Perform the keystroke\n- match key_action {\n- KeyAction::Type(text) => {\n- current_text.push_str(text);\n- }\n- KeyAction::Tab => {\n- // Tab accepts the current suggestion\n- let result = graph.parse(¤t_text);\n- if let ParserAction::ShowSuggestions(data) = result {\n- if let Some(suggestion) = &data.suggestion {\n- current_text = suggestion.completion.clone();\n- }\n- }\n- }\n- }\n- \n- // Check the result\n- let actual_action = graph.parse(¤t_text);\n- \n- println!(\" Step {}: {:?} -> '{}' -> {:?}\", \n- i + 1, key_action, current_text, actual_action);\n- \n- assert!(\n- expected.matches(&actual_action),\n- \"Flow '{}' failed at step {}\\n Expected: {:?}\\n Actual: {:?}\\n Text: '{}'\",\n- name, i + 1, expected, actual_action, current_text\n- );\n- }\n- \n- println!(\"✓ Flow '{}' passed!\", name);\n- }\n- \n- /// Legacy helper for backward compatibility (will be removed)\n- fn assert_flow(\n- name: &str,\n- actions: Vec,\n- expected_checks: Vec bool>>,\n- ) {\n- let results = simulate_keystrokes(actions);\n- \n- println!(\"\\n=== Flow: {} ===\", name);\n- for (i, (text, action)) in results.iter().enumerate() {\n- println!(\" Step {}: '{}' -> {:?}\", i + 1, text, action);\n- \n- if i < expected_checks.len() {\n- let check = &expected_checks[i];\n- assert!(\n- check(text, action),\n- \"Flow '{}' failed at step {} with text '{}' and action {:?}\",\n- name, i + 1, text, action\n- );\n- }\n- }\n- println!(\"✓ Flow '{}' passed!\", name);\n- }\n-\n- #[test]\n- fn test_https_reddit_tab_flow() {\n- // Test: typing \"https://reddit.com\" and pressing tab should give \"https://reddit.com/\"\n- assert_flow(\n- \"HTTPS Reddit with Tab\",\n- vec![\n- KeyAction::Type(\"https://reddit.com\".to_string()),\n- KeyAction::Tab,\n- ],\n- vec![\n- Box::new(|text, action| {\n- // After typing \"https://reddit.com\", should get a suggestion\n- text == \"https://reddit.com\" && matches!(action, ParserAction::ShowSuggestions(data) if \n- data.suggestion.as_ref().map(|s| s.completion == \"https://reddit.com/\").unwrap_or(false)\n- )\n- }),\n- Box::new(|text, action| {\n- // After tab, should have \"https://reddit.com/\" and show guide\n- text == \"https://reddit.com/\" && matches!(action, ParserAction::ShowMultiple { .. })\n- }),\n- ],\n- );\n- }\n-\n- #[test]\n- fn test_quick_subreddit_flow() {\n- // Test: \"r\" -> TAB -> \"rust\" (direct alias flow)\n- assert_flow(\n- \"Quick Subreddit Access\",\n- vec![\n- KeyAction::Type(\"r\".to_string()),\n- KeyAction::Tab,\n- KeyAction::Type(\"rust\".to_string()),\n- ],\n- vec![\n- Box::new(|text, action| {\n- // \"r\" should suggest \"r/\"\n- text == \"r\" && matches!(action, ParserAction::ShowSuggestions(data) if \n- data.suggestion.as_ref().map(|s| s.completion == \"r/\").unwrap_or(false)\n- )\n- }),\n- Box::new(|text, action| {\n- // After tab, should have \"r/\" and show subreddit selection\n- text == \"r/\" && matches!(action, ParserAction::ShowMultiple { .. })\n- }),\n- Box::new(|text, action| {\n- // \"r/rust\" should resolve the subreddit\n- text == \"r/rust\" && matches!(action, ParserAction::ResolveAndDisplaySubreddit { subreddit, prefix } if subreddit == \"rust\" && prefix == \"r/rust\")\n- }),\n- ],\n- );\n- }\n-\n- #[test]\n- fn test_progressive_completion_flow() {\n- // Test progressive typing: \"r\" -> \"re\" -> \"red\" -> \"redd\" -> \"reddit\" -> TAB\n- let progressive_actions = vec![\n- KeyAction::Type(\"r\".to_string()),\n- KeyAction::Type(\"e\".to_string()),\n- KeyAction::Type(\"d\".to_string()),\n- KeyAction::Type(\"d\".to_string()),\n- KeyAction::Type(\"i\".to_string()),\n- KeyAction::Type(\"t\".to_string()),\n- KeyAction::Tab,\n- ];\n- \n- let results = simulate_keystrokes(progressive_actions);\n- \n- println!(\"\\n=== Progressive Completion Flow ===\");\n- for (i, (text, action)) in results.iter().enumerate() {\n- println!(\" '{}' -> {:?}\", text, action);\n- \n- // First step: \"r\" should suggest \"r/\"\n- if i == 0 && text == \"r\" {\n- match action {\n- ParserAction::ShowSuggestions(data) => {\n- assert_eq!(\n- data.suggestion.as_ref().unwrap().completion,\n- \"r/\",\n- \"Should suggest r/ at 'r'\"\n- );\n- }\n- _ => panic!(\"Expected suggestion at 'r'\"),\n- }\n- }\n- // Other steps before tab should suggest \"reddit.com\"\n- else if i > 0 && i < results.len() - 1 {\n- match action {\n- ParserAction::ShowSuggestions(data) => {\n- assert_eq!(\n- data.suggestion.as_ref().unwrap().completion,\n- \"reddit.com\",\n- \"Should suggest reddit.com at '{}'\", text\n- );\n- }\n- _ => panic!(\"Expected suggestion at '{}'\", text),\n- }\n- }\n- }\n- \n- // After tab, should have \"reddit.com\"\n- let (final_text, _) = results.last().unwrap();\n- assert_eq!(final_text, \"reddit.com\");\n- println!(\"✓ Progressive completion flow passed!\");\n- }\n-\n #[test]\n- fn test_subreddit_sort_flow() {\n- // Test navigating to a subreddit and choosing a sort option\n- assert_flow(\n- \"Subreddit Sort Navigation\",\n- vec![\n- KeyAction::Type(\"reddit.com/r/programming/hot\".to_string()),\n- ],\n- vec![\n- Box::new(|text, action| {\n- text == \"reddit.com/r/programming/hot\" && \n- matches!(action, ParserAction::RenderEntityView { ns, pk } \n- if ns == \"reddit.subreddit\" && pk == \"programming\")\n- }),\n- ],\n- );\n+ fn parses_short_path() {\n+ assert_eq!(parse_reddit_url(\"r/rust\").unwrap(), \"rust\");\n }\n \n #[test]\n- fn test_user_profile_flow() {\n- // Test navigating to a user profile\n- assert_flow(\n- \"User Profile Navigation\",\n- vec![\n- KeyAction::Type(\"reddit.com/u/spez\".to_string()),\n- ],\n- vec![\n- Box::new(|text, action| {\n- text == \"reddit.com/u/spez\" && \n- matches!(action, ParserAction::RenderEntityView { ns, pk } if ns == \"reddit.user\" && pk == \"spez\")\n- }),\n- ],\n- );\n+ fn parses_path_with_trailing_slash() {\n+ assert_eq!(parse_reddit_url(\"r/rust/\").unwrap(), \"rust\");\n }\n \n #[test]\n- fn test_alias_shortcut_flow() {\n- // Test using the \"r/\" shortcut - now correctly goes directly to subreddit\n- assert_flow(\n- \"Alias Shortcut\",\n- vec![\n- KeyAction::Type(\"r/\".to_string()),\n- KeyAction::Type(\"technology\".to_string()),\n- ],\n- vec![\n- Box::new(|text, action| {\n- text == \"r/\" && matches!(action, ParserAction::ShowMultiple { .. })\n- }),\n- Box::new(|text, action| {\n- text == \"r/technology\" && \n- matches!(action, ParserAction::ResolveAndDisplaySubreddit { subreddit, prefix } if subreddit == \"technology\" && prefix == \"r/technology\")\n- }),\n- ],\n+ fn parses_full_url() {\n+ assert_eq!(\n+ parse_reddit_url(\"https://www.reddit.com/r/programming/hot\").unwrap(),\n+ \"programming\"\n );\n }\n \n #[test]\n- fn test_www_prefix_flow() {\n- // Test with www prefix\n- assert_flow(\n- \"WWW Prefix\",\n- vec![\n- KeyAction::Type(\"www.reddit.com\".to_string()),\n- KeyAction::Tab,\n- ],\n- vec![\n- Box::new(|text, action| {\n- // Should suggest adding slash\n- text == \"www.reddit.com\" && matches!(action, ParserAction::ShowSuggestions(data) if \n- data.suggestion.as_ref().map(|s| s.completion == \"www.reddit.com/\").unwrap_or(false)\n- )\n- }),\n- Box::new(|text, action| {\n- // After tab, should show guide\n- text == \"www.reddit.com/\" && matches!(action, ParserAction::ShowMultiple { .. })\n- }),\n- ],\n+ fn parses_url_without_scheme() {\n+ assert_eq!(\n+ parse_reddit_url(\"reddit.com/r/AskReddit\").unwrap(),\n+ \"askreddit\"\n );\n }\n \n #[test]\n- fn test_invalid_path_handling() {\n- // Test that invalid paths show errors\n- assert_flow(\n- \"Invalid Path\",\n- vec![\n- KeyAction::Type(\"reddit.com/invalid/path\".to_string()),\n- ],\n- vec![\n- Box::new(|text, action| {\n- text == \"reddit.com/invalid/path\" && \n- matches!(action, ParserAction::ShowError(_))\n- }),\n- ],\n- );\n- }\n-\n- #[test]\n- fn test_complete_user_journey() {\n- // Test a complete user journey: type partial URL, tab complete, navigate to subreddit\n- assert_flow(\n- \"Complete User Journey\",\n- vec![\n- KeyAction::Type(\"http\".to_string()),\n- KeyAction::Type(\"s://r\".to_string()),\n- KeyAction::Tab,\n- KeyAction::Type(\"/\".to_string()),\n- KeyAction::Type(\"r/\".to_string()),\n- KeyAction::Type(\"programming\".to_string()),\n- KeyAction::Type(\"/\".to_string()),\n- KeyAction::Type(\"top\".to_string()),\n- ],\n- vec![\n- Box::new(|text, action| {\n- // \"http\" should suggest \"https://\"\n- text == \"http\" && matches!(action, ParserAction::ShowSuggestions(data) if \n- data.suggestion.as_ref().map(|s| s.completion == \"https://\").unwrap_or(false)\n- )\n- }),\n- Box::new(|text, action| {\n- // \"https://r\" should suggest \"https://reddit.com\"\n- text == \"https://r\" && matches!(action, ParserAction::ShowSuggestions(data) if \n- data.suggestion.as_ref().map(|s| s.completion == \"https://reddit.com\").unwrap_or(false)\n- )\n- }),\n- Box::new(|text, action| {\n- // After tab, should have \"https://reddit.com\"\n- text == \"https://reddit.com\" && matches!(action, ParserAction::ShowSuggestions(_))\n- }),\n- Box::new(|text, action| {\n- // \"https://reddit.com/\" should show guide\n- text == \"https://reddit.com/\" && matches!(action, ParserAction::ShowMultiple { .. })\n- }),\n- Box::new(|text, action| {\n- // \"https://reddit.com/r/\" should show subreddit selection\n- text == \"https://reddit.com/r/\" && matches!(action, ParserAction::ShowMultiple { .. })\n- }),\n- Box::new(|text, action| {\n- // \"https://reddit.com/r/programming\" should resolve subreddit\n- text == \"https://reddit.com/r/programming\" && \n- matches!(action, ParserAction::ResolveAndDisplaySubreddit { subreddit, prefix } \n- if subreddit == \"programming\" && prefix == \"https://reddit.com/r/programming\")\n- }),\n- Box::new(|text, action| {\n- // \"https://reddit.com/r/programming/\" should show sort options\n- text == \"https://reddit.com/r/programming/\" && \n- matches!(action, ParserAction::ShowStaticGuide { .. })\n- }),\n- Box::new(|text, action| {\n- // \"https://reddit.com/r/programming/top\" should render entity view\n- text == \"https://reddit.com/r/programming/top\" && \n- matches!(action, ParserAction::RenderEntityView { ns, pk } \n- if ns == \"reddit.subreddit\" && pk == \"programming\")\n- }),\n- ],\n- );\n- }\n-\n- #[test]\n- fn test_multiple_tab_completions() {\n- // Test multiple tab completions in sequence\n- let actions = vec![\n- KeyAction::Type(\"h\".to_string()),\n- KeyAction::Tab, // Complete to \"https://\"\n- KeyAction::Type(\"r\".to_string()),\n- KeyAction::Tab, // Complete to \"https://reddit.com\"\n- KeyAction::Type(\"/\".to_string()),\n- ];\n- \n- let results = simulate_keystrokes(actions);\n- \n- println!(\"\\n=== Multiple Tab Completions ===\");\n- for (i, (text, _action)) in results.iter().enumerate() {\n- println!(\" Step {}: '{}'\", i + 1, text);\n- }\n- \n- // Verify the final state\n- assert_eq!(results[1].0, \"https://\"); // After first tab\n- assert_eq!(results[3].0, \"https://reddit.com\"); // After second tab\n- assert_eq!(results[4].0, \"https://reddit.com/\"); // After typing /\n- \n- println!(\"✓ Multiple tab completions work correctly!\");\n- }\n-\n- // === CONVENIENCE MACROS FOR CLEANER TESTS ===\n- \n- macro_rules! flow {\n- ($(($key:expr, $expected:expr)),* $(,)?) => {\n- vec![$(($key, $expected)),*]\n- };\n- }\n- \n- macro_rules! type_text {\n- ($text:expr) => {\n- KeyAction::Type($text.to_string())\n- };\n- }\n- \n- macro_rules! suggests {\n- ($completion:expr) => {\n- ExpectedAction::Suggestion { completion: $completion.to_string() }\n- };\n- }\n- \n- macro_rules! renders_subreddit {\n- ($subreddit:expr) => {\n- ExpectedAction::RenderSubreddit { subreddit: $subreddit.to_string(), sort: None }\n- };\n- ($subreddit:expr, $sort:expr) => {\n- ExpectedAction::RenderSubreddit { \n- subreddit: $subreddit.to_string(), \n- sort: Some($sort.to_string()) \n- }\n- };\n- }\n- \n- macro_rules! renders_subreddit_comments {\n- ($subreddit:expr) => {\n- ExpectedAction::RenderSubredditComments { subreddit: $subreddit.to_string() }\n- };\n- }\n- \n- macro_rules! resolves_subreddit {\n- ($subreddit:expr, $prefix:expr) => {\n- ExpectedAction::ResolveSubreddit { \n- subreddit: $subreddit.to_string(),\n- prefix: $prefix.to_string()\n- }\n- };\n- }\n- \n- macro_rules! shows_guide {\n- ($title_contains:expr) => {\n- ExpectedAction::Guide { title_contains: $title_contains.to_string() }\n- };\n- }\n- \n- macro_rules! multiple {\n- ($($action:expr),* $(,)?) => {\n- ExpectedAction::Multiple { expected_actions: vec![$($action),*] }\n- };\n- }\n- \n- macro_rules! scrolling_suggestions {\n- ($($completion:expr),* $(,)?) => {\n- ExpectedAction::ScrollingSuggestions { completions: vec![$($completion.to_string()),*] }\n- };\n- }\n- \n- macro_rules! db_suggestions {\n- ($partial:expr, $prefix:expr) => {\n- ExpectedAction::DbSuggestions { partial: $partial.to_string(), prefix: $prefix.to_string() }\n- };\n- }\n-\n- // === NEW DECLARATIVE TESTS ===\n-\n- #[test]\n- fn test_declarative_https_tab_flow() {\n- test_flow(\"HTTPS Tab Completion\", vec![\n- (KeyAction::Type(\"https://reddit.com\".to_string()), \n- ExpectedAction::Suggestion { completion: \"https://reddit.com/\".to_string() }),\n- (KeyAction::Tab, \n- multiple![\n- scrolling_suggestions!(\"https://reddit.com/r/\", \"https://reddit.com/u/\"),\n- shows_guide!(\"Welcome to Sorter\")\n- ]),\n- ]);\n- }\n-\n- #[test]\n- fn test_declarative_quick_subreddit_flow() {\n- test_flow(\"Quick Subreddit Flow\", vec![\n- (KeyAction::Type(\"r\".to_string()), \n- ExpectedAction::Suggestion { completion: \"r/\".to_string() }),\n- (KeyAction::Tab, \n- ExpectedAction::MultipleAny), // r/ shows subreddit selection\n- (KeyAction::Type(\"rust\".to_string()),\n- ExpectedAction::ResolveSubreddit { subreddit: \"rust\".to_string(), prefix: \"r/rust\".to_string() }),\n- ]);\n- }\n-\n- #[test]\n- fn test_declarative_complete_journey() {\n- test_flow(\"Complete User Journey\", vec![\n- (KeyAction::Type(\"http\".to_string()),\n- ExpectedAction::Suggestion { completion: \"https://\".to_string() }),\n- (KeyAction::Type(\"s://r\".to_string()),\n- ExpectedAction::Suggestion { completion: \"https://reddit.com\".to_string() }),\n- (KeyAction::Tab,\n- ExpectedAction::Suggestion { completion: \"https://reddit.com/\".to_string() }),\n- (KeyAction::Type(\"/r/programming/top\".to_string()),\n- ExpectedAction::RenderSubreddit { \n- subreddit: \"programming\".to_string(), \n- sort: Some(\"top\".to_string()) \n- }),\n- ]);\n- }\n-\n- #[test]\n- fn test_declarative_user_profile() {\n- test_flow(\"User Profile Navigation\", vec![\n- (KeyAction::Type(\"reddit.com/u/spez\".to_string()),\n- ExpectedAction::RenderUser { username: \"spez\".to_string() }),\n- ]);\n- }\n-\n- #[test]\n- fn test_declarative_error_handling() {\n- test_flow(\"Error Handling\", vec![\n- (KeyAction::Type(\"reddit.com/invalid/path\".to_string()),\n- ExpectedAction::Error { error_type: \"InvalidPath\".to_string() }),\n- ]);\n- }\n-\n- #[test]\n- fn test_declarative_progressive_completion() {\n- test_flow(\"Progressive Completion\", vec![\n- (KeyAction::Type(\"r\".to_string()),\n- ExpectedAction::Suggestion { completion: \"r/\".to_string() }),\n- (KeyAction::Type(\"e\".to_string()),\n- ExpectedAction::Suggestion { completion: \"reddit.com\".to_string() }),\n- (KeyAction::Type(\"d\".to_string()),\n- ExpectedAction::Suggestion { completion: \"reddit.com\".to_string() }),\n- (KeyAction::Type(\"dit\".to_string()),\n- ExpectedAction::Suggestion { completion: \"reddit.com\".to_string() }),\n- (KeyAction::Tab,\n- ExpectedAction::Suggestion { completion: \"reddit.com/\".to_string() }),\n- ]);\n- }\n-\n- #[test]\n- fn test_declarative_subreddit_guide() {\n- test_flow(\"Subreddit Guide\", vec![\n- (KeyAction::Type(\"reddit.com/r/programming/\".to_string()),\n- ExpectedAction::Guide { title_contains: \"What to sort\".to_string() }),\n- ]);\n- }\n-\n- #[test]\n- fn test_clean_macro_example() {\n- // This is what the tests can look like with macros!\n- test_flow(\"Clean Macro Example\", flow![\n- (type_text!(\"r\"), suggests!(\"r/\")),\n- (KeyAction::Tab, ExpectedAction::MultipleAny),\n- (type_text!(\"rust/hot\"), renders_subreddit!(\"rust\", \"hot\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_unified_subreddit_resolution_flow() {\n- // This test demonstrates the new unified behavior:\n- // - r/programming (exact) → tries exact match first\n- // - r/pro (partial) → tries exact match, then suggestions + TAB completion\n- \n- test_flow(\"Unified Resolution: Exact Match\", flow![\n- (type_text!(\"r/programming\"), resolves_subreddit!(\"programming\", \"r/programming\")),\n- ]);\n- \n- test_flow(\"Unified Resolution: Partial Match\", flow![\n- (type_text!(\"r/pro\"), resolves_subreddit!(\"pro\", \"r/pro\")),\n- ]);\n- \n- // Both go through the same action type, but dispatcher handles them differently:\n- // - If \"programming\" exists in DB → shows EntityView immediately\n- // - If \"pro\" doesn't exist in DB → shows Multiple with:\n- // 1. Suggestions (for TAB completion to best match) \n- // 2. Selection (for clickable options including import)\n- }\n-\n- #[test]\n- fn test_tab_completion_workflow_unified() {\n- // This demonstrates the desired TAB completion behavior:\n- // r/pro + TAB → r/programming (if \"programming\" is the best DB match)\n- \n- // Note: This test shows the PARSER behavior. The actual TAB completion \n- // happens in the frontend when it receives the Multiple response containing\n- // both Suggestions (for TAB) and Selection (for click options).\n- \n- let graph = build_reddit_graph();\n- \n- // 1. Parser generates unified action for partial input\n- match graph.parse(\"r/pro\") {\n- ParserAction::ResolveAndDisplaySubreddit { subreddit, prefix } => {\n- assert_eq!(subreddit, \"pro\");\n- assert_eq!(prefix, \"r/pro\");\n- println!(\"✓ Parser correctly identifies 'r/pro' as subreddit resolution\");\n- }\n- _ => panic!(\"Expected ResolveAndDisplaySubreddit for 'r/pro'\"),\n- }\n- \n- // 2. When dispatcher runs (in real app), it will return Multiple response with:\n- // - Suggestions: { completion: \"r/programming\" } (for TAB)\n- // - Selection: [ \"Import r/pro\", \"r/programming\", ... ] (for clicks)\n- \n- println!(\"✓ TAB completion workflow: r/pro → ResolveAndDisplaySubreddit → Multiple(Suggestions + Selection)\");\n- }\n-\n- #[test]\n- fn test_ultra_clean_user_journey() {\n- test_flow(\"Ultra Clean User Journey\", flow![\n- (type_text!(\"h\"), suggests!(\"https://\")),\n- (type_text!(\"ttps://r\"), suggests!(\"https://reddit.com\")), \n- (KeyAction::Tab, suggests!(\"https://reddit.com/\")),\n- (type_text!(\"/u/spez\"), ExpectedAction::RenderUser { username: \"spez\".to_string() }),\n- ]);\n- }\n-\n- // === TESTS FROM parser.tdsl ===\n-\n- #[test]\n- fn test_tdsl_basic_reddit_progression() {\n- // Tests from parser.tdsl: r -> r/, then re -> reddit.com with all intermediate steps\n- test_flow(\"TDSL Basic Reddit Progression\", flow![\n- (type_text!(\"r\"), suggests!(\"r/\")),\n- (type_text!(\"e\"), suggests!(\"reddit.com\")),\n- (type_text!(\"d\"), suggests!(\"reddit.com\")),\n- (type_text!(\"d\"), suggests!(\"reddit.com\")),\n- (type_text!(\"i\"), suggests!(\"reddit.com\")),\n- (type_text!(\"t\"), suggests!(\"reddit.com\")),\n- (type_text!(\".\"), suggests!(\"reddit.com\")),\n- (type_text!(\"c\"), suggests!(\"reddit.com\")),\n- (type_text!(\"o\"), suggests!(\"reddit.com\")),\n- (KeyAction::Tab, suggests!(\"reddit.com/\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_reddit_com_slash_infographic() {\n- // reddit.com/ -> {show infographic explaining that u (sort user posts) and r (sort subreddit posts)}\n- test_flow(\"TDSL Reddit.com/ Infographic\", flow![\n- (type_text!(\"reddit.com/\"), ExpectedAction::MultipleAny),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_subreddit_selection() {\n- // reddit.com/r/ -> reddit.com/r/{randomly chose sub from list}\n- test_flow(\"TDSL Subreddit Selection\", flow![\n- (type_text!(\"reddit.com/r/\"), ExpectedAction::MultipleAny),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_subreddit_view() {\n- // reddit.com/r/{sub} -> {resolve subreddit (exact match or suggestions)}\n- test_flow(\"TDSL Subreddit View\", flow![\n- (type_text!(\"reddit.com/r/programming\"), resolves_subreddit!(\"programming\", \"reddit.com/r/programming\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_subreddit_slash_infographic() {\n- // reddit.com/r/{sub}/ -> {show infographic or something}\n- test_flow(\"TDSL Subreddit Slash Infographic\", flow![\n- (type_text!(\"reddit.com/r/programming/\"), shows_guide!(\"What to sort\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_subreddit_comments() {\n- // reddit.com/r/{sub}/comments/{randomly chose comment from sql}\n- test_flow(\"TDSL Subreddit Comments\", flow![\n- (type_text!(\"reddit.com/r/programming/comments\"), renders_subreddit_comments!(\"programming\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_h_to_https() {\n- // h->https:// (show supported domains)\n- test_flow(\"TDSL H to HTTPS\", flow![\n- (type_text!(\"h\"), suggests!(\"https://\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_composable_https_reddit() {\n- // https://r->https://reddit.com/\n- test_flow(\"TDSL Composable HTTPS Reddit\", flow![\n- (type_text!(\"https://r\"), suggests!(\"https://reddit.com\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_composable_https_www() {\n- // https://w->https://www.\n- test_flow(\"TDSL Composable HTTPS WWW\", flow![\n- (type_text!(\"https://w\"), suggests!(\"https://www.\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_composable_https_www_reddit() {\n- // https://www.r->https://www.reddit.com/\n- test_flow(\"TDSL Composable HTTPS WWW Reddit\", flow![\n- (type_text!(\"https://www.r\"), suggests!(\"https://www.reddit.com\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_full_composable_chain() {\n- // Complete chain showing composability: h -> https:// -> https://www.reddit.com\n- test_flow(\"TDSL Full Composable Chain Step 1\", flow![\n- (type_text!(\"h\"), suggests!(\"https://\")),\n- ]);\n- \n- test_flow(\"TDSL Full Composable Chain Step 2\", flow![\n- (type_text!(\"https://w\"), suggests!(\"https://www.\")),\n- ]);\n- \n- test_flow(\"TDSL Full Composable Chain Step 3\", flow![\n- (type_text!(\"https://www.r\"), suggests!(\"https://www.reddit.com\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_progressive_reddit_paths() {\n- // Test various reddit paths work as expected\n- test_flow(\"TDSL Progressive Reddit Paths\", flow![\n- (type_text!(\"reddit.com\"), suggests!(\"reddit.com/\")),\n- (KeyAction::Tab, ExpectedAction::MultipleAny),\n- ]);\n- }\n-\n- #[test] \n- fn test_tdsl_subreddit_sorting_options() {\n- // Test that subreddit sorting options work as described\n- test_flow(\"TDSL Subreddit Sorting\", flow![\n- (type_text!(\"reddit.com/r/rust/hot\"), renders_subreddit!(\"rust\", \"hot\")),\n- ]);\n- \n- test_flow(\"TDSL Subreddit Top\", flow![\n- (type_text!(\"reddit.com/r/rust/top\"), renders_subreddit!(\"rust\", \"top\")),\n- ]);\n- \n- test_flow(\"TDSL Subreddit New\", flow![\n- (type_text!(\"reddit.com/r/rust/new\"), renders_subreddit!(\"rust\", \"new\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_all_reddit_prefixes() {\n- // Test all the prefixes mentioned in parser.tdsl work\n- // \"r\" now suggests \"r/\", all others suggest \"reddit.com\"\n- let prefixes_to_reddit = vec![\"re\", \"red\", \"redd\", \"reddi\", \"reddit\", \"reddit.\", \"reddit.c\", \"reddit.co\"];\n- \n- // Test \"r\" separately since it now suggests \"r/\"\n- test_flow(\"TDSL Prefix: r\", flow![\n- (type_text!(\"r\"), suggests!(\"r/\")),\n- ]);\n- \n- for prefix in prefixes_to_reddit {\n- test_flow(&format!(\"TDSL Prefix: {}\", prefix), flow![\n- (type_text!(prefix), suggests!(\"reddit.com\")),\n- ]);\n- }\n- }\n-\n- #[test]\n- fn test_tdsl_protocol_combinations() {\n- // Test various protocol combinations from parser.tdsl\n- let test_cases = vec![\n- (\"http://r\", \"http://reddit.com\"),\n- (\"https://r\", \"https://reddit.com\"),\n- (\"www.r\", \"www.reddit.com\"),\n- (\"https://www.r\", \"https://www.reddit.com\"),\n- (\"http://www.r\", \"http://www.reddit.com\"),\n- ];\n- \n- for (input, expected) in test_cases {\n- test_flow(&format!(\"TDSL Protocol: {}\", input), flow![\n- (type_text!(input), suggests!(expected)),\n- ]);\n- }\n+ fn rejects_empty() {\n+ assert!(parse_reddit_url(\"\").is_err());\n+ assert!(parse_reddit_url(\" \").is_err());\n }\n \n #[test]\n- fn test_tdsl_edge_case_completions() {\n- // Test edge cases mentioned in parser.tdsl\n- test_flow(\"TDSL Reddit.com completion\", flow![\n- (type_text!(\"reddit.com\"), suggests!(\"reddit.com/\")),\n- ]);\n- \n- // Test that typing full reddit.com suggests the slash\n- test_flow(\"TDSL Full domain completion\", flow![\n- (type_text!(\"reddit.com\"), suggests!(\"reddit.com/\")),\n- (KeyAction::Tab, ExpectedAction::MultipleAny),\n- ]);\n- }\n-\n- #[test]\n- fn test_real_user_trace_2025_08_07_fixed() {\n- // Based on actual WebSocket trace from 2025-08-07T01:35:46Z\n- // This test shows the CORRECT behavior after fixing the protocol preservation bug\n- test_flow(\"Real User Trace: Progressive Typing with Tab Completions (Fixed)\", flow![\n- // User started typing \"h\"\n- (type_text!(\"h\"), suggests!(\"https://\")),\n- \n- // User continued to \"ht\" \n- (type_text!(\"t\"), suggests!(\"https://\")),\n- \n- // User finished typing \"https://\" (trace shows full protocol)\n- (type_text!(\"tps://\"), suggests!(\"https://\")),\n- \n- // User started typing \"r\" after protocol\n- (type_text!(\"r\"), suggests!(\"https://reddit.com\")),\n- \n- // User continued typing \"re\"\n- (type_text!(\"e\"), suggests!(\"https://reddit.com\")),\n- \n- // User typed out or completed \"https://reddit.com\"\n- (type_text!(\"ddit.com\"), suggests!(\"https://reddit.com/\")),\n- \n- // User accepted completion to \"https://reddit.com/\"\n- // NOW suggestions should preserve the https:// protocol\n- (KeyAction::Tab, multiple![\n- scrolling_suggestions!(\"https://reddit.com/r/\", \"https://reddit.com/u/\"), // This is the key fix!\n- shows_guide!(\"Welcome to Sorter\")\n- ]),\n- ]);\n- }\n-\n- #[test]\n- fn test_protocol_preservation_bug_fix() {\n- // This test specifically verifies the fix for the protocol preservation bug\n- test_flow(\"Protocol Preservation: HTTPS Reddit Homepage\", flow![\n- (type_text!(\"https://reddit.com/\"), multiple![\n- scrolling_suggestions!(\"https://reddit.com/r/\", \"https://reddit.com/u/\"), // Should preserve https://\n- shows_guide!(\"Welcome to Sorter\")\n- ]),\n- ]);\n- \n- // Test that subreddit selection preserves protocol\n- test_flow(\"Protocol Preservation: HTTPS Subreddit Selection\", flow![\n- (type_text!(\"https://reddit.com/r/\"), multiple![\n- scrolling_suggestions!(\"https://reddit.com/r/programming\", \"https://reddit.com/r/askreddit\", \"https://reddit.com/r/aww\", \"https://reddit.com/r/rust\", \"https://reddit.com/r/webdev\"), // Should preserve https://\n- db_suggestions!(\"https://reddit.com/r/\", \"https://reddit.com/r/\")\n- ]),\n- ]);\n- \n- // Test with different protocols\n- test_flow(\"Protocol Preservation: HTTP\", flow![\n- (type_text!(\"http://reddit.com/\"), multiple![\n- scrolling_suggestions!(\"http://reddit.com/r/\", \"http://reddit.com/u/\"), // Should preserve http://\n- shows_guide!(\"Welcome to Sorter\")\n- ]),\n- ]);\n- \n- test_flow(\"Protocol Preservation: WWW\", flow![\n- (type_text!(\"www.reddit.com/\"), multiple![\n- scrolling_suggestions!(\"www.reddit.com/r/\", \"www.reddit.com/u/\"), // Should preserve www.\n- shows_guide!(\"Welcome to Sorter\")\n- ]),\n- ]);\n- \n- // Test that plain reddit.com still works\n- test_flow(\"Protocol Preservation: Plain Domain\", flow![\n- (type_text!(\"reddit.com/\"), multiple![\n- scrolling_suggestions!(\"reddit.com/r/\", \"reddit.com/u/\"), // No protocol prefix\n- shows_guide!(\"Welcome to Sorter\")\n- ]),\n- ]);\n- }\n-\n- #[test]\n- fn test_user_navigation_pattern() {\n- // Models how users actually navigate: type → tab → click suggestion → end up at destination\n- // This captures the \"jump\" from https://reddit.com/ to reddit.com/r/ seen in the trace\n- test_flow(\"User Navigation: Protocol to Domain\", flow![\n- // User types and gets to homepage\n- (type_text!(\"https://reddit.com/\"), ExpectedAction::MultipleAny),\n- ]);\n- \n- // Then they navigate (perhaps clicking a suggestion) to subreddit selection\n- test_flow(\"User Navigation: Click to Subreddit\", flow![\n- // This is where they ended up - the suggestion in the guide probably said \"reddit.com/r/\"\n- (type_text!(\"reddit.com/r/\"), ExpectedAction::MultipleAny),\n- ]);\n- }\n-\n- #[test]\n- fn test_progressive_typing_pattern() {\n- // Based on the trace pattern - users often type character by character\n- // This tests the exact sequence of suggestions they would see\n- test_flow(\"Progressive Typing Pattern\", flow![\n- (type_text!(\"h\"), suggests!(\"https://\")),\n- (type_text!(\"t\"), suggests!(\"https://\")), // Still suggests https://\n- (type_text!(\"t\"), suggests!(\"https://\")), // ht -> htt, still suggests https://\n- (type_text!(\"p\"), suggests!(\"https://\")), // http should still suggest https://\n- (type_text!(\"s\"), suggests!(\"https://\")), // https should suggest https://\n- (type_text!(\"://\"), suggests!(\"https://\")), // Even complete protocol still suggests itself\n- ]);\n- }\n-\n- #[test] \n- fn test_tab_completion_workflow() {\n- // Test what happens when user uses tab completions strategically\n- test_flow(\"Strategic Tab Completion Workflow\", flow![\n- // Start typing, get suggestion\n- (type_text!(\"h\"), suggests!(\"https://\")),\n- \n- // Accept suggestion with tab - this should move us to \"https://\"\n- (KeyAction::Tab, suggests!(\"https://\")), // After tab, we're at \"https://\" which still suggests itself\n- \n- // Start typing reddit\n- (type_text!(\"r\"), suggests!(\"https://reddit.com\")),\n- \n- // Accept reddit suggestion \n- (KeyAction::Tab, suggests!(\"https://reddit.com/\")),\n- \n- // Accept final suggestion to get to homepage\n- (KeyAction::Tab, ExpectedAction::MultipleAny),\n- ]);\n- }\n-\n- // Keep the original tests as well\n- #[test]\n- fn test_reddit_prefix_autocomplete() {\n- let graph = build_reddit_graph();\n- \n- // \"r\" now suggests \"r/\", others suggest \"reddit.com\"\n- match graph.parse(\"r\") {\n- ParserAction::ShowSuggestions(data) => {\n- assert_eq!(data.suggestion.as_ref().unwrap().completion, \"r/\");\n- println!(\"✓ 'r' → r/\");\n- }\n- _ => panic!(\"Expected suggestion for 'r'\"),\n- }\n- \n- // Test other prefixes that should suggest \"reddit.com\"\n- let prefixes = vec![\"re\", \"red\", \"redd\", \"reddi\", \"reddit\", \"reddit.\", \"reddit.c\", \"reddit.co\"];\n- \n- for prefix in prefixes {\n- match graph.parse(prefix) {\n- ParserAction::ShowSuggestions(data) => {\n- assert_eq!(data.suggestion.as_ref().unwrap().completion, \"reddit.com\");\n- println!(\"✓ '{}' → reddit.com\", prefix);\n- }\n- _ => panic!(\"Expected suggestion for '{}'\", prefix),\n- }\n- }\n- }\n- \n- #[test]\n- fn test_protocol_composition() {\n- let graph = build_reddit_graph();\n- \n- // Test protocol + reddit compositions\n- let tests = vec![\n- (\"https://r\", \"https://reddit.com\"),\n- (\"https://re\", \"https://reddit.com\"),\n- (\"https://reddit\", \"https://reddit.com\"),\n- (\"https://www.r\", \"https://www.reddit.com\"),\n- (\"https://www.reddit\", \"https://www.reddit.com\"),\n- (\"http://r\", \"http://reddit.com\"),\n- (\"www.r\", \"www.reddit.com\"),\n- ];\n- \n- for (input, expected) in tests {\n- match graph.parse(input) {\n- ParserAction::ShowSuggestions(data) => {\n- assert_eq!(data.suggestion.as_ref().unwrap().completion, expected);\n- println!(\"✓ '{}' → {}\", input, expected);\n- }\n- _ => panic!(\"Expected suggestion for '{}'\", input),\n- }\n- }\n- }\n- \n- #[test]\n- fn test_alias_and_full_paths() {\n- let graph = build_reddit_graph();\n- \n- // reddit.com/ should show guide, r/ should show subreddit selection\n- match graph.parse(\"reddit.com/\") {\n- ParserAction::ShowMultiple { actions } => {\n- let has_guide = actions.iter()\n- .any(|a| matches!(a, ParserAction::ShowStaticGuide { .. }));\n- assert!(has_guide, \"Path 'reddit.com/' should show guide\");\n- println!(\"✓ 'reddit.com/' shows Reddit root guide\");\n- }\n- _ => panic!(\"Expected Multiple action for 'reddit.com/'\"),\n- }\n- \n- match graph.parse(\"r/\") {\n- ParserAction::ShowMultiple { actions } => {\n- let has_scrolling_suggestions = actions.iter()\n- .any(|a| matches!(a, ParserAction::ShowScrollingSuggestions { .. }));\n- let has_db_suggestions = actions.iter()\n- .any(|a| matches!(a, ParserAction::SuggestSubredditsFromDb { .. }));\n- assert!(has_scrolling_suggestions && has_db_suggestions, \n- \"Path 'r/' should show scrolling suggestions and DB suggestions\");\n- println!(\"✓ 'r/' shows subreddit selection\");\n- }\n- _ => panic!(\"Expected Multiple action for 'r/'\"),\n- }\n- }\n- \n- #[test]\n- fn test_deep_navigation() {\n- let graph = build_reddit_graph();\n- \n- // Test navigation to subreddit - now uses ResolveAndDisplaySubreddit\n- match graph.parse(\"reddit.com/r/rust\") {\n- ParserAction::ResolveAndDisplaySubreddit { subreddit, prefix } => {\n- assert_eq!(subreddit, \"rust\");\n- assert_eq!(prefix, \"reddit.com/r/rust\");\n- println!(\"✓ reddit.com/r/rust recognized\");\n- }\n- _ => panic!(\"Expected ResolveAndDisplaySubreddit for subreddit\"),\n- }\n- \n- // Test with alias (r/ goes directly to subreddit, no double r/)\n- match graph.parse(\"r/programming\") {\n- ParserAction::ResolveAndDisplaySubreddit { subreddit, prefix } => {\n- assert_eq!(subreddit, \"programming\");\n- assert_eq!(prefix, \"r/programming\");\n- println!(\"✓ r/programming (alias) recognized\");\n- }\n- _ => panic!(\"Expected ResolveAndDisplaySubreddit for subreddit via alias\"),\n- }\n+ fn rejects_garbage() {\n+ assert!(parse_reddit_url(\"hello world\").is_err());\n }\n }\ndiff --git a/server/src/parser_action.rs b/server/src/parser_action.rs\ndeleted file mode 100644\nindex 2b98556b562d548ce231a726f9e704f595e8f4a1..0000000000000000000000000000000000000000\n--- a/server/src/parser_action.rs\n+++ /dev/null\n@@ -1,124 +0,0 @@\n-//! Parser output actions — graph handlers return these; HTML render turns them into markup.\n-\n-#[derive(Debug, Clone, PartialEq)]\n-pub struct Suggestion {\n- pub text: String,\n- pub completion: String,\n- pub description: Option,\n- pub score: f64,\n-}\n-\n-#[derive(Debug, Clone, PartialEq)]\n-pub struct ScrollingSuggestion {\n- pub completion: String,\n-}\n-\n-#[derive(Debug, Clone, PartialEq)]\n-pub struct GuideOption {\n- pub key: String,\n- pub label: String,\n- pub description: String,\n- pub completion: String,\n-}\n-\n-#[derive(Debug, Clone, PartialEq)]\n-pub struct SuggestionsData {\n- pub query: String,\n- pub suggestion: Option,\n-}\n-\n-#[derive(Debug, Clone, PartialEq)]\n-pub struct ErrorData {\n- pub error_type: String,\n- pub message: String,\n-}\n-\n-#[derive(Debug, Clone, PartialEq)]\n-pub enum ParserAction {\n- ShowSuggestions(SuggestionsData),\n- ShowScrollingSuggestions {\n- query: String,\n- suggestions: Vec,\n- interval_ms: u64,\n- r#loop: bool,\n- },\n- ShowStaticGuide {\n- query: String,\n- title: String,\n- subtitle: String,\n- options: Vec,\n- },\n- ShowMultiple {\n- actions: Vec,\n- },\n- ShowError(ErrorData),\n- SuggestSubredditsFromDb {\n- partial: String,\n- prefix: String,\n- },\n- ResolveAndDisplaySubreddit {\n- subreddit: String,\n- prefix: String,\n- },\n- RenderEntityView {\n- ns: String,\n- pk: String,\n- },\n-}\n-\n-impl ParserAction {\n- pub fn suggest(query: String, suggestion: Option) -> Self {\n- Self::ShowSuggestions(SuggestionsData { query, suggestion })\n- }\n-\n- pub fn error(error_type: String, message: String) -> Self {\n- Self::ShowError(ErrorData {\n- error_type,\n- message,\n- })\n- }\n-\n- pub fn multiple(actions: Vec) -> Self {\n- Self::ShowMultiple { actions }\n- }\n-\n- pub fn scrolling_suggestions(\n- query: String,\n- suggestions: Vec,\n- interval_ms: u64,\n- r#loop: bool,\n- ) -> Self {\n- Self::ShowScrollingSuggestions {\n- query,\n- suggestions,\n- interval_ms,\n- r#loop,\n- }\n- }\n-\n- pub fn guide(\n- query: String,\n- title: String,\n- subtitle: String,\n- options: Vec,\n- ) -> Self {\n- Self::ShowStaticGuide {\n- query,\n- title,\n- subtitle,\n- options,\n- }\n- }\n-\n- /// Primary tab-completion string, if any.\n- pub fn primary_completion(&self) -> Option<&str> {\n- match self {\n- Self::ShowSuggestions(data) => data\n- .suggestion\n- .as_ref()\n- .map(|s| s.completion.as_str()),\n- Self::ShowMultiple { actions } => actions.iter().find_map(|a| a.primary_completion()),\n- _ => None,\n- }\n- }\n-}\ndiff --git a/server/src/parser_render.rs b/server/src/parser_render.rs\nindex 05e4f519cc3832392282b319272b61c104eaabf6..f2341afe21476b689a536137798d97277211a962 100644\n--- a/server/src/parser_render.rs\n+++ b/server/src/parser_render.rs\n@@ -2,12 +2,9 @@ use maud::{html, Markup};\n \n use crate::{\n form_template::template_json_compact,\n- parser_action::{GuideOption, ParserAction, ScrollingSuggestion, Suggestion},\n ui_action::UI_RPC_FIELD,\n };\n \n-const SAMPLE_SUBREDDITS: &[&str] = &[\"programming\", \"askreddit\", \"rust\", \"webdev\", \"aww\"];\n-\n fn parse_query_rpc_template() -> String {\n template_json_compact(&serde_json::json!({\n \"action\": \"parse_query\",\n@@ -16,193 +13,32 @@ fn parse_query_rpc_template() -> String {\n .expect(\"parse_query rpc template\")\n }\n \n-fn completion_button(completion: &str, label: &str, primary: bool) -> Markup {\n- let class = if primary {\n- \"parser-completion parser-suggestion-primary btn-link\"\n- } else {\n- \"parser-completion btn-link\"\n- };\n- html! {\n- button\n- type=\"button\"\n- class=(class)\n- data-completion=(completion)\n- title=\"Use this path\" {\n- (label)\n- }\n- }\n-}\n-\n-fn render_suggestion(s: &Suggestion) -> Markup {\n- let desc = s\n- .description\n- .as_deref()\n- .unwrap_or(\"Tab to complete\");\n- html! {\n- p class=\"parser-suggestion\" {\n- (completion_button(&s.completion, &s.completion, true))\n- span class=\"muted small\" { \" — \" (desc) }\n- }\n- }\n-}\n-\n-fn render_scrolling(suggestions: &[ScrollingSuggestion]) -> Markup {\n- html! {\n- div class=\"parser-scrolling muted small\" {\n- p { \"Examples:\" }\n- ul class=\"parser-scroll-list\" {\n- @for s in suggestions {\n- li { (completion_button(&s.completion, &s.completion, false)) }\n- }\n- }\n- }\n- }\n-}\n-\n-fn render_guide(title: &str, subtitle: &str, options: &[GuideOption]) -> Markup {\n- html! {\n- div class=\"parser-guide\" {\n- h3 { (title) }\n- p class=\"muted\" { (subtitle) }\n- ul class=\"parser-guide-list\" {\n- @for opt in options {\n- li {\n- strong { (opt.key) \": \" }\n- (completion_button(&opt.completion, &opt.label, false))\n- span class=\"muted small\" { \" — \" (opt.description) }\n- }\n- }\n- }\n- }\n- }\n-}\n-\n-fn render_db_subs(partial: &str, prefix: &str) -> Markup {\n- let needle = partial.to_lowercase();\n- let matches: Vec<_> = SAMPLE_SUBREDDITS\n- .iter()\n- .filter(|s| s.contains(&needle) || prefix.ends_with('/') && needle.is_empty())\n- .take(6)\n- .collect();\n- html! {\n- @if !matches.is_empty() {\n- div class=\"parser-db-subs muted small\" {\n- p { \"Subreddits:\" }\n- ul {\n- @for sub in matches {\n- @let completion = format!(\"{prefix}{sub}\");\n- li { (completion_button(&completion, &format!(\"r/{sub}\"), false)) }\n- }\n- }\n- }\n- }\n- }\n-}\n-\n-fn render_action(action: &ParserAction) -> Markup {\n- match action {\n- ParserAction::ShowSuggestions(data) => html! {\n- div class=\"parser-result parser-suggestions\" {\n- @if let Some(s) = &data.suggestion {\n- (render_suggestion(s))\n- } @else {\n- p class=\"muted\" { \"No completion\" }\n- }\n- }\n- },\n- ParserAction::ShowScrollingSuggestions { suggestions, .. } => {\n- render_scrolling(suggestions)\n- }\n- ParserAction::ShowStaticGuide {\n- title,\n- subtitle,\n- options,\n- ..\n- } => render_guide(title, subtitle, options),\n- ParserAction::ShowMultiple { actions } => html! {\n- div class=\"parser-multiple\" {\n- @for a in actions {\n- (render_action(a))\n- }\n- }\n- },\n- ParserAction::ShowError(data) => html! {\n- p class=\"parser-error muted\" {\n- strong { (data.error_type) \": \" }\n- (data.message)\n- }\n- },\n- ParserAction::SuggestSubredditsFromDb { partial, prefix } => {\n- render_db_subs(partial, prefix)\n- }\n- ParserAction::ResolveAndDisplaySubreddit { subreddit, prefix } => html! {\n- div class=\"parser-resolve\" {\n- p {\n- \"Subreddit \"\n- strong { \"r/\" (subreddit) }\n- @if subreddit.len() <= 3 {\n- span class=\"muted small\" { \" (partial — tab or pick a match)\" }\n- }\n- }\n- p {\n- a class=\"parser-rank-link\"\n- href=(format!(\"/?sub={subreddit}\")) {\n- \"Rank r/\" (subreddit) \" →\"\n- }\n- }\n- (render_db_subs(subreddit, prefix))\n- }\n- },\n- ParserAction::RenderEntityView { ns, pk } => html! {\n- div class=\"parser-entity\" {\n- p {\n- \"Would open \"\n- code { (ns) \"/\" (pk) }\n- }\n- }\n- },\n- }\n-}\n-\n-/// Parser output panel (inner content for `#parser-panel`).\n-pub fn parser_panel(query: &str, action: &ParserAction) -> Markup {\n+/// Navigate panel: paste a Reddit URL and click Go.\n+pub fn navigate_panel(query: &str, error: Option<&str>) -> Markup {\n html! {\n section id=\"parser-panel\" class=\"demo-panel\" {\n h2 { \"Navigate\" }\n p class=\"muted small\" {\n- \"Type a Reddit path — \"\n- code { \"r/rust\" }\n- \", \"\n- code { \"reddit.com/r/programming/hot\" }\n- \", etc. Tab completes; each keystroke posts \"\n- code { \"__rpc__\" }\n- \" to \"\n- code { \"/ui\" }\n- \".\"\n+ \"Paste a Reddit URL or \"\n+ code { \"r/subreddit\" }\n+ \" path, then click Go to rank that subreddit.\"\n }\n form method=\"post\" action=\"/ui\" id=\"parser-form\" {\n- input\n- type=\"text\"\n+ textarea\n name=\"query\"\n id=\"parser-input\"\n- value=(query)\n- placeholder=\"r/ or reddit.com/…\"\n+ rows=\"3\"\n+ placeholder=\"https://reddit.com/r/rust or r/rust\"\n autocomplete=\"off\"\n- spellcheck=\"false\";\n+ spellcheck=\"false\" {\n+ (query)\n+ }\n input type=\"hidden\" name=(UI_RPC_FIELD) value=(parse_query_rpc_template());\n+ button type=\"submit\" class=\"btn-primary\" { \"Go\" }\n }\n- div id=\"parser-output\" {\n- @if query.is_empty() {\n- p class=\"muted\" { \"Start typing…\" }\n- } @else {\n- (render_action(action))\n- }\n+ @if let Some(msg) = error {\n+ p class=\"parser-error muted\" { (msg) }\n }\n }\n }\n }\n-\n-/// Wrap panel HTML for Idiomorph (morph `#parser-panel` only).\n-pub fn parser_panel_morph(query: &str, action: &ParserAction) -> Markup {\n- parser_panel(query, action)\n-}\ndiff --git a/server/src/ui_action.rs b/server/src/ui_action.rs\nindex 8c357b133599b798a9c06a77a826bcef05a3a407..0e030b3448b8e45acbe49d2de47ea26372445c54 100644\n--- a/server/src/ui_action.rs\n+++ b/server/src/ui_action.rs\n@@ -22,7 +22,7 @@ pub enum HtmlUiAction {\n #[serde(default)]\n scope: String,\n },\n- /// Parse address-bar query via Reddit transition graph; morph `#parser-panel`.\n+ /// Parse pasted Reddit URL/path; redirect to subreddit ranking on success.\n ParseQuery {\n query: String,\n },\ndiff --git a/server/static/sorter_ui.js b/server/static/sorter_ui.js\nindex 1e5c3c13d6f7d8f180a4126ba25b44e9801cdb5d..9c475d8e076ba527aaf7a25c177d0d651bd46ca9 100644\n--- a/server/static/sorter_ui.js\n+++ b/server/static/sorter_ui.js\n@@ -19,39 +19,6 @@\n }).then(evalJs);\n }\n \n- // Parser responses race: a slow response for an earlier keystroke can arrive\n- // after a newer one and clobber the panel. Tag each request with a monotonic\n- // sequence number and only apply a response if it is newer than the last one\n- // applied, so stale (superseded) responses are discarded.\n- var parserSeq = 0;\n- var parserApplied = 0;\n-\n- function postParserForm(form) {\n- var mySeq = ++parserSeq;\n- return fetch(form.action, {\n- method: 'POST',\n- body: new URLSearchParams(new FormData(form)),\n- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n- credentials: 'same-origin',\n- }).then(function (resp) {\n- return resp.text();\n- }).then(function (js) {\n- if (mySeq <= parserApplied) return;\n- parserApplied = mySeq;\n- evalJs(js);\n- });\n- }\n-\n- var parserTimer = null;\n-\n- function scheduleParserInput(input) {\n- if (parserTimer) clearTimeout(parserTimer);\n- parserTimer = setTimeout(function () {\n- var form = document.getElementById('parser-form');\n- if (form) postParserForm(form);\n- }, 120);\n- }\n-\n function initSorterUi() {\n document.addEventListener('submit', async function (e) {\n var f = e.target;\n@@ -67,38 +34,6 @@\n if (firstField) firstField.focus();\n }\n });\n-\n- document.addEventListener('input', function (e) {\n- if (e.target && e.target.id === 'parser-input') {\n- scheduleParserInput(e.target);\n- }\n- });\n-\n- document.addEventListener('keydown', function (e) {\n- if (!e.target || e.target.id !== 'parser-input') return;\n- if (e.key !== 'Tab') return;\n- var completion =\n- e.target.dataset.completion ||\n- (function () {\n- var btn = document.querySelector('#parser-output .parser-suggestion-primary');\n- return btn && btn.getAttribute('data-completion');\n- })();\n- if (!completion) return;\n- e.preventDefault();\n- e.target.value = completion;\n- scheduleParserInput(e.target);\n- });\n-\n- document.addEventListener('click', function (e) {\n- var btn = e.target.closest('.parser-completion');\n- if (!btn) return;\n- var input = document.getElementById('parser-input');\n- if (!input) return;\n- var completion = btn.getAttribute('data-completion');\n- if (!completion) return;\n- input.value = completion;\n- scheduleParserInput(input);\n- });\n }\n \n if (document.readyState === 'loading') {\ndiff --git a/server/static/theme_default.css b/server/static/theme_default.css\nindex c5fc9e7abf865ed689f58ff0763d3656d73e35f5..6ad0ac712bbc840bedee60f613794de9385fbbd1 100644\n--- a/server/static/theme_default.css\n+++ b/server/static/theme_default.css\n@@ -129,34 +129,12 @@ code {\n background: var(--bg);\n color: var(--fg);\n font-size: 1rem;\n+ font-family: inherit;\n+ resize: vertical;\n }\n \n-.btn-link {\n- background: none;\n- border: none;\n- padding: 0;\n- color: var(--accent);\n- cursor: pointer;\n- font: inherit;\n-}\n-\n-.btn-link:hover {\n- text-decoration: underline;\n-}\n-\n-.parser-rank-link {\n- display: inline-block;\n- margin: 0.5rem 0;\n- padding: 0.4rem 0.8rem;\n- background: var(--accent);\n- color: #0f1115;\n- border-radius: 4px;\n- font-weight: 600;\n- text-decoration: none;\n-}\n-\n-.parser-rank-link:hover {\n- filter: brightness(1.1);\n+#parser-form .btn-primary {\n+ margin-top: 0.5rem;\n }\n \n .scope-name {\ndiff --git a/server/tests/integration_ui.rs b/server/tests/integration_ui.rs\nindex 5112cb79ffe6e53df3d2432646770fc316cf4787..afeeee24e32f2d7f1d852ca96ac799fac8bce665 100644\n--- a/server/tests/integration_ui.rs\n+++ b/server/tests/integration_ui.rs\n@@ -71,11 +71,11 @@ async fn post_ui_record_vote_morphs_ranking_and_persists() {\n }\n \n #[tokio::test]\n-async fn post_ui_parse_query_morphs_parser_panel() {\n+async fn post_ui_parse_query_redirects_to_subreddit() {\n let (addr, _tmp) = start_test_server().await;\n let rpc = serde_json::json!({\n \"action\": \"parse_query\",\n- \"query\": \"r\"\n+ \"query\": \"r/rust\"\n })\n .to_string();\n let mut form = HashMap::new();\n@@ -92,7 +92,6 @@ async fn post_ui_parse_query_morphs_parser_panel() {\n .await\n .unwrap();\n \n- assert!(body.contains(\"Idiomorph.morph\"));\n- assert!(body.contains(\"parser-panel\"));\n- assert!(body.contains(\"r/\"));\n+ assert!(body.contains(\"window.location.href\"));\n+ assert!(body.contains(\"/?sub=rust\"));\n }\ndiff --git a/test/parser_race.clj b/test/parser_race.clj\ndeleted file mode 100644\nindex 23783837f8192ff24a556b73bdd670dc63b606cb..0000000000000000000000000000000000000000\n--- a/test/parser_race.clj\n+++ /dev/null\n@@ -1,185 +0,0 @@\n-(ns test.parser-race\n- \"Browser test (spel / Playwright) for the parser search-box response race.\n-\n- The search box debounces keystrokes, POSTs each to `/ui`, and eval()s the\n- returned JS, which morphs `#parser-panel` (input value + `#parser-output`).\n- If responses are applied in arrival order with no ordering guard, a slow\n- response for an *earlier* query can land after a newer one and clobber it.\n-\n- To force the race deterministically without touching the Rust server, the\n- browser talks to a small in-process reverse proxy that injects an asymmetric\n- per-query delay: the earlier query (`r/rust`) is delayed far longer than the\n- later query (`r/aww`). The later query therefore renders first, then the\n- stale earlier response arrives. Correct behavior: the panel reflects the\n- *latest* query the user typed (`r/aww`).\"\n- (:require [babashka.process :as process]\n- [clojure.java.io :as io]\n- [clojure.string :as str]\n- [clojure.test :refer [deftest is testing]]\n- [com.blockether.spel.assertions :as assert]\n- [com.blockether.spel.core :as core]\n- [com.blockether.spel.locator :as locator]\n- [com.blockether.spel.page :as page])\n- (:import [com.sun.net.httpserver HttpServer HttpHandler]\n- [java.io ByteArrayOutputStream]\n- [java.net InetSocketAddress URI URLDecoder]\n- [java.net.http HttpClient HttpClient$Version HttpRequest\n- HttpRequest$BodyPublishers HttpResponse$BodyHandlers]\n- [java.nio.charset StandardCharsets]\n- [java.util.concurrent Executors]))\n-\n-(def ^:private slow-query \"r/rust\")\n-(def ^:private fast-query \"r/aww\")\n-(def ^:private slow-delay-ms 800)\n-\n-(defn- repo-root []\n- (.getCanonicalPath (io/file (System/getProperty \"user.dir\"))))\n-\n-(defn- pick-port []\n- (with-open [s (java.net.ServerSocket. 0)]\n- (.getLocalPort s)))\n-\n-(defn- wait-health [base-url ms]\n- (let [deadline (+ (System/currentTimeMillis) ms)\n- url (str base-url \"/healthz\")]\n- (loop []\n- (let [resp (try\n- (process/shell {:out :string :err :string} \"curl\" \"-sf\" url)\n- (catch Exception _ nil))]\n- (if (and resp (zero? (:exit resp)) (= \"ok\" (str/trim (:out resp \"\"))))\n- true\n- (if (< (System/currentTimeMillis) deadline)\n- (do (Thread/sleep 200) (recur))\n- false))))))\n-\n-(defn- start-server\n- \"Builds the release binary and starts it on a random port. Returns a map with\n- :proc and :base.\"\n- [root]\n- (is (zero? (:exit (process/shell {:dir root}\n- \"cargo\" \"build\" \"--release\" \"--package\" \"sorter2-server\")))\n- \"release build succeeds\")\n- (let [bin (str root \"/target/release/sorter2-server\")]\n- (is (.exists (io/file bin)) \"binary exists\")\n- (let [data-dir (.getAbsolutePath\n- (doto (io/file (System/getProperty \"java.io.tmpdir\")\n- (str \"sorter2-race-\" (System/currentTimeMillis)))\n- (.mkdirs)))\n- port (pick-port)\n- base (str \"http://127.0.0.1:\" port)\n- proc (process/process {:dir root\n- :env {\"SORTER2_DATA_DIR\" data-dir\n- \"SORTER2_EVENT_LOG\" (str data-dir \"/events.jsonl\")\n- \"PORT\" (str port)}\n- :out :string\n- :err :string}\n- bin)]\n- {:proc proc :base base})))\n-\n-(defn- read-all-bytes ^bytes [in]\n- (let [bos (ByteArrayOutputStream.)]\n- (io/copy in bos)\n- (.toByteArray bos)))\n-\n-(defn- ui-query\n- \"Extracts the decoded `query` form field from a urlencoded body, or nil.\"\n- [body-str]\n- (some-> (re-find #\"(?:^|&)query=([^&]*)\" body-str)\n- second\n- (URLDecoder/decode \"UTF-8\")))\n-\n-(defn- start-proxy\n- \"Reverse proxy to `upstream` that sleeps `(delay-fn method path body-str)` ms\n- before forwarding each request. Runs handlers on a thread pool so concurrent\n- requests are delayed independently (the race needs out-of-order arrival).\n- Returns a map with :server and :base.\"\n- [upstream delay-fn]\n- (let [client (-> (HttpClient/newBuilder)\n- (.version HttpClient$Version/HTTP_1_1)\n- (.build))\n- server (HttpServer/create (InetSocketAddress. \"127.0.0.1\" 0) 0)\n- handler (reify HttpHandler\n- (handle [_ ex]\n- (try\n- (let [method (.getRequestMethod ex)\n- uri (.getRequestURI ex)\n- path (.getRawPath uri)\n- query (.getRawQuery uri)\n- req-body (read-all-bytes (.getRequestBody ex))\n- body-str (String. req-body StandardCharsets/UTF_8)\n- delay-ms (delay-fn method path body-str)]\n- (when (pos? delay-ms)\n- (Thread/sleep (long delay-ms)))\n- (let [target (str upstream path (when query (str \"?\" query)))\n- builder (doto (HttpRequest/newBuilder)\n- (.uri (URI/create target)))\n- ct (.getFirst (.getRequestHeaders ex) \"Content-Type\")\n- _ (when ct (.header builder \"Content-Type\" ct))\n- publisher (if (zero? (alength req-body))\n- (HttpRequest$BodyPublishers/noBody)\n- (HttpRequest$BodyPublishers/ofByteArray req-body))\n- _ (.method builder method publisher)\n- resp (.send client (.build builder)\n- (HttpResponse$BodyHandlers/ofByteArray))\n- resp-body (.body resp)\n- resp-ct (-> (.headers resp)\n- (.firstValue \"content-type\")\n- (.orElse nil))]\n- (when resp-ct\n- (.set (.getResponseHeaders ex) \"Content-Type\" resp-ct))\n- (.sendResponseHeaders ex (.statusCode resp) (alength resp-body))\n- (with-open [os (.getResponseBody ex)]\n- (.write os resp-body))))\n- (catch Throwable t\n- (let [msg (.getBytes (str \"proxy error: \" (.getMessage t))\n- StandardCharsets/UTF_8)]\n- (try\n- (.sendResponseHeaders ex 500 (alength msg))\n- (with-open [os (.getResponseBody ex)]\n- (.write os msg))\n- (catch Throwable _ nil))))\n- (finally\n- (.close ex)))))]\n- (.createContext server \"/\" handler)\n- (.setExecutor server (Executors/newCachedThreadPool))\n- (.start server)\n- {:server server\n- :base (str \"http://127.0.0.1:\" (.getPort (.getAddress server)))}))\n-\n-(defn- parser-delay-fn [method path body-str]\n- (if (and (= method \"POST\") (= path \"/ui\") (= (ui-query body-str) slow-query))\n- slow-delay-ms\n- 0))\n-\n-(deftest latest-search-query-wins\n- (testing \"a slow earlier parser response must not clobber a newer one\"\n- (let [root (repo-root)\n- {:keys [proc base]} (start-server root)]\n- (try\n- (is (wait-health base 15000) \"server responds to /healthz\")\n- (let [{:keys [server] proxy-base :base} (start-proxy base parser-delay-fn)]\n- (try\n- (core/with-testing-page [pg]\n- (page/navigate pg proxy-base)\n- (let [input (page/locator pg \"#parser-input\")\n- output (page/locator pg \"#parser-output\")]\n- ;; Type the slow query first; wait past the 120ms debounce so its\n- ;; (slow) request is in flight, then type the fast query.\n- (locator/fill input slow-query)\n- (Thread/sleep 300)\n- (locator/fill input fast-query)\n- ;; Wait until the slow response has certainly arrived and been\n- ;; (mis)applied if the race exists.\n- (Thread/sleep 2000)\n- ;; The panel must reflect the latest query, not the stale one.\n- (is (nil? (assert/contains-text (assert/assert-that output) fast-query))\n- \"output shows the latest query (r/aww)\")\n- (is (nil? (assert/contains-text\n- (assert/loc-not (assert/assert-that output)) slow-query))\n- \"output does NOT show the stale earlier query (r/rust)\")\n- (is (nil? (assert/has-value (assert/assert-that input) fast-query))\n- \"input value is the latest query (r/aww)\")))\n- (finally\n- (.stop server 0))))\n- (finally\n- (process/destroy proc))))))\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[893a2007] fullscreen?\n\nSide B — unified diff (full patch):\ndiff --git a/agents.md b/agents.md\nindex d9a924d2f77c444d9b112bbf37a480b963ace4f0..1f66ddbeddd1e40310947c7eb474c1ac4858bc42 100644\n--- a/agents.md\n+++ b/agents.md\n@@ -37,9 +37,9 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma\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) and **`#vote-edge-history-region`** (recomputed edge list). Uses **`RpcResult::PostOk`**’s **`post_id`** / **`post_index`** for the card. **`__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-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 \n-- **Garden pin / compare voting:** Cookie **`slug_garden_pin`** via **`set_garden_pin`**. Pairwise UI: **`GET /vote/compare?…`** / **`GET /r/:room_key/vote/compare?…`**. HUD: **`#slug-pin-hud`** 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+- **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 **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 \ndiff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs\nindex 5aa6a86326ba3545d261322c010e02fe86ee1c57..696e7b3605e2e68aee0351116c494c2958506add 100644\n--- a/server/src/api/ui_html.rs\n+++ b/server/src/api/ui_html.rs\n@@ -254,7 +254,17 @@ async fn dispatch_ui_action(\n };\n n\n };\n- let js = vote_compare_post_success_js(state, &nav, &left_id, &right_id).await;\n+ let js = vote_compare_post_success_js(\n+ state,\n+ &nav,\n+ &room,\n+ &thread_tag,\n+ &left_id,\n+ &right_id,\n+ pid.as_str(),\n+ post_index,\n+ )\n+ .await;\n Response::builder()\n .status(StatusCode::OK)\n .header(header::CONTENT_TYPE, \"text/javascript; charset=utf-8\")\ndiff --git a/server/src/html/garden.rs b/server/src/html/garden.rs\nindex 121d9498e8cb93d4d001dc1bbce23d74fbb958f5..41f9e9c64a80a55a9d3ece2a6a1f592cf5e8d8dd 100644\n--- a/server/src/html/garden.rs\n+++ b/server/src/html/garden.rs\n@@ -22,17 +22,17 @@ use crate::{\n },\n events::ThreadCapability,\n path_types::ItemId,\n- reducer::{ContentState, ReducerState, ScopeId},\n+ reducer::{scope_from_room_wire, ContentState, ReducerState, ScopeId},\n scope_rank::{build_children_rankings, ChildrenRankings},\n state::AppState,\n timeago,\n };\n \n use super::{\n- bc_path, bc_path_external, bc_segment, cli_panel, layout, now_ms, ratio_pct,\n- render_linkified_with_embeds_in_scope, theme_from_jar, theme_next_from_uri,\n+ bc_path, bc_path_external, bc_segment, cli_panel, layout, layout_full_bleed_chromeless, now_ms,\n+ ratio_pct, render_linkified_with_embeds_in_scope, theme_from_jar, theme_next_from_uri,\n breadcrumb_path::{ExternalOntologyPath, OntologyPath},\n- forum::{ThreadNav},\n+ forum::{ingest_entry_markup, ThreadNav},\n };\n \n /// `GET /vote/compare` — pairs `left` / `right` query params with optional `thread`.\n@@ -89,25 +89,62 @@ fn canonical_edge_items(a: &ItemId, b: &ItemId) -> (ItemId, ItemId) {\n }\n }\n \n-/// Votes whose endpoints are exactly this unordered pair, oldest first.\n-fn votes_for_edge(content: &ContentState, a: &ItemId, b: &ItemId) -> Vec {\n+/// All votes whose endpoints are exactly this unordered pair (unsorted).\n+fn edge_vote_entries_for_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> Vec {\n let (lo, hi) = canonical_edge_items(a, b);\n let lo_s = lo.as_str();\n let hi_s = hi.as_str();\n- let mut out: Vec = content\n+ content\n .item_votes\n .get(&lo)\n .into_iter()\n .flat_map(|q| q.iter())\n .filter(|v| {\n- let touches = (v.a.as_str() == lo_s && v.b.as_str() == hi_s)\n- || (v.a.as_str() == hi_s && v.b.as_str() == lo_s);\n- touches\n+ (v.a.as_str() == lo_s && v.b.as_str() == hi_s)\n+ || (v.a.as_str() == hi_s && v.b.as_str() == lo_s)\n })\n .cloned()\n- .collect();\n- out.sort_by_key(|v| v.ts);\n- out\n+ .collect()\n+}\n+\n+fn ratios_for_compare_page(v: &crate::reducer::VoteData, page_left: &ItemId, page_right: &ItemId) -> (i32, i32) {\n+ let pl = page_left.as_str();\n+ let pr = page_right.as_str();\n+ match (v.a.as_str(), v.b.as_str()) {\n+ (a, b) if a == pl && b == pr => (v.ratio_left, v.ratio_right),\n+ (a, b) if a == pr && b == pl => (v.ratio_right, v.ratio_left),\n+ _ => (v.ratio_left, v.ratio_right),\n+ }\n+}\n+\n+fn left_share_normalized(ratio_left: i32, ratio_right: i32) -> f64 {\n+ let l = ratio_left.max(0) as f64;\n+ let r = ratio_right.max(0) as f64;\n+ let sum = l + r;\n+ if sum <= 0.0 {\n+ 0.5\n+ } else {\n+ l / sum\n+ }\n+}\n+\n+/// Stronger preference for **`page_left` first**; ties **newer first**.\n+fn sort_votes_for_compare_display(\n+ mut votes: Vec,\n+ page_left: &ItemId,\n+ page_right: &ItemId,\n+) -> Vec {\n+ votes.sort_by(|va, vb| {\n+ let (ratio_left_a, ratio_right_a) = ratios_for_compare_page(va, page_left, page_right);\n+ let (ratio_left_b, ratio_right_b) = ratios_for_compare_page(vb, page_left, page_right);\n+ let sa = left_share_normalized(ratio_left_a, ratio_right_a);\n+ let sb = left_share_normalized(ratio_left_b, ratio_right_b);\n+ match sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal) {\n+ std::cmp::Ordering::Equal => vb.ts.cmp(&va.ts),\n+ o => o,\n+ }\n+ });\n+ votes\n }\n \n /// Number of vote ingests recorded for this unordered pair in `content` (same scope as ranking).\n@@ -144,39 +181,40 @@ fn vote_edge_history_markup(\n content: &ContentState,\n left: &ItemId,\n right: &ItemId,\n- nav: &ThreadNav,\n ) -> maud::Markup {\n- let votes = votes_for_edge(content, left, right);\n- let (lo, hi) = canonical_edge_items(left, right);\n+ let votes = edge_vote_entries_for_pair(content, left, right);\n+ let votes = sort_votes_for_compare_display(votes, left, right);\n+ let legend_left = item_display_path(left.as_str());\n+ let legend_right = item_display_path(right.as_str());\n html! {\n @if votes.is_empty() {\n p class=\"muted vote-edge-empty\" { \"no votes on this pair in this scope yet\" }\n } @else {\n- h3 class=\"vote-edge-history-title\" { \"votes on this edge\" }\n- ol class=\"vote-edge-history\" {\n+ h3 class=\"vote-edge-history-title\" {\n+ \"votes on this edge\"\n+ span class=\"vote-edge-history-axis muted\" { \" · \" (legend_left) \" : \" (legend_right) }\n+ }\n+ ul class=\"vote-edge-history\" {\n @for v in &votes {\n- @let (ratio_lo, ratio_hi) = if v.a == lo && v.b == hi {\n- (v.ratio_left, v.ratio_right)\n- } else {\n- (v.ratio_right, v.ratio_left)\n- };\n- @let pct = ratio_pct(ratio_lo, ratio_hi);\n- @let left_class = if lo.as_str() == left.as_str() { \"ratio-left current\" } else { \"ratio-left\" };\n- @let right_class = if hi.as_str() == left.as_str() { \"ratio-right current\" } else { \"ratio-right\" };\n- li class=\"vote-edge-history-row\" {\n+ @let (r_left, r_right) = ratios_for_compare_page(v, left, right);\n+ @let pct = ratio_pct(r_left, r_right);\n+ @let row_tip = format!(\n+ \"{}:{} counts toward {} (left of bar) vs {} (right of bar); #{} · @{}\",\n+ r_left,\n+ r_right,\n+ legend_left,\n+ legend_right,\n+ v.thread_tag,\n+ v.principal,\n+ );\n+ li class=\"vote-edge-history-row\" title=(row_tip) {\n div class=\"vote-edge-meta\" {\n- a href=(nav.garden_item_href(&lo)) {\n- code { (item_display_path(lo.as_str())) }\n- }\n- span class=\"vote-edge-ratio\" { (format!(\"{}:{}\", ratio_lo, ratio_hi)) }\n- a href=(nav.garden_item_href(&hi)) {\n- code { (item_display_path(hi.as_str())) }\n- }\n+ span class=\"vote-edge-ratio\" { (format!(\"{}:{}\", r_left, r_right)) }\n span class=\"muted\" { \" · #\" (v.thread_tag) \" · @\" (v.principal) }\n }\n- div class=\"ratio-bar vote-edge-bar\" {\n- div class=(left_class) style={(format!(\"width: {:.3}%;\", pct))} {}\n- div class=(right_class) style={(format!(\"width: {:.3}%;\", 100.0 - pct))} {}\n+ div class=\"ratio-bar vote-edge-bar\" aria-hidden=\"true\" {\n+ div class=\"ratio-left\" style={(format!(\"width: {:.3}%;\", pct))} {}\n+ div class=\"ratio-right\" style={(format!(\"width: {:.3}%;\", 100.0 - pct))} {}\n }\n @if !v.body.trim().is_empty() {\n div class=\"vote-edge-reason muted\" { (v.body.trim()) }\n@@ -192,14 +230,33 @@ fn vote_edge_history_markup(\n pub(crate) async fn vote_compare_post_success_js(\n state: &AppState,\n nav: &ThreadNav,\n+ room_wire: &str,\n+ thread_tag: &str,\n left: &ItemId,\n right: &ItemId,\n+ post_id: &str,\n+ post_idx: Option,\n ) -> String {\n let reduced = state.reduced.read().await;\n+ let scope = scope_from_room_wire(room_wire);\n+ let Some(ing) = reduced.ingests_by_id.get(post_id).cloned() else {\n+ drop(reduced);\n+ return \"console.warn('vote compare: new post not found');\".to_string();\n+ };\n+ let idx = match post_idx {\n+ Some(i) => i,\n+ None => reduced\n+ .try_thread_post_index_chronological(&scope, thread_tag, post_id)\n+ .unwrap_or(0),\n+ };\n+ let viewer = None::<&str>;\n+ let now = now_ms();\n let content = content_for_garden_view(&reduced, &nav.scope());\n- let edge_history = vote_edge_history_markup(content, left, right, nav);\n+ let edge_history = vote_edge_history_markup(content, left, right);\n+ let card = ingest_entry_markup(nav, thread_tag, idx, &ing, viewer, now, &reduced);\n drop(reduced);\n let mut b = JsBuilder::new();\n+ b = b.morph_inner_selector(\"#vote-compare-preview\", card);\n b = b.morph_inner_selector(\"#vote-edge-history-region\", edge_history);\n b.build()\n }\n@@ -1259,7 +1316,7 @@ async fn vote_compare_inner(\n .filter(|t| !t.is_empty())\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, &nav);\n+ let edge_history = vote_edge_history_markup(content, &left, &right);\n drop(reduced);\n \n let title = format!(\"vote — {} vs {}\", item_display_path(left.as_str()), item_display_path(right.as_str()));\n@@ -1282,15 +1339,6 @@ async fn vote_compare_inner(\n .expect(\"vote compare rpc json\");\n \n let body = html! {\n- nav class=\"breadcrumb\" {\n- a href=\"/\" { \"slug.social\" }\n- @let vote_bc_href = if nav.room_path_prefix_for_vote_compare().is_empty() {\n- \"/vote/compare\".to_string()\n- } else {\n- format!(\"{}/vote/compare\", nav.room_path_prefix_for_vote_compare())\n- };\n- (bc_segment(\"vote\", &vote_bc_href, true))\n- }\n section class=\"vote-compare-shell\" {\n h2 { \"compare\" }\n div class=\"vote-compare-pair\" {\n@@ -1305,6 +1353,10 @@ async fn vote_compare_inner(\n div id=\"vote-edge-history-region\" {\n (edge_history)\n }\n+ div class=\"vote-compare-preview-wrap\" {\n+ h3 { \"your vote (after post)\" }\n+ div id=\"vote-compare-preview\" class=\"vote-compare-preview\" {}\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@@ -1342,16 +1394,12 @@ async fn vote_compare_inner(\n }\n };\n \n- let (gr, gp) = garden_layout_meta(&nav);\n- let page = layout(\n+ let page = layout_full_bleed_chromeless(\n &title,\n- \"view-ontology view-ontology-light view-vote-compare\",\n+ \"view-ontology view-ontology-light view-vote-compare view-vote-compare-fullscreen\",\n body,\n- None,\n theme_from_jar(&jar),\n &theme_next_from_uri(&uri),\n- Some(&gr),\n- Some(&gp),\n );\n Html(page.into_string()).into_response()\n }\n@@ -1388,10 +1436,40 @@ mod tests {\n }));\n }\n \n+ #[test]\n+ fn vote_edge_compare_sorts_rows_by_strength_for_page_left() {\n+ use super::{\n+ content_for_garden_view, edge_vote_entries_for_pair, ratios_for_compare_page,\n+ sort_votes_for_compare_display,\n+ };\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/a 1:9 ~/topic/b {weak for a}\\n\\\n+ ~/topic/a 8:2 ~/topic/b {strong for a}\\n\",\n+ );\n+ let content = content_for_garden_view(&reduced, &ScopeId::Public);\n+ let page_left = ItemId::parse(\"~/topic/a\").unwrap().normalized_storage();\n+ let page_right = ItemId::parse(\"~/topic/b\").unwrap().normalized_storage();\n+ let raw = edge_vote_entries_for_pair(content, &page_left, &page_right);\n+ assert_eq!(raw.len(), 2);\n+ let sorted = sort_votes_for_compare_display(raw, &page_left, &page_right);\n+ let (r0, _) = ratios_for_compare_page(&sorted[0], &page_left, &page_right);\n+ assert_eq!(r0, 8, \"stronger left weight should sort first\");\n+ let (r1, _) = ratios_for_compare_page(&sorted[1], &page_left, &page_right);\n+ assert_eq!(r1, 1);\n+ }\n+\n #[test]\n fn edge_vote_count_for_pair_matches_votes_for_edge_len() {\n use super::{\n- content_for_garden_view, edge_vote_count_for_pair, votes_for_edge,\n+ content_for_garden_view, edge_vote_count_for_pair, edge_vote_entries_for_pair,\n };\n use crate::path_types::ItemId;\n let mut reduced = ReducerState::default();\n@@ -1410,9 +1488,9 @@ mod tests {\n let b = ItemId::parse(\"~/topic/b\").unwrap().normalized_storage();\n assert_eq!(\n edge_vote_count_for_pair(content, &a, &b),\n- votes_for_edge(content, &a, &b).len()\n+ edge_vote_entries_for_pair(content, &a, &b).len()\n );\n- assert_eq!(votes_for_edge(content, &a, &b).len(), 2);\n+ assert_eq!(edge_vote_entries_for_pair(content, &a, &b).len(), 2);\n }\n \n #[test]\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex 40c15debe91a708da24dc1dec2ece2a852dd6381..f57b66f510dfaade1b656990fb2cbb30d16e5bf1 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -304,6 +304,28 @@ pub(super) fn layout(\n layout_embed_controls(title, view, body, views, theme, theme_next, garden_room_wire, garden_path_prefix, true)\n }\n \n+/// Minimal document shell: no bottom controls, no garden HUD data attributes (`data-garden-room` /\n+/// `data-garden-prefix` empty). For routes that own the full viewport (e.g. vote compare).\n+pub(super) fn layout_full_bleed_chromeless(\n+ title: &str,\n+ view: &str,\n+ body: Markup,\n+ theme: &str,\n+ theme_next: &str,\n+) -> Markup {\n+ layout_embed_controls(\n+ title,\n+ view,\n+ body,\n+ None,\n+ theme,\n+ theme_next,\n+ None,\n+ None,\n+ false,\n+ )\n+}\n+\n fn layout_embed_controls(\n title: &str,\n view: &str,\ndiff --git a/server/static/theme_default.css b/server/static/theme_default.css\nindex 9178f0629cfb868348740e6dea1626dc6afb8345..a441c4f79d8cf88f5a8240f9992f47dbbd1ab46b 100644\n--- a/server/static/theme_default.css\n+++ b/server/static/theme_default.css\n@@ -1054,6 +1054,26 @@ body.view-vote-compare #vote-explain:focus {\n body.view-vote-compare #vote-compare-form button[type=\"submit\"] {\n margin-top: 6px;\n }\n+\n+body.view-vote-compare-fullscreen {\n+ max-width: none;\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+ min-height: 100vh;\n+ min-height: 100dvh;\n+ box-sizing: border-box;\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+}\n+\n .vote-edge-history-title {\n font-size: 12px;\n letter-spacing: 0.06em;\n@@ -1061,9 +1081,15 @@ body.view-vote-compare #vote-compare-form button[type=\"submit\"] {\n color: var(--meta);\n margin: 16px 0 8px;\n }\n-ol.vote-edge-history {\n+.vote-edge-history-axis {\n+ text-transform: none;\n+ letter-spacing: normal;\n+ font-size: 13px;\n+}\n+ul.vote-edge-history {\n margin: 0;\n- padding-left: 1.2em;\n+ padding-left: 1.35em;\n+ list-style-type: disc;\n }\n li.vote-edge-history-row {\n margin: 10px 0;\ndiff --git a/server/static/theme_retro.css b/server/static/theme_retro.css\nindex f2d0ddee113995817a7f9bfa8500b2f5e7d81cf4..373f15bbd75b62604cdd14b9f0fadda2d6176991 100644\n--- a/server/static/theme_retro.css\n+++ b/server/static/theme_retro.css\n@@ -176,3 +176,21 @@ nav.breadcrumb.ont-sibling-nav span.ont-sibling-nav-group-sep {\n border-right: none;\n border-top: none;\n }\n+\n+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+ min-height: 100vh;\n+ min-height: 100dvh;\n+ box-sizing: border-box;\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+}\ndiff --git a/server/static/theme_retro_craft.css b/server/static/theme_retro_craft.css\nindex efaeed06d01fd835df0621f4c621c8f034c8b536..0871e3126c656ba534306cdc3f405f802d10b589 100644\n--- a/server/static/theme_retro_craft.css\n+++ b/server/static/theme_retro_craft.css\n@@ -785,6 +785,24 @@ body.view-ontology.view-vote-compare .vote-compare-shell {\n border-radius: 3px;\n box-shadow: 0 1px 0 rgba(26, 24, 20, 0.06);\n }\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+ margin: 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+ min-height: 100vh;\n+ min-height: 100dvh;\n+ box-sizing: border-box;\n+}\n body.view-ontology.view-vote-compare .vote-compare-shell > h2 {\n margin-top: 0;\n font-size: 0.7rem;\n@@ -910,9 +928,16 @@ body.view-ontology .vote-edge-history-title {\n color: #5c574e;\n margin: 0 0 0.5rem;\n }\n-body.view-ontology ol.vote-edge-history {\n+body.view-ontology .vote-edge-history-axis {\n+ text-transform: none;\n+ letter-spacing: normal;\n+ font-size: 0.82rem;\n+ font-weight: 400;\n+}\n+body.view-ontology ul.vote-edge-history {\n margin: 0;\n- padding-left: 1.1rem;\n+ padding-left: 1.25rem;\n+ list-style-type: disc;\n }\n body.view-ontology li.vote-edge-history-row {\n margin: 0.65rem 0;\ndiff --git a/test/browser_vote_compare.clj b/test/browser_vote_compare.clj\nindex 26fcff8bceb0c10bcd5ddfdae1aa145b56b7a0d1..78fc40c160c84478ab8ee0d953b9ed878b4c712f 100644\n--- a/test/browser_vote_compare.clj\n+++ b/test/browser_vote_compare.clj\n@@ -72,13 +72,13 @@\n (is (wait-for-text pg \"body\" \"@alice\" 15000) \"alice session after login\")\n (page/navigate pg cmp-url)\n (is (wait-for-text pg \".vote-compare-shell\" \"compare\" 15000) \"vote compare shell\")\n- (is (wait-for-text pg \"ol.vote-edge-history\" \"seed edge vote\" 15000)\n+ (is (wait-for-text pg \"ul.vote-edge-history\" \"seed edge vote\" 15000)\n \"edge history lists canonical-order vote\")\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 \"#vote-compare-preview\" \"because playwright\" 20000)\n \"preview region morphs new post card\")\n- (is (wait-for-text pg \"ol.vote-edge-history\" \"because playwright\" 20000)\n+ (is (wait-for-text pg \"ul.vote-edge-history\" \"because playwright\" 20000)\n \"new vote appears in edge history after morph\"))))))\n \n (finally\n","role":"user"}],"model":"~x-ai/grok-latest"}