constitution · epochs · watch · epoch 3

comparison

c_6864b1ca8ce6 (tommy-mor) vs c_8dc1a8119370 (tommy-mor)

download prompt · raw event · cmp_3163f315934122

council reasoning

~anthropic/claude-sonnet-latest · winner B · 8:2 · permalink

Side B removes a fragile, over-engineered 1800-line keystroke-parsing state machine (with race conditions requiring a whole Playwright test harness) and replaces it with a small, correct, testable paste-and-go URL parser plus simplified UI/JS/tests — a real simplification and bugfix with lasting architectural value. Side A is purely CSS polish (padding, borders, focus outlines) across two theme files, which is cosmetic churn with no functional or structural improvement.

~x-ai/grok-latest · winner B · 1:6 · permalink

B replaces an unreliable ~1.8k-line keystroke graph parser, action types, race-prone JS, and a Playwright race test with a small deterministic paste-and-go subreddit extractor plus redirect—clear lasting design simplification. A only polishes CSS (padding, borders, focus rings, theme overrides) for voting/pin UI with no behavioral or structural fix.

openai/gpt-chat-latest · winner B · 8:1 · permalink

Side B replaces a large, unreliable autocomplete transition-graph system with a much simpler URL parser that extracts subreddit names, redirects directly to the ranking page, removes obsolete parser infrastructure, updates the UI flow, and adds focused tests for the new behavior. Side A is primarily visual polish and accessibility improvements in CSS (focus-visible states, spacing, styling, and vote-compare layout), which improve usability but do not change the project's core behavior as substantially.

sides

A — c_6864b1ca8ce6 (tommy-mor)

message

[51e73d38] voting looks better?

diff preview

diff --git a/server/static/theme_default.css b/server/static/theme_default.css
index 5f60ff7a8cf9045d8d43228baad8ba80ad057f2a..ec0fbe7acee0aa2802f978f14a9b0fc86e78c5b8 100644
--- a/server/static/theme_default.css
+++ b/server/static/theme_default.css
@@ -844,18 +844,25 @@ a.ont-vote-compare-btn {
   color: var(--ui);
   cursor: pointer;
   font-size: 11px;
-  padding: 2px 8px;
+  padding: 3px 10px;
   text-decoration: none;
   display: inline-flex;
   align-items: center;
   gap: 4px;
   font-family: inherit;
+  border-radius: 2px;
 }
 button.ont-pin-btn:hover,
 a.ont-vote-compare-btn:hover { color: var(--signal); }
+button.ont-pin-btn:focus-visible,
+a.ont-vote-compare-btn:focus-visible {
+  outline: 2px solid var(--link);
+  outline-offset: 2px;
+}
 button.ont-pin-btn-active {
   border-color: var(--link);
   color: var(--signal);
+  background: color-mix(in srgb, var(--link) 12%, var(--g4));
 }
 .ont-garden-child-actions {
   display: inline-flex;
@@ -868,12 +875,33 @@ a.ont-garden-vote-ico,
 span.ont-garden-pinned-here {
   font-size: 13px;
   line-height: 1;
-  padding: 0 2px;
-  border: none;
-  background: transparent;
+  padding: 2px 6px;
+  min-width: 26px;
+  min-height: 26px;
+  box-sizing: border-box;
+  border: var(--bv) solid;
+  border-color: var(--hi) var(--lo) var(--lo) var(--hi);
+  border-radius: 2px;
+  background: var(--g3);
   cursor: pointer;
   text-decoration: none;
   color: inherit;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+}
+a.ont-garden-vote-ico:hover {
+  color: var(--signal);
+  background: var(--g4);
+}
+span.ont-garden-pinned-here {
+  border-color: var(--link);
+  color: var(--signal);
+  cursor: default;
+}
+button.ont-garden-pin-ico:focus-visible {
+  outline: 2px solid var(--link);
+  outline-offset: 2px;
 }
 body.view-ontology-light ol.ont-ranking-list li,
 body.view-ontology-light ul.ont-group-list li {
@@ -889,8 +917,19 @@ body.view-ontology-light ul.ont-group-list li .item-link {
 
 /* Vote compare page */
 .vote-compare-shell {
-  margin: 12px 0;
+  margin: 12px 0 20px;
   max-width: 720px;
+  padding: 14px 16px 18px;
+  background: var(--g2);
+  border: var(--bv-lg) solid;
+  border-color: var(--hi) var(--lo) var(--lo) var(--hi);
+}
+body.view-vote-compare .vote-compare-shell > h2 {
+  margin-top: 0;
+  font-size: 12px;
+  letter-spacing: 0.12em;
+  text-transform: uppercase;
+  color: var(--meta);
 }
 .vote-compare-pair {
   display: flex;
@@ -899,12 +938,20 @@ body.view-ontology-light ul.ont-group-list li .item-link {
   gap: 10px 16px;
   margin: 10px 0;
 }
+.vote-compare-item {
+  text-decoration: none;
+}
 .vote-compare-item code {
   font-size: 13px;
 }
+.vote-compare-item:hover code {
+  color: var(--signal);
+}
 .vote-compare-vs {
   color: var(--meta);
-  font-size: 12px;
+  font-size: 11px;
+  letter-spacing: 0.1em;
+  text-transform: uppercase;
 }
 .vote-compare-slider-label {
   display: flex;
@@ -917,7 +964,7 @@ body.view-ontology-light ul.ont-group-list li .item-link {
 #vote-preference-slider {
   flex: 1 1 180px;
   min-width: 120px;
-  accent-color: var(--ui);
+  accent-color: var(--link);
 }
 .vote-thread-picker {
   margin: 10px 0;
@@ -931,9 +978,17 @@ body.view-ontology-light ul.ont-group-list li .item-link {
 }
 #vote-thread-select {
   min-width: 160px;
+  background: var(--g3);
+  border: var(--bv) solid;
+  border-color: var(--hi) var(--lo) var(--lo) var(--hi);
+  color: var(--ui);
+  font-size: 12px;
+  padding: 3px 8px;
 }
 #vote-edge-history-region {
   margin: 14px 0 18px;
+  padding-bottom: 8px;
+  border-bottom: 1px dashed var(--lo);
 }
 .vote-compare-preview-wrap {
   margin: 12px 0;
@@ -946,8 +1001,37 @@ body.view-ontology-light ul.ont-group-list li .item-link {
 #vote-compare-preview {
   min-height: 48px;
 }
+.vote-explain-label {
+  display: block;
+  font-size: 12px;
+  color: var(--meta);
+  margin: 12px 0 4px;
+}
+body.view-vote-compare #vote-explain {
+  width: 100%;
+  max-width: 100%;
+  box-sizing: border-box;
+  background: var(--g3);
+  border: var(--bv) solid;
+  border-color: var(--hi) var(--lo) var(--lo) var(--hi);
+  color: var(--prose);
+  font: inherit;
+  line-height: 1.45;
+  padding: 8px 10px;
+}
+body.view-vote-compare #vote-explain:focus {
+  outline: none;
+  border-color: var(--link);
+  box-shadow: 0 0 0 2px color-mix(in srgb, var(--link) 22%, transparent);
+}
+body.view-vote-compare #vote-compare-form button[type="submit"] {
+  margin-top: 6px;
+}
 .vote-edge-history-title {
-  font-size: 13px;
+  font-size: 12px;
+  letter-spacing: 0.06em;
+  text-transform: uppercase;
+  color: var(--meta);
   margin: 16px 0 8px;
 }
 ol.vote-edge-history {
@@ -968,6 +1052,8 @@ li.vote-edge-history-row {
 .vote-edge-bar {
   margin-top: 4px;
   max-width: 100%;
+  border-radius: 2px;
+  overflow: hidden;
 }
 .vote-edge-reason {
   margin-top: 4px;
diff --git a/server/static/theme_retro_craft.css b/server/static/theme_retro_craft.css
index 3853e9edaf4c77c62dcf350ff28920448dd943a8..092d4b3540b052cc86870179e4c264541c4446cc 100644
--- a/server/static/theme_retro_craft.css
+++ b/server/static/theme_retro_craft.css
@@ -587,3 +587,337 @@ body.view-ontology #controls {
     padding-right: 1.25rem;
   }
 }
+
+/* ----------------------------------------------------------------
+   Ontology garden: pin HUD, row pins, vote-compare
+   (Global craft `code` + `button[type=submit]` are tuned for dark
+   thread pages; these overrides match the cream ontology shell.)
+   ---------------------------------------------------------------- */
+body.view-ontology #slug-pin-hud.slug-pin-hud {
+  margin-left: auto;
+  max-width: min(42vw, 280px);
+  font-family: var(--font-ui);
+  font-size: 0.72rem;
+  color: #5c574e;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+body.view-ontology .slug-pin-hud-link {
+  color: #1a4a8c;
+  text-decoration: none;
+  display: inline-flex;
+  align-items: center;
+  gap: 0.25rem;
+}
+body.view-ontology .slug-pin-hud-link:hover {
+  color: #0d3d82;
+  text-decoration: underline;
+}
+body.view-ontology .slug-pin-hud-glyph {
+  font-size: 0.95rem;
+  line-height: 1;
+}
+
+body.view-ontology .ont-item-meta {
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 0.35rem;
+}
+body.view-ontology .ont-item-title {
+  flex: 1 1 auto;
+  min-width: 0;
+}
+body.view-ontology .ont-item-pin-zone {
+  flex: 0 0 auto;
+  margin-left: auto;
+  display: flex;
+  align-items: center;
+  gap: 0.4rem;
+}
+
+body.view-ontology button.ont-pin-btn,
+body.view-ontology a.ont-vote-compare-btn {
+  background: #ebe6dc;
+  border: 1px solid #c8c4bc;
+  border-radius: 2px;
+  color: #3d3a34;
+  cursor: pointer;
+  font-family: var(--font-ui);
+  font-size: 0.72rem;
+  letter-spacing: 0.04em;
+  padding: 0.25rem 0.55rem;
+  text-decoration: none;
+  display: inline-flex;
+  align-items: center;
+  gap: 0.3rem;
+}
+body.view-ontology button.ont-pin-btn:hover,
+body.view-ontology a.ont-vote-compare-btn:hover {
+  border-color: #a68e6b;
+  color: #1a1814;
+}
+body.view-ontology button.ont-pin-btn-active {
+  border-color: #1a4a8c;
+  background: color-mix(in srgb, #1a4a8c 12%, #ebe6dc);
+  color: #0d2d5c;
+}
+body.view-ontology button.ont-pin-btn:focus-visible,
+body.view-ontology a.ont-vote-compare-btn:focus-visible {
+  outline: 2px solid #1a4a8c;
+  outline-offset: 2px;
+}
+
+body.view-ontology .ont-garden-child-actions {
+  display: inline-flex;
+  align-items: center;
+  margin-right: 0.35rem;
+  vertical-align: middle;
+  gap: 0.1rem;
+}
+body.view-ontology button.ont-garden-pin-ico,
+body.view-ontology a.ont-garden-vote-ico,
+body.view-ontology span.ont-garden-pinned-here {
+  font-size: 0.95rem;
+  line-height: 1;
+  padding: 0.1rem 0.35rem;
+  min-width: 1.65rem;
+  min-height: 1.65rem;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #c8c4bc;
+  border-radius: 2px;
+  background: #f7f3eb;
+  cursor: pointer;
+  text-decoration: none;
+  color: #1a1814;
+}
+body.view-ontology a.ont-garden-vote-ico:hover {
+  border-color: #a68e6b;
+  background: #ebe6dc;
+}
+body.view-ontology span.ont-garden-pinned-here {
+  border-color: #1a4a8c;
+  background: color-mix(in srgb, #1a4a8c 10%, #f7f3eb);
+  cursor: default;
+}
+body.view-ontology form.ont-garden-pin-form {
+  display: inline;
+  margin: 0;
+  padding: 0;
+}
+body.view-ontology button.ont-garden-pin-ico:focus-visible {
+  outline: 2px solid #1a4a8c;
+  outline-offset: 2px;
+}
+
+body.view-ontology ol.ont-ranking-list li,
+body.view-ontology ul.ont-group-list li {
+  display: flex;
+  align-items: baseline;
+  gap: 0.35rem;
+}
+body.view-ontology ol.ont-ranking-list li .item-link,
+body.view-ontology ul.ont-group-list li .item-link {
+  flex: 1;
+  min-width: 0;
+}
+
+body.view-ontology code,
+body.view-ontology .vote-compare-item code,
+body.view-ontology .vote-edge-meta code {
+  background: #ebe6dc;
+  color: #1a1814;
+  border: 1px solid #d4cfc4;
+  padding: 0.12em 0.35em;
+  font-size: 0.88em;
+}
+
+body.view-ontology div.ratio-bar {
+  background: #e3ded4;
+  border: 1px solid #c8c4bc;
+}
+body.view-ontology div.ratio-left,
+body.view-ontology div.ratio-right {
+  background: #cec9bf;
+}
+body.view-ontology div.ratio-left.current,
+body.view-ontology div.ratio-right.current {
+  background: #9a7b4a;
+}
+
+body.view-ontology.view-vote-compare .vote-compare-shell {
+  margin: 0.75rem 0 1.5rem;
+  max-width: 36rem;
+  padding: 0.85rem 1rem 1.1rem;
+  background: #f7f3eb;
+  border: 1px solid #c8c4bc;
+  border-radius: 3px;
+  box-shadow: 0 1px 0 rgba(26, 24, 20, 0.06);
+}
+body.view-ontology.view-vote-compare .vote-compare-shell > h2 {
+  margin-top: 0;
+  font-size: 0.7rem;
+  letter-spacing: 0.16em;
+  text-transform: uppercase;
+  color: #5c574e;
+}
+body.view-ontology .vote-compare-pair {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 0.65rem 1rem;
+  margin: 0.65rem 0 0.85rem;
+}
+body.view-ontology .vote-compare-vs {
+  color: #8a857a;
+  font-family: var(--font-ui);
+  font-size: 0.72rem;
+  letter-spacing: 0.1em;
+  text-transform: uppercase;
+}
+body.view-ontology .vote-compare-item {
+  text-decoration: none;
+}
+body.view-ontology .vote-compare-item:hover code {
+  border-color: #a68e6b;
+  background: #f0ebe3;
+}
+
+body.view-ontology .vote-thread-picker {
+  margin: 0.85rem 0;
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 0.5rem 0.75rem;
+}
+body.view-ontology .vote-thread-picker-label {
+  font-family: var(--font-ui);
+  font-size: 0.72rem;
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+  color: #5c574e;
+}
+body.view-ontology #vote-thread-select {
+  min-width: 10rem;
+  background: #f7f3eb;
+  border: 1px solid #c8c4bc;
+  color: #1a1814;
+  font-family: var(--font-ui);
+  font-size: 0.78rem;
+  padding: 0.3rem 0.45rem;
+}
+
+body.view-ontology .vote-compare-slider-label {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 0.5rem 0.65rem;
+  margin: 0.85rem 0;
+  font-family: var(--font-ui);
+  font-size: 0.72rem;
+  color: #5c574e;
+}
+body.view-ontology #vote-preference-slider {
+  flex: 1 1 11rem;
+  min-width: 8rem;
+  accent-color: #9a7b4a;
+}
+
+body.view-ontology .vote-explain-label {
+  display: block;
+  font-family: var(--font-ui);
+  font-size: 0.72rem;
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+  color: #5c574e;
+  margin: 0.65rem 0 0.35rem;
+}
+body.view-ontology #vote-compare-form textarea,
+body.view-ontology.view-vote-compare textarea#vote-explain {
+  background: #fdfcfa;
+  border: 1px solid #c8c4bc;
+  color: #1a1814;
+  font-family: var(--font-prose);
+  font-size: 0.9rem;
+  line-height: 1.45;
+  width: 100%;
+  max-width: 100%;
+  padding: 0.55rem 0.65rem;
+  margin-top: 0.25rem;
+}
+body.view-ontology #vote-compare-form textarea:focus {
+  border-color: #a68e6b;
+  outline: none;
+  box-shadow: 0 0 0 2px color-mix(in srgb, #a68e6b 28%, transparent);
+}
+
+body.view-ontology #vote-compare-form button[type="s

… preview truncated; 1,584 characters omitted

download full diff A

B — c_8dc1a8119370 (tommy-mor)

message

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

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

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

diff preview

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

… preview truncated; 92,533 characters omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.